diff --git a/CHANGELOG.md b/CHANGELOG.md index b927ff9..1df49d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,64 @@ +## 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: + - 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`. + - 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 - Updated SDK version from `2.12.0` to `2.17.0`. @@ -37,7 +98,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/example/translation.dart b/example/translation.dart index c3b59af..bb0ec62 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( @@ -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 df36b8a..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. @@ -16,56 +20,324 @@ 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; - /// The control variable lies within the list of arguments. - In, + /// Aliases for this matcher. + final Set aliases; - /// The control variable does not lie within the list of arguments. - NotIn, + /// Creates a `Matcher`. + const Matcher(this.name, {this.aliases = const {}}); - /// The control variable falls in the range described by the arguments. - InRange, + /// 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 does not fall in the range described by the - /// arguments. - NotInRange, + if (matcher.aliases.contains(string)) { + return 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/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 deleted file mode 100644 index 9b172ec..0000000 --- a/lib/src/lexer.dart +++ /dev/null @@ -1,371 +0,0 @@ -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'; -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); - - /// 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 symbols = 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 symbol in symbols) { - TokenType? tokenType; - String? content; - - switch (symbol.type) { - case SymbolType.ExternalOpen: - case SymbolType.ExpressionOpen: - case SymbolType.ParameterOpen: - tokenType = TokenType.Text; - - if (nestingLevel == 0 && lastChoicePosition == 0) { - final precedingString = - target.substring(lastSymbolPosition, symbol.position); - if (precedingString.isNotEmpty) { - content = precedingString; - } - lastSymbolPosition = symbol.position + 1; - } - - nestingLevel++; - break; - case SymbolType.ExternalClosed: - tokenType = TokenType.External; - continue closed; - case SymbolType.ExpressionClosed: - tokenType = TokenType.Expression; - continue closed; - closed: - case SymbolType.ParameterClosed: - tokenType ??= TokenType.Parameter; - - if (nestingLevel == 1 && lastChoicePosition == 0) { - content = target.substring(lastSymbolPosition, symbol.position); - lastSymbolPosition = symbol.position + 1; - } - - nestingLevel--; - break; - case SymbolType.ChoiceIntroducer: - if (nestingLevel == 0 && lastChoicePosition == 0) { - lastChoicePosition = symbol.position + 1; - } - break; - case SymbolType.ChoiceSeparator: - tokenType = TokenType.Choice; - - if (nestingLevel == 0 && lastChoicePosition != 0) { - content = - target.substring(lastChoicePosition, symbol.position).trim(); - lastChoicePosition = symbol.position + 1; - } - break; - case SymbolType.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; - } - - 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++) { - SymbolType? symbolType; - - 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)); - } - } - - symbols.add(Symbol(SymbolType.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(Symbols.ChoiceResultDivider); - - // 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); - - var operation = Operation.Default; - final arguments = []; - final result = resultRaw; - - 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.''', - ); - } - - final command = commandParts[0]; - operation = Operation.values - .map((operation) => operation.name) - .contains(command) - ? Operation.values.byName(command) - : Operation.Default; - - final argumentsString = - commandParts[1].substring(0, commandParts[1].length - 1); - arguments.addAll( - argumentsString.contains(',') - ? argumentsString.split(',') - : argumentsString.split('-'), - ); - } else { - operation = Operation.values - .map((operation) => operation.name) - .contains(conditionRaw) - ? Operation.values.byName(conditionRaw) - : Operation.Equals; - - arguments.add(conditionRaw); - } - - choices.add( - Choice( - condition: constructCondition(operation, arguments), - result: result, - ), - ); - } - - return choices; - } - - /// Taking the [operation] 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, - List arguments, - ) { - switch (operation) { - case Operation.Default: - return (_) => true; - - case Operation.StartsWith: - return (var control) => arguments.any(control.startsWith); - case Operation.EndsWith: - return (var control) => arguments.any(control.endsWith); - case Operation.Contains: - return (var control) => arguments.any(control.contains); - case Operation.Equals: - return (var control) => - arguments.any((argument) => control == argument); - - case Operation.Greater: - case Operation.GreaterOrEqual: - case Operation.Lesser: - case Operation.LesserOrEqual: - final argumentsAreNumeric = arguments.map(isNumeric); - if (argumentsAreNumeric.contains(false)) { - log.severe( - ''' -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); - final mathematicalConditions = argumentsAsNumbers.map( - (argument) => constructMathematicalCondition( - operation, - argument, - ), - ); - - return (var control) { - if (!isNumeric(control)) { - return false; - } - final controlVariableAsNumber = num.parse(control); - return mathematicalConditions.any( - (condition) => condition.call(controlVariableAsNumber), - ); - }; - - case Operation.In: - case Operation.NotIn: - case Operation.InRange: - case Operation.NotInRange: - final numberOfNumericArguments = arguments.fold( - 0, - (previousValue, argument) => - isNumeric(argument) ? previousValue + 1 : previousValue, - ); - final isTypeMismatch = numberOfNumericArguments != 0 && - numberOfNumericArguments != arguments.length; - - if (isTypeMismatch) { - log.severe( - ''' -Could not construct a set condition: All arguments must be of the same type.''', - ); - return (_) => false; - } - - 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( - operation, - 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( - Operation operation, - num argument, - ) { - switch (operation) { - case Operation.Greater: - return (var control) => control > argument; - case Operation.GreaterOrEqual: - return (var control) => control >= argument; - case Operation.Lesser: - return (var control) => control < argument; - case Operation.LesserOrEqual: - return (var control) => control <= argument; - } - return (_) => false; - } - - /// Construct a `Condition` based on set checks. - Condition constructSetCondition( - Operation operation, - Iterable arguments, - ) { - switch (operation) { - case Operation.In: - return (var control) => arguments.contains(control); - case Operation.NotIn: - return (var control) => !arguments.contains(control); - case Operation.InRange: - return (var control) => isInRange( - control, - arguments.elementAt(0), - arguments.elementAt(1), - ); - case Operation.NotInRange: - return (var control) => !isInRange( - control, - arguments.elementAt(0), - arguments.elementAt(1), - ); - } - 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; -} - -// ignore_for_file: missing_enum_constant_in_switch diff --git a/lib/src/parser.dart b/lib/src/parser.dart index 0e36984..2b49832 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -1,6 +1,8 @@ -import 'package:sprint/sprint.dart'; - -import 'package:text_expressions/src/lexer.dart'; +import 'package:text_expressions/src/choices.dart'; +import 'package:text_expressions/src/exceptions.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'; @@ -10,48 +12,154 @@ 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; - /// Map of keys and their corresponding expressions. final Map phrases = {}; - /// Creates an instance of an expression parser. - Parser({bool quietMode = true}) - : log = Sprint('Parser', quietMode: quietMode) { - lexer = Lexer(this, quietMode: quietMode); - } - /// 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)) { - 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]!; - 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(); } } @@ -66,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 64b15aa..ef55e2a 100644 --- a/lib/src/symbols.dart +++ b/lib/src/symbols.dart @@ -1,83 +1,89 @@ -/// 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, + argumentClosed(')'), - /// Closing bracket of an external phrase. - ExternalClosed, - - /// Opening bracket of an expression. - ExpressionOpen, - - /// Closing bracket of an expression. - ExpressionClosed, + /// Symbol indicating the end of a string. + 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; + } +} - /// Opening bracket of a parameter designator. - ParameterOpen, +/// Represents an object of type `T` with an additional [position] relative to +/// its parent object. +class WithPosition { + /// The stored object. + final T object; - /// Closing bracket of a parameter designator. - ParameterClosed, + /// Position relative to the parent object of [object]. + final int position; - /// Separates the control variable and the choices inside an expression. - ChoiceIntroducer, + /// Creates an instance of `WithPosition` with the given [object] and its + /// [position] relative to its parent object. + const WithPosition(this.object, this.position); +} - /// Separates the choices inside an expression. - ChoiceSeparator, +/// Extracts a `List` of `Symbols` from [target]. +List> getSymbols(String target) { + final symbols = >[]; - /// Symbol indicating the end of a string. - EndOfString, -} + for (var position = 0; position < target.length; position++) { + final symbol = Symbol.fromCharacter(target[position]); -/// 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; + if (symbol != null) { + symbols.add(WithPosition(symbol, position)); + } + } - /// Zero-based index of the `Symbol` inside the parent string. - final int position; + symbols.add(WithPosition(Symbol.endOfString, target.length - 1)); - /// Creates an instance of `Symbol` assigning a [type] and its [position] - /// inside the string which is being parsed. - const Symbol(this.type, this.position); + return symbols; } diff --git a/lib/src/tokens.dart b/lib/src/tokens.dart index 17151c0..1f69c5e 100644 --- a/lib/src/tokens.dart +++ b/lib/src/tokens.dart @@ -1,4 +1,5 @@ -import 'package:text_expressions/src/lexer.dart'; +import 'package:text_expressions/src/choices.dart'; +import 'package:text_expressions/src/exceptions.dart'; import 'package:text_expressions/src/parser.dart'; import 'package:text_expressions/src/symbols.dart'; import 'package:text_expressions/src/utils.dart'; @@ -6,9 +7,6 @@ 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,150 +16,130 @@ 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: - return Parser.fallback; - } - } - - /// 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)) { - 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; - } - - 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); - } - - lexer.log.severe( - ''' -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. - String parseParameter(Arguments arguments) { - if (isInteger(content)) { - return parsePositionalParameter(arguments); - } - - 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.''', - ); - return Parser.fallback; - } - - 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) { - lexer.log.severe( - ''' -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.''', - ); - return Parser.fallback; - } - - 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(Symbols.ExternalOpen) && - phrase.endsWith(Symbols.ExternalClosed); - - /// 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); - - /// 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 /// 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, +} + +/// 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(tokenType, content)); + } + } + + return tokens; } diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 7f8bcb2..b610668 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -1,7 +1,6 @@ -/// 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 { +/// 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] 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