diff --git a/README.md b/README.md index 0c02130..bcf5c1a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ - [:heart: sponsor](https://github.com/sponsors/rbellens) @@ -19,45 +18,82 @@ It is partly inspired by [jsep](http://jsep.from.so/). Example 1: evaluate expression with default evaluator - // Parse expression: - Expression expression = Expression.parse("cos(x)*cos(x)+sin(x)*sin(x)==1"); - - // Create context containing all the variables and functions used in the expression - var context = { - "x": pi / 5, - "cos": cos, - "sin": sin - }; +```dart +// Parse expression: +Expression expression = Expression.parse("cos(x)*cos(x)+sin(x)*sin(x)==1"); - // Evaluate expression - final evaluator = const ExpressionEvaluator(); - var r = evaluator.eval(expression, context); +// Create context containing all the variables and functions used in the expression +var context = { + "x": pi / 5, + "cos": cos, + "sin": sin +}; +// Evaluate expression +final evaluator = const ExpressionEvaluator(); +var r = evaluator.eval(expression, context); - print(r); // = true +print(r); // = true +``` Example 2: evaluate expression with custom evaluator - // Parse expression: - Expression expression = Expression.parse("'Hello '+person.name"); +```dart +// Parse expression: +Expression expression = Expression.parse("'Hello '+person.name"); + +// Create context containing all the variables and functions used in the expression +var context = { + "person": new Person("Jane") +}; + +// The default evaluator can not handle member expressions like `person.name`. +// When you want to use these kind of expressions, you'll need to create a +// custom evaluator that implements the `evalMemberExpression` to get property +// values of an object (e.g. with `dart:mirrors` or some other strategy). +final evaluator = const MyEvaluator(); +var r = evaluator.eval(expression, context); + + +print(r); // = 'Hello Jane' +``` + + +Example 3: evaluate expression with lambdas + +```dart +// Expressions can also include lambdas. These are handled by creating a +// `MemberAccessor` that returns a `Callable` object. + +class WhereCallable extends Callable { + final List list; + WhereCallable(this.list); - // Create context containing all the variables and functions used in the expression - var context = { - "person": new Person("Jane") - }; + @override + dynamic call(ExpressionEvaluator evaluator, List args) { + var predicate = args[0] as Callable; + return list.where((e) => predicate.call(evaluator, [e]) as bool).toList(); + } +} - // The default evaluator can not handle member expressions like `person.name`. - // When you want to use these kind of expressions, you'll need to create a - // custom evaluator that implements the `evalMemberExpression` to get property - // values of an object (e.g. with `dart:mirrors` or some other strategy). - final evaluator = const MyEvaluator(); - var r = evaluator.eval(expression, context); +// Parse expression with a lambda: +var expression = Expression.parse('[1,9,2,5,3,2].where((e) => e > 2)'); +// Create an evaluator with a member accessor for `List.where`. +// The accessor for 'where' returns our custom `WhereCallable` object. +final evaluator = ExpressionEvaluator(memberAccessors: [ + MemberAccessor({ + 'where': (list) => WhereCallable(list as List), + }), +]); - print(r); // = 'Hello Jane' +// Evaluate expression: +var r = evaluator.eval(expression, {}); +print(r); // = [9, 5, 3] +``` ## Features and bugs diff --git a/lib/src/evaluator.dart b/lib/src/evaluator.dart index c33d437..2028471 100644 --- a/lib/src/evaluator.dart +++ b/lib/src/evaluator.dart @@ -74,6 +74,9 @@ class ExpressionEvaluator { if (expression is ConditionalExpression) { return evalConditionalExpression(expression, context); } + if (expression is LambdaExpression) { + return evalLambdaExpression(expression, context); + } throw ArgumentError("Unknown expression type '${expression.runtimeType}'"); } @@ -117,6 +120,9 @@ class ExpressionEvaluator { CallExpression expression, Map context) { var callee = eval(expression.callee, context); var arguments = expression.arguments.map((e) => eval(e, context)).toList(); + if (callee is Callable) { + return callee.call(this, arguments); + } return Function.apply(callee, arguments); } @@ -197,6 +203,12 @@ class ExpressionEvaluator { : eval(expression.alternate, context); } + @protected + dynamic evalLambdaExpression( + LambdaExpression expression, Map context) { + return _Lambda(expression, context); + } + @protected dynamic getMember(dynamic obj, String member) { for (var a in memberAccessors) { @@ -278,3 +290,40 @@ class _MemberAccessor implements MemberAccessor { return accessors[member]!(object); } } + +abstract class Callable { + const Callable(); + + dynamic call(ExpressionEvaluator evaluator, List args); +} + +class _Lambda implements Callable { + final LambdaExpression expression; + + /// The context in which the lambda was defined (lexical scope). + /// + /// A lambda represents a closure, which captures the context where it was + /// defined. When the lambda is called, it uses this definition-site context + /// to resolve variables, which is how closures work in Dart and most modern + /// languages. + final Map context; + + const _Lambda(this.expression, this.context); + + @override + dynamic call(ExpressionEvaluator evaluator, List args) { + var params = expression.params; + if (params.length != args.length) { + throw ArgumentError( + 'Lambda expected ${params.length} arguments, but got ${args.length}.'); + } + + var localContext = Map.from(context); + + for (var i = 0; i < params.length; i++) { + localContext[params[i].name] = args[i]; + } + + return evaluator.eval(expression.body, localContext); + } +} diff --git a/lib/src/expressions.dart b/lib/src/expressions.dart index 7b58d6b..dbb0fbc 100644 --- a/lib/src/expressions.dart +++ b/lib/src/expressions.dart @@ -102,6 +102,16 @@ class CallExpression extends SimpleExpression { String toString() => '${callee.toTokenString()}(${arguments.join(', ')})'; } +class LambdaExpression extends SimpleExpression { + final List params; + final Expression body; + + LambdaExpression(this.params, this.body); + + @override + String toString() => '(${params.join(', ')}) => $body'; +} + class UnaryExpression extends SimpleExpression { final String operator; diff --git a/lib/src/parser.dart b/lib/src/parser.dart index f709c3c..f72adf4 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -9,7 +9,7 @@ class ExpressionParser { (l) => l[1] == null ? l[0] : ConditionalExpression(l[0], l[1][0], l[1][1]))); - token.set((literal | unaryExpression | variable).cast()); + token.set((unaryExpression | variable).cast()); } // Gobbles only identifiers @@ -99,6 +99,14 @@ class ExpressionParser { mapLiteral) .cast(); + Parser get _primary => + (literal | + lambdaExpression | + group | + thisExpression | + identifier.map((v) => Variable(v))) + .cast(); + // An individual part of a binary expression: // e.g. `foo.bar(baz)`, `1`, `'abc'`, `(a % 2)` (because it's in parenthesis) final SettableParser token = undefined(); @@ -203,13 +211,28 @@ class ExpressionParser { .map((l) => Map.fromEntries(l)) .optionalWith({}); + Parser> get lambdaParameters => + (char('(').trim() & + identifier + .plusSeparated(char(','.trim())) + .map((p) => p.elements) + .castList() + .optionalWith([]) & + char(')').trim()) + .pick(1) + .cast>(); + + Parser get lambdaExpression => + (lambdaParameters.trim().seq(string('=>').trim()).seq(expression)) + .map((l) => + LambdaExpression(l[0] as List, l[2] as Expression)); + // Gobble a non-literal variable name. This variable name may include properties // e.g. `foo`, `bar.baz`, `foo['bar'].baz` // It also gobbles function calls: // e.g. `Math.acos(obj.angle)` - Parser get variable => groupOrIdentifier - .seq((memberArgument.cast() | indexArgument | callArgument).star()) - .map((l) { + Parser get variable => + _primary.seq((memberArgument.cast() | indexArgument | callArgument).star()).map((l) { var a = l[0] as Expression; var b = l[1] as List; return b.fold(a, (Expression object, argument) { @@ -234,9 +257,6 @@ class ExpressionParser { Parser get group => (char('(') & expression.trim() & char(')')).pick(1).cast(); - Parser get groupOrIdentifier => - (group | thisExpression | identifier.map((v) => Variable(v))).cast(); - Parser get memberArgument => (char('.') & identifier).pick(1).cast(); diff --git a/test/expressions_test.dart b/test/expressions_test.dart index d60f804..197f098 100644 --- a/test/expressions_test.dart +++ b/test/expressions_test.dart @@ -254,6 +254,15 @@ void main() { }); group('member expressions', () { + test('literal members', () { + var evaluator = ExpressionEvaluator(memberAccessors: [ + MemberAccessor({'length': (v) => v.length}), + MemberAccessor({'length': (v) => v.length}) + ]); + expect(evaluator.eval(Expression.parse('[1,2,3].length'), {}), 3); + expect(evaluator.eval(Expression.parse("'hello'.length"), {}), 5); + }); + test('toString member', () { var evaluator = ExpressionEvaluator(memberAccessors: [ MemberAccessor({'toString': (v) => v.toString}) @@ -323,6 +332,27 @@ void main() { expect(evaluator.eval(Expression.parse('func1( 1 )'), context), 42); expect(evaluator.eval(Expression.parse('func2( 1, 2 )'), context), 42); }); + + test('callable functions', () { + var evaluator = ExpressionEvaluator(); + var context = { + 'sum': SumCallable(), + }; + var expression = Expression.parse('sum(1, 2, 3, 4)'); + var result = evaluator.eval(expression, context); + expect(result, 10); + }); + + test('lambda functions', () { + var evaluator = ExpressionEvaluator(memberAccessors: [ + MemberAccessor({ + 'where': (list) => WhereCallable(list), + }), + ]); + var expression = Expression.parse('[1,9,2,5,3,2].where((e) => e > 2)'); + var result = evaluator.eval(expression, {}); + expect(result, [9, 5, 3]); + }); }); }); @@ -336,3 +366,41 @@ void main() { }); }); } + +class SumCallable extends Callable { + @override + dynamic call(ExpressionEvaluator evaluator, List args) { + return args.cast().fold(0, (p, e) => p + e); + } +} + +class WhereCallable extends Callable { + final List list; + + const WhereCallable(this.list); + + @override + dynamic call(ExpressionEvaluator evaluator, List args) { + if (args.length != 1) { + throw ArgumentError('where() expects one argument, got ${args.length}'); + } + final predicate = args[0]; + if (predicate is! Callable) { + throw ArgumentError( + 'Argument to where() must be a function, got ${predicate.runtimeType}'); + } + + final result = []; + for (final element in list) { + final predicateResult = predicate.call(evaluator, [element]); + if (predicateResult is! bool) { + throw ArgumentError( + 'Predicate must return a boolean value, but got ${predicateResult.runtimeType}'); + } + if (predicateResult) { + result.add(element); + } + } + return result; + } +}