diff --git a/docs/advanced-features.md b/docs/advanced-features.md index b34f8d43..19466360 100644 --- a/docs/advanced-features.md +++ b/docs/advanced-features.md @@ -73,6 +73,48 @@ parser.evaluate('$a + $b', {}); // 15 - `{ value: any }` - Return a value directly - `undefined` - Use default behavior (throws error for unknown variables) +### Per-Expression Variable Resolver + +`parser.resolve` is shared by every expression a parser produces. When you need different resolution logic for different evaluations — e.g. per-row, per-request, or per-tenant lookups — pass a resolver directly to `Expression.evaluate()` (or `parser.evaluate()`) instead of mutating `parser.resolve`. This lets a single parsed expression be reused across many calls without the cost of re-parsing or the hazards of shared mutable state. + +```js +const parser = new Parser(); +const expr = parser.parse('$user.name + " is " + $user.age'); + +// Same compiled expression, two different data sources, no parser mutation. +expr.evaluate({}, (name) => + name === '$user' ? { value: { name: 'Alice', age: 30 } } : undefined +); // 'Alice is 30' + +expr.evaluate({}, (name) => + name === '$user' ? { value: { name: 'Bob', age: 25 } } : undefined +); // 'Bob is 25' +``` + +The per-call resolver uses the same return shape as `parser.resolve` — `{ alias }`, `{ value }`, or `undefined` — and the resolution order during evaluation is: + +1. The `variables` object passed to `evaluate()` +2. The per-call `resolver` (if provided) +3. `parser.resolve` (the parser-level callback) +4. Otherwise, a `VariableError` is thrown + +Because the per-call resolver falls through to `parser.resolve` on `undefined`, the two can be layered: a parser-level resolver can provide defaults or shared lookups, while per-call resolvers handle request-specific data. + +```js +const parser = new Parser(); + +// Parser-level: shared constants that never change +parser.resolve = (name) => + name === '$pi' ? { value: Math.PI } : undefined; + +// Per-call: request-specific data +parser.parse('$pi * $radius ^ 2').evaluate({}, (name) => + name === '$radius' ? { value: 5 } : undefined +); // 78.539... +``` + +Both `Parser.evaluate(expr, variables, resolver)` and `Expression.evaluate(variables, resolver)` accept the resolver, and it propagates through nested constructs such as short-circuit `and`/`or`, the ternary `?:` operator, user-defined functions, and arrow functions. + ## Type Conversion (as Operator) The `as` operator provides type conversion capabilities. **Disabled by default.** diff --git a/docs/enhancements.md b/docs/enhancements.md index 9159fda6..eb289c37 100644 --- a/docs/enhancements.md +++ b/docs/enhancements.md @@ -22,7 +22,7 @@ This TypeScript port adds the following features over the original library: ### Developer Integration Features - **Promise support** - Custom functions can return promises (async evaluation) -- **Custom variable resolution** - `parser.resolve` callback for dynamic variable lookup +- **Custom variable resolution** - `parser.resolve` callback for dynamic variable lookup, plus a per-call `resolver` argument on `Expression.evaluate()` / `Parser.evaluate()` so a single parsed expression can be evaluated against different data sources without mutating parser state - **`as` operator** - Type conversion with customizable implementation For detailed documentation, see: diff --git a/docs/expression.md b/docs/expression.md index 81539655..18aa6b8a 100644 --- a/docs/expression.md +++ b/docs/expression.md @@ -4,7 +4,7 @@ `Parser.parse(str)` returns an `Expression` object. `Expression`s are similar to JavaScript functions, i.e. they can be "called" with variables bound to passed-in values. In fact, they can even be converted into JavaScript functions. -## evaluate(variables?: object) +## evaluate(variables?: object, resolver?: VariableResolver) Evaluate the expression, with variables bound to the values in `{variables}`. Each variable in the expression is bound to the corresponding member of the `variables` object. If there are unbound variables, `evaluate` will throw an exception. @@ -15,6 +15,21 @@ const expr = Parser.parse("2 ^ x"); console.log(expr.evaluate({ x: 3 })); // 8 ``` +The optional `resolver` argument is a per-call variable resolver. It has the same shape as `parser.resolve` — `(name) => { alias } | { value } | undefined` — but applies only to the current `evaluate()` call, so a single parsed `Expression` can be evaluated multiple times against different data sources without mutating parser state. The per-call `resolver` is consulted before `parser.resolve`; the `variables` object still takes precedence over both. + +```js +const parser = new Parser(); +const expr = parser.parse('$user.name'); + +const resolveAlice = (name) => name === '$user' ? { value: { name: 'Alice' } } : undefined; +const resolveBob = (name) => name === '$user' ? { value: { name: 'Bob' } } : undefined; + +expr.evaluate({}, resolveAlice); // 'Alice' +expr.evaluate({}, resolveBob); // 'Bob' +``` + +See [Per-Expression Variable Resolver](advanced-features.md#per-expression-variable-resolver) for details. + ## substitute(variable: string, expression: Expression | string | number) Create a new `Expression` with the specified variable replaced with another expression. This is similar to function composition. If `expression` is a string or number, it will be parsed into an `Expression`. diff --git a/docs/parser.md b/docs/parser.md index c1088121..7c9a8ee9 100644 --- a/docs/parser.md +++ b/docs/parser.md @@ -85,7 +85,7 @@ const expr = parser.parse('x * 2 + y'); Returns an [Expression](expression.md) object with methods like `evaluate()`, `simplify()`, `variables()`, etc. -### evaluate(expression: string, variables?: object) +### evaluate(expression: string, variables?: object, resolver?: VariableResolver) Parse and immediately evaluate an expression. @@ -93,6 +93,14 @@ Parse and immediately evaluate an expression. parser.evaluate('x + y', { x: 2, y: 3 }); // 5 ``` +The optional `resolver` callback is a per-call [custom variable resolver](advanced-features.md#custom-variable-name-resolution). It is tried before `parser.resolve` when a variable is not found in `variables`. + +```js +parser.evaluate('$a + $b', {}, (name) => + name.startsWith('$') ? { value: lookup(name.substring(1)) } : undefined +); // per-call resolver; parser.resolve is not mutated +``` + ## Static Methods ### Parser.parse(expression: string) @@ -103,9 +111,9 @@ Static equivalent of `new Parser().parse(expression)`. const expr = Parser.parse('x + 1'); ``` -### Parser.evaluate(expression: string, variables?: object) +### Parser.evaluate(expression: string, variables?: object, resolver?: VariableResolver) -Parse and immediately evaluate an expression. Equivalent to `Parser.parse(expr).evaluate(vars)`. +Parse and immediately evaluate an expression. Equivalent to `Parser.parse(expr).evaluate(vars, resolver)`. ```js Parser.evaluate('6 * x', { x: 7 }); // 42 @@ -202,6 +210,8 @@ The `resolve` callback should return: - `{ value: any }` - to return a value directly - `undefined` - to use default behavior (throws error for unknown variables) +For cases where different evaluations of the same parsed expression need different resolution logic, prefer passing a resolver directly to `Expression.evaluate(values, resolver)` or `parser.evaluate(expr, values, resolver)` instead of mutating `parser.resolve`. See [Per-Expression Variable Resolver](advanced-features.md#per-expression-variable-resolver). + ## Advanced Configuration ### Type Conversion (as operator) diff --git a/index.ts b/index.ts index 397a07cc..f07eb370 100644 --- a/index.ts +++ b/index.ts @@ -24,6 +24,7 @@ export type { VariableAlias, VariableValue, VariableResolveResult, + VariableResolver, OperatorFunction } from './src/types/index.js'; diff --git a/src/core/evaluate.ts b/src/core/evaluate.ts index 23e5179c..c5cf437b 100644 --- a/src/core/evaluate.ts +++ b/src/core/evaluate.ts @@ -8,7 +8,7 @@ import { ISCALAR, IOP1, IOP2, IOP3, IVAR, IVARNAME, IFUNCALL, IFUNDEF, IARROW, IEXPR, IEXPREVAL, IMEMBER, IENDSTATEMENT, IARRAY, IUNDEFINED, ICASEMATCH, IWHENMATCH, ICASEELSE, ICASECOND, IWHENCOND, IOBJECT, IPROPERTY, IOBJECTEND } from '../parsing/instruction.js'; import type { Instruction } from '../parsing/instruction.js'; import type { Expression } from './expression.js'; -import type { Value, Values, VariableResolveResult } from '../types/values.js'; +import type { Value, Values, VariableResolveResult, VariableResolver } from '../types/values.js'; import { VariableError } from '../types/errors.js'; import { ExpressionValidator } from '../validation/expression-validator.js'; @@ -44,16 +44,17 @@ type EvaluationStack = any[]; * of objects returned by the {@link Token} function. * @param expr The instance of the {@link Expression} class that invoked the evaluator. * @param values Input values provided to the expression. + * @param resolver Optional per-call variable resolver. Tried before the parser-level resolver. * @returns The return value is the expression result value or a promise that when resolved will contain * the expression result value. A promise is only returned if a caller defined function returns a promise. */ -export default function evaluate(tokens: Instruction | Instruction[], expr: Expression, values: EvaluationValues): Value | Promise { +export default function evaluate(tokens: Instruction | Instruction[], expr: Expression, values: EvaluationValues, resolver?: VariableResolver): Value | Promise { if (isExpressionEvaluator(tokens)) { return resolveExpression(tokens, values); } const nstack: EvaluationStack = []; - return runEvaluateLoop(tokens as Instruction[], expr, values, nstack); + return runEvaluateLoop(tokens as Instruction[], expr, values, nstack, 0, resolver); } /** @@ -80,11 +81,11 @@ function isPromise(obj: any): obj is Promise { * @returns The return value is the expression result value or a promise that when resolved will contain * the expression result value. A promise is only returned if a caller defined function returns a promise. */ -function runEvaluateLoop(tokens: Instruction[], expr: Expression, values: EvaluationValues, nstack: EvaluationStack, startAt: number = 0): Value | Promise { +function runEvaluateLoop(tokens: Instruction[], expr: Expression, values: EvaluationValues, nstack: EvaluationStack, startAt: number = 0, resolver?: VariableResolver): Value | Promise { const numTokens = tokens.length; for (let i = startAt; i < numTokens; i++) { const item = tokens[i]; - evaluateExpressionToken(expr, values, item, nstack); + evaluateExpressionToken(expr, values, item, nstack, resolver); const last = nstack[nstack.length - 1]; if (isPromise(last)) { // The only way a promise can get added to the stack is if a custom function was invoked that @@ -96,7 +97,7 @@ function runEvaluateLoop(tokens: Instruction[], expr: Expression, values: Evalua nstack.push(resolvedValue); // ...with the stack updated with the resolved value from the promise we can call ourselves to // continue evaluating the expression. - return runEvaluateLoop(tokens, expr, values, nstack, i + 1); + return runEvaluateLoop(tokens, expr, values, nstack, i + 1, resolver); }); } } @@ -126,7 +127,7 @@ function resolveFinalValue(nstack: EvaluationStack, values: EvaluationValues): V * the {@link Token} function. * @param nstack The stack to use for expression evaluation. */ -function evaluateExpressionToken(expr: Expression, values: EvaluationValues, token: Instruction, nstack: EvaluationStack): void { +function evaluateExpressionToken(expr: Expression, values: EvaluationValues, token: Instruction, nstack: EvaluationStack, resolver?: VariableResolver): void { let leftOperand: any, rightOperand: any, conditionValue: any; let operatorFunction: Function, functionArgs: any[], argumentCount: number; @@ -139,12 +140,12 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok // Handle special short-circuit logical operators if (token.value === 'and') { - nstack.push(leftOperand ? !!evaluate(rightOperand, expr, values) : false); + nstack.push(leftOperand ? !!evaluate(rightOperand, expr, values, resolver) : false); } else if (token.value === 'or') { - nstack.push(leftOperand ? true : !!evaluate(rightOperand, expr, values)); + nstack.push(leftOperand ? true : !!evaluate(rightOperand, expr, values, resolver)); } else if (token.value === '=') { operatorFunction = expr.binaryOps[token.value]; - nstack.push(operatorFunction(leftOperand, evaluate(rightOperand, expr, values), values)); + nstack.push(operatorFunction(leftOperand, evaluate(rightOperand, expr, values, resolver), values)); } else { operatorFunction = expr.binaryOps[token.value]; nstack.push(operatorFunction(resolveExpression(leftOperand, values), resolveExpression(rightOperand, values))); @@ -155,7 +156,7 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok conditionValue = nstack.pop(); if (token.value === '?') { - nstack.push(evaluate(conditionValue ? trueValue : falseValue, expr, values)); + nstack.push(evaluate(conditionValue ? trueValue : falseValue, expr, values, resolver)); } else { operatorFunction = expr.ternaryOps[token.value]; nstack.push(operatorFunction( @@ -182,31 +183,21 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok valueResolved = true; } else { // We don't recognize the IVAR token. Before throwing an error for an undefined variable we - // give the parser a shot at resolving the IVAR for us. By default this callback will return - // undefined and fail to resolve, but the creator of the parser can replace the resolve callback - // with their own implementation to resolve variables. That can return values that look like: - // { alias: "xxx" } - use xxx as the IVAR token instead of what was typed. - // { value: } use as the value for the variable. - const resolvedVariable: VariableResolveResult | undefined = expr.parser.resolve(variableName); - if (typeof resolvedVariable === 'object' && resolvedVariable && 'alias' in resolvedVariable && typeof resolvedVariable.alias === 'string') { - // The parser's resolver function returned { alias: "xxx" }, we want to use - // resolved.alias in place of token.value. - if (resolvedVariable.alias in values) { - const aliasValue = values[resolvedVariable.alias]; - // Security: Validate that functions from context are allowed - ExpressionValidator.validateAllowedFunction(aliasValue, expr.functions, expr.toString()); - nstack.push(aliasValue); - valueResolved = true; - } - } else if (typeof resolvedVariable === 'object' && resolvedVariable && 'value' in resolvedVariable) { - // The parser's resolver function returned { value: }, use - // as the value of the token. - const resolvedValue = resolvedVariable.value; - // Security: Validate that functions from context are allowed - ExpressionValidator.validateAllowedFunction(resolvedValue, expr.functions, expr.toString()); - nstack.push(resolvedValue); - valueResolved = true; + // give custom resolvers a shot at resolving the IVAR for us. Per-call resolvers (supplied to + // Expression.evaluate) take precedence over the parser-level resolver. A resolver result can + // look like: + // { alias: "xxx" } - use xxx as the IVAR token instead of what was typed. + // { value: } - use as the value for the variable. + // Returning undefined means "I don't know this variable" and passes the attempt on to the + // next resolver in the chain. + let resolvedVariable: VariableResolveResult | undefined; + if (resolver) { + resolvedVariable = resolver(variableName); + } + if (resolvedVariable === undefined) { + resolvedVariable = expr.parser.resolve(variableName); } + valueResolved = applyResolvedVariable(resolvedVariable, values, expr, nstack); } if (!valueResolved) { throw new VariableError( @@ -247,7 +238,7 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok for (let i = 0, len = functionParams.length; i < len; i++) { localScope[functionParams[i]] = functionArguments[i]; } - return evaluate(expressionToEvaluate, expr, localScope); + return evaluate(expressionToEvaluate, expr, localScope, resolver); }; // Set function name for debugging Object.defineProperty(userDefinedFunction, 'name', { @@ -277,7 +268,7 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok for (let i = 0, len = functionParams.length; i < len; i++) { localScope[functionParams[i]] = functionArguments[i]; } - return evaluate(expressionToEvaluate, expr, localScope); + return evaluate(expressionToEvaluate, expr, localScope, resolver); }; // Set function name for debugging (anonymous arrow function) Object.defineProperty(arrowFunction, 'name', { @@ -290,7 +281,7 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok return arrowFunction; })()); } else if (type === IEXPR) { - nstack.push(createExpressionEvaluator(token, expr)); + nstack.push(createExpressionEvaluator(token, expr, resolver)); } else if (type === IEXPREVAL) { nstack.push(token); } else if (type === IMEMBER) { @@ -389,18 +380,49 @@ function evaluateExpressionToken(expr: Expression, values: EvaluationValues, tok } } -function createExpressionEvaluator(token: Instruction, expr: Expression): ExpressionEvaluator { +function createExpressionEvaluator(token: Instruction, expr: Expression, resolver?: VariableResolver): ExpressionEvaluator { if (isExpressionEvaluator(token)) { return token; } return { type: IEXPREVAL, value: function (scope: EvaluationValues): Value | Promise { - return evaluate(token.value as Instruction[], expr, scope); + return evaluate(token.value as Instruction[], expr, scope, resolver); } }; } +/** + * Dispatches a {@link VariableResolveResult} onto the evaluation stack. + * Handles `{ alias }` and `{ value }` shapes, runs function allow-listing, and reports + * whether the variable was resolved so the caller can decide whether to throw. + */ +function applyResolvedVariable( + resolvedVariable: VariableResolveResult | undefined, + values: EvaluationValues, + expr: Expression, + nstack: EvaluationStack +): boolean { + if (typeof resolvedVariable === 'object' && resolvedVariable && 'alias' in resolvedVariable && typeof resolvedVariable.alias === 'string') { + // Resolver returned { alias: "xxx" } - look xxx up in the values map. + if (resolvedVariable.alias in values) { + const aliasValue = values[resolvedVariable.alias]; + ExpressionValidator.validateAllowedFunction(aliasValue, expr.functions, expr.toString()); + nstack.push(aliasValue); + return true; + } + return false; + } + if (typeof resolvedVariable === 'object' && resolvedVariable && 'value' in resolvedVariable) { + // Resolver returned { value: } - use directly. + const resolvedValue = resolvedVariable.value; + ExpressionValidator.validateAllowedFunction(resolvedValue, expr.functions, expr.toString()); + nstack.push(resolvedValue); + return true; + } + return false; +} + function isExpressionEvaluator(n: any): n is ExpressionEvaluator { return n && n.type === IEXPREVAL; } diff --git a/src/core/expression.ts b/src/core/expression.ts index 916c615e..cc174003 100644 --- a/src/core/expression.ts +++ b/src/core/expression.ts @@ -9,6 +9,7 @@ import type { Value, SymbolOptions, VariableResolveResult, + VariableResolver, ReadonlyValues } from '../types/index.js'; @@ -95,6 +96,10 @@ export class Expression { * Evaluates the expression with the given variable values. * * @param values - Object containing variable values + * @param resolver - Optional per-call variable resolver. Tried before the parser-level + * resolver (if any) when a variable is not present in `values`. Lets a single parsed + * Expression be evaluated multiple times against different variable sources without + * mutating parser state. * @returns The computed result of the expression * @throws {VariableError} When the expression references undefined variables * @throws {EvaluationError} When runtime evaluation fails @@ -102,12 +107,18 @@ export class Expression { * ```typescript * const expr = parser.parse('2 + 3 * x'); * const result = expr.evaluate({ x: 4 }); // Returns 14 + * + * // Per-call resolver + * const expr2 = parser.parse('$a + $b'); + * const result2 = expr2.evaluate({}, (t) => + * t.startsWith('$') ? { value: lookup(t.substring(1)) } : undefined + * ); * ``` */ - evaluate(values?: ReadonlyValues): Value | Promise { + evaluate(values?: ReadonlyValues, resolver?: VariableResolver): Value | Promise { const safeValues = values || {}; - return evaluate(this.tokens, this, safeValues); + return evaluate(this.tokens, this, safeValues, resolver); } /** diff --git a/src/parsing/parser.ts b/src/parsing/parser.ts index 8f809c64..d8f777a6 100644 --- a/src/parsing/parser.ts +++ b/src/parsing/parser.ts @@ -3,7 +3,7 @@ import { TEOF } from './token.js'; import { TokenStream } from './token-stream.js'; import { ParserState } from './parser-state.js'; import { Expression } from '../core/expression.js'; -import type { Value, VariableResolveResult, Values } from '../types/values.js'; +import type { Value, VariableResolveResult, VariableResolver, Values } from '../types/values.js'; import type { Instruction } from './instruction.js'; import type { OperatorFunction } from '../types/parser.js'; import { atan2, condition, fac, filter, fold, gamma, hypot, indexOf, join, map, max, min, random, roundTo, sum, json, stringLength, isEmpty, stringContains, startsWith, endsWith, searchCount, trim, toUpper, toLower, toTitle, split, repeat, reverse, left, right, replace, replaceFirst, naturalSort, toNumber, toBoolean, padLeft, padRight, padBoth, slice, urlEncode, base64Encode, base64Decode, coalesceString, merge, keys, values, flatten, count, clamp, reduce, find, some, every, unique, distinct, isArray, isObject, isNumber, isString, isBoolean, isNull, isUndefined, isFunctionValue } from '../functions/index.js'; @@ -71,11 +71,6 @@ interface ParserOptions { operators?: Record; } -/** - * Variable resolver function type for custom variable resolution - */ -type VariableResolver = (token: string) => VariableResolveResult; - export class Parser { public options: ParserOptions; public keywords: string[]; @@ -301,10 +296,12 @@ export class Parser { /** * Parses and immediately evaluates a mathematical expression. - * This is a convenience method equivalent to `parser.parse(expr).evaluate(variables)`. + * This is a convenience method equivalent to `parser.parse(expr).evaluate(variables, resolver)`. * * @param expr - The mathematical expression string to evaluate * @param variables - Optional object containing variable values + * @param resolver - Optional per-call variable resolver. Tried before the parser-level + * resolver (if any) when a variable is not present in `variables`. * @returns The result of evaluating the expression * @throws {ParseError} When the expression contains syntax errors * @throws {VariableError} When the expression references undefined variables @@ -315,8 +312,8 @@ export class Parser { * const result = parser.evaluate('2 + 3 * x', { x: 4 }); // Returns 14 * ``` */ - evaluate(expr: string, variables?: Values): Value | Promise { - return this.parse(expr).evaluate(variables); + evaluate(expr: string, variables?: Values, resolver?: VariableResolver): Value | Promise { + return this.parse(expr).evaluate(variables, resolver); } private static readonly optionNameMap: Record = { diff --git a/src/types/values.ts b/src/types/values.ts index 5d667ba6..b0368055 100644 --- a/src/types/values.ts +++ b/src/types/values.ts @@ -47,3 +47,10 @@ export interface VariableValue { } export type VariableResolveResult = VariableAlias | VariableValue | Value | undefined; + +/** + * Custom variable resolver callback signature. + * Given a variable name (as it appears in the expression), returns a resolution result + * or `undefined` to indicate that this resolver does not handle the variable. + */ +export type VariableResolver = (token: string) => VariableResolveResult; diff --git a/test/expression/expression-advanced.ts b/test/expression/expression-advanced.ts index 8b94303e..5f63a725 100644 --- a/test/expression/expression-advanced.ts +++ b/test/expression/expression-advanced.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { Parser } from '../../index'; +import type { VariableResolver } from '../../index'; describe('Expression Advanced Features TypeScript Test', () => { describe('function definitions', () => { @@ -238,6 +239,110 @@ describe('Expression Advanced Features TypeScript Test', () => { }); }); + describe('per-expression variable resolver', () => { + it('should resolve variables via a per-call resolver passed to Expression.evaluate', () => { + const parser = new Parser(); + const data = { a: 5, b: 1 }; + const resolver: VariableResolver = (token) => + token.startsWith('$') ? { value: (data as any)[token.substring(1)] } : undefined; + expect(parser.parse('$a + $b').evaluate({}, resolver)).toBe(6); + }); + + it('should prefer the per-call resolver over the parser-level resolver', () => { + const parser = new Parser(); + (parser as any).resolve = (token: string) => + token === '$a' ? { value: 10 } : undefined; + const resolver: VariableResolver = (token) => + token === '$a' ? { value: 1 } : undefined; + expect(parser.parse('$a').evaluate({}, resolver)).toBe(1); + }); + + it('should fall back to the parser-level resolver when the per-call resolver returns undefined', () => { + const parser = new Parser(); + (parser as any).resolve = (token: string) => + token === '$b' ? { value: 4 } : undefined; + const resolver: VariableResolver = (token) => + token === '$a' ? { value: 3 } : undefined; + expect(parser.parse('$a + $b').evaluate({}, resolver)).toBe(7); + }); + + it('should prefer the values map over the per-call resolver', () => { + const parser = new Parser(); + const resolver: VariableResolver = () => ({ value: 100 }); + expect(parser.parse('a').evaluate({ a: 2 }, resolver)).toBe(2); + }); + + it('should support the { alias } shape in a per-call resolver', () => { + const parser = new Parser(); + const obj = { variables: { a: 5, b: 1 } }; + const resolver: VariableResolver = (token) => + token === '$v' ? { alias: 'variables' } : undefined; + expect(parser.parse('$v.a + $v.b').evaluate(obj, resolver)).toBe(6); + }); + + it('should support the { value } shape in a per-call resolver', () => { + const parser = new Parser(); + const data = { variables: { a: 5, b: 1 } }; + const resolver: VariableResolver = (token) => + token.startsWith('$') ? { value: (data.variables as any)[token.substring(1)] } : undefined; + expect(parser.parse('$a + $b').evaluate({}, resolver)).toBe(6); + }); + + it('should accept a resolver via the Parser.evaluate shortcut', () => { + const parser = new Parser(); + const resolver: VariableResolver = (token) => + token === '$a' ? { value: 42 } : undefined; + expect(parser.evaluate('$a + 1', {}, resolver)).toBe(43); + }); + + it('should throw a VariableError when neither resolver handles the variable', () => { + const parser = new Parser(); + const resolver: VariableResolver = () => undefined; + expect(() => parser.parse('$missing').evaluate({}, resolver)).toThrow(/undefined variable: \$missing/); + }); + + it('should allow the same Expression to be evaluated with different per-call resolvers', () => { + const parser = new Parser(); + const expression = parser.parse('$user.name'); + const resolverA: VariableResolver = (token) => + token === '$user' ? { value: { name: 'Alice' } } : undefined; + const resolverB: VariableResolver = (token) => + token === '$user' ? { value: { name: 'Bob' } } : undefined; + expect(expression.evaluate({}, resolverA)).toBe('Alice'); + expect(expression.evaluate({}, resolverB)).toBe('Bob'); + }); + + it('should not leak the resolver across evaluations', () => { + const parser = new Parser(); + const expression = parser.parse('$a'); + const resolver: VariableResolver = () => ({ value: 9 }); + expect(expression.evaluate({}, resolver)).toBe(9); + expect(() => expression.evaluate({})).toThrow(/undefined variable: \$a/); + }); + + it('should propagate the resolver through short-circuit and/or', () => { + const parser = new Parser(); + const resolver: VariableResolver = (token) => { + if (token === '$t') return { value: true }; + if (token === '$v') return { value: 7 }; + return undefined; + }; + expect(parser.parse('$t and $v').evaluate({}, resolver)).toBe(true); + expect(parser.parse('$t or $v').evaluate({}, resolver)).toBe(true); + }); + + it('should propagate the resolver through the ternary operator', () => { + const parser = new Parser(); + const resolver: VariableResolver = (token) => { + if (token === '$cond') return { value: true }; + if (token === '$a') return { value: 'yes' }; + if (token === '$b') return { value: 'no' }; + return undefined; + }; + expect(parser.parse('$cond ? $a : $b').evaluate({}, resolver)).toBe('yes'); + }); + }); + describe('?? (nullish coalescing) operator', () => { it('should succeed with variables set to undefined', () => { expect(Parser.evaluate('x = undefined; x + 1')).toBeUndefined();