From 6f0a251065d35c41bbc27251f49f8b810f86f062 Mon Sep 17 00:00:00 2001 From: vxern Date: Tue, 10 Jan 2023 21:48:13 +0000 Subject: [PATCH 1/3] misc!: Remove `sprint` and throw exceptions instead of logging errors. --- CHANGELOG.md | 12 +++++++ example/translation.dart | 2 +- lib/src/exceptions.dart | 34 +++++++++++++++++++ lib/src/lexer.dart | 24 +++++--------- lib/src/parser.dart | 17 +++------- lib/src/tokens.dart | 72 +++++++++++++++++++++++++--------------- lib/src/utils.dart | 23 ------------- pubspec.yaml | 7 ++-- 8 files changed, 106 insertions(+), 85 deletions(-) create mode 100644 lib/src/exceptions.dart delete mode 100644 lib/src/utils.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b927ff9..8251e64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## 2.0.0 (Work in progress) + +- Additions: + - Exceptions: + - `MissingKeyException` - Thrown when a key is not present. + - `ParserException` - Thrown at various points during the parsing of + expressions. +- Changes: + - Instead of logging an error, the package will now throw an exception. +- Deletions: + - Removed `sprint` dependency. + ## 1.2.0 - Updated SDK version from `2.12.0` to `2.17.0`. diff --git a/example/translation.dart b/example/translation.dart index c3b59af..ccef026 100644 --- a/example/translation.dart +++ b/example/translation.dart @@ -6,7 +6,7 @@ import 'utils.dart'; /// the given key. class Translation { /// The parser utilised by the translation service. - final Parser parser = Parser(quietMode: false); + final Parser parser = Parser(); /// Load the strings corresponding to the language code provided. void load(Language language) => parser.load( diff --git a/lib/src/exceptions.dart b/lib/src/exceptions.dart new file mode 100644 index 0000000..177a947 --- /dev/null +++ b/lib/src/exceptions.dart @@ -0,0 +1,34 @@ +/// Exception thrown when the parser attempts to process a key that does not +/// exist. +class MissingKeyException implements Exception { + /// A message describing the missing key error. + final String message; + + /// The name of the key that was missing. + final String key; + + /// Creates a new `MissingKeyException` with an error [message] and a [key] + /// indicating which key was missing. + const MissingKeyException(this.message, this.key); + + /// Returns a description of this missing key exception. + @override + String toString() => '$message: The key $key does not exist.'; +} + +/// Exception thrown by the parser to indicate an issue with an expression. +class ParserException implements Exception { + /// A brief description of the parser error. + final String message; + + /// A more in-depth description of the error. + final String cause; + + /// Creates a new `ParserException` with a titular [message] and the [cause] + /// of this exception. + const ParserException(this.message, this.cause); + + /// Returns a description of this parser exception. + @override + String toString() => '$message: $cause'; +} diff --git a/lib/src/lexer.dart b/lib/src/lexer.dart index 9b172ec..f380f9a 100644 --- a/lib/src/lexer.dart +++ b/lib/src/lexer.dart @@ -1,5 +1,3 @@ -import 'package:sprint/sprint.dart'; - import 'package:text_expressions/src/choices.dart'; import 'package:text_expressions/src/parser.dart'; import 'package:text_expressions/src/symbols.dart'; @@ -8,15 +6,11 @@ import 'package:text_expressions/src/tokens.dart'; /// The lexer handles the breaking of strings into singular `Tokens`s and /// `Symbol`s for the purpose of fine-grained control over parsing. class Lexer { - /// Instance of `Sprint` for logging messages specific to the `Lexer`. - final Sprint log; - /// Instance of the `Parser` by whom this `Lexer` is employed. final Parser parser; /// Creates an instance of `Lexer`, passing in the parser it is employed by. - Lexer(this.parser, {bool quietMode = false}) - : log = Sprint('Lexer', quietMode: quietMode); + Lexer(this.parser); /// Extracts a `List` of `Tokens` from [target]. List getTokens(String target) { @@ -177,9 +171,9 @@ class Lexer { if (conditionRaw.contains(Symbols.ArgumentOpen)) { final commandParts = conditionRaw.split(Symbols.ArgumentOpen); if (commandParts.length > 2) { - log.severe( - ''' -Could not parse choice: Expected a command and optional arguments inside parentheses, but found multiple parentheses.''', + throw const FormatException( + 'Could not parse choice: Expected a command and optional arguments ' + 'inside parentheses, but found multiple parentheses.', ); } @@ -245,14 +239,13 @@ Could not parse choice: Expected a command and optional arguments inside parenth case Operation.LesserOrEqual: final argumentsAreNumeric = arguments.map(isNumeric); if (argumentsAreNumeric.contains(false)) { - log.severe( + throw FormatException( ''' Could not construct mathematical condition: '${operation.name}' requires that its argument(s) be numeric. One of the provided arguments $arguments is not numeric, and thus is not parsable as a number. To prevent runtime exceptions, the condition has been set to evaluate to `false`.''', ); - return (_) => false; } final argumentsAsNumbers = arguments.map(num.parse); @@ -286,11 +279,10 @@ To prevent runtime exceptions, the condition has been set to evaluate to `false` numberOfNumericArguments != arguments.length; if (isTypeMismatch) { - log.severe( - ''' -Could not construct a set condition: All arguments must be of the same type.''', + throw const FormatException( + 'Could not construct a set condition: All arguments must be of the ' + 'same type.', ); - return (_) => false; } final rangeType = numberOfNumericArguments == 0 ? String : num; diff --git a/lib/src/parser.dart b/lib/src/parser.dart index 0e36984..cfbee96 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -1,5 +1,4 @@ -import 'package:sprint/sprint.dart'; - +import 'package:text_expressions/src/exceptions.dart'; import 'package:text_expressions/src/lexer.dart'; /// Map of letters used for range checks. @@ -10,12 +9,6 @@ const characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; /// for expressions to be defined externally and 'included' in a phrase through /// the use of acute angles '<>'. class Parser { - /// Used as a fallback 'translation' for an inexistent key. - static const String fallback = '?'; - - /// Instance of `Sprint` message printer for the parser. - final Sprint log; - /// Instace of `Lexer` for breaking phrases into their parsable components. late final Lexer lexer; @@ -23,9 +16,8 @@ class Parser { final Map phrases = {}; /// Creates an instance of an expression parser. - Parser({bool quietMode = true}) - : log = Sprint('Parser', quietMode: quietMode) { - lexer = Lexer(this, quietMode: quietMode); + Parser() { + lexer = Lexer(this); } /// Loads a new set of [phrases] into the parser, clearing the previous set. @@ -45,8 +37,7 @@ class Parser { Set positional = const {}, }) { if (!phrases.containsKey(key)) { - log.warn("Could not parse phrase: The key '$key' does not exist."); - return Parser.fallback; + throw MissingKeyException('Could not parse phrase', key); } final phrase = phrases[key]!; diff --git a/lib/src/tokens.dart b/lib/src/tokens.dart index 17151c0..aec3e1a 100644 --- a/lib/src/tokens.dart +++ b/lib/src/tokens.dart @@ -1,7 +1,7 @@ +import 'package:text_expressions/src/exceptions.dart'; import 'package:text_expressions/src/lexer.dart'; import 'package:text_expressions/src/parser.dart'; import 'package:text_expressions/src/symbols.dart'; -import 'package:text_expressions/src/utils.dart'; /// A representation of a part of a string which needs different handling /// of [content] based on its [type]. @@ -33,7 +33,10 @@ class Token { case TokenType.Text: return parseText(); case TokenType.Choice: - return Parser.fallback; + throw const ParserException( + 'Could not parse phrase', + 'Choices cannot be parsed as stand-alone entities.', + ); } } @@ -41,15 +44,7 @@ class Token { /// expression, it is first parsed, and then returned. String parseExternal(Arguments arguments) { if (!lexer.parser.phrases.containsKey(content)) { - lexer.log.severe( - ''' -Could not parse external phrase: The key '<$content>' does not exist.''', - ); - return Parser.fallback; - } - - if (!lexer.parser.phrases.containsKey(content)) { - return Parser.fallback; + throw MissingKeyException('Could not parse external phrase', content); } final phrase = lexer.parser.phrases[content].toString(); @@ -79,11 +74,11 @@ Could not parse external phrase: The key '<$content>' does not exist.''', return lexer.parser.parse(matchedChoice.result, arguments); } - lexer.log.severe( - ''' -Could not parse expression: The control variable '$controlVariable' does not match any choice defined inside the expression.''', + throw ParserException( + 'Could not parse expression', + "The control variable '$controlVariable' " + 'does not match any choice defined inside the expression.', ); - return Parser.fallback; } /// Fetches the argument described by the parameter and returns its value. @@ -93,11 +88,12 @@ Could not parse expression: The control variable '$controlVariable' does not mat } if (!arguments.named.containsKey(content)) { - lexer.log.severe( - ''' -Could not parse a named parameter: An argument with the name '$content' hadn't been supplied to the parser at the time of parsing the named parameter of the same name.''', + throw ParserException( + 'Could not parse a named parameter', + "An argument with the name '$content' hadn't been supplied to the " + 'parser at the time of parsing the named parameter of the same ' + 'name.', ); - return Parser.fallback; } return arguments.named[content].toString(); @@ -108,19 +104,18 @@ Could not parse a named parameter: An argument with the name '$content' hadn't b final index = int.parse(content); if (index < 0) { - lexer.log.severe( - ''' -Could not parse a positional parameter: The index must not be negative.''', + throw const ParserException( + 'Could not parse a positional parameter', + 'The index must not be negative.', ); - return Parser.fallback; } if (index >= arguments.positional.length) { - lexer.log.severe( - ''' -Could not parse a positional parameter: Attempted to access an argument at position $index, but ${arguments.positional.length} argument(s) were supplied.''', + throw ParserException( + 'Could not parse a positional parameter', + 'Attempted to access an argument at position $index, but ' + '${arguments.positional.length} argument(s) were supplied.', ); - return Parser.fallback; } return arguments.positional.elementAt(index).toString(); @@ -165,3 +160,26 @@ enum TokenType { /// A string of text which does not require to be parsed. Text, } + +/// Extension on `Iterable` providing a `firstWhereOrNull()` function that +/// returns `null` if an element is not found, rather than throw `StateError`. +extension NullSafeAccess on Iterable { + /// Returns the first element that satisfies the given predicate [test]. + /// + /// If no elements satisfy [test], the result of invoking the [orElse] + /// function is returned. + /// + /// Unlike `Iterable.firstWhere()`, this function defaults to returning `null` + /// if an element is not found. + E? firstWhereOrNull(bool Function(E) test, {E Function()? orElse}) { + for (final element in this) { + if (test(element)) { + return element; + } + } + if (orElse != null) { + return orElse(); + } + return null; + } +} diff --git a/lib/src/utils.dart b/lib/src/utils.dart deleted file mode 100644 index 7f8bcb2..0000000 --- a/lib/src/utils.dart +++ /dev/null @@ -1,23 +0,0 @@ -/// Extension with a superior implementation of the problematic -/// `Iterable.firstWhere()` method, which defaults to throwing a `StateError` -/// if an element is not found, rather than returning `null`. -extension NullSafety on Iterable { - /// Returns the first element that satisfies the given predicate [test]. - /// - /// If no elements satisfy [test], the result of invoking the [orElse] - /// function is returned. - /// - /// Unlike `Iterable.firstWhere()`, this function defaults to returning `null` - /// if an element is not found. - E? firstWhereOrNull(bool Function(E) test, {E Function()? orElse}) { - for (final element in this) { - if (test(element)) { - return element; - } - } - if (orElse != null) { - return orElse(); - } - return null; - } -} diff --git a/pubspec.yaml b/pubspec.yaml index 9865aa7..c0a6158 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,9 +1,9 @@ name: text_expressions -version: 1.2.0 +version: 2.0.0 description: >- A tiny and complete tool to supercharge static JSON strings - with dynamic, user-defined expressions. +with dynamic, user-defined expressions. homepage: https://github.com/wordcollector/text_expressions repository: https://github.com/wordcollector/text_expressions @@ -12,8 +12,5 @@ issue_tracker: https://github.com/wordcollector/text_expressions/issues environment: sdk: '>=2.17.0 <3.0.0' -dependencies: - sprint: ^1.0.4 - dev_dependencies: words: ^0.1.1 From d843bea6d35ec7473c0840399192f610d8577ab3 Mon Sep 17 00:00:00 2001 From: vxern Date: Wed, 11 Jan 2023 21:52:55 +0000 Subject: [PATCH 2/3] refactor!: Various enum improvements. Rename 'operation' to 'matcher. --- CHANGELOG.md | 33 ++++- README.md | 34 ++--- example/polish/expressions.json | 2 +- lib/src/choices.dart | 106 +++++++++++----- lib/src/lexer.dart | 217 ++++++++++++++++---------------- lib/src/symbols.dart | 95 +++++++------- lib/src/tokens.dart | 28 ++--- 7 files changed, 286 insertions(+), 229 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8251e64..cfbb551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,37 @@ - `MissingKeyException` - Thrown when a key is not present. - `ParserException` - Thrown at various points during the parsing of expressions. -- Changes: - - Instead of logging an error, the package will now throw an exception. - Deletions: - Removed `sprint` dependency. +- Changes: + - BREAKING: Instead of logging an error, the package will now throw an + exception. + - The members of all enums have been converted to `camelCase`. + - 'Operations' have been renamed to 'matchers'. + - Several matchers were renamed and/or received aliases: + - `Default` is now known as `always` in the private API. + - `Always`, `Fallback` and `Otherwise` are now synonymous with `Default`. + - + - `=` and `==` are now synonymous with `Equals`. + - `Greater` has been renamed to `IsGreater`. + - `Greater`, `GT`, `GTR` and `>` are now synonymous with `IsGreater`. + - `GreaterOrEqual` has been renamed to `IsGreaterOrEqual`. + - `GreaterOrEqual`, `GTE` and `>=` are now synonymous with + `IsGreaterOrEqual`. + - `Lesser` has been renamed to `IsLesser`. + - `Lesser`, `LS`, `LSS` and `<` are now synonymous with `IsLesser`. + - `LesserOrEqual` has been renamed to `IsLesserOrEqual`. + - `LesserOrEqual`, `LSE` and `<=` are now synonymous with + `IsLesserOrEqual`. + - `In` has been renamed to `IsInGroup`. + - `In`, 'IsIn' and 'InGroup' are now synonymous with `IsInGroup`. + - `NotIn` has been renamed to `IsNotInGroup`. + - `NotIn`, `!In`, `IsNotIn`, `NotInGroup` and `!InGroup` are now + synonymous with `IsNotInGroup`. + - `InRange` has been renamed to `IsInRange`. + - `InRange` is now synonymous with `IsInRange`. + - `NotInRange` has been renamed to `IsNotInRange`. + - `NotInRange` and `!InRange` are now synonymous with `IsNotInRange`. ## 1.2.0 @@ -49,7 +76,7 @@ ## 1.0.1 -- Added `In` and `NotIn` operations. +- Added `In` and `NotIn` matchers. ## 1.0.0 diff --git a/README.md b/README.md index 95bcf99..dd050eb 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ## Table of Contents - [The Syntax](#the-syntax) -- [Case Operations](#case-operations) +- [Case Matchers](#case-matchers) ## The Syntax @@ -45,34 +45,34 @@ expressions small and understandable. `[{temperature} ~ Lesser(15):Too cold./Lesser(30):Temperate./Default:It's too hot!]` -### Case Operations +### Case Matchers -The parser supports several comparison operations, which can be used to match a -parameter to a case. +The parser supports several case matchers, which can be used to match the +control variable to an argument. -String-exclusive operations: +String-exclusive matchers: - `StartsWith` - `EndsWith` - `Contains` -Indifferent operations: +Indifferent matchers: - `Equals` * -- `In` -- `NotIn` -- `InRange` -- `NotInRange` +- `IsIn` +- `IsNotIn` +- `IsInRange` +- `IsNotInRange` -Number-exclusive operations: +Number-exclusive matchers: -- `Greater` -- `GreaterOrEqual` -- `Lesser` -- `LesserOrEqual` +- `IsGreater` +- `IsGreaterOrEqual` +- `IsLesser` +- `IsLesserOrEqual` Other: -- `Default` +- `Always` -* If no operation has been defined, the operation will default to 'Equals' +* If no matcher has been defined, the matcher will default to 'Equals' diff --git a/example/polish/expressions.json b/example/polish/expressions.json index ba079f2..2be2736 100644 --- a/example/polish/expressions.json +++ b/example/polish/expressions.json @@ -1,3 +1,3 @@ { - "ordinalMasculineSingularInstrumental": "[{number} ~ 1:pierwszym/2:drugim/3:trzecim/4:czwartym/5:piątym/GreaterOrEqual(6):{number}[{number} ~ EndsWith(2,3):im/Default:ym]]" + "ordinalMasculineSingularInstrumental": "[{number} ~ 1:pierwszym/2:drugim/3:trzecim/4:czwartym/5:piątym/GreaterOrEqual(6):{number}-[{number} ~ EndsWith(2,3):im/Default:ym]]" } diff --git a/lib/src/choices.dart b/lib/src/choices.dart index df36b8a..9b1c872 100644 --- a/lib/src/choices.dart +++ b/lib/src/choices.dart @@ -16,56 +16,102 @@ class Choice { /// Creates an instance of `Choice` with the [condition] required for this /// `Choice` to match and the [result] returned if matched. - Choice({ - required this.condition, - required this.result, - }); + Choice({required this.condition, required this.result}); /// Returns true if the [condition] for this `Choice` being matched with /// the control variable yields true. bool isMatch(String controlVariable) => condition(controlVariable); } -/// Describes how the control variable is matched to the argument/s. -enum Operation { - /// The choice is accepted regardless of the condition. - Default, +/// Defines the method by which the control variable is checked against a +/// condition. +enum Matcher { + /// Always matches. This matcher acts as a fallback for when no other case has + /// matched. + always('Default', aliases: {'Always', 'Fallback', 'Otherwise'}), - /// The control variable is identical to the argument. - Equals, + /// The argument is identical to the control variable. + equals('Equals', aliases: {'=', '=='}), - /// The control variable starts with a portion or the entirety of the - /// argument. - StartsWith, + /// The control variable string starts with the same sequence of characters + /// as the argument. + startsWith('StartsWith'), - /// The control variable ends with a portion or the entirety of the argument. - EndsWith, + /// The control variable string ends with the same sequence of characters as + /// the argument. + endsWith('EndsWith'), - /// The control variable contains a portion or the entirety of the argument. - Contains, + /// The control variable string contains with the same sequence of characters + /// as the argument. + contains('Contains'), /// The control variable is greater than the argument. - Greater, + /// + /// If the argument is not numeric, the character(s) of the argument and the + /// control variable will be compared by codepoint instead. + isGreater('IsGreater', aliases: {'Greater', 'GT', 'GTR', '>'}), /// The control variable is greater than or equal to the argument. - GreaterOrEqual, + /// + /// If the argument is not numeric, the character(s) of the argument and the + /// control variable will be compared by codepoint instead. + isGreaterOrEqual( + 'IsGreaterOrEqual', + aliases: {'GreaterOrEqual', 'GTE', '>='}, + ), /// The control variable is lesser than the argument. - Lesser, + /// + /// If the argument is not numeric, the character(s) of the argument and the + /// control variable will be compared by codepoint instead. + isLesser('IsLesser', aliases: {'Lesser', 'LS', 'LSS', '<'}), /// The control variable is lesser than or equal to the argument. - LesserOrEqual, + /// + /// If the argument is not numeric, the character(s) of the argument and the + /// control variable will be compared by codepoint instead. + isLesserOrEqual('IsLesserOrEqual', aliases: {'LesserOrEqual', 'LSE', '<='}), + + /// The control variable lies within the provided list of arguments. + isInGroup('IsInGroup', aliases: {'IsIn', 'In', 'InGroup'}), + + /// The control variable does not lie within the provided list of arguments. + isNotInGroup( + 'IsNotInGroup', + aliases: {'IsNotIn', 'NotIn', '!In', 'NotInGroup', '!InGroup'}, + ), + + /// The control variable falls in the range expression specified as the + /// argument. + isInRange('IsInRange', aliases: {'InRange'}), + + /// The control variable does not fall in the range expression specified as + /// the argument. + isNotInRange('IsNotInRange', aliases: {'NotInRange', '!InRange'}); + + /// The name of this matcher, as defined in the phrase getting parsed. + final String name; + + /// Aliases for this matcher. + final Set aliases; - /// The control variable lies within the list of arguments. - In, + /// Creates a `Matcher`. + const Matcher(this.name, {this.aliases = const {}}); - /// The control variable does not lie within the list of arguments. - NotIn, + /// Taking a [string], attempts to resolve it to a `Matcher` by checking if it + /// matches the name of a particular matcher, or alternatively if it uses one + /// of the defined aliases. + static Matcher? fromString(String string) { + for (final matcher in Matcher.values) { + if (matcher.name == string) { + return matcher; + } - /// The control variable falls in the range described by the arguments. - InRange, + if (matcher.aliases.contains(string)) { + return matcher; + } + } - /// The control variable does not fall in the range described by the - /// arguments. - NotInRange, + return null; + } } diff --git a/lib/src/lexer.dart b/lib/src/lexer.dart index f380f9a..ff4db80 100644 --- a/lib/src/lexer.dart +++ b/lib/src/lexer.dart @@ -18,7 +18,7 @@ class Lexer { // In order to break the string down correctly into tokens, the parser // must see exactly where each symbol lies in the string. - final symbols = getSymbols(target); + final symbolsWithPositions = getSymbols(target); // How deeply nested the current symbol being parsed is. var nestingLevel = 0; @@ -28,72 +28,84 @@ class Lexer { var lastChoicePosition = 0; // Iterate over symbols, finding and extracting tokens. - for (final symbol in symbols) { + for (final symbolWithPosition in symbolsWithPositions) { TokenType? tokenType; String? content; - switch (symbol.type) { - case SymbolType.ExternalOpen: - case SymbolType.ExpressionOpen: - case SymbolType.ParameterOpen: - tokenType = TokenType.Text; + final symbol = symbolWithPosition.object; + + switch (symbol) { + case Symbol.externalOpen: + case Symbol.expressionOpen: + case Symbol.parameterOpen: + tokenType = TokenType.text; if (nestingLevel == 0 && lastChoicePosition == 0) { - final precedingString = - target.substring(lastSymbolPosition, symbol.position); + final precedingString = target.substring( + lastSymbolPosition, + symbolWithPosition.position, + ); if (precedingString.isNotEmpty) { content = precedingString; } - lastSymbolPosition = symbol.position + 1; + lastSymbolPosition = symbolWithPosition.position + 1; } nestingLevel++; break; - case SymbolType.ExternalClosed: - tokenType = TokenType.External; + case Symbol.externalClosed: + tokenType = TokenType.external; continue closed; - case SymbolType.ExpressionClosed: - tokenType = TokenType.Expression; + case Symbol.expressionClosed: + tokenType = TokenType.expression; continue closed; closed: - case SymbolType.ParameterClosed: - tokenType ??= TokenType.Parameter; + case Symbol.parameterClosed: + tokenType ??= TokenType.parameter; if (nestingLevel == 1 && lastChoicePosition == 0) { - content = target.substring(lastSymbolPosition, symbol.position); - lastSymbolPosition = symbol.position + 1; + content = target.substring( + lastSymbolPosition, + symbolWithPosition.position, + ); + lastSymbolPosition = symbolWithPosition.position + 1; } nestingLevel--; break; - case SymbolType.ChoiceIntroducer: + case Symbol.choiceIntroducer: if (nestingLevel == 0 && lastChoicePosition == 0) { - lastChoicePosition = symbol.position + 1; + lastChoicePosition = symbolWithPosition.position + 1; } break; - case SymbolType.ChoiceSeparator: - tokenType = TokenType.Choice; + case Symbol.choiceSeparator: + tokenType = TokenType.choice; if (nestingLevel == 0 && lastChoicePosition != 0) { - content = - target.substring(lastChoicePosition, symbol.position).trim(); - lastChoicePosition = symbol.position + 1; + content = target + .substring(lastChoicePosition, symbolWithPosition.position) + .trim(); + lastChoicePosition = symbolWithPosition.position + 1; } break; - case SymbolType.EndOfString: + case Symbol.endOfString: if (lastSymbolPosition == target.length) { break; } if (lastChoicePosition == 0) { - tokenType = TokenType.Text; + tokenType = TokenType.text; content = target.substring(lastSymbolPosition); break; } - tokenType = TokenType.Choice; + tokenType = TokenType.choice; content = target.substring(lastChoicePosition).trim(); break; + case Symbol.choiceResultDivider: + case Symbol.argumentOpen: + case Symbol.argumentClosed: + break; } if (tokenType != null && content != null) { @@ -105,45 +117,18 @@ class Lexer { } /// Extracts a `List` of `Symbols` from [target]. - List getSymbols(String target) { - final symbols = []; + List> getSymbols(String target) { + final symbols = >[]; for (var position = 0; position < target.length; position++) { - SymbolType? symbolType; + final symbol = Symbol.fromCharacter(target[position]); - switch (target[position]) { - case Symbols.ExternalOpen: - symbolType = SymbolType.ExternalOpen; - break; - case Symbols.ExternalClosed: - symbolType = SymbolType.ExternalClosed; - break; - case Symbols.ExpressionOpen: - symbolType = SymbolType.ExpressionOpen; - break; - case Symbols.ExpressionClosed: - symbolType = SymbolType.ExpressionClosed; - break; - case Symbols.ParameterOpen: - symbolType = SymbolType.ParameterOpen; - break; - case Symbols.ParameterClosed: - symbolType = SymbolType.ParameterClosed; - break; - case Symbols.ChoiceIntroducer: - symbolType = SymbolType.ChoiceIntroducer; - break; - case Symbols.ChoiceSeparator: - symbolType = SymbolType.ChoiceSeparator; - break; - } - - if (symbolType != null) { - symbols.add(Symbol(symbolType, position)); + if (symbol != null) { + symbols.add(WithPosition(symbol, position)); } } - symbols.add(Symbol(SymbolType.EndOfString, target.length - 1)); + symbols.add(WithPosition(Symbol.endOfString, target.length - 1)); return symbols; } @@ -153,23 +138,23 @@ class Lexer { final choices = []; for (final token in tokens.where( - (token) => token.type == TokenType.Choice, + (token) => token.type == TokenType.choice, )) { // Split case into operable parts. - final parts = token.content.split(Symbols.ChoiceResultDivider); + final parts = token.content.split(Symbol.choiceResultDivider.character); // The first part of a case is the command. final conditionRaw = parts.removeAt(0); // The other parts of a case are the result. - final resultRaw = parts.join(Symbols.ChoiceResultDivider); + final resultRaw = parts.join(Symbol.choiceResultDivider.character); - var operation = Operation.Default; + var matcher = Matcher.always; final arguments = []; final result = resultRaw; - if (conditionRaw.contains(Symbols.ArgumentOpen)) { - final commandParts = conditionRaw.split(Symbols.ArgumentOpen); + if (conditionRaw.contains(Symbol.argumentOpen.character)) { + final commandParts = conditionRaw.split(Symbol.argumentOpen.character); if (commandParts.length > 2) { throw const FormatException( 'Could not parse choice: Expected a command and optional arguments ' @@ -178,11 +163,7 @@ class Lexer { } final command = commandParts[0]; - operation = Operation.values - .map((operation) => operation.name) - .contains(command) - ? Operation.values.byName(command) - : Operation.Default; + matcher = Matcher.fromString(command) ?? Matcher.always; final argumentsString = commandParts[1].substring(0, commandParts[1].length - 1); @@ -192,18 +173,14 @@ class Lexer { : argumentsString.split('-'), ); } else { - operation = Operation.values - .map((operation) => operation.name) - .contains(conditionRaw) - ? Operation.values.byName(conditionRaw) - : Operation.Equals; + matcher = Matcher.fromString(conditionRaw) ?? Matcher.equals; arguments.add(conditionRaw); } choices.add( Choice( - condition: constructCondition(operation, arguments), + condition: constructCondition(matcher, arguments), result: result, ), ); @@ -212,36 +189,36 @@ class Lexer { return choices; } - /// Taking the [operation] and the [arguments] passed into it, construct a + /// Taking the [matcher] and the [arguments] passed into it, construct a /// `Condition` that must be met for a `Choice` to be matched to the control /// variable of an expression. Condition constructCondition( - Operation operation, + Matcher matcher, List arguments, ) { - switch (operation) { - case Operation.Default: + switch (matcher) { + case Matcher.always: return (_) => true; - case Operation.StartsWith: + case Matcher.startsWith: return (var control) => arguments.any(control.startsWith); - case Operation.EndsWith: + case Matcher.endsWith: return (var control) => arguments.any(control.endsWith); - case Operation.Contains: + case Matcher.contains: return (var control) => arguments.any(control.contains); - case Operation.Equals: + case Matcher.equals: return (var control) => arguments.any((argument) => control == argument); - case Operation.Greater: - case Operation.GreaterOrEqual: - case Operation.Lesser: - case Operation.LesserOrEqual: + case Matcher.isGreater: + case Matcher.isGreaterOrEqual: + case Matcher.isLesser: + case Matcher.isLesserOrEqual: final argumentsAreNumeric = arguments.map(isNumeric); if (argumentsAreNumeric.contains(false)) { throw FormatException( ''' -Could not construct mathematical condition: '${operation.name}' requires that its argument(s) be numeric. +Could not construct mathematical condition: '${matcher.name}' requires that its argument(s) be numeric. One of the provided arguments $arguments is not numeric, and thus is not parsable as a number. To prevent runtime exceptions, the condition has been set to evaluate to `false`.''', @@ -251,7 +228,7 @@ To prevent runtime exceptions, the condition has been set to evaluate to `false` final argumentsAsNumbers = arguments.map(num.parse); final mathematicalConditions = argumentsAsNumbers.map( (argument) => constructMathematicalCondition( - operation, + matcher, argument, ), ); @@ -266,10 +243,10 @@ To prevent runtime exceptions, the condition has been set to evaluate to `false` ); }; - case Operation.In: - case Operation.NotIn: - case Operation.InRange: - case Operation.NotInRange: + case Matcher.isInGroup: + case Matcher.isNotInGroup: + case Matcher.isInRange: + case Matcher.isNotInRange: final numberOfNumericArguments = arguments.fold( 0, (previousValue, argument) => @@ -293,7 +270,7 @@ To prevent runtime exceptions, the condition has been set to evaluate to `false` final argumentsAsNumbers = arguments.map(getNumericValue); final setCondition = constructSetCondition( - operation, + matcher, argumentsAsNumbers, ); @@ -309,44 +286,64 @@ To prevent runtime exceptions, the condition has been set to evaluate to `false` /// Construct a `Condition` based on mathematical checks. Condition constructMathematicalCondition( - Operation operation, + Matcher matcher, num argument, ) { - switch (operation) { - case Operation.Greater: + switch (matcher) { + case Matcher.isGreater: return (var control) => control > argument; - case Operation.GreaterOrEqual: + case Matcher.isGreaterOrEqual: return (var control) => control >= argument; - case Operation.Lesser: + case Matcher.isLesser: return (var control) => control < argument; - case Operation.LesserOrEqual: + case Matcher.isLesserOrEqual: return (var control) => control <= argument; + case Matcher.always: + case Matcher.equals: + case Matcher.startsWith: + case Matcher.endsWith: + case Matcher.contains: + case Matcher.isInGroup: + case Matcher.isNotInGroup: + case Matcher.isInRange: + case Matcher.isNotInRange: + break; } return (_) => false; } /// Construct a `Condition` based on set checks. Condition constructSetCondition( - Operation operation, + Matcher matcher, Iterable arguments, ) { - switch (operation) { - case Operation.In: + switch (matcher) { + case Matcher.isInGroup: return (var control) => arguments.contains(control); - case Operation.NotIn: + case Matcher.isNotInGroup: return (var control) => !arguments.contains(control); - case Operation.InRange: + case Matcher.isInRange: return (var control) => isInRange( control, arguments.elementAt(0), arguments.elementAt(1), ); - case Operation.NotInRange: + case Matcher.isNotInRange: return (var control) => !isInRange( control, arguments.elementAt(0), arguments.elementAt(1), ); + case Matcher.always: + case Matcher.equals: + case Matcher.startsWith: + case Matcher.endsWith: + case Matcher.contains: + case Matcher.isGreater: + case Matcher.isGreaterOrEqual: + case Matcher.isLesser: + case Matcher.isLesserOrEqual: + break; } return (_) => false; } @@ -359,5 +356,3 @@ To prevent runtime exceptions, the condition has been set to evaluate to `false` bool isInRange(num subject, num minimum, num maximum) => minimum <= subject || subject <= maximum; } - -// ignore_for_file: missing_enum_constant_in_switch diff --git a/lib/src/symbols.dart b/lib/src/symbols.dart index 64b15aa..0d7c29e 100644 --- a/lib/src/symbols.dart +++ b/lib/src/symbols.dart @@ -1,83 +1,72 @@ -/// List of symbols' characters used and understood by the `Lexer`. -class Symbols { +/// Enumerator representation of characters used in tokenising a string. +enum Symbol { /// Opening bracket of an external phrase. - static const ExternalOpen = '<'; + externalOpen('<'), /// Closing bracket of an external phrase. - static const ExternalClosed = '>'; + externalClosed('>'), /// Opening bracket of an expression. - static const ExpressionOpen = '['; + expressionOpen('['), /// Closing bracket of an expression. - static const ExpressionClosed = ']'; + expressionClosed(']'), /// Opening bracket of a parameter designator. - static const ParameterOpen = '{'; + parameterOpen('{'), /// Closing bracket of a parameter designator. - static const ParameterClosed = '}'; + parameterClosed('}'), /// Separates the control variable and the choices inside an expression. - static const ChoiceIntroducer = '~'; + choiceIntroducer('~'), /// Separates the choices inside an expression. - static const ChoiceSeparator = '/'; + choiceSeparator('/'), /// Separates the condition for matching a choice with the control variable /// and the result of the matching. - static const ChoiceResultDivider = ':'; + choiceResultDivider(':'), - /// Opening bracket of the arguments used by the operation in constructing a + /// Opening bracket of the arguments used by the matcher in constructing a /// condition. - static const ArgumentOpen = '('; + argumentOpen('('), - /// Closing bracket of the arguments used by the operation in constructing a + /// Closing bracket of the arguments used by the matcher in constructing a /// condition. - static const ArgumentClosed = ')'; -} - -/// Enumerator representation of characters used in tokenising a string. -enum SymbolType { - /// Opening bracket of an external phrase. - ExternalOpen, - - /// Closing bracket of an external phrase. - ExternalClosed, - - /// Opening bracket of an expression. - ExpressionOpen, - - /// Closing bracket of an expression. - ExpressionClosed, - - /// Opening bracket of a parameter designator. - ParameterOpen, - - /// Closing bracket of a parameter designator. - ParameterClosed, - - /// Separates the control variable and the choices inside an expression. - ChoiceIntroducer, - - /// Separates the choices inside an expression. - ChoiceSeparator, + argumentClosed(')'), /// Symbol indicating the end of a string. - EndOfString, + endOfString(''); + + /// The character representing this `SymbolType`. + final String character; + + /// Creates a `SymbolType` with the [character] that represents it. + const Symbol(this.character); + + /// Taking a [character], attempts to resolve it to the `Symbol` that is + /// represented by the character. Otherwise, returns `null`. + static Symbol? fromCharacter(String character) { + for (final symbol in Symbol.values) { + if (symbol.character == character) { + return symbol; + } + } + return null; + } } -/// Representation of a character significant to the tokenisation of a string by -/// splitting it into its `Token` components by the `Lexer`. -class Symbol { - /// The [type] of this `Symbol` which describes what `Token` this symbol is a - /// component of. - final SymbolType type; +/// Represents an object of type `T` with an additional [position] relative to +/// its parent object. +class WithPosition { + /// The stored object. + final T object; - /// Zero-based index of the `Symbol` inside the parent string. + /// Position relative to the parent object of [object]. final int position; - /// Creates an instance of `Symbol` assigning a [type] and its [position] - /// inside the string which is being parsed. - const Symbol(this.type, this.position); + /// Creates an instance of `WithPosition` with the given [object] and its + /// [position] relative to its parent object. + const WithPosition(this.object, this.position); } diff --git a/lib/src/tokens.dart b/lib/src/tokens.dart index aec3e1a..4d10b32 100644 --- a/lib/src/tokens.dart +++ b/lib/src/tokens.dart @@ -24,15 +24,15 @@ class Token { /// the [type] of this token. String parse(Arguments arguments) { switch (type) { - case TokenType.External: + case TokenType.external: return parseExternal(arguments); - case TokenType.Expression: + case TokenType.expression: return parseExpression(arguments); - case TokenType.Parameter: + case TokenType.parameter: return parseParameter(arguments); - case TokenType.Text: + case TokenType.text: return parseText(); - case TokenType.Choice: + case TokenType.choice: throw const ParserException( 'Could not parse phrase', 'Choices cannot be parsed as stand-alone entities.', @@ -127,14 +127,14 @@ class Token { /// Checks if [phrase] is an external phrase, which needs to be 'included' in /// the main phrase. bool isExternal(String phrase) => - phrase.startsWith(Symbols.ExternalOpen) && - phrase.endsWith(Symbols.ExternalClosed); + phrase.startsWith(Symbol.externalOpen.character) && + phrase.endsWith(Symbol.externalOpen.character); /// Checks if [phrase] is an expression, which needs to be 'included' in the /// main phrase. bool isExpression(String phrase) => - phrase.startsWith(Symbols.ExpressionOpen) && - phrase.endsWith(Symbols.ExpressionClosed); + phrase.startsWith(Symbol.expressionOpen.character) && + phrase.endsWith(Symbol.expressionClosed.character); /// Returns `true` if [target] is an integer. bool isInteger(String target) => int.tryParse(target) != null; @@ -144,21 +144,21 @@ class Token { /// manipulate the token's content. enum TokenType { /// A phrase (value) defined under a different key. - External, + external, /// A one-line switch-case statement. - Expression, + expression, /// An argument designator which allows for external parameters to be inserted /// into the phrase being parsed. - Parameter, + parameter, /// A choice (case) in an expression (switch statement) that is matched /// against the control variable. - Choice, + choice, /// A string of text which does not require to be parsed. - Text, + text, } /// Extension on `Iterable` providing a `firstWhereOrNull()` function that From c7137d437e1b91d99d42fb4203a027f6dd20722c Mon Sep 17 00:00:00 2001 From: vxern Date: Thu, 12 Jan 2023 18:52:05 +0000 Subject: [PATCH 3/3] misc!: Move declarations into files that are more appropriate locations for them. --- CHANGELOG.md | 80 +++++---- example/translation.dart | 2 +- lib/src/choices.dart | 226 ++++++++++++++++++++++++ lib/src/lexer.dart | 358 --------------------------------------- lib/src/parser.dart | 156 +++++++++++++++-- lib/src/symbols.dart | 17 ++ lib/src/tokens.dart | 244 +++++++++++--------------- lib/src/utils.dart | 22 +++ 8 files changed, 560 insertions(+), 545 deletions(-) delete mode 100644 lib/src/lexer.dart create mode 100644 lib/src/utils.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index cfbb551..1df49d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,37 +5,59 @@ - `MissingKeyException` - Thrown when a key is not present. - `ParserException` - Thrown at various points during the parsing of expressions. -- Deletions: - - Removed `sprint` dependency. - Changes: - - BREAKING: Instead of logging an error, the package will now throw an - exception. - - The members of all enums have been converted to `camelCase`. + - Removed `sprint` dependency. + - BREAKING: Instead of logging an error, the package will now throw an + exception. + - Improved enums: + - The members of all enums have been converted to `camelCase`. - 'Operations' have been renamed to 'matchers'. - - Several matchers were renamed and/or received aliases: - - `Default` is now known as `always` in the private API. - - `Always`, `Fallback` and `Otherwise` are now synonymous with `Default`. - - - - `=` and `==` are now synonymous with `Equals`. - - `Greater` has been renamed to `IsGreater`. - - `Greater`, `GT`, `GTR` and `>` are now synonymous with `IsGreater`. - - `GreaterOrEqual` has been renamed to `IsGreaterOrEqual`. - - `GreaterOrEqual`, `GTE` and `>=` are now synonymous with - `IsGreaterOrEqual`. - - `Lesser` has been renamed to `IsLesser`. - - `Lesser`, `LS`, `LSS` and `<` are now synonymous with `IsLesser`. - - `LesserOrEqual` has been renamed to `IsLesserOrEqual`. - - `LesserOrEqual`, `LSE` and `<=` are now synonymous with - `IsLesserOrEqual`. - - `In` has been renamed to `IsInGroup`. - - `In`, 'IsIn' and 'InGroup' are now synonymous with `IsInGroup`. - - `NotIn` has been renamed to `IsNotInGroup`. - - `NotIn`, `!In`, `IsNotIn`, `NotInGroup` and `!InGroup` are now - synonymous with `IsNotInGroup`. - - `InRange` has been renamed to `IsInRange`. - - `InRange` is now synonymous with `IsInRange`. - - `NotInRange` has been renamed to `IsNotInRange`. - - `NotInRange` and `!InRange` are now synonymous with `IsNotInRange`. + - Several matchers were renamed and/or received aliases: + - `Default` is now known as `always` in the private API. + - `Always`, `Fallback` and `Otherwise` are now synonymous with + `Default`. + - + - `=` and `==` are now synonymous with `Equals`. + - `Greater` has been renamed to `IsGreater`. + - `Greater`, `GT`, `GTR` and `>` are now synonymous with `IsGreater`. + - `GreaterOrEqual` has been renamed to `IsGreaterOrEqual`. + - `GreaterOrEqual`, `GTE` and `>=` are now synonymous with + `IsGreaterOrEqual`. + - `Lesser` has been renamed to `IsLesser`. + - `Lesser`, `LS`, `LSS` and `<` are now synonymous with `IsLesser`. + - `LesserOrEqual` has been renamed to `IsLesserOrEqual`. + - `LesserOrEqual`, `LSE` and `<=` are now synonymous with + `IsLesserOrEqual`. + - `In` has been renamed to `IsInGroup`. + - `In`, 'IsIn' and 'InGroup' are now synonymous with `IsInGroup`. + - `NotIn` has been renamed to `IsNotInGroup`. + - `NotIn`, `!In`, `IsNotIn`, `NotInGroup` and `!InGroup` are now + synonymous with `IsNotInGroup`. + - `InRange` has been renamed to `IsInRange`. + - `InRange` is now synonymous with `IsInRange`. + - `NotInRange` has been renamed to `IsNotInRange`. + - `NotInRange` and `!InRange` are now synonymous with `IsNotInRange`. + - Reorganised project: + - Removed `lexer.dart`, moving the declarations therein to: + - `choices.dart`: `getChoices()`, `constructCondition()`, + `constructMathematicalCondition()`, `constructSetCondition()`, + `isNumeric()`, `isInRange()`. + - `symbols.dart`: `getSymbols()`. + - `tokens.dart`: `getTokens()`. + - Reduced `Token` to a simple data class by: + - Removing unused funtions: `isExternal()`. + - Moving its parser-related methods into `parser.dart`: + - Into `Parser`: `parse()` (as `parseToken()`), `parseExternal()`, + `parseExpression()`, `parseParameter()`, `parsePositionalParameter()`. + - As standalone functions: `isExpression()`, `isInteger()`. + - Renamed declarations of `Parser`: + - `parseKey()` -> `process()`. + - `parse()` -> `_process()`. + - `parseToken()` -> `_parseToken()`. + - `parseExternal()` -> `_processExternalClause()`. + - `parseExpression()` -> `_processExpressionClause()`. + - `parseParameter()` -> `_processParameterClause()`. + - `parsePositionalParameter()` -> `_processPositionalParameter()`. ## 1.2.0 diff --git a/example/translation.dart b/example/translation.dart index ccef026..bb0ec62 100644 --- a/example/translation.dart +++ b/example/translation.dart @@ -21,7 +21,7 @@ class Translation { Map named = const {}, Set positional = const {}, }) => - parser.parseKey(key, named: named, positional: positional); + parser.process(key, named: named, positional: positional); } enum Language { english, polish, romanian } diff --git a/lib/src/choices.dart b/lib/src/choices.dart index 9b1c872..1b1c287 100644 --- a/lib/src/choices.dart +++ b/lib/src/choices.dart @@ -1,3 +1,7 @@ +import 'package:text_expressions/src/parser.dart'; +import 'package:text_expressions/src/symbols.dart'; +import 'package:text_expressions/src/tokens.dart'; + /// A signature for a function that will return `true` if the condition for /// matching the control variable with a `Choice` has been met, and `false` /// otherwise. @@ -115,3 +119,225 @@ enum Matcher { return null; } } + +/// Extracts a `List` of `Choices` from [tokens]. +List getChoices(List tokens) { + final choices = []; + + for (final token in tokens.where( + (token) => token.type == TokenType.choice, + )) { + // Split case into operable parts. + final parts = token.content.split(Symbol.choiceResultDivider.character); + + // The first part of a case is the command. + final conditionRaw = parts.removeAt(0); + + // The other parts of a case are the result. + final resultRaw = parts.join(Symbol.choiceResultDivider.character); + + var matcher = Matcher.always; + final arguments = []; + final result = resultRaw; + + if (conditionRaw.contains(Symbol.argumentOpen.character)) { + final commandParts = conditionRaw.split(Symbol.argumentOpen.character); + if (commandParts.length > 2) { + throw const FormatException( + 'Could not parse choice: Expected a command and optional arguments ' + 'inside parentheses, but found multiple parentheses.', + ); + } + + final command = commandParts[0]; + matcher = Matcher.fromString(command) ?? Matcher.always; + + final argumentsString = + commandParts[1].substring(0, commandParts[1].length - 1); + arguments.addAll( + argumentsString.contains(',') + ? argumentsString.split(',') + : argumentsString.split('-'), + ); + } else { + matcher = Matcher.fromString(conditionRaw) ?? Matcher.equals; + + arguments.add(conditionRaw); + } + + choices.add( + Choice( + condition: constructCondition(matcher, arguments), + result: result, + ), + ); + } + + return choices; +} + +/// Taking the [matcher] and the [arguments] passed into it, construct a +/// `Condition` that must be met for a `Choice` to be matched to the control +/// variable of an expression. +Condition constructCondition( + Matcher matcher, + List arguments, +) { + switch (matcher) { + case Matcher.always: + return (_) => true; + + case Matcher.startsWith: + return (var control) => arguments.any(control.startsWith); + case Matcher.endsWith: + return (var control) => arguments.any(control.endsWith); + case Matcher.contains: + return (var control) => arguments.any(control.contains); + case Matcher.equals: + return (var control) => arguments.any((argument) => control == argument); + + case Matcher.isGreater: + case Matcher.isGreaterOrEqual: + case Matcher.isLesser: + case Matcher.isLesserOrEqual: + final argumentsAreNumeric = arguments.map(isNumeric); + if (argumentsAreNumeric.contains(false)) { + throw FormatException( + ''' +Could not construct mathematical condition: '${matcher.name}' requires that its argument(s) be numeric. +One of the provided arguments $arguments is not numeric, and thus is not parsable as a number. + +To prevent runtime exceptions, the condition has been set to evaluate to `false`.''', + ); + } + + final argumentsAsNumbers = arguments.map(num.parse); + final mathematicalConditions = argumentsAsNumbers.map( + (argument) => constructMathematicalCondition( + matcher, + argument, + ), + ); + + return (var control) { + if (!isNumeric(control)) { + return false; + } + final controlVariableAsNumber = num.parse(control); + return mathematicalConditions.any( + (condition) => condition.call(controlVariableAsNumber), + ); + }; + + case Matcher.isInGroup: + case Matcher.isNotInGroup: + case Matcher.isInRange: + case Matcher.isNotInRange: + final numberOfNumericArguments = arguments.fold( + 0, + (previousValue, argument) => + isNumeric(argument) ? previousValue + 1 : previousValue, + ); + final isTypeMismatch = numberOfNumericArguments != 0 && + numberOfNumericArguments != arguments.length; + + if (isTypeMismatch) { + throw const FormatException( + 'Could not construct a set condition: All arguments must be of the ' + 'same type.', + ); + } + + final rangeType = numberOfNumericArguments == 0 ? String : num; + // If the character is a number, parse it, otherwise get its position + // within the [characters] array. + final getNumericValue = + rangeType is String ? characters.indexOf : num.parse; + + final argumentsAsNumbers = arguments.map(getNumericValue); + final setCondition = constructSetCondition( + matcher, + argumentsAsNumbers, + ); + + return (var control) { + if (!isNumeric(control)) { + return false; + } + final controlVariableAsNumber = num.parse(control); + return setCondition(controlVariableAsNumber); + }; + } +} + +/// Construct a `Condition` based on mathematical checks. +Condition constructMathematicalCondition( + Matcher matcher, + num argument, +) { + switch (matcher) { + case Matcher.isGreater: + return (var control) => control > argument; + case Matcher.isGreaterOrEqual: + return (var control) => control >= argument; + case Matcher.isLesser: + return (var control) => control < argument; + case Matcher.isLesserOrEqual: + return (var control) => control <= argument; + case Matcher.always: + case Matcher.equals: + case Matcher.startsWith: + case Matcher.endsWith: + case Matcher.contains: + case Matcher.isInGroup: + case Matcher.isNotInGroup: + case Matcher.isInRange: + case Matcher.isNotInRange: + break; + } + return (_) => false; +} + +/// Construct a `Condition` based on set checks. +Condition constructSetCondition( + Matcher matcher, + Iterable arguments, +) { + switch (matcher) { + case Matcher.isInGroup: + return (var control) => arguments.contains(control); + case Matcher.isNotInGroup: + return (var control) => !arguments.contains(control); + case Matcher.isInRange: + return (var control) => isInRange( + control, + arguments.elementAt(0), + arguments.elementAt(1), + ); + case Matcher.isNotInRange: + return (var control) => !isInRange( + control, + arguments.elementAt(0), + arguments.elementAt(1), + ); + case Matcher.always: + case Matcher.equals: + case Matcher.startsWith: + case Matcher.endsWith: + case Matcher.contains: + case Matcher.isGreater: + case Matcher.isGreaterOrEqual: + case Matcher.isLesser: + case Matcher.isLesserOrEqual: + break; + } + return (_) => false; +} + +/// Returns `true` if [target] is numeric. +bool isNumeric(String target) => num.tryParse(target) != null; + +/// Returns `true` if [subject] falls within the range bound by [minimum] +/// (inclusive) and [maximum] (inclusive). +bool isInRange(num subject, num minimum, num maximum) => + minimum <= subject || subject <= maximum; diff --git a/lib/src/lexer.dart b/lib/src/lexer.dart deleted file mode 100644 index ff4db80..0000000 --- a/lib/src/lexer.dart +++ /dev/null @@ -1,358 +0,0 @@ -import 'package:text_expressions/src/choices.dart'; -import 'package:text_expressions/src/parser.dart'; -import 'package:text_expressions/src/symbols.dart'; -import 'package:text_expressions/src/tokens.dart'; - -/// The lexer handles the breaking of strings into singular `Tokens`s and -/// `Symbol`s for the purpose of fine-grained control over parsing. -class Lexer { - /// Instance of the `Parser` by whom this `Lexer` is employed. - final Parser parser; - - /// Creates an instance of `Lexer`, passing in the parser it is employed by. - Lexer(this.parser); - - /// Extracts a `List` of `Tokens` from [target]. - List getTokens(String target) { - final tokens = []; - - // In order to break the string down correctly into tokens, the parser - // must see exactly where each symbol lies in the string. - final symbolsWithPositions = getSymbols(target); - - // How deeply nested the current symbol being parsed is. - var nestingLevel = 0; - // Used for obtaining substrings of the subject string. - var lastSymbolPosition = 0; - // Used for obtaining substrings of choices. - var lastChoicePosition = 0; - - // Iterate over symbols, finding and extracting tokens. - for (final symbolWithPosition in symbolsWithPositions) { - TokenType? tokenType; - String? content; - - final symbol = symbolWithPosition.object; - - switch (symbol) { - case Symbol.externalOpen: - case Symbol.expressionOpen: - case Symbol.parameterOpen: - tokenType = TokenType.text; - - if (nestingLevel == 0 && lastChoicePosition == 0) { - final precedingString = target.substring( - lastSymbolPosition, - symbolWithPosition.position, - ); - if (precedingString.isNotEmpty) { - content = precedingString; - } - lastSymbolPosition = symbolWithPosition.position + 1; - } - - nestingLevel++; - break; - case Symbol.externalClosed: - tokenType = TokenType.external; - continue closed; - case Symbol.expressionClosed: - tokenType = TokenType.expression; - continue closed; - closed: - case Symbol.parameterClosed: - tokenType ??= TokenType.parameter; - - if (nestingLevel == 1 && lastChoicePosition == 0) { - content = target.substring( - lastSymbolPosition, - symbolWithPosition.position, - ); - lastSymbolPosition = symbolWithPosition.position + 1; - } - - nestingLevel--; - break; - case Symbol.choiceIntroducer: - if (nestingLevel == 0 && lastChoicePosition == 0) { - lastChoicePosition = symbolWithPosition.position + 1; - } - break; - case Symbol.choiceSeparator: - tokenType = TokenType.choice; - - if (nestingLevel == 0 && lastChoicePosition != 0) { - content = target - .substring(lastChoicePosition, symbolWithPosition.position) - .trim(); - lastChoicePosition = symbolWithPosition.position + 1; - } - break; - case Symbol.endOfString: - if (lastSymbolPosition == target.length) { - break; - } - - if (lastChoicePosition == 0) { - tokenType = TokenType.text; - content = target.substring(lastSymbolPosition); - break; - } - - tokenType = TokenType.choice; - content = target.substring(lastChoicePosition).trim(); - break; - case Symbol.choiceResultDivider: - case Symbol.argumentOpen: - case Symbol.argumentClosed: - break; - } - - if (tokenType != null && content != null) { - tokens.add(Token(this, tokenType, content)); - } - } - - return tokens; - } - - /// Extracts a `List` of `Symbols` from [target]. - List> getSymbols(String target) { - final symbols = >[]; - - for (var position = 0; position < target.length; position++) { - final symbol = Symbol.fromCharacter(target[position]); - - if (symbol != null) { - symbols.add(WithPosition(symbol, position)); - } - } - - symbols.add(WithPosition(Symbol.endOfString, target.length - 1)); - - return symbols; - } - - /// Extracts a `List` of `Choices` from [tokens]. - List getChoices(List tokens) { - final choices = []; - - for (final token in tokens.where( - (token) => token.type == TokenType.choice, - )) { - // Split case into operable parts. - final parts = token.content.split(Symbol.choiceResultDivider.character); - - // The first part of a case is the command. - final conditionRaw = parts.removeAt(0); - - // The other parts of a case are the result. - final resultRaw = parts.join(Symbol.choiceResultDivider.character); - - var matcher = Matcher.always; - final arguments = []; - final result = resultRaw; - - if (conditionRaw.contains(Symbol.argumentOpen.character)) { - final commandParts = conditionRaw.split(Symbol.argumentOpen.character); - if (commandParts.length > 2) { - throw const FormatException( - 'Could not parse choice: Expected a command and optional arguments ' - 'inside parentheses, but found multiple parentheses.', - ); - } - - final command = commandParts[0]; - matcher = Matcher.fromString(command) ?? Matcher.always; - - final argumentsString = - commandParts[1].substring(0, commandParts[1].length - 1); - arguments.addAll( - argumentsString.contains(',') - ? argumentsString.split(',') - : argumentsString.split('-'), - ); - } else { - matcher = Matcher.fromString(conditionRaw) ?? Matcher.equals; - - arguments.add(conditionRaw); - } - - choices.add( - Choice( - condition: constructCondition(matcher, arguments), - result: result, - ), - ); - } - - return choices; - } - - /// Taking the [matcher] and the [arguments] passed into it, construct a - /// `Condition` that must be met for a `Choice` to be matched to the control - /// variable of an expression. - Condition constructCondition( - Matcher matcher, - List arguments, - ) { - switch (matcher) { - case Matcher.always: - return (_) => true; - - case Matcher.startsWith: - return (var control) => arguments.any(control.startsWith); - case Matcher.endsWith: - return (var control) => arguments.any(control.endsWith); - case Matcher.contains: - return (var control) => arguments.any(control.contains); - case Matcher.equals: - return (var control) => - arguments.any((argument) => control == argument); - - case Matcher.isGreater: - case Matcher.isGreaterOrEqual: - case Matcher.isLesser: - case Matcher.isLesserOrEqual: - final argumentsAreNumeric = arguments.map(isNumeric); - if (argumentsAreNumeric.contains(false)) { - throw FormatException( - ''' -Could not construct mathematical condition: '${matcher.name}' requires that its argument(s) be numeric. -One of the provided arguments $arguments is not numeric, and thus is not parsable as a number. - -To prevent runtime exceptions, the condition has been set to evaluate to `false`.''', - ); - } - - final argumentsAsNumbers = arguments.map(num.parse); - final mathematicalConditions = argumentsAsNumbers.map( - (argument) => constructMathematicalCondition( - matcher, - argument, - ), - ); - - return (var control) { - if (!isNumeric(control)) { - return false; - } - final controlVariableAsNumber = num.parse(control); - return mathematicalConditions.any( - (condition) => condition.call(controlVariableAsNumber), - ); - }; - - case Matcher.isInGroup: - case Matcher.isNotInGroup: - case Matcher.isInRange: - case Matcher.isNotInRange: - final numberOfNumericArguments = arguments.fold( - 0, - (previousValue, argument) => - isNumeric(argument) ? previousValue + 1 : previousValue, - ); - final isTypeMismatch = numberOfNumericArguments != 0 && - numberOfNumericArguments != arguments.length; - - if (isTypeMismatch) { - throw const FormatException( - 'Could not construct a set condition: All arguments must be of the ' - 'same type.', - ); - } - - final rangeType = numberOfNumericArguments == 0 ? String : num; - // If the character is a number, parse it, otherwise get its position - // within the [characters] array. - final getNumericValue = - rangeType is String ? characters.indexOf : num.parse; - - final argumentsAsNumbers = arguments.map(getNumericValue); - final setCondition = constructSetCondition( - matcher, - argumentsAsNumbers, - ); - - return (var control) { - if (!isNumeric(control)) { - return false; - } - final controlVariableAsNumber = num.parse(control); - return setCondition(controlVariableAsNumber); - }; - } - } - - /// Construct a `Condition` based on mathematical checks. - Condition constructMathematicalCondition( - Matcher matcher, - num argument, - ) { - switch (matcher) { - case Matcher.isGreater: - return (var control) => control > argument; - case Matcher.isGreaterOrEqual: - return (var control) => control >= argument; - case Matcher.isLesser: - return (var control) => control < argument; - case Matcher.isLesserOrEqual: - return (var control) => control <= argument; - case Matcher.always: - case Matcher.equals: - case Matcher.startsWith: - case Matcher.endsWith: - case Matcher.contains: - case Matcher.isInGroup: - case Matcher.isNotInGroup: - case Matcher.isInRange: - case Matcher.isNotInRange: - break; - } - return (_) => false; - } - - /// Construct a `Condition` based on set checks. - Condition constructSetCondition( - Matcher matcher, - Iterable arguments, - ) { - switch (matcher) { - case Matcher.isInGroup: - return (var control) => arguments.contains(control); - case Matcher.isNotInGroup: - return (var control) => !arguments.contains(control); - case Matcher.isInRange: - return (var control) => isInRange( - control, - arguments.elementAt(0), - arguments.elementAt(1), - ); - case Matcher.isNotInRange: - return (var control) => !isInRange( - control, - arguments.elementAt(0), - arguments.elementAt(1), - ); - case Matcher.always: - case Matcher.equals: - case Matcher.startsWith: - case Matcher.endsWith: - case Matcher.contains: - case Matcher.isGreater: - case Matcher.isGreaterOrEqual: - case Matcher.isLesser: - case Matcher.isLesserOrEqual: - break; - } - return (_) => false; - } - - /// Returns `true` if [target] is numeric. - bool isNumeric(String target) => num.tryParse(target) != null; - - /// Returns `true` if [subject] falls within the range bound by [minimum] - /// (inclusive) and [maximum] (inclusive). - bool isInRange(num subject, num minimum, num maximum) => - minimum <= subject || subject <= maximum; -} diff --git a/lib/src/parser.dart b/lib/src/parser.dart index cfbee96..2b49832 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -1,5 +1,8 @@ +import 'package:text_expressions/src/choices.dart'; import 'package:text_expressions/src/exceptions.dart'; -import 'package:text_expressions/src/lexer.dart'; +import 'package:text_expressions/src/symbols.dart'; +import 'package:text_expressions/src/tokens.dart'; +import 'package:text_expressions/src/utils.dart'; /// Map of letters used for range checks. const characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; @@ -9,32 +12,28 @@ const characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; /// for expressions to be defined externally and 'included' in a phrase through /// the use of acute angles '<>'. class Parser { - /// Instace of `Lexer` for breaking phrases into their parsable components. - late final Lexer lexer; - /// Map of keys and their corresponding expressions. final Map phrases = {}; - /// Creates an instance of an expression parser. - Parser() { - lexer = Lexer(this); - } - /// Loads a new set of [phrases] into the parser, clearing the previous set. void load({required Map phrases}) => this.phrases ..clear() ..addAll(phrases); - /// Takes [phrase], tokenises it, parses each `Token` and returns the - /// accumulation of the parsed tokens as a string. - String parse(String phrase, Arguments arguments) => - lexer.getTokens(phrase).map((token) => token.parse(arguments)).join(); - /// Takes [key], retrieves the phrase associated with [key] and parses it. + @Deprecated('Use `process()` instead') String parseKey( String key, { Map named = const {}, Set positional = const {}, + }) => + process(key, named: named, positional: positional); + + /// Takes [key], retrieves the phrase associated with [key] and parses it. + String process( + String key, { + Map named = const {}, + Set positional = const {}, }) { if (!phrases.containsKey(key)) { throw MissingKeyException('Could not parse phrase', key); @@ -42,7 +41,125 @@ class Parser { final phrase = phrases[key]!; - return parse(phrase, Arguments(named, positional)); + return _process(phrase, Arguments(named, positional)); + } + + /// Takes [phrase], tokenises it, parses each `Token` and returns the + /// accumulation of the parsed tokens as a string. + String _process(String phrase, Arguments arguments) => + getTokens(phrase).map((token) => _process(phrase, arguments)).join(); + + /// Taking a [token] and an [arguments] object, processes the [token] and + /// returns the produced `String`. + String _processToken(Token token, Arguments arguments) { + switch (token.type) { + case TokenType.external: + return _processExternalClause(token, arguments); + case TokenType.expression: + return _processExpressionClause(token, arguments); + case TokenType.parameter: + return _processParameterClause(token, arguments); + case TokenType.text: + return token.content; + case TokenType.choice: + throw const ParserException( + 'Could not parse phrase', + 'Choices cannot be parsed as stand-alone entities.', + ); + } + } + + /// Fetches the external phrase from [Parser.phrases]. If the phrase is an + /// expression, it is first parsed, and then returned. + String _processExternalClause(Token token, Arguments arguments) { + if (!phrases.containsKey(token.content)) { + throw MissingKeyException( + 'Could not parse external phrase', + token.content, + ); + } + + final phrase = phrases[token.content].toString(); + + if (!isExpression(phrase)) { + return phrase; + } + + return _processExpressionClause(token, arguments, phrase); + } + + // TODO(vxern): Document. + String _processExpressionClause( + Token token, + Arguments arguments, [ + String? phrase, + ]) { + // Remove the surrounding brackets to leave just the content. + final phraseContent = + phrase != null ? phrase.substring(1, phrase.length - 1) : token.content; + + final tokens = getTokens(phraseContent); + + final controlVariable = _processToken(tokens.removeAt(0), arguments); + final choices = getChoices(tokens); + + final matchedChoice = + choices.firstWhereOrNull((choice) => choice.isMatch(controlVariable)); + + if (matchedChoice != null) { + return _process(matchedChoice.result, arguments); + } + + throw ParserException( + 'Could not parse expression', + "The control variable '$controlVariable' " + 'does not match any choice defined inside the expression.', + ); + } + + // TODO(vxern): Document. + String _processParameterClause(Token token, Arguments arguments) { + if (isInteger(token.content)) { + return _processPositionalParameter(token, arguments); + } + + return _processNamedParameter(token, arguments); + } + + // TODO(vxern): Document. + String _processNamedParameter(Token token, Arguments arguments) { + if (!arguments.named.containsKey(token.content)) { + throw ParserException( + 'Could not parse a named parameter', + "An argument with the name '${token.content}' hadn't been supplied to " + 'the parser at the time of parsing the named parameter of the same ' + 'name.', + ); + } + + return arguments.named[token.content].toString(); + } + + // TODO(vxern): Document. + String _processPositionalParameter(Token token, Arguments arguments) { + final index = int.parse(token.content); + + if (index < 0) { + throw const ParserException( + 'Could not parse a positional parameter', + 'The index must not be negative.', + ); + } + + if (index >= arguments.positional.length) { + throw ParserException( + 'Could not parse a positional parameter', + 'Attempted to access an argument at position $index, but ' + '${arguments.positional.length} argument(s) were supplied.', + ); + } + + return arguments.positional.elementAt(index).toString(); } } @@ -57,3 +174,12 @@ class Arguments { /// Creates an instance of a container for arguments passed into the parser. const Arguments(this.named, this.positional); } + +/// Checks if [phrase] is an expression, which needs to be 'included' in the +/// main phrase. +bool isExpression(String phrase) => + phrase.startsWith(Symbol.expressionOpen.character) && + phrase.endsWith(Symbol.expressionClosed.character); + +/// Returns `true` if [target] is an integer. +bool isInteger(String target) => int.tryParse(target) != null; diff --git a/lib/src/symbols.dart b/lib/src/symbols.dart index 0d7c29e..ef55e2a 100644 --- a/lib/src/symbols.dart +++ b/lib/src/symbols.dart @@ -70,3 +70,20 @@ class WithPosition { /// [position] relative to its parent object. const WithPosition(this.object, this.position); } + +/// Extracts a `List` of `Symbols` from [target]. +List> getSymbols(String target) { + final symbols = >[]; + + for (var position = 0; position < target.length; position++) { + final symbol = Symbol.fromCharacter(target[position]); + + if (symbol != null) { + symbols.add(WithPosition(symbol, position)); + } + } + + symbols.add(WithPosition(Symbol.endOfString, target.length - 1)); + + return symbols; +} diff --git a/lib/src/tokens.dart b/lib/src/tokens.dart index 4d10b32..1f69c5e 100644 --- a/lib/src/tokens.dart +++ b/lib/src/tokens.dart @@ -1,14 +1,12 @@ +import 'package:text_expressions/src/choices.dart'; import 'package:text_expressions/src/exceptions.dart'; -import 'package:text_expressions/src/lexer.dart'; import 'package:text_expressions/src/parser.dart'; import 'package:text_expressions/src/symbols.dart'; +import 'package:text_expressions/src/utils.dart'; /// A representation of a part of a string which needs different handling /// of [content] based on its [type]. class Token { - /// Instance of the `Lexer` working with this token. - final Lexer lexer; - /// Identifies the [content] as being of a certain type, and is used to /// decide how [content] should be parsed. final TokenType type; @@ -18,126 +16,7 @@ class Token { /// Creates an instance of `Token` with the passed [type] and optional /// [content]. - const Token(this.lexer, this.type, this.content); - - /// Parses this token by calling the correct parsing function corresponding to - /// the [type] of this token. - String parse(Arguments arguments) { - switch (type) { - case TokenType.external: - return parseExternal(arguments); - case TokenType.expression: - return parseExpression(arguments); - case TokenType.parameter: - return parseParameter(arguments); - case TokenType.text: - return parseText(); - case TokenType.choice: - throw const ParserException( - 'Could not parse phrase', - 'Choices cannot be parsed as stand-alone entities.', - ); - } - } - - /// Fetches the external phrase from [Parser.phrases]. If the phrase is an - /// expression, it is first parsed, and then returned. - String parseExternal(Arguments arguments) { - if (!lexer.parser.phrases.containsKey(content)) { - throw MissingKeyException('Could not parse external phrase', content); - } - - final phrase = lexer.parser.phrases[content].toString(); - - if (!isExpression(phrase)) { - return phrase; - } - - return parseExpression(arguments, phrase); - } - - /// Resolves the expression, and returns the result. - String parseExpression(Arguments arguments, [String? phrase]) { - // Remove the surrounding brackets to leave just the content. - final phraseContent = - phrase != null ? phrase.substring(1, phrase.length - 1) : content; - - final tokens = lexer.getTokens(phraseContent); - - final controlVariable = tokens.removeAt(0).parse(arguments); - final choices = lexer.getChoices(tokens); - - final matchedChoice = - choices.firstWhereOrNull((choice) => choice.isMatch(controlVariable)); - - if (matchedChoice != null) { - return lexer.parser.parse(matchedChoice.result, arguments); - } - - throw ParserException( - 'Could not parse expression', - "The control variable '$controlVariable' " - 'does not match any choice defined inside the expression.', - ); - } - - /// Fetches the argument described by the parameter and returns its value. - String parseParameter(Arguments arguments) { - if (isInteger(content)) { - return parsePositionalParameter(arguments); - } - - if (!arguments.named.containsKey(content)) { - throw ParserException( - 'Could not parse a named parameter', - "An argument with the name '$content' hadn't been supplied to the " - 'parser at the time of parsing the named parameter of the same ' - 'name.', - ); - } - - return arguments.named[content].toString(); - } - - /// Returns the parameter described by the index ([content]) of this `Token`. - String parsePositionalParameter(Arguments arguments) { - final index = int.parse(content); - - if (index < 0) { - throw const ParserException( - 'Could not parse a positional parameter', - 'The index must not be negative.', - ); - } - - if (index >= arguments.positional.length) { - throw ParserException( - 'Could not parse a positional parameter', - 'Attempted to access an argument at position $index, but ' - '${arguments.positional.length} argument(s) were supplied.', - ); - } - - return arguments.positional.elementAt(index).toString(); - } - - /// Returns this token's [content]. - String parseText() => content; - - /// Checks if [phrase] is an external phrase, which needs to be 'included' in - /// the main phrase. - bool isExternal(String phrase) => - phrase.startsWith(Symbol.externalOpen.character) && - phrase.endsWith(Symbol.externalOpen.character); - - /// Checks if [phrase] is an expression, which needs to be 'included' in the - /// main phrase. - bool isExpression(String phrase) => - phrase.startsWith(Symbol.expressionOpen.character) && - phrase.endsWith(Symbol.expressionClosed.character); - - /// Returns `true` if [target] is an integer. - bool isInteger(String target) => int.tryParse(target) != null; + const Token(this.type, this.content); } /// The type of a token which decides how the parser will parse and @@ -161,25 +40,106 @@ enum TokenType { text, } -/// Extension on `Iterable` providing a `firstWhereOrNull()` function that -/// returns `null` if an element is not found, rather than throw `StateError`. -extension NullSafeAccess on Iterable { - /// Returns the first element that satisfies the given predicate [test]. - /// - /// If no elements satisfy [test], the result of invoking the [orElse] - /// function is returned. - /// - /// Unlike `Iterable.firstWhere()`, this function defaults to returning `null` - /// if an element is not found. - E? firstWhereOrNull(bool Function(E) test, {E Function()? orElse}) { - for (final element in this) { - if (test(element)) { - return element; - } +/// Extracts a `List` of `Tokens` from [target]. +List getTokens(String target) { + final tokens = []; + + // In order to break the string down correctly into tokens, the parser + // must see exactly where each symbol lies in the string. + final symbolsWithPositions = getSymbols(target); + + // How deeply nested the current symbol being parsed is. + var nestingLevel = 0; + // Used for obtaining substrings of the subject string. + var lastSymbolPosition = 0; + // Used for obtaining substrings of choices. + var lastChoicePosition = 0; + + // Iterate over symbols, finding and extracting tokens. + for (final symbolWithPosition in symbolsWithPositions) { + TokenType? tokenType; + String? content; + + final symbol = symbolWithPosition.object; + + switch (symbol) { + case Symbol.externalOpen: + case Symbol.expressionOpen: + case Symbol.parameterOpen: + tokenType = TokenType.text; + + if (nestingLevel == 0 && lastChoicePosition == 0) { + final precedingString = target.substring( + lastSymbolPosition, + symbolWithPosition.position, + ); + if (precedingString.isNotEmpty) { + content = precedingString; + } + lastSymbolPosition = symbolWithPosition.position + 1; + } + + nestingLevel++; + break; + case Symbol.externalClosed: + tokenType = TokenType.external; + continue closed; + case Symbol.expressionClosed: + tokenType = TokenType.expression; + continue closed; + closed: + case Symbol.parameterClosed: + tokenType ??= TokenType.parameter; + + if (nestingLevel == 1 && lastChoicePosition == 0) { + content = target.substring( + lastSymbolPosition, + symbolWithPosition.position, + ); + lastSymbolPosition = symbolWithPosition.position + 1; + } + + nestingLevel--; + break; + case Symbol.choiceIntroducer: + if (nestingLevel == 0 && lastChoicePosition == 0) { + lastChoicePosition = symbolWithPosition.position + 1; + } + break; + case Symbol.choiceSeparator: + tokenType = TokenType.choice; + + if (nestingLevel == 0 && lastChoicePosition != 0) { + content = target + .substring(lastChoicePosition, symbolWithPosition.position) + .trim(); + lastChoicePosition = symbolWithPosition.position + 1; + } + break; + case Symbol.endOfString: + if (lastSymbolPosition == target.length) { + break; + } + + if (lastChoicePosition == 0) { + tokenType = TokenType.text; + content = target.substring(lastSymbolPosition); + break; + } + + tokenType = TokenType.choice; + content = target.substring(lastChoicePosition).trim(); + break; + case Symbol.choiceResultDivider: + case Symbol.argumentOpen: + case Symbol.argumentClosed: + break; } - if (orElse != null) { - return orElse(); + + if (tokenType != null && content != null) { + tokens.add(Token(tokenType, content)); } - return null; } + + return tokens; } diff --git a/lib/src/utils.dart b/lib/src/utils.dart new file mode 100644 index 0000000..b610668 --- /dev/null +++ b/lib/src/utils.dart @@ -0,0 +1,22 @@ +/// Extension on `Iterable` providing a `firstWhereOrNull()` function that +/// returns `null` if an element is not found, rather than throw `StateError`. +extension NullSafeAccess on Iterable { + /// Returns the first element that satisfies the given predicate [test]. + /// + /// If no elements satisfy [test], the result of invoking the [orElse] + /// function is returned. + /// + /// Unlike `Iterable.firstWhere()`, this function defaults to returning `null` + /// if an element is not found. + E? firstWhereOrNull(bool Function(E) test, {E Function()? orElse}) { + for (final element in this) { + if (test(element)) { + return element; + } + } + if (orElse != null) { + return orElse(); + } + return null; + } +}