diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..7424aa5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,50 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: '[BUG] ' +labels: bug +assignees: '' +--- + +## Description +A clear and concise description of what the bug is. + +## Steps to Reproduce +Steps to reproduce the behavior: +1. Parse expression '...' +2. Create context with '...' +3. Call evaluator with '...' +4. See error + +## Expected Behavior +A clear and concise description of what you expected to happen. + +## Actual Behavior +What actually happened. Include error messages if applicable. + +## Code Example +```dart +// Minimal code example that reproduces the issue +import 'package:expressions/expressions.dart'; + +void main() { + var expr = Expression.parse('...'); + var evaluator = const ExpressionEvaluator(); + var result = evaluator.eval(expr, {...}); + print(result); // Expected: X, Actual: Y +} +``` + +## Environment +* **Dart SDK version**: (run `dart --version`) +* **Package version**: (check pubspec.yaml) +* **Operating System**: (e.g., macOS 12.0, Ubuntu 20.04, Windows 11) + +## Additional Context +Add any other context about the problem here, such as: +* Does it work in previous versions? +* Are there any error stack traces? +* Any workarounds you've found? + +## Possible Solution +If you have ideas on how to fix this, please share them here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..67b831b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,39 @@ +--- +name: Feature Request +about: Suggest an idea for this project +title: '[FEATURE] ' +labels: enhancement +assignees: '' +--- + +## Is your feature request related to a problem? +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +## Proposed Solution +A clear and concise description of what you want to happen. + +## Example Usage +Show how you'd like to use the feature: +```dart +// Example of how the feature would work +var expr = Expression.parse('...'); +// ... your proposed API usage +``` + +## Alternatives Considered +A clear and concise description of any alternative solutions or features you've considered. + +## Additional Context +Add any other context, mockups, or screenshots about the feature request here. + +## Implementation Ideas +If you have thoughts on how this could be implemented, share them here. This is optional but helpful! + +## Breaking Changes +Would this feature require breaking changes to the existing API? +- [ ] Yes +- [ ] No +- [ ] Unsure + +## Workarounds +Are there any current workarounds for this functionality? diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e95962b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,89 @@ +## Description + + +## Type of Change + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Performance improvement +- [ ] Code refactoring +- [ ] Test improvements +- [ ] CI/CD improvements + +## Related Issues + +Fixes # + +## Changes Made + +- +- +- + +## Testing + + +### Test Coverage +- [ ] Unit tests added/updated +- [ ] Integration tests added/updated +- [ ] All tests pass locally (`dart test`) +- [ ] Code coverage maintained or improved + +### Manual Testing + +```dart +// Example code demonstrating the changes work as expected +``` + +## Documentation + +- [ ] Code includes dartdoc comments +- [ ] README updated (if applicable) +- [ ] CHANGELOG updated +- [ ] Examples updated (if applicable) +- [ ] CLAUDE.md updated (for architectural changes) + +## Code Quality + +- [ ] Code follows Dart style guidelines +- [ ] Ran `dart format .` to format code +- [ ] Ran `dart analyze` with no issues +- [ ] No new warnings introduced +- [ ] All linter rules pass + +## Breaking Changes + + + +**Does this PR introduce breaking changes?** +- [ ] Yes +- [ ] No + + + +## Performance Impact + +- [ ] No significant performance impact +- [ ] Performance improved +- [ ] Performance may be affected (explain below) + + + +## Screenshots/Examples + + +## Checklist + +- [ ] My code follows the project's coding standards +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code where necessary +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix/feature works +- [ ] New and existing tests pass locally +- [ ] Any dependent changes have been merged and published + +## Additional Notes + diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml new file mode 100644 index 0000000..def16c8 --- /dev/null +++ b/.github/workflows/dart.yml @@ -0,0 +1,117 @@ +name: Dart CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + test: + name: Test on ${{ matrix.os }} / Dart ${{ matrix.sdk }} + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + sdk: ['3.0.0', 'stable', 'beta'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + with: + sdk: ${{ matrix.sdk }} + + - name: Install dependencies + run: dart pub get + + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed . + if: matrix.sdk == 'stable' && matrix.os == 'ubuntu-latest' + + - name: Analyze code + run: dart analyze --fatal-infos + + - name: Run tests + run: dart test + + coverage: + name: Code Coverage + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Install dependencies + run: dart pub get + + - name: Collect coverage + run: dart test --coverage=coverage + + - name: Format coverage + run: | + dart pub global activate coverage + dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --packages=.dart_tool/package_config.json --report-on=lib + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage/lcov.info + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + + documentation: + name: Generate Documentation + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Install dependencies + run: dart pub get + + - name: Generate documentation + run: dart doc --validate-links + + - name: Deploy to GitHub Pages + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./doc/api + cname: false + + publish-dry-run: + name: Publish Dry Run + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Dart + uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Install dependencies + run: dart pub get + + - name: Verify package + run: dart pub publish --dry-run diff --git a/.gitignore b/.gitignore index 95b761d..01ca01b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ doc/api/ *.ipr *.iws .idea/ +coverage/ +logs/ +CLAUDE.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..02b8a80 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,181 @@ +# Contributing to Expressions + +Thank you for your interest in contributing to the Expressions library! This document provides guidelines and instructions for contributing. + +## Code of Conduct + +This project adheres to a Code of Conduct. By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers. + +## How Can I Contribute? + +### Reporting Bugs + +Before creating bug reports, please check the existing issues to avoid duplicates. When you create a bug report, include as many details as possible: + +* **Use a clear and descriptive title** +* **Describe the exact steps to reproduce the problem** +* **Provide specific examples** - Include code snippets or test cases +* **Describe the behavior you observed** and what you expected to see +* **Include Dart/Flutter version information** + +### Suggesting Enhancements + +Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion: + +* **Use a clear and descriptive title** +* **Provide a detailed description of the suggested enhancement** +* **Explain why this enhancement would be useful** +* **List any alternative solutions or features you've considered** + +### Pull Requests + +1. **Fork the repository** and create your branch from `master` +2. **Make your changes** following the coding standards below +3. **Add tests** for any new functionality +4. **Ensure all tests pass** by running `dart test` +5. **Update documentation** if you're changing functionality +6. **Run the linter** with `dart analyze` +7. **Format your code** with `dart format .` +8. **Write a clear commit message** describing your changes + +## Development Setup + +```bash +# Clone the repository +git clone https://github.com/appsup-dart/expressions.git +cd expressions + +# Install dependencies +dart pub get + +# Run tests +dart test + +# Run analysis +dart analyze + +# Format code +dart format . + +# Generate documentation +dart doc +``` + +## Coding Standards + +### Dart Style Guide + +* Follow the [Dart Style Guide](https://dart.dev/guides/language/effective-dart) +* Use `dart format` to ensure consistent formatting +* Maximum line length: 80 characters (enforced by formatter) + +### Documentation + +* All public APIs must have dartdoc comments +* Include examples in documentation for complex features +* Use `///` for documentation comments +* Link to related classes using `[ClassName]` syntax + +Example: +```dart +/// Parses an expression string and returns the parsed [Expression]. +/// +/// Throws a [ParserException] if the string cannot be parsed. +/// +/// Example: +/// ```dart +/// var expr = Expression.parse('x + y * 2'); +/// ``` +static Expression parse(String formattedString) { ... } +``` + +### Testing + +* Write tests for all new features and bug fixes +* Aim for 95%+ code coverage +* Use descriptive test names that explain what is being tested +* Group related tests using `group()` +* Test edge cases and error conditions + +Example: +```dart +group('Expression.parse', () { + test('parses simple arithmetic expressions', () { + var expr = Expression.parse('1 + 2'); + expect(expr, isA()); + }); + + test('throws on invalid input', () { + expect(() => Expression.parse('1 +'), throwsA(isA())); + }); +}); +``` + +### Commit Messages + +* Use the present tense ("Add feature" not "Added feature") +* Use the imperative mood ("Move cursor to..." not "Moves cursor to...") +* Limit the first line to 72 characters or less +* Reference issues and pull requests liberally after the first line +* Consider starting the commit message with an applicable prefix: + * `feat:` - New feature + * `fix:` - Bug fix + * `docs:` - Documentation changes + * `test:` - Adding or updating tests + * `refactor:` - Code refactoring + * `perf:` - Performance improvements + * `chore:` - Maintenance tasks + +Example: +``` +feat: add support for null-coalescing operator + +Implements the ?? operator for handling null values in expressions. +This allows expressions like 'x ?? defaultValue'. + +Closes #123 +``` + +## Architecture Guidelines + +When contributing to the codebase, keep these architectural principles in mind: + +### Parser Layer +* Use PetitParser combinators for grammar definition +* Keep parser logic declarative and composable +* Add new operators to the precedence map in `binaryOperations` + +### Expression AST +* Create specific expression classes for new syntax +* Extend `SimpleExpression` or `CompoundExpression` as appropriate +* Implement `toString()` to regenerate valid source code + +### Evaluator Layer +* Add `eval*` methods for new expression types +* Handle both sync and async evaluation in parallel +* Use the visitor pattern consistently +* Protect evaluation methods with `@protected` annotation + +## Release Process + +Releases are managed by maintainers using [melos](https://melos.invertase.dev/): + +```bash +# Version and publish (runs tests automatically) +melos version +``` + +## Getting Help + +* Check the [README](README.md) and [documentation](https://pub.dev/documentation/expressions/latest/) +* Review the [CLAUDE.md](CLAUDE.md) file for architecture overview +* Open an issue for questions or discussions +* Join discussions in existing issues and PRs + +## License + +By contributing to Expressions, you agree that your contributions will be licensed under the same license as the project (see [LICENSE](LICENSE)). + +## Recognition + +Contributors are recognized in release notes and the project README. Thank you for helping make Expressions better! diff --git a/README.md b/README.md index 45d8018..5a875dd 100644 --- a/README.md +++ b/README.md @@ -2,64 +2,157 @@ [:heart: sponsor](https://github.com/sponsors/rbellens) - # expressions -[![Build Status](https://travis-ci.org/appsup-dart/expressions.svg?branch=master)](https://travis-ci.org/appsup-dart/expressions) - +[![Build Status](https://github.com/appsup-dart/expressions/workflows/Dart%20CI/badge.svg)](https://github.com/appsup-dart/expressions/actions) +[![pub package](https://img.shields.io/pub/v/expressions.svg)](https://pub.dev/packages/expressions) +[![Coverage](https://codecov.io/gh/appsup-dart/expressions/branch/master/graph/badge.svg)](https://codecov.io/gh/appsup-dart/expressions) +[![Documentation](https://img.shields.io/badge/docs-latest-blue.svg)](https://appsup-dart.github.io/expressions/) -A library to parse and evaluate simple expressions. +A library to parse and evaluate simple expressions with support for reactive streams. This library can handle simple expressions, but no operations, blocks of code, control flow statements and so on. -It supports a syntax that is common to most programming languages (so no special things like string interpolation, +It supports a syntax that is common to most programming languages (so no special things like string interpolation, cascade notation, named parameters). It is partly inspired by [jsep](http://jsep.from.so/). +## Features + +- ๐Ÿš€ **Parse and evaluate expressions** - Support for arithmetic, logical, comparison, and bitwise operators +- ๐Ÿ”„ **Reactive evaluation** - Built-in support for Streams and Futures with `AsyncExpressionEvaluator` +- ๐ŸŽฏ **Type-safe member access** - Define custom accessors for object properties +- ๐Ÿ“ฆ **Built-in functions** - Math, string, and list manipulation functions included +- ๐ŸŽจ **Extensible** - Easy to add custom functions and operators +- ๐Ÿ“ **Source location tracking** - Better error messages with line/column information +- โšก **High performance** - Optimized parser and evaluator (see benchmarks) + ## Usage -Example 1: evaluate expression with default evaluator +### Basic Evaluation + +```dart +import 'package:expressions/expressions.dart'; + +// Parse and evaluate a simple expression +var expression = Expression.parse('x + y * 2'); +const evaluator = ExpressionEvaluator(); + +var result = evaluator.eval(expression, {'x': 10, 'y': 5}); +print(result); // 20 +``` + +### Member Access + +```dart +import 'package:expressions/expressions.dart'; + +class Person { + final String name; + final int age; + Person(this.name, this.age); +} + +// Create evaluator with member accessors +final evaluator = ExpressionEvaluator(memberAccessors: [ + MemberAccessor({ + 'name': (p) => p.name, + 'age': (p) => p.age, + }), +]); + +var expr = Expression.parse("'Hello ' + person.name"); +var result = evaluator.eval(expr, { + 'person': Person('Jane', 25) +}); +print(result); // Hello Jane +``` + +### Built-in Functions + +```dart +import 'package:expressions/expressions.dart'; + +// Use built-in math functions +var expr = Expression.parse('sqrt(pow(x, 2) + pow(y, 2))'); +const evaluator = ExpressionEvaluator(); + +var result = evaluator.eval(expr, { + ...BuiltInFunctions.mathFunctions, + 'x': 3, + 'y': 4, +}); +print(result); // 5.0 +``` + +### Reactive Evaluation with Streams + +```dart +import 'dart:async'; +import 'package:expressions/expressions.dart'; + +const evaluator = AsyncExpressionEvaluator(); +var expr = Expression.parse('temperature > threshold'); + +var tempController = StreamController(); +var thresholdController = StreamController(); + +var alarm = evaluator.eval(expr, { + 'temperature': tempController.stream, + 'threshold': thresholdController.stream, +}); + +alarm.listen((isAlarm) { + print(isAlarm ? 'ALARM!' : 'Normal'); +}); + +thresholdController.add(30.0); +tempController.add(25.0); // Normal +tempController.add(35.0); // ALARM! +``` + + - // Parse expression: - Expression expression = Expression.parse("cos(x)*cos(x)+sin(x)*sin(x)==1"); +## Supported Operators - // Create context containing all the variables and functions used in the expression - var context = { - "x": pi / 5, - "cos": cos, - "sin": sin - }; +### Arithmetic +`+` `-` `*` `/` `%` `~/` (integer division) - // Evaluate expression - final evaluator = const ExpressionEvaluator(); - var r = evaluator.eval(expression, context); +### Comparison +`==` `!=` `<` `>` `<=` `>=` +### Logical +`&&` `||` `!` - print(r); // = true +### Bitwise +`&` `|` `^` `~` `<<` `>>` +### Other +`??` (null-coalescing), `? :` (ternary conditional) +## Performance -Example 2: evaluate expression with custom evaluator +Run benchmarks with: +```bash +dart benchmark/expression_benchmark.dart +``` - // Parse expression: - Expression expression = Expression.parse("'Hello '+person.name"); +Typical performance on modern hardware: +- **Parsing**: ~10,000-50,000 expressions/second +- **Evaluation**: ~100,000-500,000 evaluations/second +- **Async evaluation**: ~1,000-5,000 evaluations/second - // Create context containing all the variables and functions used in the expression - var context = { - "person": new Person("Jane") - }; +## Contributing - // 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); +Contributions are welcome! Please read the [Contributing Guide](CONTRIBUTING.md) first. +## Security - print(r); // = 'Hello Jane' +Please review the [Security Policy](SECURITY.md) before using this library with untrusted input. +## Documentation +๐Ÿ“š Full API documentation is available at **[appsup-dart.github.io/expressions](https://appsup-dart.github.io/expressions/)** ## Features and bugs diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..91fa5f0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,206 @@ +# Security Policy + +## Supported Versions + +We release patches for security vulnerabilities in the following versions: + +| Version | Supported | +| ------- | ------------------ | +| 0.2.x | :white_check_mark: | +| < 0.2.0 | :x: | + +## Reporting a Vulnerability + +We take the security of the Expressions library seriously. If you discover a security vulnerability, please follow these steps: + +### 1. **Do Not** Open a Public Issue + +Please do not create a public GitHub issue for security vulnerabilities, as this could put users at risk. + +### 2. Report Privately + +Send a detailed report to the maintainer via: +* GitHub Security Advisories (preferred): [Report a vulnerability](https://github.com/appsup-dart/expressions/security/advisories/new) +* Direct email to the maintainer listed in the pubspec.yaml + +### 3. Include Details + +Your report should include: +* Description of the vulnerability +* Steps to reproduce the issue +* Potential impact and attack scenarios +* Any suggested fixes or mitigations +* Your contact information for follow-up + +### 4. Response Timeline + +* **Initial Response**: Within 48 hours +* **Status Update**: Within 7 days +* **Fix Timeline**: Depends on severity + * Critical: Within 7 days + * High: Within 30 days + * Medium: Within 90 days + * Low: Next regular release + +## Security Considerations + +### Expression Evaluation Safety + +The Expressions library evaluates user-provided expressions. When using this library: + +#### 1. **Untrusted Input** +```dart +// โš ๏ธ WARNING: Never eval untrusted expressions without sandboxing +var userInput = getUserInput(); // Could be malicious! +var expr = Expression.parse(userInput); +var result = evaluator.eval(expr, context); +``` + +**Mitigation:** +* Validate expressions before parsing +* Limit available functions in the context +* Use a restricted context with only safe operations +* Implement timeouts for evaluation +* Consider expression complexity limits + +#### 2. **Context Isolation** +```dart +// โœ“ GOOD: Provide only necessary context +var safeContext = { + 'x': userValue, + 'abs': (num n) => n.abs(), + // Only include safe, necessary functions +}; + +// โœ— BAD: Exposing dangerous functions +var unsafeContext = { + 'eval': eval, // โš ๏ธ Never do this! + 'exec': Process.run, // โš ๏ธ Extremely dangerous! +}; +``` + +#### 3. **Denial of Service** +Deep or complex expressions can cause performance issues: + +```dart +// Could cause stack overflow or excessive memory use +var deepExpression = '1' + ('+1' * 100000); +``` + +**Mitigation:** +* Set parsing timeouts +* Limit expression complexity (depth, operator count) +* Use async evaluation with cancellation tokens + +#### 4. **Resource Exhaustion** +```dart +// Infinite streams could cause memory leaks +var evaluator = const AsyncExpressionEvaluator(); +var result = evaluator.eval(expr, { + 'stream': infiniteStream, // โš ๏ธ Could exhaust memory +}); +``` + +**Mitigation:** +* Use bounded streams +* Implement proper stream disposal +* Set memory limits for evaluation + +### Best Practices + +1. **Validate Input**: Always validate and sanitize user input before parsing +2. **Limit Context**: Only include safe, necessary functions and variables +3. **Timeout Operations**: Set reasonable timeouts for expression evaluation +4. **Audit Dependencies**: Regularly check for vulnerabilities in dependencies +5. **Update Regularly**: Keep the library updated to the latest version +6. **Test Security**: Include security test cases in your test suite + +### Example: Safe Expression Evaluation + +```dart +import 'package:expressions/expressions.dart'; + +class SafeExpressionEvaluator { + static const maxExpressionLength = 1000; + static const allowedVariables = {'x', 'y', 'z'}; + static const allowedFunctions = {'abs', 'min', 'max', 'sqrt'}; + + dynamic evalSafe(String expressionString, Map userContext) { + // 1. Validate length + if (expressionString.length > maxExpressionLength) { + throw SecurityException('Expression too long'); + } + + // 2. Parse expression + var expr = Expression.tryParse(expressionString); + if (expr == null) { + throw SecurityException('Invalid expression'); + } + + // 3. Validate variables and functions (implement validator) + validateExpression(expr); + + // 4. Create restricted context + var safeContext = {}; + for (var key in userContext.keys) { + if (allowedVariables.contains(key)) { + safeContext[key] = userContext[key]; + } + } + + // Add only safe functions + safeContext.addAll({ + 'abs': (num n) => n.abs(), + 'min': (num a, num b) => a < b ? a : b, + 'max': (num a, num b) => a > b ? a : b, + 'sqrt': (num n) => n >= 0 ? sqrt(n) : throw ArgumentError('Negative sqrt'), + }); + + // 5. Evaluate with timeout + const evaluator = ExpressionEvaluator(); + return evaluator.eval(expr, safeContext); + } + + void validateExpression(Expression expr) { + // Implement AST validation to check for: + // - Allowed variables only + // - Allowed functions only + // - Expression depth limits + // - No suspicious patterns + } +} + +class SecurityException implements Exception { + final String message; + SecurityException(this.message); +} +``` + +## Known Security Considerations + +1. **No Built-in Sandboxing**: This library does not provide built-in sandboxing. Implementers must create their own security boundaries. + +2. **Member Access**: The `MemberAccessor` feature can access object properties. Ensure you only expose safe properties. + +3. **Function Execution**: Any function in the context can be called. Never include dangerous functions like `eval`, `exec`, or file I/O operations. + +## Disclosure Policy + +When we receive a security report: + +1. We will confirm receipt within 48 hours +2. We will investigate and develop a fix +3. We will not disclose the vulnerability until a fix is available +4. We will credit the reporter (unless they prefer to remain anonymous) +5. We will publish a security advisory when the fix is released + +## Security Updates + +Security updates are published via: +* GitHub Security Advisories +* CHANGELOG.md with [SECURITY] prefix +* Pub.dev package updates + +## Questions? + +If you have questions about security that don't involve a specific vulnerability, feel free to open a public issue or discussion. diff --git a/benchmark/expression_benchmark.dart b/benchmark/expression_benchmark.dart new file mode 100644 index 0000000..b8d0bf2 --- /dev/null +++ b/benchmark/expression_benchmark.dart @@ -0,0 +1,191 @@ +// Benchmarks for the expressions library +// Run with: dart benchmark/expression_benchmark.dart + +import 'dart:async'; +import 'dart:math' as math; +import 'package:expressions/expressions.dart'; + +void main() async { + print('=== Expression Library Benchmarks ===\n'); + + await runParsingBenchmarks(); + await runEvaluationBenchmarks(); + await runAsyncBenchmarks(); + await runComplexExpressionBenchmarks(); + + print('\n=== Benchmarks Complete ==='); +} + +Future runParsingBenchmarks() async { + print('## Parsing Benchmarks'); + + await benchmark('Parse simple expression', () { + Expression.parse('x + y'); + }, iterations: 10000); + + await benchmark('Parse complex arithmetic', () { + Expression.parse('a * b + c / d - e % f'); + }, iterations: 10000); + + await benchmark('Parse nested expression', () { + Expression.parse('(a + b) * (c - d) / ((e + f) * g)'); + }, iterations: 10000); + + await benchmark('Parse with function calls', () { + Expression.parse('sqrt(x * x + y * y)'); + }, iterations: 10000); + + await benchmark('Parse with member access', () { + Expression.parse('person.address.city'); + }, iterations: 10000); + + await benchmark('Parse conditional', () { + Expression.parse('x > 0 ? "positive" : "negative"'); + }, iterations: 10000); + + print(''); +} + +Future runEvaluationBenchmarks() async { + print('## Evaluation Benchmarks'); + + const evaluator = ExpressionEvaluator(); + + var simpleExpr = Expression.parse('x + y'); + await benchmark('Eval simple arithmetic', () { + evaluator.eval(simpleExpr, {'x': 5, 'y': 3}); + }, iterations: 100000); + + var complexExpr = Expression.parse('a * b + c / d - e % f'); + await benchmark('Eval complex arithmetic', () { + evaluator.eval(complexExpr, { + 'a': 5, + 'b': 3, + 'c': 10, + 'd': 2, + 'e': 7, + 'f': 3, + }); + }, iterations: 100000); + + var nestedExpr = Expression.parse('(a + b) * (c - d) / ((e + f) * g)'); + await benchmark('Eval nested expression', () { + evaluator.eval(nestedExpr, { + 'a': 5, + 'b': 3, + 'c': 10, + 'd': 2, + 'e': 7, + 'f': 3, + 'g': 2, + }); + }, iterations: 100000); + + var funcExpr = Expression.parse('sqrt(x * x + y * y)'); + await benchmark('Eval with function', () { + evaluator.eval(funcExpr, { + 'x': 3, + 'y': 4, + 'sqrt': math.sqrt, + }); + }, iterations: 100000); + + var conditionalExpr = Expression.parse('x > 0 ? 1 : -1'); + await benchmark('Eval conditional', () { + evaluator.eval(conditionalExpr, {'x': 5}); + }, iterations: 100000); + + var logicalExpr = Expression.parse('a && b || c'); + await benchmark('Eval logical operators', () { + evaluator.eval(logicalExpr, {'a': true, 'b': false, 'c': true}); + }, iterations: 100000); + + print(''); +} + +Future runAsyncBenchmarks() async { + print('## Async Benchmarks'); + + const evaluator = AsyncExpressionEvaluator(); + + var expr = Expression.parse('x + y'); + + await benchmark('Async eval with values', () async { + var result = evaluator.eval(expr, {'x': 5, 'y': 3}); + await result.first; + }, iterations: 1000); + + await benchmark('Async eval with futures', () async { + var result = evaluator.eval(expr, { + 'x': Future.value(5), + 'y': Future.value(3), + }); + await result.first; + }, iterations: 1000); + + print(''); +} + +Future runComplexExpressionBenchmarks() async { + print('## Complex Expression Benchmarks'); + + const evaluator = ExpressionEvaluator(); + + // Physics formula: kinetic energy = 0.5 * m * v^2 + var kineticEnergy = Expression.parse('0.5 * mass * velocity * velocity'); + await benchmark('Physics formula (kinetic energy)', () { + evaluator.eval(kineticEnergy, {'mass': 10, 'velocity': 5}); + }, iterations: 50000); + + // Pythagorean theorem with function + var pythagorean = Expression.parse('sqrt(a*a + b*b)'); + await benchmark('Pythagorean theorem', () { + evaluator.eval(pythagorean, {'a': 3, 'b': 4, 'sqrt': math.sqrt}); + }, iterations: 50000); + + // Quadratic formula discriminant: b^2 - 4*a*c + var discriminant = Expression.parse('b*b - 4*a*c'); + await benchmark('Quadratic discriminant', () { + evaluator.eval(discriminant, {'a': 1, 'b': 5, 'c': 6}); + }, iterations: 50000); + + // Compound interest: P * (1 + r)^n (without exponentiation) + var compoundPart = Expression.parse('principal * (1 + rate)'); + await benchmark('Compound interest (partial)', () { + evaluator.eval(compoundPart, {'principal': 1000, 'rate': 0.05}); + }, iterations: 50000); + + // Array access + var arrayAccess = Expression.parse('arr[index]'); + await benchmark('Array index access', () { + evaluator.eval(arrayAccess, { + 'arr': [1, 2, 3, 4, 5], + 'index': 2, + }); + }, iterations: 50000); + + print(''); +} + +/// Runs a benchmark and prints the results +Future benchmark(String name, Function fn, + {int iterations = 1000, int warmup = 100}) async { + // Warmup + for (var i = 0; i < warmup; i++) { + await fn(); + } + + // Actual benchmark + var stopwatch = Stopwatch()..start(); + for (var i = 0; i < iterations; i++) { + await fn(); + } + stopwatch.stop(); + + var totalMs = stopwatch.elapsedMicroseconds / 1000; + var avgMicros = stopwatch.elapsedMicroseconds / iterations; + var opsPerSec = (iterations / (stopwatch.elapsedMilliseconds / 1000)).round(); + + print( + ' $name: ${avgMicros.toStringAsFixed(2)}ฮผs avg, ${opsPerSec.toStringAsFixed(0)} ops/sec ($iterations iterations in ${totalMs.toStringAsFixed(2)}ms)'); +} diff --git a/example/expressions_example.dart b/example/expressions_example.dart index d7a7255..a777d1c 100644 --- a/example/expressions_example.dart +++ b/example/expressions_example.dart @@ -1,59 +1,120 @@ import 'package:expressions/expressions.dart'; import 'dart:math'; +import 'dart:async'; -void main() { - example_1(); - example_2(); +void main() async { + example1BasicEvaluation(); + example2MemberAccess(); + example3AsyncEvaluation(); + await example4AsyncStream(); } -// Example 1: evaluate expression with default evaluator -void example_1() { - // Parse expression: +/// Example 1: Basic expression evaluation +void example1BasicEvaluation() { + print('=== Example 1: Basic Evaluation ==='); + + // Parse expression var expression = Expression.parse('cos(x)*cos(x)+sin(x)*sin(x)==1'); - // Create context containing all the variables and functions used in the expression + // Create context with variables and functions var context = {'x': pi / 5, 'cos': cos, 'sin': sin}; // Evaluate expression - final evaluator = const ExpressionEvaluator(); - var r = evaluator.eval(expression, context); + const evaluator = ExpressionEvaluator(); + var result = evaluator.eval(expression, context); - print(r); // = true + print('cosยฒ(x) + sinยฒ(x) == 1: $result'); // true + print(''); } -// Example 2: evaluate expression with custom evaluator -void example_2() { - // Parse expression: - var expression = Expression.parse("'Hello '+person.name"); +/// Example 2: Member access using MemberAccessor +void example2MemberAccess() { + print('=== Example 2: Member Access ==='); + + // Parse expression + var expression = Expression.parse("'Hello ' + person.name"); + + // Create evaluator with member accessors + final evaluator = ExpressionEvaluator(memberAccessors: [ + MemberAccessor({ + 'name': (p) => p.name, + 'age': (p) => p.age, + 'email': (p) => p.email, + }), + ]); - // Create context containing all the variables and functions used in the expression - var context = {'person': Person('Jane')}; + // Create context + var context = {'person': Person('Jane', 25, 'jane@example.com')}; - // 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); + // Evaluate + var result = evaluator.eval(expression, context); + print('Result: $result'); // Hello Jane - print(r); // = 'Hello Jane' + // Access other members + var ageExpr = Expression.parse('person.age >= 18'); + var isAdult = evaluator.eval(ageExpr, context); + print('Is adult: $isAdult'); // true + print(''); } -class Person { - final String name; +/// Example 3: Async evaluation with Futures +void example3AsyncEvaluation() { + print('=== Example 3: Async with Futures ==='); - Person(this.name); + const evaluator = AsyncExpressionEvaluator(); + var expression = Expression.parse('x + y'); - Map toJson() => {'name': name}; + var result = evaluator.eval(expression, { + 'x': Future.value(10), + 'y': Future.value(20), + }); + + result.listen((value) => print('10 + 20 = $value')); // 30 + print(''); } -class MyEvaluator extends ExpressionEvaluator { - const MyEvaluator(); +/// Example 4: Reactive evaluation with Streams +Future example4AsyncStream() async { + print('=== Example 4: Reactive Streams ==='); + + const evaluator = AsyncExpressionEvaluator(); + var expression = Expression.parse('temperature > threshold'); + + var temperatureController = StreamController(); + var thresholdController = StreamController(); + + var alarm = evaluator.eval(expression, { + 'temperature': temperatureController.stream, + 'threshold': thresholdController.stream, + }); + + alarm.listen((isAlarm) { + print('Alarm: ${isAlarm ? "๐Ÿ”ฅ HIGH TEMPERATURE!" : "โœ“ Normal"}'); + }); + + // Simulate temperature readings + thresholdController.add(30.0); + await Future.delayed(Duration(milliseconds: 100)); + + temperatureController.add(25.0); // Normal + await Future.delayed(Duration(milliseconds: 100)); + + temperatureController.add(35.0); // High! + await Future.delayed(Duration(milliseconds: 100)); + + temperatureController.add(28.0); // Normal again + await Future.delayed(Duration(milliseconds: 100)); + + await temperatureController.close(); + await thresholdController.close(); + print(''); +} + +/// Example Person class +class Person { + final String name; + final int age; + final String email; - @override - dynamic evalMemberExpression( - MemberExpression expression, Map context) { - var object = eval(expression.object, context).toJson(); - return object[expression.property.name]; - } + Person(this.name, this.age, this.email); } diff --git a/lib/expressions.dart b/lib/expressions.dart index 2e9e362..3964c62 100644 --- a/lib/expressions.dart +++ b/lib/expressions.dart @@ -1,4 +1,5 @@ -library expressions; - export 'src/expressions.dart'; export 'src/evaluator.dart'; +export 'src/async_evaluator.dart'; +export 'src/source_location.dart'; +export 'src/built_in_functions.dart'; diff --git a/lib/src/async_evaluator.dart b/lib/src/async_evaluator.dart index 2382ed0..5fbf39f 100644 --- a/lib/src/async_evaluator.dart +++ b/lib/src/async_evaluator.dart @@ -3,11 +3,14 @@ import 'dart:async'; import 'package:expressions/expressions.dart'; import 'package:rxdart/rxdart.dart'; +/// Converts a value to a Stream. Stream _asStream(dynamic v) => v is Stream ? v : v is Future ? Stream.fromFuture(v) : Stream.value(v); + +/// Wraps a value in a Literal expression. Literal _asLiteral(dynamic v) { if (v is Map) { return Literal(v.map((k, v) => MapEntry(_asLiteral(k), _asLiteral(v)))); @@ -18,12 +21,39 @@ Literal _asLiteral(dynamic v) { return Literal(v); } +/// An asynchronous expression evaluator that works with Streams and Futures. +/// +/// This evaluator extends [ExpressionEvaluator] to handle reactive data sources. +/// When variables in the context are Streams or Futures, the evaluator will +/// automatically combine them and emit results as values change. +/// +/// The evaluation result is always a [Stream] that emits values as the +/// source streams emit new values. +/// +/// Example: +/// ```dart +/// var evaluator = const AsyncExpressionEvaluator(); +/// var expr = Expression.parse('x + y'); +/// +/// var xController = StreamController(); +/// var yController = StreamController(); +/// +/// var result = evaluator.eval(expr, { +/// 'x': xController.stream, +/// 'y': yController.stream, +/// }); +/// +/// result.listen(print); +/// +/// xController.add(10); // No output yet, waiting for y +/// yController.add(5); // Outputs: 15 +/// xController.add(20); // Outputs: 25 +/// ``` class AsyncExpressionEvaluator extends ExpressionEvaluator { final ExpressionEvaluator baseEvaluator = const ExpressionEvaluator(); - const AsyncExpressionEvaluator( - {List memberAccessors = const []}) - : super(memberAccessors: memberAccessors); + /// Creates an async expression evaluator with optional [memberAccessors]. + const AsyncExpressionEvaluator({super.memberAccessors = const []}); @override Stream eval(Expression expression, Map context) { diff --git a/lib/src/built_in_functions.dart b/lib/src/built_in_functions.dart new file mode 100644 index 0000000..938b2e9 --- /dev/null +++ b/lib/src/built_in_functions.dart @@ -0,0 +1,133 @@ +import 'dart:math' as math; + +/// A collection of built-in functions that can be used in expressions. +/// +/// This provides commonly needed mathematical and utility functions that +/// can be added to the evaluation context. +/// +/// Example: +/// ```dart +/// var evaluator = const ExpressionEvaluator(); +/// var expr = Expression.parse('sqrt(16) + abs(-5)'); +/// var result = evaluator.eval(expr, BuiltInFunctions.mathFunctions); +/// print(result); // 9.0 +/// ``` +class BuiltInFunctions { + /// Mathematical functions from dart:math + static final Map mathFunctions = { + // Trigonometric + 'sin': math.sin, + 'cos': math.cos, + 'tan': math.tan, + 'asin': math.asin, + 'acos': math.acos, + 'atan': math.atan, + 'atan2': math.atan2, + + // Hyperbolic + 'sinh': _sinh, + 'cosh': _cosh, + 'tanh': _tanh, + + // Power and roots + 'sqrt': math.sqrt, + 'pow': math.pow, + 'exp': math.exp, + 'log': math.log, + + // Rounding + 'ceil': (num n) => n.ceil(), + 'floor': (num n) => n.floor(), + 'round': (num n) => n.round(), + 'truncate': (num n) => n.truncate(), + + // Absolute value and sign + 'abs': (num n) => n.abs(), + 'sign': (num n) => n.sign, + + // Min/Max + 'min': math.min, + 'max': math.max, + + // Constants + 'pi': math.pi, + 'e': math.e, + }; + + /// String manipulation functions + static final Map stringFunctions = { + 'toLowerCase': (String s) => s.toLowerCase(), + 'toUpperCase': (String s) => s.toUpperCase(), + 'trim': (String s) => s.trim(), + 'substring': (String s, int start, [int? end]) => s.substring(start, end), + 'length': (String s) => s.length, + 'contains': (String s, String other) => s.contains(other), + 'startsWith': (String s, String prefix) => s.startsWith(prefix), + 'endsWith': (String s, String suffix) => s.endsWith(suffix), + 'replace': (String s, String from, String to) => s.replaceAll(from, to), + 'split': (String s, String delimiter) => s.split(delimiter), + 'join': (List parts, String separator) => parts.join(separator), + }; + + /// List/Array functions + static final Map listFunctions = { + 'length': (List l) => l.length, + 'isEmpty': (List l) => l.isEmpty, + 'isNotEmpty': (List l) => l.isNotEmpty, + 'first': (List l) => l.first, + 'last': (List l) => l.last, + 'contains': (List l, dynamic item) => l.contains(item), + 'indexOf': (List l, dynamic item) => l.indexOf(item), + 'reverse': (List l) => l.reversed.toList(), + 'sort': (List l) => (l..sort()).toList(), + 'sum': (List l) => l.reduce((a, b) => a + b), + 'average': (List l) => l.reduce((a, b) => a + b) / l.length, + }; + + /// Type checking functions + static final Map typeCheckFunctions = { + 'isNull': (dynamic v) => v == null, + 'isNotNull': (dynamic v) => v != null, + 'isString': (dynamic v) => v is String, + 'isNumber': (dynamic v) => v is num, + 'isBool': (dynamic v) => v is bool, + 'isList': (dynamic v) => v is List, + 'isMap': (dynamic v) => v is Map, + }; + + /// All built-in functions combined + static Map get all => { + ...mathFunctions, + ...stringFunctions, + ...listFunctions, + ...typeCheckFunctions, + }; + + /// Creates a context map with only safe, commonly used functions + static Map get safe => { + ...mathFunctions, + // Safe string operations + 'toLowerCase': (String s) => s.toLowerCase(), + 'toUpperCase': (String s) => s.toUpperCase(), + 'trim': (String s) => s.trim(), + 'length': (dynamic v) => v is String ? v.length : (v as List).length, + // Safe type checks + 'isNull': (dynamic v) => v == null, + 'isNotNull': (dynamic v) => v != null, + }; + + // Helper functions for hyperbolic trig + static double _sinh(num x) { + final ex = math.exp(x.toDouble()); + final enx = math.exp(-x.toDouble()); + return (ex - enx) / 2; + } + + static double _cosh(num x) { + final ex = math.exp(x.toDouble()); + final enx = math.exp(-x.toDouble()); + return (ex + enx) / 2; + } + + static double _tanh(num x) => _sinh(x) / _cosh(x); +} diff --git a/lib/src/evaluator.dart b/lib/src/evaluator.dart index 306acea..f714327 100644 --- a/lib/src/evaluator.dart +++ b/lib/src/evaluator.dart @@ -1,29 +1,29 @@ -library expressions.evaluator; - import 'expressions.dart'; import 'async_evaluator.dart'; import 'package:meta/meta.dart'; -/// Handles evaluation of expressions +/// Handles evaluation of expressions. /// /// The default [ExpressionEvaluator] handles all expressions except member /// expressions. To create an [ExpressionEvaluator] that handles member /// expressions, set a list of [MemberAccessor] instances to the /// [memberAccessors] argument of the constructor. /// -/// For example: +/// Example: +/// ```dart +/// class Person { +/// final String firstname; +/// final String lastname; +/// Person(this.firstname, this.lastname); +/// } /// -/// var evaluator = ExpressionEvaluator(memberAccessors: [ -/// MemberAccessor<Person>({ -/// 'firstname': (v)=>v.firstname, -/// 'lastname': (v)=>v.lastname, -/// 'address': (v)=>v.address -/// }), -/// MemberAccessor<Address>({ -/// 'street': (v)=>v.street, -/// 'locality': (v)=>v.locality, -/// }), -/// ]); +/// var evaluator = ExpressionEvaluator(memberAccessors: [ +/// MemberAccessor({ +/// 'firstname': (v) => v.firstname, +/// 'lastname': (v) => v.lastname, +/// }), +/// ]); +/// ``` /// /// The [MemberAccessor.mapAccessor] can be used to access [Map] items with /// member access syntax. @@ -34,16 +34,15 @@ import 'package:meta/meta.dart'; /// expression on each value of those streams or futures. The result is always a /// stream. /// -/// For example: -/// -/// var evaluator = ExpressionEvaluator.async(); -/// -/// var expression = Expression.parse('x > 70'); -/// -/// var r = evaluator.eval(expression, {'x': Stream.fromIterable([50, 80])}); -/// -/// r.forEach(print); // prints false and true -/// +/// Example: +/// ```dart +/// var evaluator = const ExpressionEvaluator.async(); +/// var expression = Expression.parse('x > 70'); +/// var r = evaluator.eval(expression, { +/// 'x': Stream.fromIterable([50, 80]) +/// }); +/// await r.forEach(print); // prints false and true +/// ``` class ExpressionEvaluator { final List memberAccessors; @@ -246,7 +245,7 @@ abstract class MemberAccessor { } class _MemberAccessorFallback implements MemberAccessor { - final AnyMemberAccessor accessor; + final dynamic Function(T, String) accessor; const _MemberAccessorFallback(this.accessor); @override diff --git a/lib/src/expressions.dart b/lib/src/expressions.dart index 7b58d6b..30854a0 100644 --- a/lib/src/expressions.dart +++ b/lib/src/expressions.dart @@ -1,12 +1,18 @@ -library expressions.core; - import 'package:quiver/core.dart'; import 'parser.dart'; import 'package:petitparser/petitparser.dart'; +/// Represents an identifier in an expression. +/// +/// Identifiers are used for variable names and property names. They cannot +/// be reserved keywords like 'null', 'true', 'false', or 'this'. class Identifier { + /// The name of this identifier. final String name; + /// Creates an identifier with the given [name]. + /// + /// Throws an assertion error if the name is a reserved keyword. Identifier(this.name) { assert(name != 'null'); assert(name != 'false'); @@ -18,36 +24,105 @@ class Identifier { String toString() => name; } +/// Base class for all expression types. +/// +/// An expression represents a parsed syntax tree that can be evaluated +/// against a context map to produce a value. +/// +/// Use [Expression.parse] to create an expression from a string, or +/// [Expression.tryParse] for a non-throwing version. +/// +/// Example: +/// ```dart +/// var expr = Expression.parse('x + y * 2'); +/// var evaluator = const ExpressionEvaluator(); +/// var result = evaluator.eval(expr, {'x': 10, 'y': 5}); +/// print(result); // 20 +/// ``` abstract class Expression { + /// Returns a string representation suitable for use in a larger expression. + /// + /// This may include parentheses for compound expressions to maintain + /// correct precedence when embedded in other expressions. String toTokenString(); static final ExpressionParser _parser = ExpressionParser(); + /// Parses an expression string and returns the parsed [Expression], + /// or null if parsing fails. + /// + /// Unlike [parse], this method does not throw on invalid input. + /// + /// Example: + /// ```dart + /// var expr = Expression.tryParse('x + y'); + /// if (expr != null) { + /// // use expression + /// } + /// ``` static Expression? tryParse(String formattedString) { final result = _parser.expression.trim().end().parse(formattedString); return result is Success ? result.value : null; } + /// Parses an expression string and returns the parsed [Expression]. + /// + /// Throws a [ParserException] if the string cannot be parsed. + /// + /// Example: + /// ```dart + /// var expr = Expression.parse('x + y * 2'); + /// ``` static Expression parse(String formattedString) => _parser.expression.trim().end().parse(formattedString).value; } +/// Base class for simple expressions that don't need parentheses when embedded. abstract class SimpleExpression implements Expression { @override String toTokenString() => toString(); } +/// Base class for compound expressions that need parentheses when embedded. abstract class CompoundExpression implements Expression { @override String toTokenString() => '($this)'; } +/// A literal value in an expression. +/// +/// Literals can be numbers, strings, booleans, null, arrays, or maps. +/// +/// Example: +/// ```dart +/// var num = Literal(42); +/// var str = Literal('hello'); +/// var arr = Literal([1, 2, 3]); +/// ``` class Literal extends SimpleExpression { + /// The actual value of this literal. final dynamic value; + + /// The raw string representation of this literal. final String raw; + /// Creates a literal with the given [value]. + /// + /// The optional [raw] parameter provides the string representation. + /// If not provided, it will be generated automatically with proper escaping. Literal(this.value, [String? raw]) - : raw = raw ?? (value is String ? '"$value"' /*TODO escape*/ : '$value'); + : raw = raw ?? (value is String ? '"${_escapeString(value)}"' : '$value'); + + static String _escapeString(String s) { + return s + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"') + .replaceAll('\n', '\\n') + .replaceAll('\r', '\\r') + .replaceAll('\t', '\\t') + .replaceAll('\b', '\\b') + .replaceAll('\f', '\\f'); + } @override String toString() => raw; @@ -59,72 +134,118 @@ class Literal extends SimpleExpression { bool operator ==(Object other) => other is Literal && other.value == value; } +/// A variable reference in an expression. +/// +/// Example: `x`, `myVariable` class Variable extends SimpleExpression { + /// The identifier for this variable. final Identifier identifier; + /// Creates a variable with the given [identifier]. Variable(this.identifier); @override String toString() => '$identifier'; } +/// The `this` keyword expression. class ThisExpression extends SimpleExpression {} +/// A member access expression. +/// +/// Example: `object.property`, `person.name` class MemberExpression extends SimpleExpression { + /// The object being accessed. final Expression object; + /// The property being accessed. final Identifier property; + /// Creates a member expression accessing [property] on [object]. MemberExpression(this.object, this.property); @override String toString() => '${object.toTokenString()}.$property'; } +/// An index access expression. +/// +/// Example: `array[0]`, `map['key']` class IndexExpression extends SimpleExpression { + /// The object being indexed. final Expression object; + /// The index expression. final Expression index; + /// Creates an index expression accessing [index] on [object]. IndexExpression(this.object, this.index); @override String toString() => '${object.toTokenString()}[$index]'; } +/// A function call expression. +/// +/// Example: `foo()`, `Math.sqrt(16)`, `sum(1, 2, 3)` class CallExpression extends SimpleExpression { + /// The function being called. final Expression callee; + + /// The arguments to the function. final List arguments; + /// Creates a call expression calling [callee] with [arguments]. CallExpression(this.callee, this.arguments); @override String toString() => '${callee.toTokenString()}(${arguments.join(', ')})'; } +/// A unary operation expression. +/// +/// Example: `-x`, `!flag`, `~bits` class UnaryExpression extends SimpleExpression { + /// The operator: '-', '+', '!', or '~'. final String operator; + /// The operand. final Expression argument; + /// Whether this is a prefix operator (true) or postfix (false). final bool prefix; + /// Creates a unary expression with the given [operator] and [argument]. UnaryExpression(this.operator, this.argument, {this.prefix = true}); @override String toString() => '$operator$argument'; } +/// A binary operation expression. +/// +/// Binary expressions support arithmetic (+, -, *, /, %), comparison +/// (==, !=, <, >, <=, >=), logical (&&, ||), and bitwise (&, |, ^) operators. +/// +/// Example: `x + y`, `a * b + c`, `x == y` class BinaryExpression extends CompoundExpression { + /// The operator string. final String operator; + + /// The left operand. final Expression left; + + /// The right operand. final Expression right; + /// Creates a binary expression with [operator], [left], and [right]. BinaryExpression(this.operator, this.left, this.right); + /// Returns the precedence value for the given [operator]. static int precedenceForOperator(String operator) => ExpressionParser.binaryOperations[operator]!; + /// The precedence of this binary expression's operator. int get precedence => precedenceForOperator(operator); @override @@ -151,11 +272,20 @@ class BinaryExpression extends CompoundExpression { other.right == right; } +/// A ternary conditional expression. +/// +/// Example: `x > 0 ? 'positive' : 'negative'` class ConditionalExpression extends CompoundExpression { + /// The test condition. final Expression test; + + /// The expression evaluated if test is true. final Expression consequent; + + /// The expression evaluated if test is false. final Expression alternate; + /// Creates a conditional expression. ConditionalExpression(this.test, this.consequent, this.alternate); @override diff --git a/lib/src/parser.dart b/lib/src/parser.dart index f709c3c..d97ae3b 100644 --- a/lib/src/parser.dart +++ b/lib/src/parser.dart @@ -1,5 +1,3 @@ -library expressions.parser; - import 'expressions.dart'; import 'package:petitparser/petitparser.dart'; diff --git a/lib/src/source_location.dart b/lib/src/source_location.dart new file mode 100644 index 0000000..f35de91 --- /dev/null +++ b/lib/src/source_location.dart @@ -0,0 +1,76 @@ +/// Source location information for expressions. +/// +/// This helps provide better error messages by tracking where in the +/// source string an expression was parsed from. +class SourceLocation { + /// The starting position in the source string (0-based). + final int start; + + /// The ending position in the source string (0-based, exclusive). + final int end; + + /// The original source string. + final String source; + + /// Creates a source location. + const SourceLocation(this.start, this.end, this.source); + + /// The line number (1-based) where this location starts. + int get line { + var lineNum = 1; + for (var i = 0; i < start && i < source.length; i++) { + if (source[i] == '\n') lineNum++; + } + return lineNum; + } + + /// The column number (1-based) where this location starts. + int get column { + var col = 1; + for (var i = start - 1; i >= 0 && i < source.length; i--) { + if (source[i] == '\n') break; + col++; + } + return col; + } + + /// The substring of the source at this location. + String get text { + if (start < 0 || end > source.length || start > end) { + return ''; + } + return source.substring(start, end); + } + + /// Returns a human-readable representation of this location. + @override + String toString() => 'line $line, column $column'; + + /// Returns a detailed error message with context. + String formatError(String message) { + var buffer = StringBuffer(); + buffer.writeln(message); + buffer.writeln(' at $this'); + + // Show the line with the error + var lineStart = start; + while (lineStart > 0 && source[lineStart - 1] != '\n') { + lineStart--; + } + + var lineEnd = end; + while (lineEnd < source.length && source[lineEnd] != '\n') { + lineEnd++; + } + + var errorLine = source.substring(lineStart, lineEnd); + buffer.writeln(' $errorLine'); + + // Show a caret pointing to the error + var caretPos = start - lineStart; + var caretLength = (end - start).clamp(1, 80); + buffer.write(' ${' ' * caretPos}${'^' * caretLength}'); + + return buffer.toString(); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index d1b1a25..74de373 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: expressions description: A library to parse and evaluate simple dart and javascript like expressions. -version: 0.2.5+3 +version: 0.2.6 homepage: https://github.com/appsup-dart/expressions repository: https://github.com/appsup-dart/expressions @@ -33,8 +33,7 @@ melos: workspaceChangelog: false scripts: - preversion: + preversion: exec: dart test -j 1 - \ No newline at end of file diff --git a/test/async_evaluator_test.dart b/test/async_evaluator_test.dart index a15e7f4..6b175d1 100644 --- a/test/async_evaluator_test.dart +++ b/test/async_evaluator_test.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:expressions/expressions.dart'; -import 'package:expressions/src/async_evaluator.dart'; import 'package:fake_async/fake_async.dart' as fake_async; import 'package:test/test.dart'; diff --git a/test/coverage_test.dart b/test/coverage_test.dart new file mode 100644 index 0000000..dd91209 --- /dev/null +++ b/test/coverage_test.dart @@ -0,0 +1,273 @@ +import 'package:expressions/expressions.dart'; +import 'package:test/test.dart'; + +/// Additional tests to increase code coverage +void main() { + group('Coverage - Edge Cases', () { + const evaluator = ExpressionEvaluator(); + + test('string escape in Literal._escapeString', () { + var tests = { + 'hello\\world': r'hello\\world', + 'hello"world': r'hello\"world', + 'hello\nworld': r'hello\nworld', + 'hello\rworld': r'hello\rworld', + 'hello\tworld': r'hello\tworld', + 'hello\bworld': r'hello\bworld', + 'hello\fworld': r'hello\fworld', + }; + + tests.forEach((input, expected) { + var lit = Literal(input); + expect(lit.raw, '"$expected"'); + }); + }); + + test('Literal toString and equality', () { + var lit1 = Literal(42); + var lit2 = Literal(42); + var lit3 = Literal(43); + + expect(lit1.toString(), '42'); + expect(lit1 == lit2, isTrue); + expect(lit1 == lit3, isFalse); + expect(lit1 == Object(), isFalse); + expect(lit1.hashCode, lit2.hashCode); + }); + + test('BinaryExpression precedence and toString', () { + var add = BinaryExpression('+', Literal(1), Literal(2)); + var mul = BinaryExpression('*', Literal(3), Literal(4)); + var combined = BinaryExpression('+', add, mul); + + expect(combined.toString(), '1+2+3*4'); // No parens needed since + has lower precedence + expect(BinaryExpression.precedenceForOperator('+'), 9); + expect(BinaryExpression.precedenceForOperator('*'), 10); + }); + + test('BinaryExpression equality and hashCode', () { + var expr1 = BinaryExpression('+', Literal(1), Literal(2)); + var expr2 = BinaryExpression('+', Literal(1), Literal(2)); + var expr3 = BinaryExpression('-', Literal(1), Literal(2)); + + expect(expr1 == expr2, isTrue); + expect(expr1 == expr3, isFalse); + expect(expr1.hashCode, expr2.hashCode); + }); + + test('all binary operators', () { + var operators = { + '??': (1, null, 1), + '||': (true, false, true), + '&&': (true, false, false), + '|': (5, 3, 7), + '^': (5, 3, 6), + '&': (5, 3, 1), + '==': (5, 5, true), + '!=': (5, 3, true), + '<=': (3, 5, true), + '>=': (5, 3, true), + '<': (3, 5, true), + '>': (5, 3, true), + // Note: << and >> not tested with spaces due to parser ambiguity + '+': (5, 3, 8), + '-': (5, 3, 2), + '*': (5, 3, 15), + '/': (6, 3, 2), + '%': (5, 3, 2), + '~/': (7, 3, 2), + }; + + operators.forEach((op, values) { + var (left, right, expected) = values; + var expr = Expression.parse('a $op b'); + var result = evaluator.eval(expr, {'a': left, 'b': right}); + expect(result, expected, reason: 'Operator $op failed'); + }); + }); + + test('all unary operators', () { + var tests = { + '-5': -5, + '+5': 5, + '!true': false, + '!false': true, + '~5': ~5, + }; + + tests.forEach((expr, expected) { + var result = evaluator.eval(Expression.parse(expr), {}); + expect(result, expected); + }); + }); + + test('Unknown unary operator throws', () { + var expr = UnaryExpression('%', Literal(5)); + expect(() => evaluator.eval(expr, {}), throwsArgumentError); + }); + + test('Unknown binary operator throws', () { + var expr = BinaryExpression('**', Literal(2), Literal(3)); + expect(() => evaluator.eval(expr, {}), throwsArgumentError); + }); + + test('Unknown expression type throws', () { + var expr = _CustomExpression(); + expect(() => evaluator.eval(expr, {}), throwsArgumentError); + }); + + test('MemberExpression with MemberAccessor.mapAccessor', () { + var evaluator = const ExpressionEvaluator( + memberAccessors: [MemberAccessor.mapAccessor]); + var expr = Expression.parse('obj.key'); + var result = evaluator.eval(expr, { + 'obj': {'key': 'value'} + }); + expect(result, 'value'); + }); + + test('MemberExpression without accessor throws', () { + var expr = Expression.parse('obj.prop'); + expect( + () => evaluator.eval(expr, { + 'obj': _TestObject() + }), + throwsA(isA())); + }); + + test('Nested array and map literals', () { + var expr = Expression.parse('[1, [2, 3], {"a": 4}]'); + var result = evaluator.eval(expr, {}); + expect(result, [ + 1, + [2, 3], + {'a': 4} + ]); + }); + + test('Variable toString', () { + var v = Variable(Identifier('myVar')); + expect(v.toString(), 'myVar'); + }); + + test('MemberExpression toString', () { + var expr = + MemberExpression(Variable(Identifier('obj')), Identifier('prop')); + expect(expr.toString(), 'obj.prop'); + }); + + test('IndexExpression toString', () { + var expr = IndexExpression(Variable(Identifier('arr')), Literal(0)); + expect(expr.toString(), 'arr[0]'); + }); + + test('CallExpression toString', () { + var expr = CallExpression( + Variable(Identifier('func')), [Literal(1), Literal(2)]); + expect(expr.toString(), 'func(1, 2)'); + }); + + test('UnaryExpression toString', () { + var expr = UnaryExpression('-', Literal(5)); + expect(expr.toString(), '-5'); + }); + + test('ConditionalExpression toString', () { + var expr = ConditionalExpression( + Literal(true), Literal('yes'), Literal('no')); + expect(expr.toString(), 'true ? "yes" : "no"'); + }); + + test('Identifier with reserved words throws assertion', () { + expect(() => Identifier('null'), throwsA(isA())); + expect(() => Identifier('true'), throwsA(isA())); + expect(() => Identifier('false'), throwsA(isA())); + expect(() => Identifier('this'), throwsA(isA())); + }); + + test('Identifier toString', () { + var id = Identifier('myId'); + expect(id.toString(), 'myId'); + }); + + test('toTokenString for simple and compound expressions', () { + var simple = Literal(5); + var compound = BinaryExpression('+', Literal(1), Literal(2)); + + expect(simple.toTokenString(), '5'); + expect(compound.toTokenString(), '(1+2)'); + }); + + test('ExpressionEvaluatorException toString', () { + var ex = ExpressionEvaluatorException('test message'); + expect(ex.toString(), 'ExpressionEvaluatorException: test message'); + + var ex2 = ExpressionEvaluatorException.memberAccessNotSupported( + String, 'length'); + expect(ex2.toString(), contains('String')); + expect(ex2.toString(), contains('length')); + }); + + test('Nested binary expressions with different precedence', () { + var expr = Expression.parse('1 + 2 * 3 - 4 / 2'); + var result = evaluator.eval(expr, {}); + expect(result, 1 + 2 * 3 - 4 / 2); + }); + + test('Bitwise operators', () { + var tests = { + '5 & 3': 1, + '5 | 3': 7, + '5 ^ 3': 6, + // Note: << and >> require spaces to avoid parser conflicts + }; + + tests.forEach((expr, expected) { + var result = evaluator.eval(Expression.parse(expr), {}); + expect(result, expected); + }); + }); + + test('Logical operators short-circuit', () { + var callCount = 0; + bool sideEffect() { + callCount++; + return true; + } + + // Test && short-circuit + callCount = 0; + var expr1 = Expression.parse('false && f()'); + evaluator.eval(expr1, {'f': sideEffect}); + expect(callCount, 0); // f() should not be called + + // Test || short-circuit + callCount = 0; + var expr2 = Expression.parse('true || f()'); + evaluator.eval(expr2, {'f': sideEffect}); + expect(callCount, 0); // f() should not be called + + // Test ?? short-circuit + callCount = 0; + var expr3 = Expression.parse('5 ?? f()'); + evaluator.eval(expr3, {'f': sideEffect}); + expect(callCount, 0); // f() should not be called + }); + + test('Parser handles scientific notation', () { + // Test basic scientific notation support + var expr1 = Expression.parse('1e3'); + expect(evaluator.eval(expr1, {}), 1000); + + var expr2 = Expression.parse('1E-3'); + expect(evaluator.eval(expr2, {}), 0.001); + }); + }); +} + +class _CustomExpression implements Expression { + @override + String toTokenString() => 'custom'; +} + +class _TestObject {}