From 6036875fa7fab33d6ba6bc816c85ea4a1cc1078f Mon Sep 17 00:00:00 2001 From: Marc Bachmann Date: Fri, 5 Sep 2025 08:49:11 +0200 Subject: [PATCH 1/5] chore: Directly use input instead of lexer to highlight error in expression --- errors.js | 4 ++-- evaluator.js | 25 ++++++++++++++----------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/errors.js b/errors.js index c7dbe80..456d598 100644 --- a/errors.js +++ b/errors.js @@ -5,7 +5,7 @@ export class ParseError extends Error { super(message) this.name = 'ParseError' - const pos = node && (node.pos ? node : nodePositionCache.get(node)) + const pos = node && (node.input ? node : nodePositionCache.get(node)) if (pos) this.message = formatErrorWithHighlight(this.message, pos) } } @@ -15,7 +15,7 @@ export class EvaluationError extends Error { super(message) this.name = 'EvaluationError' - const pos = node && (node.pos ? node : nodePositionCache.get(node)) + const pos = node && (node.input ? node : nodePositionCache.get(node)) if (pos) this.message = formatErrorWithHighlight(this.message, pos) } } diff --git a/evaluator.js b/evaluator.js index 63c356e..3a684ac 100644 --- a/evaluator.js +++ b/evaluator.js @@ -113,7 +113,7 @@ class Lexer { return this.readIdentifier() } - throw new ParseError(`Unexpected character: ${ch}`, {pos: this.pos, lexer: this}) + throw new ParseError(`Unexpected character: ${ch}`, {pos: this.pos, input: this.input}) } readNumber() { @@ -136,11 +136,14 @@ class Lexer { if (isHex) { throw new EvaluationError('Invalid hex number: unexpected dot', { pos: this.pos, - lexer: this + input: this.input }) } if (isFloat) { - throw new EvaluationError('Invalid number: multiple dots', {pos: this.pos, lexer: this}) + throw new EvaluationError('Invalid number: multiple dots', { + pos: this.pos, + input: this.input + }) } this.pos++ if (!isFloat) isFloat = true @@ -149,7 +152,7 @@ class Lexer { if (!isHex) { throw new EvaluationError('Invalid number: unexpected hex digit', { pos: this.pos, - lexer: this + input: this.input }) } } else { @@ -225,7 +228,7 @@ class Lexer { if (ch === '\n' || ch === '\r') { throw new EvaluationError('Newlines not allowed in single-quoted strings', { pos: this.pos - chars.length - 1, - lexer: this + input: this.input }) } @@ -235,7 +238,7 @@ class Lexer { if (this.pos >= this.length) { throw new EvaluationError('Unterminated escape sequence', { pos: this.pos - chars.length - 1, - lexer: this + input: this.input }) } @@ -435,7 +438,7 @@ class Lexer { if (!RESERVED.has(text)) return {type: TOKEN.IDENTIFIER, value: text, pos: start} throw new ParseError(`Reserved word not allowed as identifier: ${text}`, { pos: start, - lexer: this + input: this.input }) } } @@ -448,7 +451,7 @@ class Parser { } createNode(pos, node) { - nodePositionCache.set(node, {pos, lexer: this.lexer}) + nodePositionCache.set(node, {pos, input: this.lexer.input}) return node } @@ -456,7 +459,7 @@ class Parser { if (this.currentToken.type !== expectedType) { throw new ParseError( `Expected ${TOKEN_BY_NUMBER[expectedType]}, got ${TOKEN_BY_NUMBER[this.currentToken.type]}`, - {pos: this.currentToken.pos, lexer: this.lexer} + {pos: this.currentToken.pos, input: this.lexer.input} ) } const token = this.currentToken @@ -474,7 +477,7 @@ class Parser { if (!this.match(TOKEN.EOF)) { throw new ParseError(`Unexpected character: '${this.lexer.input[this.lexer.pos - 1]}'`, { pos: this.currentToken.pos, - lexer: this.lexer + input: this.lexer.input }) } return result @@ -672,7 +675,7 @@ class Parser { throw new ParseError(`Unexpected token: ${TOKEN_BY_NUMBER[this.currentToken.type]}`, { pos: this.currentToken.pos, - lexer: this.lexer + input: this.lexer.input }) } From 703425f951342c8d162857f98e4c2e16dbff64da Mon Sep 17 00:00:00 2001 From: Marc Bachmann Date: Fri, 5 Sep 2025 14:02:45 +0200 Subject: [PATCH 2/5] chore: Update benchmarks --- README.md | 57 +++++++++++++---------- benchmark/README.md | 108 +++++++++++++++++++++++++++++--------------- benchmark/index.js | 9 ++++ errors.js | 4 +- evaluator.js | 63 ++++++++++++-------------- 5 files changed, 146 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index e685a28..af2b554 100644 --- a/README.md +++ b/README.md @@ -400,33 +400,44 @@ This implementation is designed for high performance: Comparison with the `cel-js` package shows significant performance improvements: #### Parsing Performance -- **7x faster** on average across all expressions -- Simple literals: **2.5-6x faster** -- Complex expressions: **5-16x faster** -- Best performance on arithmetic operations and string methods +- **Average speedup: 3.38x faster** +- Range: 1.62x - 10.91x faster across different expression types +- Fastest on array creation (10.91x) and map creation (6.69x) +- Consistently faster on all 21 test expressions #### Evaluation Performance -- **12.5x faster** on average for supported operations -- Simple value access: **15-21x faster** -- Property access: **7-13x faster** -- Complex logic: **8-17x faster** - -#### Memory Usage -- **8-13x less memory** for parsed ASTs -- Number literals use 342 bytes vs 4.5KB (13x less) -- Complex expressions use 5KB vs 34KB (7x less) -- More stable memory growth patterns - -#### Combined Parse + Evaluate -- **11x faster** on average -- Simple expressions: **20x faster** -- Complex authorization checks: **6x faster** +- **Average speedup: 12.94x faster** +- Range: 5.88x - 28.54x faster +- Array creation: **28.54x faster** +- Map creation: **21.80x faster** +- Simple primitives: **15-20x faster** +- Complex authorization checks: **5.88x faster** + +#### Combined Parse + Evaluate Performance +- **Average speedup: 6.10x faster** +- Range: 1.07x - 18.92x faster +- Simple expressions: **17-19x faster** +- Complex expressions: **2-4x faster** +- List comprehensions: **3.59x faster** + +#### Specific Operation Performance Highlights + +| Operation | Parse | Evaluate | Combined | +|-----------|-------|----------|----------| +| Simple Number (42) | 5.36x | 17.88x | 17.87x | +| Simple Boolean (true) | 5.85x | 19.66x | 18.92x | +| Array Creation [1,2,3,4,5] | 10.91x | 28.54x | 13.72x | +| Map Creation {"foo": 1, ...} | 6.69x | 21.80x | 9.28x | +| Property Access (user.name) | 2.70x | 10.28x | 4.23x | +| Complex Logic | 2.24x | 11.47x | 2.21x | +| Authorization Check | 1.63x | 5.88x | 2.16x | +| List Comprehension | 2.29x | 7.46x | 3.59x | #### Feature Advantages -@marcbachmann/cel-js supports many features not available in cel-js: +@marcbachmann/cel-js supports features not available in cel-js: - ✅ String methods (`startsWith`, `endsWith`, `contains`, `matches`) - ✅ Macros (`has`, `all`, `exists`, `exists_one`, `map`, `filter`) -- ✅ Type functions (`string`, `bytes`, `timestamp`) +- ✅ Type functions (`string`, `bytes`, `timestamp`, `size`) - ✅ Bytes literals and operations - ✅ Raw strings and escape sequences - ✅ Triple-quoted strings @@ -444,8 +455,8 @@ npm run benchmark:memory node --expose-gc benchmark/memory.js ``` -**Test Environment**: Node.js v24.1.0 on Apple Silicon (M1/M2) -**Iterations**: 10,000 parse operations, 10,000 evaluate operations +**Test Environment**: Node.js v24.6.0 on Darwin ARM64 (Apple Silicon) +**Benchmark Configuration**: 10,000 iterations each for parsing and evaluation, with 5,000 warmup iterations See the [benchmark directory](./benchmark/README.md) for detailed benchmark documentation and results. diff --git a/benchmark/README.md b/benchmark/README.md index 8c37874..1f28269 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -5,29 +5,32 @@ This directory contains comprehensive benchmarks comparing the performance of @m ## Overview The benchmarks demonstrate that @marcbachmann/cel-js: -- **Parses expressions 7x faster** than cel-js (average across all expressions) -- **Evaluates expressions 12.5x faster** than cel-js (average for supported features) -- **Uses 8-13x less memory** for AST representation +- **Parses expressions 3.38x faster** on average (range: 1.62x - 10.91x) +- **Evaluates expressions 12.94x faster** on average (range: 5.88x - 28.54x) +- **Combined parse+eval 6.10x faster** on average (range: 1.07x - 18.92x) - **Supports more CEL features** including string methods, macros, and bytes operations ## Real-World Performance Examples -From actual benchmark runs on Node.js v24.1.0 (Apple Silicon): +From actual benchmark runs on Node.js v24.6.0 (Darwin ARM64): ### Simple Operations -- Number literal `42`: **21x faster** evaluation -- String literal `"hello"`: **15x faster** evaluation -- Property access `user.name`: **13x faster** evaluation +- Number literal `42`: **5.36x faster** parsing, **17.88x faster** evaluation +- Boolean literal `true`: **5.85x faster** parsing, **19.66x faster** evaluation +- String literal `"hello world"`: **2.60x faster** parsing, **15.16x faster** evaluation +- Property access `user.name`: **2.70x faster** parsing, **10.28x faster** evaluation -### Complex Operations -- Arithmetic `(a + b) * c - d / e`: **11x faster** parsing, **11x faster** evaluation -- Authorization check with multiple conditions: **5.5x faster** parsing, **8x faster** evaluation -- Nested ternary conditions: **8x faster** parsing, **15x faster** evaluation +### Collection Operations +- Array creation `[1,2,3,4,5]`: **10.91x faster** parsing, **28.54x faster** evaluation +- Map creation `{"foo": 1, ...}`: **6.69x faster** parsing, **21.80x faster** evaluation +- Array membership `"admin" in roles`: **1.91x faster** parsing, **12.07x faster** evaluation +- Map access `config["timeout"]`: **2.31x faster** parsing, **11.04x faster** evaluation -### Memory Efficiency -- Simple number: 342 bytes vs 4.5KB (13x less memory) -- Complex auth expression: 5KB vs 34KB (7x less memory) -- Better memory allocation patterns with less GC pressure +### Complex Operations +- Arithmetic `(a + b) * c - (d / e)`: **2.19x faster** parsing, **7.30x faster** evaluation +- Authorization check: **1.63x faster** parsing, **5.88x faster** evaluation +- Nested ternary conditions: **2.84x faster** parsing, **12.35x faster** evaluation +- List comprehension `filter().map()`: **2.29x faster** parsing, **7.46x faster** evaluation ## Setup @@ -96,33 +99,41 @@ node --expose-gc benchmark/memory.js Benchmark Configuration: Parse iterations: 10,000 Evaluate iterations: 10,000 - Warmup iterations: 1,000 - Test expressions: 19 + Warmup iterations: 5,000 + Test expressions: 21 -▸ String Methods - Expression: name.startsWith("John") && email.endsWith("@example.com") +Comparing against: cel-js package +Platform: darwin arm64 +Node.js: v24.6.0 + +============================================================ +EVALUATION PERFORMANCE +============================================================ + +▸ Array Creation + Expression: [1, 2, 3, 4, 5] @marcbachmann/cel-js: - Total: 28.54ms (350,387 ops/sec) - Mean: 0.003ms + Total: 0.71ms (14,046,581 ops/sec) + Mean: 0.000ms cel-js package: - Not Supported: 🔴 + Total: 20.32ms (492,192 ops/sec) + Mean: 0.002ms - Result: Only @marcbachmann/cel-js supports this expression ✅ + Result: @marcbachmann/cel-js is 28.54x faster 🚀 -▸ Simple Arithmetic - Expression: 1 + 2 * 3 +▸ String Methods + Expression: name.startsWith("John") && email.endsWith("@example.com") @marcbachmann/cel-js: - Total: 12.34ms (810,372 ops/sec) - Mean: 0.001ms + Total: 2.60ms (3,839,324 ops/sec) + Mean: 0.000ms cel-js package: - Total: 45.67ms (218,993 ops/sec) - Mean: 0.005ms + Not Supported: 🔴 - Result: @marcbachmann/cel-js is 3.70x faster 🚀 + Result: Only @marcbachmann/cel-js supports this expression ✅ ``` ## Performance Characteristics @@ -153,15 +164,40 @@ Benchmark Configuration: | Feature | @marcbachmann/cel-js | cel-js | |---------|---------------------|---------| +| **Basic Operations** | | | | Basic CEL syntax | ✅ | ✅ | -| String methods | ✅ | ❌ | -| Macros (has, all, exists) | ✅ | ❌ | -| Bytes literals | ✅ | ❌ | -| Type conversions | ✅ | ❌ | -| Raw strings | ✅ | ❌ | -| Unicode escapes | ✅ | Limited | +| Arithmetic operators | ✅ | ✅ | +| Logical operators | ✅ | ✅ | +| Comparison operators | ✅ | ✅ | +| **Advanced Features** | | | +| String methods (`startsWith`, `endsWith`, `contains`, `matches`) | ✅ | ❌ | +| Macros (`has`, `all`, `exists`, `exists_one`, `map`, `filter`) | ✅ | ❌ | +| Bytes literals (e.g., `b"hello"`) | ✅ | ❌ | +| Type conversions (`string()`, `bytes()`, `timestamp()`) | ✅ | ❌ | +| Function calls (`size()`, etc.) | ✅ | ❌ | +| Raw strings (e.g., `r"\n"`) | ✅ | ❌ | +| Unicode escapes (`\u`, `\U`) | ✅ | Limited | | Triple-quoted strings | ✅ | ❌ | +### Performance Summary Table + +| Expression Type | Parse Speedup | Evaluate Speedup | Combined Speedup | +|----------------|---------------|------------------|------------------| +| Simple Number (42) | 5.36x | 17.88x | 17.87x | +| Simple Boolean (true) | 5.85x | 19.66x | 18.92x | +| Simple String | 2.60x | 15.16x | 5.57x | +| Basic Arithmetic | 2.77x | 11.08x | 5.63x | +| Complex Arithmetic | 2.19x | 7.30x | 3.49x | +| Variable Access | 2.70x | 10.28x | 4.23x | +| Deep Property Access | 1.62x | 8.12x | 2.49x | +| Array Index Access | 3.96x | 13.54x | 6.69x | +| Array Creation | 10.91x | 28.54x | 13.72x | +| Map Creation | 6.69x | 21.80x | 9.28x | +| Logical Expression | 2.31x | 9.90x | 4.19x | +| Complex Authorization | 1.63x | 5.88x | 2.16x | +| List Comprehension | 2.29x | 7.46x | 3.59x | +| **Average** | **3.38x** | **12.94x** | **6.10x** | + ## Customizing Benchmarks You can modify test expressions and iterations by editing the respective files: diff --git a/benchmark/index.js b/benchmark/index.js index ad2a46c..5ef6fa3 100644 --- a/benchmark/index.js +++ b/benchmark/index.js @@ -80,6 +80,11 @@ let TEST_EXPRESSIONS = [ expression: '"admin" in roles', context: {roles: ['user', 'admin', 'moderator']} }, + { + name: 'Array Creation', + expression: '[1, 2, 3, 4, 5]', + context: {roles: ['user', 'admin', 'moderator']} + }, // Logical operations { @@ -132,6 +137,10 @@ let TEST_EXPRESSIONS = [ expression: 'config["timeout"] > 30 && config["retries"] <= 3', context: {config: {timeout: 60, retries: 2}} }, + { + name: 'Map Creation', + expression: '{"foo": 1, "bar": 2, "baz": 3, "test": 4}' + }, // Complex real-world example { diff --git a/errors.js b/errors.js index 456d598..da9cb0a 100644 --- a/errors.js +++ b/errors.js @@ -21,9 +21,9 @@ export class EvaluationError extends Error { } function formatErrorWithHighlight(message, position) { + if (!position?.input) return message const pos = position.pos - const input = position.lexer.input - if (!input) return message + const input = position.input let lineNum = 1 let currentPos = 0 diff --git a/evaluator.js b/evaluator.js index 3a684ac..462c083 100644 --- a/evaluator.js +++ b/evaluator.js @@ -8,38 +8,23 @@ class Lexer { this.length = input.length } - // Skip whitespace and comments - skipWhitespace() { - while (this.pos < this.length) { - switch (this.input[this.pos]) { - case ' ': - case '\t': - case '\n': - case '\r': - this.pos++ - continue - - case '/': - if (this.input[this.pos + 1] === '/') { - // Skip comment - while (this.pos < this.length && this.input[this.pos] !== '\n') this.pos++ - continue - } - } - break - } - } - // Read next token nextToken() { - this.skipWhitespace() - if (this.pos >= this.length) return {type: TOKEN.EOF, value: null} const ch = this.input[this.pos] const next = this.input[this.pos + 1] switch (ch) { + // Whitespaces + case ' ': + case '\t': + case '\n': + case '\r': + this.pos++ + return this.nextToken() + + // Operators case '=': if (next !== '=') break return {type: TOKEN.EQ, value: '==', pos: (this.pos += 2)} @@ -56,6 +41,11 @@ class Lexer { case '*': return {type: TOKEN.MULTIPLY, value: '*', pos: this.pos++} case '/': + // Skip comment + if (next === '/') { + while (this.pos < this.length && this.input[this.pos] !== '\n') this.pos++ + return this.nextToken() + } return {type: TOKEN.DIVIDE, value: '/', pos: this.pos++} case '%': return {type: TOKEN.MODULO, value: '%', pos: this.pos++} @@ -103,14 +93,16 @@ class Lexer { this.pos++ return this.readString(ch.toLowerCase()) } - } - - // Numbers (including hex with 0x prefix) - if (ch >= '0' && ch <= '9') return this.readNumber() - - // Identifiers and keywords - if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_') { - return this.readIdentifier() + return this.readIdentifier() + default: { + // Numbers (including hex with 0x prefix) + if (ch >= '0' && ch <= '9') return this.readNumber() + + // Identifiers and keywords + if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_') { + return this.readIdentifier() + } + } } throw new ParseError(`Unexpected character: ${ch}`, {pos: this.pos, input: this.input}) @@ -929,7 +921,10 @@ const handlers = { return fn.handler(...ast[2].map((arg) => s.eval(arg))) }, array(ast, s) { - return ast[1].map((el) => s.eval(el)) + const elements = ast[1] + const result = new Array(elements.length) + for (let i = 0; i < elements.length; i++) result[i] = s.eval(elements[i]) + return result }, object(ast, s) { const result = {} @@ -1001,7 +996,7 @@ class Evaluator { } eval(ast) { - if (typeof ast !== 'object' || !Array.isArray(ast)) return ast + if (!Array.isArray(ast)) return ast const handler = this.handlers[ast[0]] if (handler) return handler.call(this.handlers, ast, this) From 96548e5830292e3f98dfb7f59d7188b40a52c20b Mon Sep 17 00:00:00 2001 From: Marc Bachmann Date: Sun, 7 Sep 2025 15:04:25 +0200 Subject: [PATCH 3/5] chore: Reduce instance functions instantiation --- evaluator.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/evaluator.js b/evaluator.js index 462c083..047eab2 100644 --- a/evaluator.js +++ b/evaluator.js @@ -1189,22 +1189,26 @@ function isEqual(a, b) { } const globalEvaluator = new Evaluator() +const globalInstanceFunctions = new InstanceFunctions({}) + function evaluateAST(ast, context, instanceFunctions) { if (context !== undefined && typeof context !== 'object') throw new EvaluationError('Context must be an object') const evaluator = globalEvaluator evaluator.ctx = context - evaluator.fns = new InstanceFunctions(instanceFunctions) + evaluator.fns = instanceFunctions + ? new InstanceFunctions(instanceFunctions) + : globalInstanceFunctions + return evaluator.eval(ast) } export function parse(expression) { const ast = new Parser(expression).parse() - // eslint-disable-next-line no-shadow - const evaluate = (context, functions) => evaluateAST(ast, context, functions) - evaluate.ast = ast - return evaluate + const evaluateParsed = (context, functions) => evaluateAST(ast, context, functions) + evaluateParsed.ast = ast + return evaluateParsed } export function evaluate(expression, context, functions) { From a043358ca91098c29ffe8c0d9900200c2a7fa479 Mon Sep 17 00:00:00 2001 From: Marc Bachmann Date: Tue, 9 Sep 2025 10:49:38 +0200 Subject: [PATCH 4/5] chore: Add consistent debugType checks --- evaluator.js | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/evaluator.js b/evaluator.js index 047eab2..303ae24 100644 --- a/evaluator.js +++ b/evaluator.js @@ -786,14 +786,13 @@ const handlers = { const left = s.eval(ast[1]) const right = s.eval(ast[2]) const leftType = debugType(left) - if (leftType !== debugType(right)) { - throw new EvaluationError(`no such overload: ${leftType} + ${debugType(right)}`, ast) + const rightType = debugType(right) + if (leftType !== rightType) { + throw new EvaluationError(`no such overload: ${leftType} + ${rightType}`, ast) } switch (leftType) { case 'Number': - if (Number.isFinite(right) && Number.isFinite(left)) return left + right - break case 'String': return left + right case 'List': @@ -806,20 +805,20 @@ const handlers = { } } - throw new EvaluationError(`no such overload: ${debugType(left)} + ${debugType(right)}`, ast) + throw new EvaluationError(`no such overload: ${leftType} + ${rightType}`, ast) }, '-'(ast, s) { const left = s.eval(ast[1]) + const leftType = debugType(left) if (ast.length === 2) { - if (typeof left !== 'number') { - throw new EvaluationError(`no such overload: -${debugType(left)}`, ast) - } + if (leftType !== 'Number') throw new EvaluationError(`no such overload: -${leftType}`, ast) return -left } const right = s.eval(ast[2]) - if (typeof left !== 'number' || typeof right !== 'number') { - throw new EvaluationError(`no such overload: ${debugType(left)} - ${debugType(right)}`, ast) + const rightType = debugType(right) + if (!(leftType === 'Number' && rightType === 'Number')) { + throw new EvaluationError(`no such overload: ${leftType} - ${rightType}`, ast) } return left - right }, From bc0891c3f3e8217af78a28a89c31fce0d0d5aa1c Mon Sep 17 00:00:00 2001 From: Marc Bachmann Date: Tue, 9 Sep 2025 10:50:13 +0200 Subject: [PATCH 5/5] fix: Do not allow unary plus in the parser --- evaluator.js | 7 +----- test/unary-operators.test.js | 44 ++++++++++++++++++++++++++++-------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/evaluator.js b/evaluator.js index 303ae24..965d6bf 100644 --- a/evaluator.js +++ b/evaluator.js @@ -578,7 +578,7 @@ class Parser { return expr } - // Unary ::= ('!' | '-' | '+')* Postfix + // Unary ::= ('!' | '-')* Postfix parseUnary() { if (this.match(TOKEN.NOT) || this.match(TOKEN.MINUS)) { const token = this.currentToken @@ -587,11 +587,6 @@ class Parser { return this.createNode(token.pos, [token.value, operand]) } - if (this.match(TOKEN.PLUS)) { - this.currentToken = this.lexer.nextToken() // Skip unary + - return this.parseUnary() - } - return this.parsePostfix() } diff --git a/test/unary-operators.test.js b/test/unary-operators.test.js index f87283b..e81d739 100644 --- a/test/unary-operators.test.js +++ b/test/unary-operators.test.js @@ -38,6 +38,40 @@ describe('unary operators', () => { testThrows(`!{}`, 'NOT operator can only be applied to boolean values') }) + describe('unary plus', () => { + test('supports unary plus as operator', (t) => { + t.assert.strictEqual(evaluate('1 + 2'), 3) + t.assert.strictEqual(evaluate('1 +2'), 3) + t.assert.strictEqual(evaluate('1+2'), 3) + }) + + test('rejects unary plus in front of group', (t) => { + t.assert.throws(() => evaluate('+(1 + 2)'), { + name: 'ParseError', + message: /Unexpected token: PLUS/ + }) + }) + + test('rejects unary plus', (t) => { + t.assert.throws(() => evaluate('+2'), { + name: 'ParseError', + message: /Unexpected token: PLUS/ + }) + }) + + test('rejects unary plus after operator', (t) => { + t.assert.throws(() => evaluate('1 ++ 2'), { + name: 'ParseError', + message: /Unexpected token: PLUS/ + }) + + t.assert.throws(() => evaluate('1 + + 2'), { + name: 'ParseError', + message: /Unexpected token: PLUS/ + }) + }) + }) + describe('unary minus', () => { test('should negate positive number', (t) => { t.assert.strictEqual(evaluate('-5'), -5) @@ -64,16 +98,6 @@ describe('unary operators', () => { }) }) - describe('unary plus (should be ignored)', () => { - test('should handle unary plus with numbers', (t) => { - t.assert.strictEqual(evaluate('+5'), 5) - }) - - test('should handle unary plus with expressions', (t) => { - t.assert.strictEqual(evaluate('+(1 + 2)'), 3) - }) - }) - describe('combined unary operators', () => { test('should handle NOT and minus together', (t) => { t.assert.strictEqual(evaluate('!(-1 < 0)'), false)