diff --git a/bench/expr.bench.ts b/bench/expr.bench.ts index 6f22b96..b719a39 100644 --- a/bench/expr.bench.ts +++ b/bench/expr.bench.ts @@ -3,38 +3,38 @@ import { bench, describe } from "vitest"; import { compile, evaluate, register } from "../dist/index.esm.js"; const context = { - user: { - name: "John", - age: 30, - isAdmin: true, - scores: [85, 90, 78, 92], - address: { - city: "New York", - zip: "10001", - }, - }, - products: [ - { id: 1, name: "Laptop", price: 1200 }, - { id: 2, name: "Phone", price: 800 }, - { id: 3, name: "Tablet", price: 500 }, - ], - calculateTotal: (items: any[]) => { - const total = items.reduce((sum, item) => sum + item.price, 0); - return total; - }, - applyDiscount: (total: number, percentage: number) => { - const value = total * (1 - percentage / 100); - return value; - }, + user: { + name: "John", + age: 30, + isAdmin: true, + scores: [85, 90, 78, 92], + address: { + city: "New York", + zip: "10001", + }, + }, + products: [ + { id: 1, name: "Laptop", price: 1200 }, + { id: 2, name: "Phone", price: 800 }, + { id: 3, name: "Tablet", price: 500 }, + ], + calculateTotal: (items: any[]) => { + const total = items.reduce((sum, item) => sum + item.price, 0); + return total; + }, + applyDiscount: (total: number, percentage: number) => { + const value = total * (1 - percentage / 100); + return value; + }, }; const simpleExpression = "user.age + 5"; const mediumExpression = 'user.scores[2] > 80 ? "Good" : "Needs improvement"'; const complexExpression = - '@applyDiscount(@calculateTotal(products), 10) > 2000 ? "High value" : "Standard"'; + '@applyDiscount(@calculateTotal(products), 10) > 2000 ? "High value" : "Standard"'; const complexExpression2 = - 'applyDiscount(calculateTotal(products), 10) > 2000 ? "High value" : "Standard"'; + 'applyDiscount(calculateTotal(products), 10) > 2000 ? "High value" : "Standard"'; const simpleExpressionCompiler = compile(simpleExpression); const mediumExpressionCompiler = compile(mediumExpression); @@ -48,80 +48,80 @@ parser.functions.calculateTotal = context.calculateTotal; parser.functions.applyDiscount = context.applyDiscount; const newFunctionSimple = new Function( - "context", - `with(context) { return ${simpleExpression}; }`, + "context", + `with(context) { return ${simpleExpression}; }`, ); const newFunctionMedium = new Function( - "context", - `with(context) { return ${mediumExpression}; }`, + "context", + `with(context) { return ${mediumExpression}; }`, ); const newFunctionComplex = new Function( - "context", - `with(context) { return ${complexExpression2}; }`, + "context", + `with(context) { return ${complexExpression2}; }`, ); describe("Simple Expression Benchmarks", () => { - bench("evaluate after compile (baseline) only interpreter", () => { - simpleExpressionCompiler(context); - }); - - bench("new Function (vs evaluate)", () => { - newFunctionSimple(context); - }); - - bench( - "evaluate without compile (vs evaluate) tokenize + parse + interpreter", - () => { - evaluate(simpleExpression, context); - }, - ); - - bench("expr-eval Parser (vs evaluate)", () => { - // @ts-ignore - Parser.evaluate(simpleExpression, context); - }); + bench("evaluate after compile (baseline) only interpreter", () => { + simpleExpressionCompiler(context); + }); + + bench("new Function (vs evaluate)", () => { + newFunctionSimple(context); + }); + + bench( + "evaluate without compile (vs evaluate) tokenize + parse + interpreter", + () => { + evaluate(simpleExpression, context); + }, + ); + + bench("expr-eval Parser (vs evaluate)", () => { + // @ts-ignore + Parser.evaluate(simpleExpression, context); + }); }); describe("Medium Expression Benchmarks", () => { - bench("evaluate after compile (baseline) only interpreter", () => { - mediumExpressionCompiler(context); - }); - - bench("new Function (vs evaluate)", () => { - newFunctionMedium(context); - }); - - bench( - "evaluate without compile (vs evaluate) tokenize + parse + interpreter", - () => { - evaluate(mediumExpression, context); - }, - ); - - bench("expr-eval Parser (vs evaluate)", () => { - // @ts-ignore - Parser.evaluate(mediumExpression, context); - }); + bench("evaluate after compile (baseline) only interpreter", () => { + mediumExpressionCompiler(context); + }); + + bench("new Function (vs evaluate)", () => { + newFunctionMedium(context); + }); + + bench( + "evaluate without compile (vs evaluate) tokenize + parse + interpreter", + () => { + evaluate(mediumExpression, context); + }, + ); + + bench("expr-eval Parser (vs evaluate)", () => { + // @ts-ignore + Parser.evaluate(mediumExpression, context); + }); }); describe("Complex Expression Benchmarks", () => { - bench("evaluate after compile (baseline) only interpreter", () => { - complexExpressionCompiler(context); - }); - - bench("new Function (vs evaluate)", () => { - newFunctionComplex(context); - }); - - bench( - "evaluate without compile (vs evaluate) tokenize + parse + interpreter", - () => { - evaluate(complexExpression2, context); - }, - ); - - bench("expr-eval Parser (vs evaluate)", () => { - // @ts-ignore - parser.evaluate(complexExpression2, context); - }); + bench("evaluate after compile (baseline) only interpreter", () => { + complexExpressionCompiler(context); + }); + + bench("new Function (vs evaluate)", () => { + newFunctionComplex(context); + }); + + bench( + "evaluate without compile (vs evaluate) tokenize + parse + interpreter", + () => { + evaluate(complexExpression2, context); + }, + ); + + bench("expr-eval Parser (vs evaluate)", () => { + // @ts-ignore + parser.evaluate(complexExpression2, context); + }); }); diff --git a/biome.json b/biome.json index 3dc1fbf..2a0ca2d 100644 --- a/biome.json +++ b/biome.json @@ -1,31 +1,32 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "vcs": { - "enabled": false, - "clientKind": "git", - "useIgnoreFile": false - }, - "files": { - "ignoreUnknown": false, - "ignore": ["dist", "node_modules", "coverage"] - }, - "formatter": { - "enabled": true, - "indentStyle": "tab" - }, - "organizeImports": { - "enabled": true - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true - }, - "ignore": ["node_modules", "dist", "tests", "bench", "coverage"] - }, - "javascript": { - "formatter": { - "quoteStyle": "double" - } - } + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "ignore": ["dist", "node_modules", "coverage"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + }, + "ignore": ["node_modules", "dist", "tests", "bench", "coverage"] + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } } diff --git a/package.json b/package.json index ecb919e..c1c74f3 100644 --- a/package.json +++ b/package.json @@ -1,40 +1,53 @@ { - "name": "@antv/expr", - "version": "1.0.1", - "description": "A secure, high-performance expression evaluator for dynamic chart rendering", - "main": "dist/index.cjs.js", - "module": "dist/index.esm.js", - "types": "dist/index.d.ts", - "files": ["dist", "LICENSE", "README.md", "package.json"], - "scripts": { - "build": "rollup -c", - "test": "vitest run --coverage", - "benchmark": "vitest bench", - "prepublishOnly": "pnpm run test && pnpm run build" - }, - "keywords": [ - "expression", - "evaluator", - "parser", - "secure", - "antv", - "chart", - "expr" - ], - "repository": { - "type": "git", - "url": "https://github.com/antvis/expr" - }, - "license": "MIT", - "devDependencies": { - "@biomejs/biome": "1.9.4", - "@rollup/plugin-node-resolve": "^16.0.0", - "@rollup/plugin-terser": "^0.4.4", - "@rollup/plugin-typescript": "^12.1.2", - "@vitest/coverage-v8": "^3.0.8", - "expr-eval": "^2.0.2", - "rollup": "^4.34.6", - "tslib": "^2.8.1", - "vitest": "^3.0.8" - } + "name": "@antv/expr", + "version": "1.0.1", + "description": "A secure, high-performance expression evaluator for dynamic chart rendering", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts", + "files": ["dist", "LICENSE", "README.md", "package.json"], + "scripts": { + "build": "rollup -c && npm run size", + "test": "vitest run --coverage", + "size": "limit-size", + "benchmark": "vitest bench", + "prepublishOnly": "pnpm run test && pnpm run build" + }, + "keywords": [ + "expression", + "evaluator", + "parser", + "secure", + "antv", + "chart", + "expr" + ], + "devDependencies": { + "@biomejs/biome": "1.9.4", + "@rollup/plugin-node-resolve": "^16.0.0", + "@rollup/plugin-terser": "^0.4.4", + "@rollup/plugin-typescript": "^12.1.2", + "@vitest/coverage-v8": "^3.0.8", + "expr-eval": "^2.0.2", + "limit-size": "^0.1.4", + "rollup": "^4.34.6", + "tslib": "^2.8.1", + "vitest": "^3.0.8" + }, + "limit-size": [ + { + "path": "dist/index.cjs.js", + "limit": "8 Kb" + }, + { + "path": "dist/index.cjs.js", + "limit": "3 Kb", + "gzip": true + } + ], + "repository": { + "type": "git", + "url": "https://github.com/antvis/expr" + }, + "license": "MIT" } diff --git a/rollup.config.mjs b/rollup.config.mjs index 6644fd2..5ff5f4c 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -3,32 +3,32 @@ import terser from "@rollup/plugin-terser"; import typescript from "@rollup/plugin-typescript"; export default [ - { - input: "src/index.ts", - output: [ - { - file: "dist/index.esm.js", - format: "esm", - }, - { - file: "dist/index.cjs.js", - format: "cjs", - }, - ], - plugins: [ - nodeResolve(), - typescript({ - tsconfig: "./tsconfig.json", - outDir: "dist", - }), - terser({ - compress: { - drop_console: true, - }, - output: { - comments: false, - }, - }), - ], - }, + { + input: "src/index.ts", + output: [ + { + file: "dist/index.esm.js", + format: "esm", + }, + { + file: "dist/index.cjs.js", + format: "cjs", + }, + ], + plugins: [ + nodeResolve(), + typescript({ + tsconfig: "./tsconfig.json", + outDir: "dist", + }), + terser({ + compress: { + drop_console: true, + }, + output: { + comments: false, + }, + }), + ], + }, ]; diff --git a/src/compile.ts b/src/compile.ts new file mode 100644 index 0000000..223cbaa --- /dev/null +++ b/src/compile.ts @@ -0,0 +1,28 @@ +import { getFunctions } from "./functions"; +import { + type Context, + createInterpreterState, + evaluateAst, +} from "./interpreter"; +import { parse } from "./parser"; +import { tokenize } from "./tokenizer"; + +/** + * Compile an expression into a reusable function + * @param expression - The expression to compile + * @returns A function that evaluates the expression with a given context + */ +export function compile( + expression: string, + // biome-ignore lint/suspicious/noExplicitAny: +): (context?: Context) => any { + const tokens = tokenize(expression); + const ast = parse(tokens); + const interpreterState = createInterpreterState({}, getFunctions()); + + // Return a function that can be called with different contexts + // biome-ignore lint/suspicious/noExplicitAny: Return type depends on the expression + return (context: Context = {}): any => { + return evaluateAst(ast, interpreterState, context); + }; +} diff --git a/src/error.ts b/src/error.ts new file mode 100644 index 0000000..9470a81 --- /dev/null +++ b/src/error.ts @@ -0,0 +1,13 @@ +/** + * Error class for expression parsing errors. + */ +export class ExpressionError extends Error { + constructor( + message: string, + public readonly position?: number, + public readonly token?: string, + ) { + super(message); + this.name = "ExpressionError"; + } +} diff --git a/src/evaluate.ts b/src/evaluate.ts new file mode 100644 index 0000000..2f41e58 --- /dev/null +++ b/src/evaluate.ts @@ -0,0 +1,16 @@ +import { compile } from "./compile"; +import type { Context } from "./interpreter"; + +/** + * Evaluate an expression with a given context + * @param expression - The expression to evaluate + * @param context - The context to use for evaluation + * @returns The result of evaluating the expression + */ +export function evaluate( + expression: string, + context: Context = {}, + // biome-ignore lint/suspicious/noExplicitAny: Return type depends on the expression +): any { + return compile(expression)(context); +} diff --git a/src/functions.ts b/src/functions.ts new file mode 100644 index 0000000..13e6640 --- /dev/null +++ b/src/functions.ts @@ -0,0 +1,32 @@ +// Global registry for functions that can be used in expressions +// biome-ignore lint/suspicious/noExplicitAny: Function registry needs to support any function type +type ExpressionFunction = (...args: any[]) => any; + +// Register some common Math functions by default +const exprGlobalFunctions: Record = { + abs: Math.abs, + ceil: Math.ceil, + floor: Math.floor, + max: Math.max, + min: Math.min, + round: Math.round, + sqrt: Math.sqrt, + pow: Math.pow, +}; + +/** + * Register a function to be used in expressions with the @ prefix + * @param name - The name of the function to register + * @param fn - The function implementation + */ +export function register(name: string, fn: ExpressionFunction): void { + exprGlobalFunctions[name] = fn; +} + +/** + * Get all the registered functions + * @returns + */ +export function getFunctions(): Record { + return exprGlobalFunctions; +} diff --git a/src/index.ts b/src/index.ts index 0c111c2..89f5930 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,78 +1,4 @@ -import { - type Context, - createInterpreterState, - evaluateAst, -} from "./interpreter"; -import { parse } from "./parser"; -import { tokenize } from "./tokenizer"; -import { ExpressionError } from "./utils"; - -// Global registry for functions that can be used in expressions -// biome-ignore lint/suspicious/noExplicitAny: Function registry needs to support any function type -type ExpressionFunction = (...args: any[]) => any; -const exprGlobalFunctions: Record = {}; - -/** - * Register a function to be used in expressions with the @ prefix - * @param name - The name of the function to register - * @param fn - The function implementation - */ -function register(name: string, fn: ExpressionFunction): void { - exprGlobalFunctions[name] = fn; -} - -// Register some common Math functions by default -register("abs", Math.abs); -register("ceil", Math.ceil); -register("floor", Math.floor); -register("max", Math.max); -register("min", Math.min); -register("round", Math.round); -register("sqrt", Math.sqrt); -register("pow", Math.pow); - -/** - * Compile an expression into a reusable function - * @param expression - The expression to compile - * @returns A function that evaluates the expression with a given context - */ -function compile( - expression: string, - // biome-ignore lint/suspicious/noExplicitAny: -): (context?: Context) => any { - const tokens = tokenize(expression); - const ast = parse(tokens); - const interpreterState = createInterpreterState({}, exprGlobalFunctions); - - // Return a function that can be called with different contexts - // biome-ignore lint/suspicious/noExplicitAny: Return type depends on the expression - return (context: Context = {}): any => { - try { - return evaluateAst(ast, interpreterState, context); - } catch (error) { - if (error instanceof ExpressionError) { - throw error; - } - - // Pass through other errors without wrapping them - throw error; - } - }; -} - -/** - * Evaluate an expression with a given context - * @param expression - The expression to evaluate - * @param context - The context to use for evaluation - * @returns The result of evaluating the expression - */ -function evaluate( - expression: string, - context: Context = {}, - // biome-ignore lint/suspicious/noExplicitAny: Return type depends on the expression -): any { - const evaluator = compile(expression); - return evaluator(context); -} - -export { compile, evaluate, register, ExpressionError }; +export { register } from "./functions"; +export { compile } from "./compile"; +export { evaluate } from "./evaluate"; +export { ExpressionError } from "./error"; diff --git a/src/interpreter.ts b/src/interpreter.ts index 57ff58a..9917624 100644 --- a/src/interpreter.ts +++ b/src/interpreter.ts @@ -1,16 +1,16 @@ +import { ExpressionError } from "./error"; import { - type BinaryExpression, - type CallExpression, - type ConditionalExpression, - type Expression, - type Identifier, - type Literal, - type MemberExpression, - NodeType, - type Program, - type UnaryExpression, + type BinaryExpression, + type CallExpression, + type ConditionalExpression, + type Expression, + type Identifier, + type Literal, + type MemberExpression, + NodeType, + type Program, + type UnaryExpression, } from "./parser"; -import { ExpressionError } from "./utils"; // biome-ignore lint/suspicious/noExplicitAny: export type Context = Record; @@ -23,8 +23,8 @@ export type Functions = Record any>; * @property functions - Functions available for calling during evaluation */ interface InterpreterState { - context: Context; - functions: Functions; + context: Context; + functions: Functions; } /** @@ -34,13 +34,13 @@ interface InterpreterState { * @returns A new interpreter state */ export const createInterpreterState = ( - context: Context = {}, - functions: Functions = {}, + context: Context = {}, + functions: Functions = {}, ): InterpreterState => { - return { - context, - functions, - }; + return { + context, + functions, + }; }; /** @@ -54,197 +54,197 @@ export const createInterpreterState = ( * const result = evaluate(ast, state); */ export const evaluateAst = ( - ast: Program, - state: InterpreterState, - context?: Context, + ast: Program, + state: InterpreterState, + context?: Context, ): unknown => { - let evaluationState = state; - if (context) { - evaluationState = { - ...state, - context: { ...state.context, ...context }, - }; - } - - // Define all evaluation functions within the closure to access evaluationState - /** - * Evaluates a literal value - * @param node - Literal node - * @returns The literal value - * @example "hello" → "hello" - * @example 42 → 42 - */ - const evaluateLiteral = (node: Literal): number | string | boolean | null => { - return node.value; - }; - - /** - * Evaluates an identifier by looking up its value in the context - * @param node - Identifier node - * @returns The value from context - * @example data → context.data - */ - const evaluateIdentifier = (node: Identifier): unknown => { - if (!(node.name in evaluationState.context)) { - throw new ExpressionError(`Undefined variable: ${node.name}`); - } - return evaluationState.context[node.name]; - }; - - /** - * Evaluates a member expression (property access) - * @param node - MemberExpression node - * @returns The accessed property value - * @example data.value → context.data.value - * @example data["value"] → context.data["value"] - */ - const evaluateMemberExpression = (node: MemberExpression): unknown => { - const object = evaluateNode(node.object); - if (object == null) { - throw new ExpressionError("Cannot access property of null or undefined"); - } - - const property = node.computed - ? evaluateNode(node.property) - : (node.property as Identifier).name; - - // biome-ignore lint/suspicious/noExplicitAny: - return (object as any)[property as string | number]; - }; - - /** - * Evaluates a function call - * @param node - CallExpression node - * @returns The function result - * @example @sum(1, 2) → functions.sum(1, 2) - */ - const evaluateCallExpression = (node: CallExpression): unknown => { - const func = evaluationState.functions[node.callee.name]; - if (!func) { - throw new ExpressionError(`Undefined function: ${node.callee.name}`); - } - - const args = node.arguments.map((arg) => evaluateNode(arg)); - return func(...args); - }; - - /** - * Evaluates a binary expression - * @param node - BinaryExpression node - * @returns The result of the binary operation - * @example a + b → context.a + context.b - * @example x > y → context.x > context.y - */ - const evaluateBinaryExpression = (node: BinaryExpression): unknown => { - const left = evaluateNode(node.left); - const right = evaluateNode(node.right); - - switch (node.operator) { - case "+": - // For addition, handle both numeric addition and string concatenation - // biome-ignore lint/suspicious/noExplicitAny: - return (left as any) + (right as any); - case "-": - return (left as number) - (right as number); - case "*": - return (left as number) * (right as number); - case "/": - return (left as number) / (right as number); - case "%": - return (left as number) % (right as number); - case "===": - return left === right; - case "!==": - return left !== right; - case ">": - return (left as number) > (right as number); - case ">=": - return (left as number) >= (right as number); - case "<": - return (left as number) < (right as number); - case "<=": - return (left as number) <= (right as number); - case "&&": - return (left as boolean) && (right as boolean); - case "||": - return (left as boolean) || (right as boolean); - default: - throw new ExpressionError(`Unknown operator: ${node.operator}`); - } - }; - - /** - * Evaluates a unary expression - * @param node - UnaryExpression node - * @returns The result of the unary operation - * @example !valid → !context.valid - * @example -num → -context.num - */ - const evaluateUnaryExpression = (node: UnaryExpression): unknown => { - const argument = evaluateNode(node.argument); - - if (node.prefix) { - switch (node.operator) { - case "!": - return !argument; - case "-": - if (typeof argument !== "number") { - throw new ExpressionError( - `Cannot apply unary - to non-number: ${argument}`, - ); - } - return -argument; - default: - throw new ExpressionError(`Unknown operator: ${node.operator}`); - } - } - // Currently we don't support postfix operators - throw new ExpressionError( - `Postfix operators are not supported: ${node.operator}`, - ); - }; - - /** - * Evaluates a conditional (ternary) expression - * @param node - ConditionalExpression node - * @returns The result of the conditional expression - * @example a ? b : c → context.a ? context.b : context.c - */ - const evaluateConditionalExpression = ( - node: ConditionalExpression, - ): unknown => { - const test = evaluateNode(node.test); - return test ? evaluateNode(node.consequent) : evaluateNode(node.alternate); - }; - - /** - * Evaluates a single AST node - * @param node - The node to evaluate - * @returns The result of evaluation - */ - const evaluateNode = (node: Expression): unknown => { - switch (node.type) { - case NodeType.Literal: - return evaluateLiteral(node); - case NodeType.Identifier: - return evaluateIdentifier(node); - case NodeType.MemberExpression: - return evaluateMemberExpression(node); - case NodeType.CallExpression: - return evaluateCallExpression(node); - case NodeType.BinaryExpression: - return evaluateBinaryExpression(node); - case NodeType.UnaryExpression: - return evaluateUnaryExpression(node); - case NodeType.ConditionalExpression: - return evaluateConditionalExpression(node); - default: - throw new ExpressionError( - `Evaluation error: Unsupported node type: ${(node as Expression).type}`, - ); - } - }; - - // Start evaluation with the root node - return evaluateNode(ast.body); + let evaluationState = state; + if (context) { + evaluationState = { + ...state, + context: { ...state.context, ...context }, + }; + } + + // Define all evaluation functions within the closure to access evaluationState + /** + * Evaluates a literal value + * @param node - Literal node + * @returns The literal value + * @example "hello" → "hello" + * @example 42 → 42 + */ + const evaluateLiteral = (node: Literal): number | string | boolean | null => { + return node.value; + }; + + /** + * Evaluates an identifier by looking up its value in the context + * @param node - Identifier node + * @returns The value from context + * @example data → context.data + */ + const evaluateIdentifier = (node: Identifier): unknown => { + if (!(node.name in evaluationState.context)) { + throw new ExpressionError(`Undefined variable: ${node.name}`); + } + return evaluationState.context[node.name]; + }; + + /** + * Evaluates a member expression (property access) + * @param node - MemberExpression node + * @returns The accessed property value + * @example data.value → context.data.value + * @example data["value"] → context.data["value"] + */ + const evaluateMemberExpression = (node: MemberExpression): unknown => { + const object = evaluateNode(node.object); + if (object == null) { + throw new ExpressionError("Cannot access property of null or undefined"); + } + + const property = node.computed + ? evaluateNode(node.property) + : (node.property as Identifier).name; + + // biome-ignore lint/suspicious/noExplicitAny: + return (object as any)[property as string | number]; + }; + + /** + * Evaluates a function call + * @param node - CallExpression node + * @returns The function result + * @example @sum(1, 2) → functions.sum(1, 2) + */ + const evaluateCallExpression = (node: CallExpression): unknown => { + const func = evaluationState.functions[node.callee.name]; + if (!func) { + throw new ExpressionError(`Undefined function: ${node.callee.name}`); + } + + const args = node.arguments.map((arg) => evaluateNode(arg)); + return func(...args); + }; + + /** + * Evaluates a binary expression + * @param node - BinaryExpression node + * @returns The result of the binary operation + * @example a + b → context.a + context.b + * @example x > y → context.x > context.y + */ + const evaluateBinaryExpression = (node: BinaryExpression): unknown => { + const left = evaluateNode(node.left); + const right = evaluateNode(node.right); + + switch (node.operator) { + case "+": + // For addition, handle both numeric addition and string concatenation + // biome-ignore lint/suspicious/noExplicitAny: + return (left as any) + (right as any); + case "-": + return (left as number) - (right as number); + case "*": + return (left as number) * (right as number); + case "/": + return (left as number) / (right as number); + case "%": + return (left as number) % (right as number); + case "===": + return left === right; + case "!==": + return left !== right; + case ">": + return (left as number) > (right as number); + case ">=": + return (left as number) >= (right as number); + case "<": + return (left as number) < (right as number); + case "<=": + return (left as number) <= (right as number); + case "&&": + return (left as boolean) && (right as boolean); + case "||": + return (left as boolean) || (right as boolean); + default: + throw new ExpressionError(`Unknown operator: ${node.operator}`); + } + }; + + /** + * Evaluates a unary expression + * @param node - UnaryExpression node + * @returns The result of the unary operation + * @example !valid → !context.valid + * @example -num → -context.num + */ + const evaluateUnaryExpression = (node: UnaryExpression): unknown => { + const argument = evaluateNode(node.argument); + + if (node.prefix) { + switch (node.operator) { + case "!": + return !argument; + case "-": + if (typeof argument !== "number") { + throw new ExpressionError( + `Cannot apply unary - to non-number: ${argument}`, + ); + } + return -argument; + default: + throw new ExpressionError(`Unknown operator: ${node.operator}`); + } + } + // Currently we don't support postfix operators + throw new ExpressionError( + `Postfix operators are not supported: ${node.operator}`, + ); + }; + + /** + * Evaluates a conditional (ternary) expression + * @param node - ConditionalExpression node + * @returns The result of the conditional expression + * @example a ? b : c → context.a ? context.b : context.c + */ + const evaluateConditionalExpression = ( + node: ConditionalExpression, + ): unknown => { + const test = evaluateNode(node.test); + return test ? evaluateNode(node.consequent) : evaluateNode(node.alternate); + }; + + /** + * Evaluates a single AST node + * @param node - The node to evaluate + * @returns The result of evaluation + */ + const evaluateNode = (node: Expression): unknown => { + switch (node.type) { + case NodeType.Literal: + return evaluateLiteral(node); + case NodeType.Identifier: + return evaluateIdentifier(node); + case NodeType.MemberExpression: + return evaluateMemberExpression(node); + case NodeType.CallExpression: + return evaluateCallExpression(node); + case NodeType.BinaryExpression: + return evaluateBinaryExpression(node); + case NodeType.UnaryExpression: + return evaluateUnaryExpression(node); + case NodeType.ConditionalExpression: + return evaluateConditionalExpression(node); + default: + throw new ExpressionError( + `Evaluation error: Unsupported node type: ${(node as Expression).type}`, + ); + } + }; + + // Start evaluation with the root node + return evaluateNode(ast.body); }; diff --git a/src/parser.ts b/src/parser.ts index 9e7a821..909c0f8 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,5 +1,5 @@ +import { ExpressionError } from "./error"; import { type Token, TokenType } from "./tokenizer"; -import { ExpressionError } from "./utils"; /** * All possible node types in the Abstract Syntax Tree (AST) @@ -13,14 +13,14 @@ import { ExpressionError } from "./utils"; * - ConditionalExpression: Ternary operator expressions */ export enum NodeType { - Program = 0, - Literal = 1, - Identifier = 2, - MemberExpression = 3, - CallExpression = 4, - BinaryExpression = 5, - UnaryExpression = 6, - ConditionalExpression = 7, + Program = 0, + Literal = 1, + Identifier = 2, + MemberExpression = 3, + CallExpression = 4, + BinaryExpression = 5, + UnaryExpression = 6, + ConditionalExpression = 7, } /** @@ -28,7 +28,7 @@ export enum NodeType { * Every node must have a type property identifying its kind */ export interface Node { - type: NodeType; + type: NodeType; } /** @@ -36,8 +36,8 @@ export interface Node { * Contains a single expression as its body */ export interface Program extends Node { - type: NodeType.Program; - body: Expression; + type: NodeType.Program; + body: Expression; } /** @@ -45,21 +45,21 @@ export interface Program extends Node { * All expressions are nodes that can produce a value */ export type Expression = - | Literal - | Identifier - | MemberExpression - | CallExpression - | BinaryExpression - | UnaryExpression - | ConditionalExpression; + | Literal + | Identifier + | MemberExpression + | CallExpression + | BinaryExpression + | UnaryExpression + | ConditionalExpression; /** * Represents literal values in the code * Examples: 42, "hello", true, null */ export interface Literal extends Node { - type: NodeType.Literal; - value: string | number | boolean | null; // The actual value + type: NodeType.Literal; + value: string | number | boolean | null; // The actual value } /** @@ -67,8 +67,8 @@ export interface Literal extends Node { * Examples: variable names, property names */ export interface Identifier extends Node { - type: NodeType.Identifier; - name: string; + type: NodeType.Identifier; + name: string; } /** @@ -78,10 +78,10 @@ export interface Identifier extends Node { * - obj["prop"] (computed: true) */ export interface MemberExpression extends Node { - type: NodeType.MemberExpression; - object: Expression; // The object being accessed - property: Expression; // The property being accessed - computed: boolean; // true for obj["prop"], false for obj.prop + type: NodeType.MemberExpression; + object: Expression; // The object being accessed + property: Expression; // The property being accessed + computed: boolean; // true for obj["prop"], false for obj.prop } /** @@ -89,9 +89,9 @@ export interface MemberExpression extends Node { * Example: @sum(a, b) */ export interface CallExpression extends Node { - type: NodeType.CallExpression; - callee: Identifier; // Function name - arguments: Expression[]; // Array of argument expressions + type: NodeType.CallExpression; + callee: Identifier; // Function name + arguments: Expression[]; // Array of argument expressions } /** @@ -99,10 +99,10 @@ export interface CallExpression extends Node { * Examples: a + b, x * y, foo === bar */ export interface BinaryExpression extends Node { - type: NodeType.BinaryExpression; - operator: string; // The operator (+, -, *, /, etc.) - left: Expression; // Left-hand operand - right: Expression; // Right-hand operand + type: NodeType.BinaryExpression; + operator: string; // The operator (+, -, *, /, etc.) + left: Expression; // Left-hand operand + right: Expression; // Right-hand operand } /** @@ -110,10 +110,10 @@ export interface BinaryExpression extends Node { * Example: !valid */ export interface UnaryExpression extends Node { - type: NodeType.UnaryExpression; - operator: string; // The operator (!, -, etc.) - argument: Expression; // The operand - prefix: boolean; // true for prefix operators, false for postfix + type: NodeType.UnaryExpression; + operator: string; // The operator (!, -, etc.) + argument: Expression; // The operand + prefix: boolean; // true for prefix operators, false for postfix } /** @@ -121,44 +121,44 @@ export interface UnaryExpression extends Node { * Example: condition ? trueValue : falseValue */ export interface ConditionalExpression extends Node { - type: NodeType.ConditionalExpression; - test: Expression; // The condition - consequent: Expression; // Value if condition is true - alternate: Expression; // Value if condition is false + type: NodeType.ConditionalExpression; + test: Expression; // The condition + consequent: Expression; // Value if condition is true + alternate: Expression; // Value if condition is false } // Operator precedence lookup table for O(1) access const OPERATOR_PRECEDENCE = new Map([ - ["||", 2], - ["&&", 3], - ["===", 4], - ["!==", 4], - [">", 5], - [">=", 5], - ["<", 5], - ["<=", 5], - ["+", 6], - ["-", 6], - ["*", 7], - ["/", 7], - ["%", 7], - ["!", 8], + ["||", 2], + ["&&", 3], + ["===", 4], + ["!==", 4], + [">", 5], + [">=", 5], + ["<", 5], + ["<=", 5], + ["+", 6], + ["-", 6], + ["*", 7], + ["/", 7], + ["%", 7], + ["!", 8], ]); // Pre-create common AST nodes for reuse const NULL_LITERAL: Literal = { - type: NodeType.Literal, - value: null, + type: NodeType.Literal, + value: null, }; const TRUE_LITERAL: Literal = { - type: NodeType.Literal, - value: true, + type: NodeType.Literal, + value: true, }; const FALSE_LITERAL: Literal = { - type: NodeType.Literal, - value: false, + type: NodeType.Literal, + value: false, }; /** @@ -169,326 +169,326 @@ const FALSE_LITERAL: Literal = { * @returns AST representing the expression */ export const parse = (tokens: Token[]): Program => { - // Use closure to encapsulate the parser state - let current = 0; - const length = tokens.length; - - /** - * Returns the current token without consuming it - * @returns The current token or null if at end of input - */ - const peek = (): Token | null => { - if (current >= length) return null; - return tokens[current]; - }; - - /** - * Consumes and returns the current token, advancing the parser position - * @returns The consumed token - */ - const consume = (): Token => { - return tokens[current++]; - }; - - /** - * Checks if the current token matches the expected type - * @param type - The token type to match - * @returns boolean indicating if current token matches - */ - const match = (type: TokenType): boolean => { - const token = peek(); - return token !== null && token.type === type; - }; - - /** - * Gets operator precedence - * @param token - The token to check - * @returns Precedence level (-1 to 9) or -1 if not an operator - */ - const getOperatorPrecedence = (token: Token): number => { - if (token.type === TokenType.OPERATOR) { - return OPERATOR_PRECEDENCE.get(token.value) || -1; - } - - if (token.type === TokenType.DOT || token.type === TokenType.BRACKET_LEFT) { - return 9; // Highest precedence for member access - } - - if (token.type === TokenType.QUESTION) { - return 1; // Make it higher than -1 but lower than other operators - } - - return -1; - }; - - /** - * Parses member access expressions - * @param object - The object expression being accessed - * @returns MemberExpression node - */ - const parseMemberExpression = (object: Expression): MemberExpression => { - const token = consume(); // consume . or [ - let property: Expression; - let computed: boolean; - - if (token.type === TokenType.DOT) { - if (!match(TokenType.IDENTIFIER)) { - const token = peek(); - throw new ExpressionError( - "Expected property name", - current, - token ? token.value : "", - ); - } - const identifierToken = consume(); - property = { - type: NodeType.Identifier, - name: identifierToken.value, - }; - computed = false; - } else { - // BRACKET_LEFT - property = parseExpression(0); - - if (!match(TokenType.BRACKET_RIGHT)) { - const token = peek(); - throw new ExpressionError( - "Expected closing bracket", - current, - token ? token.value : "", - ); - } - consume(); // consume ] - computed = true; - } - - return { - type: NodeType.MemberExpression, - object, - property, - computed, - }; - }; - - /** - * Parses function call expressions - * @returns CallExpression node - */ - const parseCallExpression = (): CallExpression => { - const token = consume(); // consume FUNCTION token - const args: Expression[] = []; - - if (!match(TokenType.PAREN_LEFT)) { - const token = peek(); - throw new ExpressionError( - "Expected opening parenthesis after function name", - current, - token ? token.value : "", - ); - } - consume(); // consume ( - - // Parse arguments - while (true) { - // First check for right parenthesis - if (match(TokenType.PAREN_RIGHT)) { - consume(); // consume ) - break; - } - - // Then check for end of input before doing anything else - if (!peek()) { - const token = peek(); - throw new ExpressionError( - "Expected closing parenthesis", - current, - token ? token.value : "", - ); - } - - // If we have arguments already, we need a comma - if (args.length > 0) { - if (!match(TokenType.COMMA)) { - const token = peek(); - throw new ExpressionError( - "Expected comma between function arguments", - current, - token ? token.value : "", - ); - } - consume(); // consume , - } - - const arg = parseExpression(0); - args.push(arg); - } - - return { - type: NodeType.CallExpression, - callee: { - type: NodeType.Identifier, - name: token.value, - }, - arguments: args, - }; - }; - - /** - * Parses primary expressions (literals, identifiers, parenthesized expressions) - * @returns Expression node - */ - const parsePrimary = (): Expression => { - const token = peek(); - if (!token) - throw new ExpressionError( - "Unexpected end of input", - current, - "", - ); - - // Handle unary operators - if ( - token.type === TokenType.OPERATOR && - (token.value === "!" || token.value === "-") - ) { - consume(); // consume operator - const argument = parsePrimary(); - return { - type: NodeType.UnaryExpression, - operator: token.value, - argument, - prefix: true, - }; - } - - switch (token.type) { - case TokenType.NUMBER: { - consume(); // consume number - return { - type: NodeType.Literal, - value: Number(token.value), - }; - } - - case TokenType.STRING: { - consume(); // consume string - return { - type: NodeType.Literal, - value: token.value, - }; - } - - case TokenType.BOOLEAN: { - consume(); // consume boolean - return token.value === "true" ? TRUE_LITERAL : FALSE_LITERAL; - } - - case TokenType.NULL: { - consume(); // consume null - return NULL_LITERAL; - } - - case TokenType.IDENTIFIER: { - consume(); // consume identifier - return { - type: NodeType.Identifier, - name: token.value, - }; - } - - case TokenType.FUNCTION: - return parseCallExpression(); - - case TokenType.PAREN_LEFT: { - consume(); // consume ( - const expr = parseExpression(0); - if (!match(TokenType.PAREN_RIGHT)) { - const token = peek(); - throw new ExpressionError( - "Expected closing parenthesis", - current, - token ? token.value : "", - ); - } - consume(); // consume ) - return expr; - } - - default: - throw new ExpressionError( - `Unexpected token: ${token.type}`, - current, - token.value, - ); - } - }; - - /** - * Parses expressions with operator precedence - * @param precedence - Current precedence level - * @returns Expression node - */ - const parseExpression = (precedence = 0): Expression => { - let left = parsePrimary(); - - while (current < length) { - const token = tokens[current]; // Inline peek() for performance - const nextPrecedence = getOperatorPrecedence(token); - - if (nextPrecedence <= precedence) break; - - if (token.type === TokenType.QUESTION) { - consume(); // consume ? - const consequent = parseExpression(0); - if (!match(TokenType.COLON)) { - const token = peek(); - throw new ExpressionError( - "Expected : in conditional expression", - current, - token ? token.value : "", - ); - } - consume(); // consume : - const alternate = parseExpression(0); - left = { - type: NodeType.ConditionalExpression, - test: left, - consequent, - alternate, - }; - continue; - } - - if (token.type === TokenType.OPERATOR) { - consume(); // consume operator - const right = parseExpression(nextPrecedence); - left = { - type: NodeType.BinaryExpression, - operator: token.value, - left, - right, - }; - continue; - } - - if ( - token.type === TokenType.DOT || - token.type === TokenType.BRACKET_LEFT - ) { - left = parseMemberExpression(left); - continue; - } - - break; - } - - return left; - }; - - // Start parsing from the initial state - const expression = parseExpression(); - return { - type: NodeType.Program, - body: expression, - }; + // Use closure to encapsulate the parser state + let current = 0; + const length = tokens.length; + + /** + * Returns the current token without consuming it + * @returns The current token or null if at end of input + */ + const peek = (): Token | null => { + if (current >= length) return null; + return tokens[current]; + }; + + /** + * Consumes and returns the current token, advancing the parser position + * @returns The consumed token + */ + const consume = (): Token => { + return tokens[current++]; + }; + + /** + * Checks if the current token matches the expected type + * @param type - The token type to match + * @returns boolean indicating if current token matches + */ + const match = (type: TokenType): boolean => { + const token = peek(); + return token !== null && token.type === type; + }; + + /** + * Gets operator precedence + * @param token - The token to check + * @returns Precedence level (-1 to 9) or -1 if not an operator + */ + const getOperatorPrecedence = (token: Token): number => { + if (token.type === TokenType.OPERATOR) { + return OPERATOR_PRECEDENCE.get(token.value) || -1; + } + + if (token.type === TokenType.DOT || token.type === TokenType.BRACKET_LEFT) { + return 9; // Highest precedence for member access + } + + if (token.type === TokenType.QUESTION) { + return 1; // Make it higher than -1 but lower than other operators + } + + return -1; + }; + + /** + * Parses member access expressions + * @param object - The object expression being accessed + * @returns MemberExpression node + */ + const parseMemberExpression = (object: Expression): MemberExpression => { + const token = consume(); // consume . or [ + let property: Expression; + let computed: boolean; + + if (token.type === TokenType.DOT) { + if (!match(TokenType.IDENTIFIER)) { + const token = peek(); + throw new ExpressionError( + "Expected property name", + current, + token ? token.value : "", + ); + } + const identifierToken = consume(); + property = { + type: NodeType.Identifier, + name: identifierToken.value, + }; + computed = false; + } else { + // BRACKET_LEFT + property = parseExpression(0); + + if (!match(TokenType.BRACKET_RIGHT)) { + const token = peek(); + throw new ExpressionError( + "Expected closing bracket", + current, + token ? token.value : "", + ); + } + consume(); // consume ] + computed = true; + } + + return { + type: NodeType.MemberExpression, + object, + property, + computed, + }; + }; + + /** + * Parses function call expressions + * @returns CallExpression node + */ + const parseCallExpression = (): CallExpression => { + const token = consume(); // consume FUNCTION token + const args: Expression[] = []; + + if (!match(TokenType.PAREN_LEFT)) { + const token = peek(); + throw new ExpressionError( + "Expected opening parenthesis after function name", + current, + token ? token.value : "", + ); + } + consume(); // consume ( + + // Parse arguments + while (true) { + // First check for right parenthesis + if (match(TokenType.PAREN_RIGHT)) { + consume(); // consume ) + break; + } + + // Then check for end of input before doing anything else + if (!peek()) { + const token = peek(); + throw new ExpressionError( + "Expected closing parenthesis", + current, + token ? token.value : "", + ); + } + + // If we have arguments already, we need a comma + if (args.length > 0) { + if (!match(TokenType.COMMA)) { + const token = peek(); + throw new ExpressionError( + "Expected comma between function arguments", + current, + token ? token.value : "", + ); + } + consume(); // consume , + } + + const arg = parseExpression(0); + args.push(arg); + } + + return { + type: NodeType.CallExpression, + callee: { + type: NodeType.Identifier, + name: token.value, + }, + arguments: args, + }; + }; + + /** + * Parses primary expressions (literals, identifiers, parenthesized expressions) + * @returns Expression node + */ + const parsePrimary = (): Expression => { + const token = peek(); + if (!token) + throw new ExpressionError( + "Unexpected end of input", + current, + "", + ); + + // Handle unary operators + if ( + token.type === TokenType.OPERATOR && + (token.value === "!" || token.value === "-") + ) { + consume(); // consume operator + const argument = parsePrimary(); + return { + type: NodeType.UnaryExpression, + operator: token.value, + argument, + prefix: true, + }; + } + + switch (token.type) { + case TokenType.NUMBER: { + consume(); // consume number + return { + type: NodeType.Literal, + value: Number(token.value), + }; + } + + case TokenType.STRING: { + consume(); // consume string + return { + type: NodeType.Literal, + value: token.value, + }; + } + + case TokenType.BOOLEAN: { + consume(); // consume boolean + return token.value === "true" ? TRUE_LITERAL : FALSE_LITERAL; + } + + case TokenType.NULL: { + consume(); // consume null + return NULL_LITERAL; + } + + case TokenType.IDENTIFIER: { + consume(); // consume identifier + return { + type: NodeType.Identifier, + name: token.value, + }; + } + + case TokenType.FUNCTION: + return parseCallExpression(); + + case TokenType.PAREN_LEFT: { + consume(); // consume ( + const expr = parseExpression(0); + if (!match(TokenType.PAREN_RIGHT)) { + const token = peek(); + throw new ExpressionError( + "Expected closing parenthesis", + current, + token ? token.value : "", + ); + } + consume(); // consume ) + return expr; + } + + default: + throw new ExpressionError( + `Unexpected token: ${token.type}`, + current, + token.value, + ); + } + }; + + /** + * Parses expressions with operator precedence + * @param precedence - Current precedence level + * @returns Expression node + */ + const parseExpression = (precedence = 0): Expression => { + let left = parsePrimary(); + + while (current < length) { + const token = tokens[current]; // Inline peek() for performance + const nextPrecedence = getOperatorPrecedence(token); + + if (nextPrecedence <= precedence) break; + + if (token.type === TokenType.QUESTION) { + consume(); // consume ? + const consequent = parseExpression(0); + if (!match(TokenType.COLON)) { + const token = peek(); + throw new ExpressionError( + "Expected : in conditional expression", + current, + token ? token.value : "", + ); + } + consume(); // consume : + const alternate = parseExpression(0); + left = { + type: NodeType.ConditionalExpression, + test: left, + consequent, + alternate, + }; + continue; + } + + if (token.type === TokenType.OPERATOR) { + consume(); // consume operator + const right = parseExpression(nextPrecedence); + left = { + type: NodeType.BinaryExpression, + operator: token.value, + left, + right, + }; + continue; + } + + if ( + token.type === TokenType.DOT || + token.type === TokenType.BRACKET_LEFT + ) { + left = parseMemberExpression(left); + continue; + } + + break; + } + + return left; + }; + + // Start parsing from the initial state + const expression = parseExpression(); + return { + type: NodeType.Program, + body: expression, + }; }; diff --git a/src/tokenizer.ts b/src/tokenizer.ts index 52f27a2..87dc813 100644 --- a/src/tokenizer.ts +++ b/src/tokenizer.ts @@ -1,23 +1,23 @@ -import { ExpressionError } from "./utils"; +import { ExpressionError } from "./error"; // token type enum export enum TokenType { - STRING = 0, - NUMBER = 1, - BOOLEAN = 2, - NULL = 3, - IDENTIFIER = 4, - OPERATOR = 5, - FUNCTION = 6, - DOT = 7, - BRACKET_LEFT = 8, - BRACKET_RIGHT = 9, - PAREN_LEFT = 10, - PAREN_RIGHT = 11, - COMMA = 12, - QUESTION = 13, - COLON = 14, - DOLLAR = 15, + STRING = 0, + NUMBER = 1, + BOOLEAN = 2, + NULL = 3, + IDENTIFIER = 4, + OPERATOR = 5, + FUNCTION = 6, + DOT = 7, + BRACKET_LEFT = 8, + BRACKET_RIGHT = 9, + PAREN_LEFT = 10, + PAREN_RIGHT = 11, + COMMA = 12, + QUESTION = 13, + COLON = 14, + DOLLAR = 15, } // Character code constants for faster comparison @@ -59,66 +59,66 @@ const CHAR_CARRIAGE_RETURN = 13; // '\r' // Use a Set for faster lookups const WHITESPACE_CHARS = new Set([ - CHAR_SPACE, - CHAR_TAB, - CHAR_NEWLINE, - CHAR_CARRIAGE_RETURN, + CHAR_SPACE, + CHAR_TAB, + CHAR_NEWLINE, + CHAR_CARRIAGE_RETURN, ]); const OPERATOR_START_CHARS = new Set([ - CHAR_PLUS, - CHAR_MINUS, - CHAR_MULTIPLY, - CHAR_DIVIDE, - CHAR_MODULO, - CHAR_EXCLAMATION, - CHAR_AMPERSAND, - CHAR_PIPE, - CHAR_EQUAL, - CHAR_LESS_THAN, - CHAR_GREATER_THAN, + CHAR_PLUS, + CHAR_MINUS, + CHAR_MULTIPLY, + CHAR_DIVIDE, + CHAR_MODULO, + CHAR_EXCLAMATION, + CHAR_AMPERSAND, + CHAR_PIPE, + CHAR_EQUAL, + CHAR_LESS_THAN, + CHAR_GREATER_THAN, ]); // Token type lookup maps for common tokens const KEYWORDS = new Map([ - ["true", TokenType.BOOLEAN], - ["false", TokenType.BOOLEAN], - ["null", TokenType.NULL], + ["true", TokenType.BOOLEAN], + ["false", TokenType.BOOLEAN], + ["null", TokenType.NULL], ]); // Operator to token type mapping (sorted by length for optimization) const OPERATOR_TOKENS = new Map([ - // 3-character operators - ["===", true], - ["!==", true], - - // 2-character operators - ["<=", true], - [">=", true], - ["&&", true], - ["||", true], - - // 1-character operators - ["+", true], - ["-", true], - ["*", true], - ["/", true], - ["%", true], - ["!", true], - ["<", true], - [">", true], + // 3-character operators + ["===", true], + ["!==", true], + + // 2-character operators + ["<=", true], + [">=", true], + ["&&", true], + ["||", true], + + // 1-character operators + ["+", true], + ["-", true], + ["*", true], + ["/", true], + ["%", true], + ["!", true], + ["<", true], + [">", true], ]); // Single character token map for O(1) lookup const SINGLE_CHAR_TOKENS = new Map([ - [CHAR_DOT, TokenType.DOT], - [CHAR_BRACKET_LEFT, TokenType.BRACKET_LEFT], - [CHAR_BRACKET_RIGHT, TokenType.BRACKET_RIGHT], - [CHAR_PAREN_LEFT, TokenType.PAREN_LEFT], - [CHAR_PAREN_RIGHT, TokenType.PAREN_RIGHT], - [CHAR_COMMA, TokenType.COMMA], - [CHAR_QUESTION, TokenType.QUESTION], - [CHAR_COLON, TokenType.COLON], - [CHAR_DOLLAR, TokenType.DOLLAR], + [CHAR_DOT, TokenType.DOT], + [CHAR_BRACKET_LEFT, TokenType.BRACKET_LEFT], + [CHAR_BRACKET_RIGHT, TokenType.BRACKET_RIGHT], + [CHAR_PAREN_LEFT, TokenType.PAREN_LEFT], + [CHAR_PAREN_RIGHT, TokenType.PAREN_RIGHT], + [CHAR_COMMA, TokenType.COMMA], + [CHAR_QUESTION, TokenType.QUESTION], + [CHAR_COLON, TokenType.COLON], + [CHAR_DOLLAR, TokenType.DOLLAR], ]); /** @@ -127,53 +127,53 @@ const SINGLE_CHAR_TOKENS = new Map([ * @property value - The actual string value of the token */ export interface Token { - type: TokenType; - value: string; + type: TokenType; + value: string; } // Pre-allocate token objects for single character tokens to reduce object creation const CHAR_TOKEN_CACHE = new Map(); for (const [code, type] of SINGLE_CHAR_TOKENS.entries()) { - CHAR_TOKEN_CACHE.set(code, { type, value: String.fromCharCode(code) }); + CHAR_TOKEN_CACHE.set(code, { type, value: String.fromCharCode(code) }); } /** * Check if a character code is a digit (0-9) */ function isDigit(code: number): boolean { - return code >= CHAR_0 && code <= CHAR_9; + return code >= CHAR_0 && code <= CHAR_9; } /** * Check if a character code is a letter (a-z, A-Z) or underscore */ function isAlpha(code: number): boolean { - return ( - (code >= CHAR_a && code <= CHAR_z) || - (code >= CHAR_A && code <= CHAR_Z) || - code === CHAR_UNDERSCORE - ); + return ( + (code >= CHAR_a && code <= CHAR_z) || + (code >= CHAR_A && code <= CHAR_Z) || + code === CHAR_UNDERSCORE + ); } /** * Check if a character code is alphanumeric (a-z, A-Z, 0-9) or underscore */ function isAlphaNumeric(code: number): boolean { - return isAlpha(code) || isDigit(code); + return isAlpha(code) || isDigit(code); } /** * Check if a character code is whitespace */ function isWhitespace(code: number): boolean { - return WHITESPACE_CHARS.has(code); + return WHITESPACE_CHARS.has(code); } /** * Check if a character code can start an operator */ function isOperatorStart(code: number): boolean { - return OPERATOR_START_CHARS.has(code); + return OPERATOR_START_CHARS.has(code); } /** @@ -188,230 +188,230 @@ function isOperatorStart(code: number): boolean { * @throws Error for unexpected or invalid characters */ export const tokenize = (expr: string): Token[] => { - const input = expr; - const length = input.length; - // Pre-allocate tokens array with estimated capacity to avoid resizing - const tokens: Token[] = new Array(Math.ceil(length / 3)); - let tokenCount = 0; - let pos = 0; - - /** - * Reads a string literal token, handling escape sequences - * @returns String token - * @throws Error for unterminated strings - */ - - function readString(quoteChar: number): Token { - const start = pos + 1; // Skip opening quote - pos++; - let value = ""; - let hasEscape = false; - - while (pos < length) { - const char = input.charCodeAt(pos); - if (char === quoteChar) { - // If no escape sequences, use substring directly - if (!hasEscape) { - value = input.substring(start, pos); - } - pos++; // Skip closing quote - return { type: TokenType.STRING, value }; - } - if (char === CHAR_BACKSLASH) { - // Handle escape sequence - if (!hasEscape) { - // First escape encountered, copy characters so far - value = input.substring(start, pos); - hasEscape = true; - } - pos++; - value += input[pos]; - } else if (hasEscape) { - // Only append if we're building the escaped string - value += input[pos]; - } - pos++; - } - - throw new ExpressionError( - `Unterminated string starting with ${String.fromCharCode(quoteChar)}`, - pos, - input.substring(Math.max(0, pos - 10), pos), - ); - } - - /** - * Reads a numeric token, handling integers, decimals, and negative numbers - * @returns Number token - */ - function readNumber(): Token { - const start = pos; - - // Handle negative sign if present - if (input.charCodeAt(pos) === CHAR_MINUS) { - pos++; - } - - // Read digits before decimal point - while (pos < length && isDigit(input.charCodeAt(pos))) { - pos++; - } - - // Handle decimal point and digits after it - if (pos < length && input.charCodeAt(pos) === CHAR_DOT) { - pos++; - while (pos < length && isDigit(input.charCodeAt(pos))) { - pos++; - } - } - - const value = input.slice(start, pos); - return { type: TokenType.NUMBER, value }; - } - - /** - * Reads a function name token after @ symbol - * @returns Function token - */ - function readFunction(): Token { - pos++; // Skip @ symbol - const start = pos; - - // First character must be a letter or underscore - if (pos < length && isAlpha(input.charCodeAt(pos))) { - pos++; - - // Subsequent characters can be alphanumeric - while (pos < length && isAlphaNumeric(input.charCodeAt(pos))) { - pos++; - } - } - - const value = input.slice(start, pos); - return { type: TokenType.FUNCTION, value }; - } - - /** - * Reads an identifier token, also handling boolean and null literals - * @returns Identifier, boolean, or null token - */ - function readIdentifier(): Token { - const start = pos++; // First character already checked - - // Read remaining characters - while (pos < length && isAlphaNumeric(input.charCodeAt(pos))) { - pos++; - } - - const value = input.slice(start, pos); - - // Check if it's a keyword (true, false, null) - const keywordType = KEYWORDS.get(value); - if (keywordType) { - return { type: keywordType, value }; - } - - return { type: TokenType.IDENTIFIER, value }; - } - - /** - * Reads an operator token, checking multi-character operators first - * @returns Operator token - */ - function readOperator(): Token { - // Try to match 3-character operators - if (pos + 2 < length) { - const op3 = input.substring(pos, pos + 3); - if (OPERATOR_TOKENS.has(op3)) { - pos += 3; - return { type: TokenType.OPERATOR, value: op3 }; - } - } - - // Try to match 2-character operators - if (pos + 1 < length) { - const op2 = input.substring(pos, pos + 2); - if (OPERATOR_TOKENS.has(op2)) { - pos += 2; - return { type: TokenType.OPERATOR, value: op2 }; - } - } - - // Try to match 1-character operators - const op1 = input[pos]; - if (OPERATOR_TOKENS.has(op1)) { - pos++; - return { type: TokenType.OPERATOR, value: op1 }; - } - - throw new ExpressionError( - `Unknown operator at position ${pos}: ${input.substring(pos, pos + 1)}`, - pos, - input.substring(Math.max(0, pos - 10), pos), - ); - } - - // Main tokenization loop - while (pos < length) { - const charCode = input.charCodeAt(pos); - - // Fast path for whitespace - if (isWhitespace(charCode)) { - pos++; - continue; - } - - // Fast path for single-character tokens - const cachedToken = CHAR_TOKEN_CACHE.get(charCode); - if (cachedToken) { - tokens[tokenCount++] = cachedToken; - pos++; - continue; - } - - // Handle string literals - if (charCode === CHAR_DOUBLE_QUOTE || charCode === CHAR_SINGLE_QUOTE) { - tokens[tokenCount++] = readString(charCode); - continue; - } - - // Handle numbers (including negative numbers) - if ( - isDigit(charCode) || - (charCode === CHAR_MINUS && - pos + 1 < length && - isDigit(input.charCodeAt(pos + 1))) - ) { - tokens[tokenCount++] = readNumber(); - continue; - } - - // Handle function calls starting with @ - if (charCode === CHAR_AT) { - tokens[tokenCount++] = readFunction(); - continue; - } - - // Handle identifiers (including keywords) - if (isAlpha(charCode)) { - tokens[tokenCount++] = readIdentifier(); - continue; - } - - // Handle operators - if (isOperatorStart(charCode)) { - tokens[tokenCount++] = readOperator(); - continue; - } - - // If we get here, we have an unexpected character - throw new ExpressionError( - `Unexpected character: ${input[pos]}`, - pos, - input.substring(Math.max(0, pos - 10), pos), - ); - } - - // Trim the tokens array to the actual number of tokens - return tokenCount === tokens.length ? tokens : tokens.slice(0, tokenCount); + const input = expr; + const length = input.length; + // Pre-allocate tokens array with estimated capacity to avoid resizing + const tokens: Token[] = new Array(Math.ceil(length / 3)); + let tokenCount = 0; + let pos = 0; + + /** + * Reads a string literal token, handling escape sequences + * @returns String token + * @throws Error for unterminated strings + */ + + function readString(quoteChar: number): Token { + const start = pos + 1; // Skip opening quote + pos++; + let value = ""; + let hasEscape = false; + + while (pos < length) { + const char = input.charCodeAt(pos); + if (char === quoteChar) { + // If no escape sequences, use substring directly + if (!hasEscape) { + value = input.substring(start, pos); + } + pos++; // Skip closing quote + return { type: TokenType.STRING, value }; + } + if (char === CHAR_BACKSLASH) { + // Handle escape sequence + if (!hasEscape) { + // First escape encountered, copy characters so far + value = input.substring(start, pos); + hasEscape = true; + } + pos++; + value += input[pos]; + } else if (hasEscape) { + // Only append if we're building the escaped string + value += input[pos]; + } + pos++; + } + + throw new ExpressionError( + `Unterminated string starting with ${String.fromCharCode(quoteChar)}`, + pos, + input.substring(Math.max(0, pos - 10), pos), + ); + } + + /** + * Reads a numeric token, handling integers, decimals, and negative numbers + * @returns Number token + */ + function readNumber(): Token { + const start = pos; + + // Handle negative sign if present + if (input.charCodeAt(pos) === CHAR_MINUS) { + pos++; + } + + // Read digits before decimal point + while (pos < length && isDigit(input.charCodeAt(pos))) { + pos++; + } + + // Handle decimal point and digits after it + if (pos < length && input.charCodeAt(pos) === CHAR_DOT) { + pos++; + while (pos < length && isDigit(input.charCodeAt(pos))) { + pos++; + } + } + + const value = input.slice(start, pos); + return { type: TokenType.NUMBER, value }; + } + + /** + * Reads a function name token after @ symbol + * @returns Function token + */ + function readFunction(): Token { + pos++; // Skip @ symbol + const start = pos; + + // First character must be a letter or underscore + if (pos < length && isAlpha(input.charCodeAt(pos))) { + pos++; + + // Subsequent characters can be alphanumeric + while (pos < length && isAlphaNumeric(input.charCodeAt(pos))) { + pos++; + } + } + + const value = input.slice(start, pos); + return { type: TokenType.FUNCTION, value }; + } + + /** + * Reads an identifier token, also handling boolean and null literals + * @returns Identifier, boolean, or null token + */ + function readIdentifier(): Token { + const start = pos++; // First character already checked + + // Read remaining characters + while (pos < length && isAlphaNumeric(input.charCodeAt(pos))) { + pos++; + } + + const value = input.slice(start, pos); + + // Check if it's a keyword (true, false, null) + const keywordType = KEYWORDS.get(value); + if (keywordType) { + return { type: keywordType, value }; + } + + return { type: TokenType.IDENTIFIER, value }; + } + + /** + * Reads an operator token, checking multi-character operators first + * @returns Operator token + */ + function readOperator(): Token { + // Try to match 3-character operators + if (pos + 2 < length) { + const op3 = input.substring(pos, pos + 3); + if (OPERATOR_TOKENS.has(op3)) { + pos += 3; + return { type: TokenType.OPERATOR, value: op3 }; + } + } + + // Try to match 2-character operators + if (pos + 1 < length) { + const op2 = input.substring(pos, pos + 2); + if (OPERATOR_TOKENS.has(op2)) { + pos += 2; + return { type: TokenType.OPERATOR, value: op2 }; + } + } + + // Try to match 1-character operators + const op1 = input[pos]; + if (OPERATOR_TOKENS.has(op1)) { + pos++; + return { type: TokenType.OPERATOR, value: op1 }; + } + + throw new ExpressionError( + `Unknown operator at position ${pos}: ${input.substring(pos, pos + 1)}`, + pos, + input.substring(Math.max(0, pos - 10), pos), + ); + } + + // Main tokenization loop + while (pos < length) { + const charCode = input.charCodeAt(pos); + + // Fast path for whitespace + if (isWhitespace(charCode)) { + pos++; + continue; + } + + // Fast path for single-character tokens + const cachedToken = CHAR_TOKEN_CACHE.get(charCode); + if (cachedToken) { + tokens[tokenCount++] = cachedToken; + pos++; + continue; + } + + // Handle string literals + if (charCode === CHAR_DOUBLE_QUOTE || charCode === CHAR_SINGLE_QUOTE) { + tokens[tokenCount++] = readString(charCode); + continue; + } + + // Handle numbers (including negative numbers) + if ( + isDigit(charCode) || + (charCode === CHAR_MINUS && + pos + 1 < length && + isDigit(input.charCodeAt(pos + 1))) + ) { + tokens[tokenCount++] = readNumber(); + continue; + } + + // Handle function calls starting with @ + if (charCode === CHAR_AT) { + tokens[tokenCount++] = readFunction(); + continue; + } + + // Handle identifiers (including keywords) + if (isAlpha(charCode)) { + tokens[tokenCount++] = readIdentifier(); + continue; + } + + // Handle operators + if (isOperatorStart(charCode)) { + tokens[tokenCount++] = readOperator(); + continue; + } + + // If we get here, we have an unexpected character + throw new ExpressionError( + `Unexpected character: ${input[pos]}`, + pos, + input.substring(Math.max(0, pos - 10), pos), + ); + } + + // Trim the tokens array to the actual number of tokens + return tokenCount === tokens.length ? tokens : tokens.slice(0, tokenCount); }; diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index bed974a..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,10 +0,0 @@ -export class ExpressionError extends Error { - constructor( - message: string, - public readonly position?: number, - public readonly token?: string, - ) { - super(message); - this.name = "ExpressionError"; - } -} diff --git a/tests/api-integration.test.ts b/tests/api-integration.test.ts index 5cdf3dd..b498574 100644 --- a/tests/api-integration.test.ts +++ b/tests/api-integration.test.ts @@ -2,150 +2,150 @@ import { describe, expect, it } from "vitest"; import { ExpressionError, compile, evaluate, register } from "../src"; describe("API Integration Tests", () => { - describe("evaluate function", () => { - it("should evaluate simple expressions", () => { - expect(evaluate("42")).toBe(42); - expect(evaluate("'hello'")).toBe("hello"); - expect(evaluate("true")).toBe(true); - expect(evaluate("null")).toBe(null); - }); - - it("should evaluate expressions with context", () => { - const context = { x: 10, y: 5 }; - expect(evaluate("x + y", context)).toBe(15); - expect(evaluate("x - y", context)).toBe(5); - expect(evaluate("x * y", context)).toBe(50); - expect(evaluate("x / y", context)).toBe(2); - }); - - it("should throw ExpressionError for invalid expressions", () => { - expect(() => evaluate("")).toThrow(ExpressionError); - expect(() => evaluate("x + y")).toThrow(ExpressionError); - }); - }); - - describe("compile function", () => { - it("should create a function that can be called with different contexts", () => { - const expr = compile("x + y"); - expect(expr({ x: 10, y: 5 })).toBe(15); - expect(expr({ x: 20, y: 30 })).toBe(50); - }); - - it("should throw when compiling invalid expressions", () => { - expect(() => compile("")).toThrow(ExpressionError); - }); - }); - - describe("register function", () => { - it("should register custom functions that can be used in expressions", () => { - register("sum", (...args) => args.reduce((a, b) => a + b, 0)); - expect(evaluate("@sum(1, 2, 3)")).toBe(6); - }); - - it("should allow registered functions to be used in compiled expressions", () => { - register("multiply", (a, b) => a * b); - const expr = compile("@multiply(x, y)"); - expect(expr({ x: 10, y: 5 })).toBe(50); - }); - }); - - describe("Variable References", () => { - it("should handle nested property access", () => { - const context = { - user: { profile: { name: "John", age: 30 } }, - }; - expect(evaluate("user.profile.name", context)).toBe("John"); - expect(evaluate("user.profile.age", context)).toBe(30); - }); - - it("should handle array access", () => { - const context = { items: [10, 20, 30] }; - expect(evaluate("items[0]", context)).toBe(10); - expect(evaluate("items[1]", context)).toBe(20); - expect(evaluate("items[2]", context)).toBe(30); - }); - - it("should handle mixed dot and bracket notation", () => { - const context = { data: { items: [{ value: 42 }] } }; - expect(evaluate("data.items[0].value", context)).toBe(42); - expect(evaluate("data['items'][0]['value']", context)).toBe(42); - }); - }); - - describe("Arithmetic Operations", () => { - const context = { a: 10, b: 3, c: 2 }; - - it("should handle basic arithmetic", () => { - expect(evaluate("a + b", context)).toBe(13); - expect(evaluate("a - b", context)).toBe(7); - expect(evaluate("a * b", context)).toBe(30); - expect(evaluate("a / b", context)).toBe(10 / 3); - }); - - it("should handle operator precedence", () => { - expect(evaluate("a + b * c", context)).toBe(16); // 10 + (3 * 2) - expect(evaluate("(a + b) * c", context)).toBe(26); // (10 + 3) * 2 - }); - - it("should handle modulo operation", () => { - expect(evaluate("a % b", context)).toBe(1); // 10 % 3 = 1 - }); - }); - - describe("Comparison and Logical Operations", () => { - const context = { - age: 20, - status: "active", - isAdmin: true, - isDeleted: false, - }; - - it("should handle comparison operators", () => { - expect(evaluate("age >= 18", context)).toBe(true); - expect(evaluate("age < 18", context)).toBe(false); - expect(evaluate("age === 20", context)).toBe(true); - expect(evaluate("age !== 21", context)).toBe(true); - }); - - it("should handle logical operators", () => { - expect(evaluate("isAdmin && !isDeleted", context)).toBe(true); - expect(evaluate("isAdmin || isDeleted", context)).toBe(true); - expect(evaluate("!isAdmin", context)).toBe(false); - }); - - it("should handle combined logical expressions", () => { - expect( - evaluate("(age >= 18 && status === 'active') || isAdmin", context), - ).toBe(true); - expect(evaluate("age < 18 && status === 'active'", context)).toBe(false); - }); - }); - - describe("Conditional (Ternary) Expressions", () => { - const context = { age: 20, score: 85 }; - - it("should handle simple ternary expressions", () => { - expect(evaluate("age >= 18 ? 'adult' : 'minor'", context)).toBe("adult"); - expect(evaluate("age < 18 ? 'minor' : 'adult'", context)).toBe("adult"); - }); - - it("should handle nested ternary expressions", () => { - const expr = "score >= 90 ? 'A' : score >= 80 ? 'B' : 'C'"; - expect(evaluate(expr, context)).toBe("B"); - }); - }); - - describe("Error Handling", () => { - it("should provide detailed error information", () => { - expect(() => evaluate("x +")).toThrow(ExpressionError); - }); - }); - - describe("Security Features", () => { - it("should prevent access to global objects", () => { - // Testing that we can't access window/global objects - expect(() => evaluate("window")).toThrow(ExpressionError); - expect(() => evaluate("global")).toThrow(ExpressionError); - }); - }); + describe("evaluate function", () => { + it("should evaluate simple expressions", () => { + expect(evaluate("42")).toBe(42); + expect(evaluate("'hello'")).toBe("hello"); + expect(evaluate("true")).toBe(true); + expect(evaluate("null")).toBe(null); + }); + + it("should evaluate expressions with context", () => { + const context = { x: 10, y: 5 }; + expect(evaluate("x + y", context)).toBe(15); + expect(evaluate("x - y", context)).toBe(5); + expect(evaluate("x * y", context)).toBe(50); + expect(evaluate("x / y", context)).toBe(2); + }); + + it("should throw ExpressionError for invalid expressions", () => { + expect(() => evaluate("")).toThrow(ExpressionError); + expect(() => evaluate("x + y")).toThrow(ExpressionError); + }); + }); + + describe("compile function", () => { + it("should create a function that can be called with different contexts", () => { + const expr = compile("x + y"); + expect(expr({ x: 10, y: 5 })).toBe(15); + expect(expr({ x: 20, y: 30 })).toBe(50); + }); + + it("should throw when compiling invalid expressions", () => { + expect(() => compile("")).toThrow(ExpressionError); + }); + }); + + describe("register function", () => { + it("should register custom functions that can be used in expressions", () => { + register("sum", (...args) => args.reduce((a, b) => a + b, 0)); + expect(evaluate("@sum(1, 2, 3)")).toBe(6); + }); + + it("should allow registered functions to be used in compiled expressions", () => { + register("multiply", (a, b) => a * b); + const expr = compile("@multiply(x, y)"); + expect(expr({ x: 10, y: 5 })).toBe(50); + }); + }); + + describe("Variable References", () => { + it("should handle nested property access", () => { + const context = { + user: { profile: { name: "John", age: 30 } }, + }; + expect(evaluate("user.profile.name", context)).toBe("John"); + expect(evaluate("user.profile.age", context)).toBe(30); + }); + + it("should handle array access", () => { + const context = { items: [10, 20, 30] }; + expect(evaluate("items[0]", context)).toBe(10); + expect(evaluate("items[1]", context)).toBe(20); + expect(evaluate("items[2]", context)).toBe(30); + }); + + it("should handle mixed dot and bracket notation", () => { + const context = { data: { items: [{ value: 42 }] } }; + expect(evaluate("data.items[0].value", context)).toBe(42); + expect(evaluate("data['items'][0]['value']", context)).toBe(42); + }); + }); + + describe("Arithmetic Operations", () => { + const context = { a: 10, b: 3, c: 2 }; + + it("should handle basic arithmetic", () => { + expect(evaluate("a + b", context)).toBe(13); + expect(evaluate("a - b", context)).toBe(7); + expect(evaluate("a * b", context)).toBe(30); + expect(evaluate("a / b", context)).toBe(10 / 3); + }); + + it("should handle operator precedence", () => { + expect(evaluate("a + b * c", context)).toBe(16); // 10 + (3 * 2) + expect(evaluate("(a + b) * c", context)).toBe(26); // (10 + 3) * 2 + }); + + it("should handle modulo operation", () => { + expect(evaluate("a % b", context)).toBe(1); // 10 % 3 = 1 + }); + }); + + describe("Comparison and Logical Operations", () => { + const context = { + age: 20, + status: "active", + isAdmin: true, + isDeleted: false, + }; + + it("should handle comparison operators", () => { + expect(evaluate("age >= 18", context)).toBe(true); + expect(evaluate("age < 18", context)).toBe(false); + expect(evaluate("age === 20", context)).toBe(true); + expect(evaluate("age !== 21", context)).toBe(true); + }); + + it("should handle logical operators", () => { + expect(evaluate("isAdmin && !isDeleted", context)).toBe(true); + expect(evaluate("isAdmin || isDeleted", context)).toBe(true); + expect(evaluate("!isAdmin", context)).toBe(false); + }); + + it("should handle combined logical expressions", () => { + expect( + evaluate("(age >= 18 && status === 'active') || isAdmin", context), + ).toBe(true); + expect(evaluate("age < 18 && status === 'active'", context)).toBe(false); + }); + }); + + describe("Conditional (Ternary) Expressions", () => { + const context = { age: 20, score: 85 }; + + it("should handle simple ternary expressions", () => { + expect(evaluate("age >= 18 ? 'adult' : 'minor'", context)).toBe("adult"); + expect(evaluate("age < 18 ? 'minor' : 'adult'", context)).toBe("adult"); + }); + + it("should handle nested ternary expressions", () => { + const expr = "score >= 90 ? 'A' : score >= 80 ? 'B' : 'C'"; + expect(evaluate(expr, context)).toBe("B"); + }); + }); + + describe("Error Handling", () => { + it("should provide detailed error information", () => { + expect(() => evaluate("x +")).toThrow(ExpressionError); + }); + }); + + describe("Security Features", () => { + it("should prevent access to global objects", () => { + // Testing that we can't access window/global objects + expect(() => evaluate("window")).toThrow(ExpressionError); + expect(() => evaluate("global")).toThrow(ExpressionError); + }); + }); }); diff --git a/tests/coverage-improvement.test.ts b/tests/coverage-improvement.test.ts index 89afa3c..ed3fda0 100644 --- a/tests/coverage-improvement.test.ts +++ b/tests/coverage-improvement.test.ts @@ -5,154 +5,154 @@ import { NodeType, parse } from "../src/parser"; import { TokenType, tokenize } from "../src/tokenizer"; describe("Coverage Improvement Tests", () => { - describe("Expression Error Handling", () => { - it("should handle non-ExpressionError errors during evaluation", () => { - // Mock the evaluate function to throw a generic error - const originalEvaluate = vi - .spyOn(console, "error") - .mockImplementation(() => { - throw new Error("Generic error"); - }); - - expect(() => evaluate("a + b", {})).toThrow(); - - // Restore the original function - originalEvaluate.mockRestore(); - }); - - it("should handle unknown errors during evaluation", () => { - // Mock the evaluate function to throw a non-Error object - const originalEvaluate = vi - .spyOn(console, "error") - .mockImplementation(() => { - throw "Not an error object"; - }); - - expect(() => evaluate("a + b", {})).toThrow(); - - // Restore the original function - originalEvaluate.mockRestore(); - }); - - it("should handle empty expressions", () => { - expect(() => evaluate("")).toThrow("Unexpected end of input"); - }); - - it("should compile expressions correctly", () => { - const compiled = compile("a + b"); - expect(compiled).toBeDefined(); - expect(typeof compiled).toBe("function"); - }); - }); - - describe("Tokenizer Edge Cases", () => { - it("should handle negative numbers in expressions", () => { - const tokens = tokenize("-42.5"); - - expect(tokens).toHaveLength(1); - expect(tokens[0]).toEqual({ type: TokenType.NUMBER, value: "-42.5" }); - }); - - it("should handle function names with underscores", () => { - const tokens = tokenize("@calculate_total(a, b)"); - - expect(tokens).toHaveLength(6); - expect(tokens[0]).toEqual({ - type: TokenType.FUNCTION, - value: "calculate_total", - }); - }); - }); - - describe("Parser Edge Cases", () => { - it("should throw error for missing comma between function arguments", () => { - // Create function call missing comma "@func(a b)" - const tokens = tokenize("@func(a b)"); - - expect(() => parse(tokens)).toThrow( - "Expected comma between function arguments", - ); - }); - - it("should throw error for unclosed function call", () => { - // Create unclosed function call "@func(a, b" - const tokens = tokenize("@func(a, b"); - - expect(() => parse(tokens)).toThrow("Expected closing parenthesis"); - }); - - it("should handle complex member expressions", () => { - // Test complex member expression "obj.prop[index].nested" - const tokens = tokenize("obj.prop[index].nested"); - const ast = parse(tokens); - - expect(ast.type).toBe(NodeType.Program); - expect(ast.body.type).toBe(NodeType.MemberExpression); - }); - }); - - describe("Interpreter Edge Cases", () => { - it("should handle null values in member expressions", () => { - const interpreterState = createInterpreterState(); - const ast: any = { - type: NodeType.Program, - body: { - type: NodeType.MemberExpression, - object: { - type: NodeType.Literal, - value: null, - raw: "null", - }, - property: { - type: NodeType.Identifier, - name: "prop", - }, - computed: false, - }, - }; - - expect(() => evaluateAst(ast, interpreterState, {})).toThrow( - "Cannot access property of null", - ); - }); - - it("should handle undefined functions in call expressions", () => { - const interpreterState = createInterpreterState(); - const ast: any = { - type: NodeType.Program, - body: { - type: NodeType.CallExpression, - callee: { - type: NodeType.Identifier, - name: "undefinedFunc", - }, - arguments: [], - }, - }; - - expect(() => evaluateAst(ast, interpreterState, {})).toThrow( - "Undefined function", - ); - }); - - it("should handle unsupported unary operators", () => { - const interpreterState = createInterpreterState(); - const ast: any = { - type: NodeType.Program, - body: { - type: NodeType.UnaryExpression, - operator: "~", // - argument: { - type: NodeType.Literal, - value: 5, - raw: "5", - }, - }, - }; - - expect(() => evaluateAst(ast, interpreterState, {})).toThrow( - "Postfix operators are not supported: ~", - ); - }); - }); + describe("Expression Error Handling", () => { + it("should handle non-ExpressionError errors during evaluation", () => { + // Mock the evaluate function to throw a generic error + const originalEvaluate = vi + .spyOn(console, "error") + .mockImplementation(() => { + throw new Error("Generic error"); + }); + + expect(() => evaluate("a + b", {})).toThrow(); + + // Restore the original function + originalEvaluate.mockRestore(); + }); + + it("should handle unknown errors during evaluation", () => { + // Mock the evaluate function to throw a non-Error object + const originalEvaluate = vi + .spyOn(console, "error") + .mockImplementation(() => { + throw "Not an error object"; + }); + + expect(() => evaluate("a + b", {})).toThrow(); + + // Restore the original function + originalEvaluate.mockRestore(); + }); + + it("should handle empty expressions", () => { + expect(() => evaluate("")).toThrow("Unexpected end of input"); + }); + + it("should compile expressions correctly", () => { + const compiled = compile("a + b"); + expect(compiled).toBeDefined(); + expect(typeof compiled).toBe("function"); + }); + }); + + describe("Tokenizer Edge Cases", () => { + it("should handle negative numbers in expressions", () => { + const tokens = tokenize("-42.5"); + + expect(tokens).toHaveLength(1); + expect(tokens[0]).toEqual({ type: TokenType.NUMBER, value: "-42.5" }); + }); + + it("should handle function names with underscores", () => { + const tokens = tokenize("@calculate_total(a, b)"); + + expect(tokens).toHaveLength(6); + expect(tokens[0]).toEqual({ + type: TokenType.FUNCTION, + value: "calculate_total", + }); + }); + }); + + describe("Parser Edge Cases", () => { + it("should throw error for missing comma between function arguments", () => { + // Create function call missing comma "@func(a b)" + const tokens = tokenize("@func(a b)"); + + expect(() => parse(tokens)).toThrow( + "Expected comma between function arguments", + ); + }); + + it("should throw error for unclosed function call", () => { + // Create unclosed function call "@func(a, b" + const tokens = tokenize("@func(a, b"); + + expect(() => parse(tokens)).toThrow("Expected closing parenthesis"); + }); + + it("should handle complex member expressions", () => { + // Test complex member expression "obj.prop[index].nested" + const tokens = tokenize("obj.prop[index].nested"); + const ast = parse(tokens); + + expect(ast.type).toBe(NodeType.Program); + expect(ast.body.type).toBe(NodeType.MemberExpression); + }); + }); + + describe("Interpreter Edge Cases", () => { + it("should handle null values in member expressions", () => { + const interpreterState = createInterpreterState(); + const ast: any = { + type: NodeType.Program, + body: { + type: NodeType.MemberExpression, + object: { + type: NodeType.Literal, + value: null, + raw: "null", + }, + property: { + type: NodeType.Identifier, + name: "prop", + }, + computed: false, + }, + }; + + expect(() => evaluateAst(ast, interpreterState, {})).toThrow( + "Cannot access property of null", + ); + }); + + it("should handle undefined functions in call expressions", () => { + const interpreterState = createInterpreterState(); + const ast: any = { + type: NodeType.Program, + body: { + type: NodeType.CallExpression, + callee: { + type: NodeType.Identifier, + name: "undefinedFunc", + }, + arguments: [], + }, + }; + + expect(() => evaluateAst(ast, interpreterState, {})).toThrow( + "Undefined function", + ); + }); + + it("should handle unsupported unary operators", () => { + const interpreterState = createInterpreterState(); + const ast: any = { + type: NodeType.Program, + body: { + type: NodeType.UnaryExpression, + operator: "~", // + argument: { + type: NodeType.Literal, + value: 5, + raw: "5", + }, + }, + }; + + expect(() => evaluateAst(ast, interpreterState, {})).toThrow( + "Postfix operators are not supported: ~", + ); + }); + }); }); diff --git a/tests/edge-cases.test.ts b/tests/edge-cases.test.ts index e8099cf..26d36b1 100644 --- a/tests/edge-cases.test.ts +++ b/tests/edge-cases.test.ts @@ -2,146 +2,146 @@ import { describe, expect, it } from "vitest"; import { ExpressionError, compile, evaluate, register } from "../src"; describe("Edge Cases and Advanced Scenarios", () => { - describe("Empty and Invalid Inputs", () => { - it("should handle empty expressions", () => { - expect(() => evaluate("")).toThrow(ExpressionError); - expect(() => evaluate(" ")).toThrow(ExpressionError); - }); - - it("should handle invalid syntax", () => { - expect(() => evaluate("x +")).toThrow(ExpressionError); - expect(() => evaluate("(x + y")).toThrow(ExpressionError); - expect(() => evaluate("x + y)")).toThrow(ExpressionError); - }); - - it("should handle invalid tokens", () => { - expect(() => evaluate("x $ y")).toThrow(ExpressionError); - expect(() => evaluate("#invalid")).toThrow(ExpressionError); - }); - }); - - describe("Type Coercion", () => { - it("should handle string concatenation", () => { - expect(evaluate("'hello' + ' world'")).toBe("hello world"); - expect(evaluate("'value: ' + 42")).toBe("value: 42"); - expect(evaluate("'is true: ' + true")).toBe("is true: true"); - }); - - it("should handle boolean coercion in logical operations", () => { - expect(evaluate("0 && 'anything'")).toBe(0); - expect(evaluate("1 && 'something'")).toBe("something"); - expect(evaluate("'' || 'fallback'")).toBe("fallback"); - expect(evaluate("'value' || 'fallback'")).toBe("value"); - }); - - it("should handle numeric coercion", () => { - expect(evaluate("'5' - 2")).toBe(3); - expect(evaluate("'10' / '2'")).toBe(5); - expect(evaluate("'3' * 4")).toBe(12); - }); - }); - - describe("Deep Nesting", () => { - it("should handle deeply nested expressions", () => { - const expr = "(x + y) * z / 2"; - expect(evaluate(expr, { x: 1, y: 2, z: 3 })).toBe(4.5); // ((1+2)*3/2) = 4.5 - }); - - it("should handle deeply nested object access", () => { - const context = { - a: { b: { c: { d: { e: { value: 42 } } } } }, - }; - expect(evaluate("a.b.c.d.e.value", context)).toBe(42); - }); - - it("should handle deeply nested array access", () => { - const context = { - matrix: [[[[5]]]], - }; - expect(evaluate("matrix[0][0][0][0]", context)).toBe(5); - }); - }); - - describe("Large Numbers and Precision", () => { - it("should handle large integers", () => { - expect(evaluate("1000000000 * 1000000000")).toBe(1000000000000000000); - }); - - it("should handle floating point precision", () => { - // JavaScript floating point precision issues - expect(evaluate("0.1 + 0.2")).toBeCloseTo(0.3); - expect(evaluate("0.1 + 0.2 === 0.3")).toBe(false); // JS behavior - }); - }); - - describe("Complex Function Usage", () => { - it("should support nested function calls", () => { - register("inner", (a, b) => a + b); - register("outer", (a, b) => a * b); - expect(evaluate("@outer(@inner(x, y), z)", { x: 2, y: 3, z: 4 })).toBe( - 20, - ); // (2+3)*4 = 20 - }); - - it("should handle function calls with complex expressions as arguments", () => { - register("calculate", (a, b, c) => a + b + c); - expect( - evaluate("@calculate(x + y, z * 2, w ? 1 : 0)", { - x: 1, - y: 2, - z: 3, - w: true, - }), - ).toBe(10); // (1+2) + (3*2) + 1 = 10 - }); - }); - - describe("Context Manipulation", () => { - it("should not modify the original context", () => { - const context = { x: 5, y: 10 }; - evaluate("x + y", context); - expect(context).toEqual({ x: 5, y: 10 }); // Context should be unchanged - }); - - it("should handle undefined context values", () => { - expect(() => evaluate("x + 5", { y: 10 })).toThrow(); - expect(() => evaluate("x.y.z", { x: {} })).toThrow(); - }); - }); - - describe("Performance Considerations", () => { - it("should benefit from pre-compilation", () => { - const expr = compile("x + y"); - - // This is more of a conceptual test since we can't easily measure performance in a unit test - // But we can verify that the compiled expression works correctly - expect(expr({ x: 1, y: 2 })).toBe(3); - expect(expr({ x: 10, y: 20 })).toBe(30); - }); - }); - - describe("Error Cases", () => { - it("should handle division by zero", () => { - expect(evaluate("10 / 0")).toBe(Number.POSITIVE_INFINITY); - expect(evaluate("-10 / 0")).toBe(Number.NEGATIVE_INFINITY); - }); - - it("should handle invalid property access", () => { - expect(() => evaluate("null.property")).toThrow(); - expect(() => evaluate("undefined.property")).toThrow(); - }); - - it("should handle invalid array access", () => { - const context = { arr: [1, 2, 3] }; - expect(evaluate("arr[10]", context)).toBe(undefined); - expect(evaluate("arr['invalid']", context)).toBe(undefined); - }); - }); - - describe("Security Edge Cases", () => { - it("should prevent access to global objects even with tricky expressions", () => { - // These should throw errors or return undefined, not expose global objects - expect(() => evaluate("this")).toThrow(); - }); - }); + describe("Empty and Invalid Inputs", () => { + it("should handle empty expressions", () => { + expect(() => evaluate("")).toThrow(ExpressionError); + expect(() => evaluate(" ")).toThrow(ExpressionError); + }); + + it("should handle invalid syntax", () => { + expect(() => evaluate("x +")).toThrow(ExpressionError); + expect(() => evaluate("(x + y")).toThrow(ExpressionError); + expect(() => evaluate("x + y)")).toThrow(ExpressionError); + }); + + it("should handle invalid tokens", () => { + expect(() => evaluate("x $ y")).toThrow(ExpressionError); + expect(() => evaluate("#invalid")).toThrow(ExpressionError); + }); + }); + + describe("Type Coercion", () => { + it("should handle string concatenation", () => { + expect(evaluate("'hello' + ' world'")).toBe("hello world"); + expect(evaluate("'value: ' + 42")).toBe("value: 42"); + expect(evaluate("'is true: ' + true")).toBe("is true: true"); + }); + + it("should handle boolean coercion in logical operations", () => { + expect(evaluate("0 && 'anything'")).toBe(0); + expect(evaluate("1 && 'something'")).toBe("something"); + expect(evaluate("'' || 'fallback'")).toBe("fallback"); + expect(evaluate("'value' || 'fallback'")).toBe("value"); + }); + + it("should handle numeric coercion", () => { + expect(evaluate("'5' - 2")).toBe(3); + expect(evaluate("'10' / '2'")).toBe(5); + expect(evaluate("'3' * 4")).toBe(12); + }); + }); + + describe("Deep Nesting", () => { + it("should handle deeply nested expressions", () => { + const expr = "(x + y) * z / 2"; + expect(evaluate(expr, { x: 1, y: 2, z: 3 })).toBe(4.5); // ((1+2)*3/2) = 4.5 + }); + + it("should handle deeply nested object access", () => { + const context = { + a: { b: { c: { d: { e: { value: 42 } } } } }, + }; + expect(evaluate("a.b.c.d.e.value", context)).toBe(42); + }); + + it("should handle deeply nested array access", () => { + const context = { + matrix: [[[[5]]]], + }; + expect(evaluate("matrix[0][0][0][0]", context)).toBe(5); + }); + }); + + describe("Large Numbers and Precision", () => { + it("should handle large integers", () => { + expect(evaluate("1000000000 * 1000000000")).toBe(1000000000000000000); + }); + + it("should handle floating point precision", () => { + // JavaScript floating point precision issues + expect(evaluate("0.1 + 0.2")).toBeCloseTo(0.3); + expect(evaluate("0.1 + 0.2 === 0.3")).toBe(false); // JS behavior + }); + }); + + describe("Complex Function Usage", () => { + it("should support nested function calls", () => { + register("inner", (a, b) => a + b); + register("outer", (a, b) => a * b); + expect(evaluate("@outer(@inner(x, y), z)", { x: 2, y: 3, z: 4 })).toBe( + 20, + ); // (2+3)*4 = 20 + }); + + it("should handle function calls with complex expressions as arguments", () => { + register("calculate", (a, b, c) => a + b + c); + expect( + evaluate("@calculate(x + y, z * 2, w ? 1 : 0)", { + x: 1, + y: 2, + z: 3, + w: true, + }), + ).toBe(10); // (1+2) + (3*2) + 1 = 10 + }); + }); + + describe("Context Manipulation", () => { + it("should not modify the original context", () => { + const context = { x: 5, y: 10 }; + evaluate("x + y", context); + expect(context).toEqual({ x: 5, y: 10 }); // Context should be unchanged + }); + + it("should handle undefined context values", () => { + expect(() => evaluate("x + 5", { y: 10 })).toThrow(); + expect(() => evaluate("x.y.z", { x: {} })).toThrow(); + }); + }); + + describe("Performance Considerations", () => { + it("should benefit from pre-compilation", () => { + const expr = compile("x + y"); + + // This is more of a conceptual test since we can't easily measure performance in a unit test + // But we can verify that the compiled expression works correctly + expect(expr({ x: 1, y: 2 })).toBe(3); + expect(expr({ x: 10, y: 20 })).toBe(30); + }); + }); + + describe("Error Cases", () => { + it("should handle division by zero", () => { + expect(evaluate("10 / 0")).toBe(Number.POSITIVE_INFINITY); + expect(evaluate("-10 / 0")).toBe(Number.NEGATIVE_INFINITY); + }); + + it("should handle invalid property access", () => { + expect(() => evaluate("null.property")).toThrow(); + expect(() => evaluate("undefined.property")).toThrow(); + }); + + it("should handle invalid array access", () => { + const context = { arr: [1, 2, 3] }; + expect(evaluate("arr[10]", context)).toBe(undefined); + expect(evaluate("arr['invalid']", context)).toBe(undefined); + }); + }); + + describe("Security Edge Cases", () => { + it("should prevent access to global objects even with tricky expressions", () => { + // These should throw errors or return undefined, not expose global objects + expect(() => evaluate("this")).toThrow(); + }); + }); }); diff --git a/tests/interpreter.test.ts b/tests/interpreter.test.ts index 5902340..dc8afd0 100644 --- a/tests/interpreter.test.ts +++ b/tests/interpreter.test.ts @@ -4,148 +4,148 @@ import { parse } from "../src/parser"; import { tokenize } from "../src/tokenizer"; describe("Interpreter", () => { - async function evaluateExpression( - input: string, - context = {}, - functions = {}, - ) { - const tokens = tokenize(input); - const ast = parse(tokens); - const interpreterState = createInterpreterState({}, functions); - return evaluateAst(ast, interpreterState, context); - } - - describe("Literals", () => { - it("should evaluate number literals", async () => { - expect(await evaluateExpression("42")).toBe(42); - }); - - it("should evaluate string literals", async () => { - expect(await evaluateExpression('"hello"')).toBe("hello"); - }); - - it("should evaluate boolean literals", async () => { - expect(await evaluateExpression("true")).toBe(true); - expect(await evaluateExpression("false")).toBe(false); - }); - - it("should evaluate null", async () => { - expect(await evaluateExpression("null")).toBe(null); - }); - }); - - describe("Member Expressions", () => { - const context = { - data: { - value: 42, - nested: { - array: [1, 2, 3], - }, - }, - }; - - it("should evaluate dot notation", async () => { - expect(await evaluateExpression("data.value", context)).toBe(42); - }); - - it("should evaluate bracket notation", async () => { - expect(await evaluateExpression('data["value"]', context)).toBe(42); - }); - - it("should evaluate nested access", async () => { - expect(await evaluateExpression("data.nested.array[1]", context)).toBe(2); - }); - }); - - describe("Function Calls", () => { - const functions = { - sum: (...args: number[]) => args.reduce((a, b) => a + b, 0), - max: Math.max, - }; - - it("should evaluate function calls", async () => { - expect(await evaluateExpression("@sum(1, 2, 3)", {}, functions)).toBe(6); - }); - - it("should evaluate nested expressions in arguments", async () => { - const context = { x: 1, y: 2 }; - expect( - await evaluateExpression("@max(x, y, 3)", context, functions), - ).toBe(3); - }); - }); - - describe("Binary Expressions", () => { - const context = { a: 5, b: 3 }; - - it("should evaluate arithmetic operators", async () => { - expect(await evaluateExpression("a + b", context)).toBe(8); - expect(await evaluateExpression("a - b", context)).toBe(2); - expect(await evaluateExpression("a * b", context)).toBe(15); - expect(await evaluateExpression("a / b", context)).toBe(5 / 3); - }); - - it("should evaluate comparison operators", async () => { - expect(await evaluateExpression("a > b", context)).toBe(true); - expect(await evaluateExpression("a === b", context)).toBe(false); - }); - - it("should evaluate logical operators", async () => { - expect(await evaluateExpression("true && false")).toBe(false); - expect(await evaluateExpression("true || false")).toBe(true); - }); - }); - - describe("Conditional Expressions", () => { - it("should evaluate simple conditionals", async () => { - expect(await evaluateExpression("true ? 1 : 2")).toBe(1); - expect(await evaluateExpression("false ? 1 : 2")).toBe(2); - }); - - it("should evaluate nested conditionals", async () => { - const input = "true ? false ? 1 : 2 : 3"; - expect(await evaluateExpression(input)).toBe(2); - }); - }); - - describe("Complex Expressions", () => { - const context = { - data: { - values: [1, 2, 3], - status: "active", - }, - }; - - const functions = { - sum: (arr: number[]) => arr.reduce((a, b) => a + b, 0), - }; - - it("should evaluate complex expressions", async () => { - const input = '@sum(data.values) > 5 ? data["status"] : "inactive"'; - expect(await evaluateExpression(input, context, functions)).toBe( - "active", - ); - }); - }); - - describe("Error Handling", () => { - it("should throw for undefined variables", async () => { - await expect(evaluateExpression("unknownVar")).rejects.toThrow( - "Undefined variable", - ); - }); - - it("should throw for undefined functions", async () => { - await expect(evaluateExpression("@unknown()")).rejects.toThrow( - "Undefined function", - ); - }); - - it("should throw for null property access", async () => { - const context = { data: null }; - await expect(evaluateExpression("data.value", context)).rejects.toThrow( - "Cannot access property of null", - ); - }); - }); + async function evaluateExpression( + input: string, + context = {}, + functions = {}, + ) { + const tokens = tokenize(input); + const ast = parse(tokens); + const interpreterState = createInterpreterState({}, functions); + return evaluateAst(ast, interpreterState, context); + } + + describe("Literals", () => { + it("should evaluate number literals", async () => { + expect(await evaluateExpression("42")).toBe(42); + }); + + it("should evaluate string literals", async () => { + expect(await evaluateExpression('"hello"')).toBe("hello"); + }); + + it("should evaluate boolean literals", async () => { + expect(await evaluateExpression("true")).toBe(true); + expect(await evaluateExpression("false")).toBe(false); + }); + + it("should evaluate null", async () => { + expect(await evaluateExpression("null")).toBe(null); + }); + }); + + describe("Member Expressions", () => { + const context = { + data: { + value: 42, + nested: { + array: [1, 2, 3], + }, + }, + }; + + it("should evaluate dot notation", async () => { + expect(await evaluateExpression("data.value", context)).toBe(42); + }); + + it("should evaluate bracket notation", async () => { + expect(await evaluateExpression('data["value"]', context)).toBe(42); + }); + + it("should evaluate nested access", async () => { + expect(await evaluateExpression("data.nested.array[1]", context)).toBe(2); + }); + }); + + describe("Function Calls", () => { + const functions = { + sum: (...args: number[]) => args.reduce((a, b) => a + b, 0), + max: Math.max, + }; + + it("should evaluate function calls", async () => { + expect(await evaluateExpression("@sum(1, 2, 3)", {}, functions)).toBe(6); + }); + + it("should evaluate nested expressions in arguments", async () => { + const context = { x: 1, y: 2 }; + expect( + await evaluateExpression("@max(x, y, 3)", context, functions), + ).toBe(3); + }); + }); + + describe("Binary Expressions", () => { + const context = { a: 5, b: 3 }; + + it("should evaluate arithmetic operators", async () => { + expect(await evaluateExpression("a + b", context)).toBe(8); + expect(await evaluateExpression("a - b", context)).toBe(2); + expect(await evaluateExpression("a * b", context)).toBe(15); + expect(await evaluateExpression("a / b", context)).toBe(5 / 3); + }); + + it("should evaluate comparison operators", async () => { + expect(await evaluateExpression("a > b", context)).toBe(true); + expect(await evaluateExpression("a === b", context)).toBe(false); + }); + + it("should evaluate logical operators", async () => { + expect(await evaluateExpression("true && false")).toBe(false); + expect(await evaluateExpression("true || false")).toBe(true); + }); + }); + + describe("Conditional Expressions", () => { + it("should evaluate simple conditionals", async () => { + expect(await evaluateExpression("true ? 1 : 2")).toBe(1); + expect(await evaluateExpression("false ? 1 : 2")).toBe(2); + }); + + it("should evaluate nested conditionals", async () => { + const input = "true ? false ? 1 : 2 : 3"; + expect(await evaluateExpression(input)).toBe(2); + }); + }); + + describe("Complex Expressions", () => { + const context = { + data: { + values: [1, 2, 3], + status: "active", + }, + }; + + const functions = { + sum: (arr: number[]) => arr.reduce((a, b) => a + b, 0), + }; + + it("should evaluate complex expressions", async () => { + const input = '@sum(data.values) > 5 ? data["status"] : "inactive"'; + expect(await evaluateExpression(input, context, functions)).toBe( + "active", + ); + }); + }); + + describe("Error Handling", () => { + it("should throw for undefined variables", async () => { + await expect(evaluateExpression("unknownVar")).rejects.toThrow( + "Undefined variable", + ); + }); + + it("should throw for undefined functions", async () => { + await expect(evaluateExpression("@unknown()")).rejects.toThrow( + "Undefined function", + ); + }); + + it("should throw for null property access", async () => { + const context = { data: null }; + await expect(evaluateExpression("data.value", context)).rejects.toThrow( + "Cannot access property of null", + ); + }); + }); }); diff --git a/tests/math-functions.test.ts b/tests/math-functions.test.ts index 036a34b..23877e1 100644 --- a/tests/math-functions.test.ts +++ b/tests/math-functions.test.ts @@ -2,204 +2,204 @@ import { describe, expect, it } from "vitest"; import { evaluate } from "../src"; describe("Default Math Functions", () => { - describe("abs function", () => { - it("should return the absolute value of a number", async () => { - const result = await evaluate("@abs(-5)"); - expect(result).toBe(5); - }); - - it("should handle zero", async () => { - const result = await evaluate("@abs(0)"); - expect(result).toBe(0); - }); - - it("should handle positive numbers", async () => { - const result = await evaluate("@abs(10)"); - expect(result).toBe(10); - }); - }); - - describe("ceil function", () => { - it("should round up to the nearest integer", async () => { - const result = await evaluate("@ceil(4.3)"); - expect(result).toBe(5); - }); - - it("should not change integers", async () => { - const result = await evaluate("@ceil(7)"); - expect(result).toBe(7); - }); - - it("should handle negative numbers", async () => { - const result = await evaluate("@ceil(-3.7)"); - expect(result).toBe(-3); - }); - }); - - describe("floor function", () => { - it("should round down to the nearest integer", async () => { - const result = await evaluate("@floor(4.9)"); - expect(result).toBe(4); - }); - - it("should not change integers", async () => { - const result = await evaluate("@floor(7)"); - expect(result).toBe(7); - }); - - it("should handle negative numbers", async () => { - const result = await evaluate("@floor(-3.1)"); - expect(result).toBe(-4); - }); - }); - - describe("max function", () => { - it("should return the largest of two numbers", async () => { - const result = await evaluate("@max(5, 10)"); - expect(result).toBe(10); - }); - - it("should handle multiple arguments", async () => { - const result = await evaluate("@max(5, 10, 3, 8, 15, 2)"); - expect(result).toBe(15); - }); - - it("should handle negative numbers", async () => { - const result = await evaluate("@max(-5, -10, -3)"); - expect(result).toBe(-3); - }); - - it("should handle variables in context", async () => { - const result = await evaluate("@max(a, b, c)", { a: 5, b: 10, c: 3 }); - expect(result).toBe(10); - }); - }); - - describe("min function", () => { - it("should return the smallest of two numbers", async () => { - const result = await evaluate("@min(5, 10)"); - expect(result).toBe(5); - }); - - it("should handle multiple arguments", async () => { - const result = await evaluate("@min(5, 10, 3, 8, 15, 2)"); - expect(result).toBe(2); - }); - - it("should handle negative numbers", async () => { - const result = await evaluate("@min(-5, -10, -3)"); - expect(result).toBe(-10); - }); - - it("should handle variables in context", async () => { - const result = await evaluate("@min(a, b, c)", { a: 5, b: 10, c: 3 }); - expect(result).toBe(3); - }); - }); - - describe("round function", () => { - it("should round to the nearest integer", async () => { - const result = await evaluate("@round(4.3)"); - expect(result).toBe(4); - }); - - it("should round up for values >= .5", async () => { - const result = await evaluate("@round(4.5)"); - expect(result).toBe(5); - }); - - it("should handle negative numbers", async () => { - const result = await evaluate("@round(-3.7)"); - expect(result).toBe(-4); - }); - - it("should handle negative numbers with .5", async () => { - const result = await evaluate("@round(-3.5)"); - expect(result).toBe(-3); - }); - }); - - describe("sqrt function", () => { - it("should return the square root of a positive number", async () => { - const result = await evaluate("@sqrt(16)"); - expect(result).toBe(4); - }); - - it("should handle non-perfect squares", async () => { - const result = await evaluate("@sqrt(2)"); - expect(result).toBeCloseTo(1.4142, 4); - }); - - it("should handle zero", async () => { - const result = await evaluate("@sqrt(0)"); - expect(result).toBe(0); - }); - - it("should return NaN for negative numbers", async () => { - const result = await evaluate("@sqrt(-4)"); - expect(result).toBeNaN(); - }); - }); - - describe("pow function", () => { - it("should return the base raised to the exponent", async () => { - const result = await evaluate("@pow(2, 3)"); - expect(result).toBe(8); - }); - - it("should handle fractional exponents", async () => { - const result = await evaluate("@pow(4, 0.5)"); - expect(result).toBe(2); - }); - - it("should handle negative exponents", async () => { - const result = await evaluate("@pow(2, -2)"); - expect(result).toBe(0.25); - }); - - it("should handle zero base with positive exponent", async () => { - const result = await evaluate("@pow(0, 5)"); - expect(result).toBe(0); - }); - - it("should handle zero base with zero exponent", async () => { - const result = await evaluate("@pow(0, 0)"); - expect(result).toBe(1); // This is the mathematical convention - }); - - it("should handle variables in context", async () => { - const result = await evaluate("@pow(base, exponent)", { - base: 3, - exponent: 4, - }); - expect(result).toBe(81); - }); - }); - - describe("Combined math functions", () => { - it("should allow nesting of math functions", async () => { - const result = await evaluate("@round(@sqrt(@pow(x, 2) + @pow(y, 2)))", { - x: 3, - y: 4, - }); - expect(result).toBe(5); // sqrt(3² + 4²) = sqrt(25) = 5 - }); - - it("should work with expressions as arguments", async () => { - const result = await evaluate("@max(@abs(x), @abs(y), @abs(z))", { - x: -5, - y: 3, - z: -8, - }); - expect(result).toBe(8); - }); - - it("should handle complex mathematical expressions", async () => { - const result = await evaluate( - "@pow(@floor(x / y), 2) + @ceil(@sqrt(z))", - { x: 10, y: 3, z: 15 }, - ); - expect(result).toBe(13); // pow(floor(10/3), 2) + ceil(sqrt(15)) = 3² + ceil(3.87) = 9 + 4 = 13 - }); - }); + describe("abs function", () => { + it("should return the absolute value of a number", async () => { + const result = await evaluate("@abs(-5)"); + expect(result).toBe(5); + }); + + it("should handle zero", async () => { + const result = await evaluate("@abs(0)"); + expect(result).toBe(0); + }); + + it("should handle positive numbers", async () => { + const result = await evaluate("@abs(10)"); + expect(result).toBe(10); + }); + }); + + describe("ceil function", () => { + it("should round up to the nearest integer", async () => { + const result = await evaluate("@ceil(4.3)"); + expect(result).toBe(5); + }); + + it("should not change integers", async () => { + const result = await evaluate("@ceil(7)"); + expect(result).toBe(7); + }); + + it("should handle negative numbers", async () => { + const result = await evaluate("@ceil(-3.7)"); + expect(result).toBe(-3); + }); + }); + + describe("floor function", () => { + it("should round down to the nearest integer", async () => { + const result = await evaluate("@floor(4.9)"); + expect(result).toBe(4); + }); + + it("should not change integers", async () => { + const result = await evaluate("@floor(7)"); + expect(result).toBe(7); + }); + + it("should handle negative numbers", async () => { + const result = await evaluate("@floor(-3.1)"); + expect(result).toBe(-4); + }); + }); + + describe("max function", () => { + it("should return the largest of two numbers", async () => { + const result = await evaluate("@max(5, 10)"); + expect(result).toBe(10); + }); + + it("should handle multiple arguments", async () => { + const result = await evaluate("@max(5, 10, 3, 8, 15, 2)"); + expect(result).toBe(15); + }); + + it("should handle negative numbers", async () => { + const result = await evaluate("@max(-5, -10, -3)"); + expect(result).toBe(-3); + }); + + it("should handle variables in context", async () => { + const result = await evaluate("@max(a, b, c)", { a: 5, b: 10, c: 3 }); + expect(result).toBe(10); + }); + }); + + describe("min function", () => { + it("should return the smallest of two numbers", async () => { + const result = await evaluate("@min(5, 10)"); + expect(result).toBe(5); + }); + + it("should handle multiple arguments", async () => { + const result = await evaluate("@min(5, 10, 3, 8, 15, 2)"); + expect(result).toBe(2); + }); + + it("should handle negative numbers", async () => { + const result = await evaluate("@min(-5, -10, -3)"); + expect(result).toBe(-10); + }); + + it("should handle variables in context", async () => { + const result = await evaluate("@min(a, b, c)", { a: 5, b: 10, c: 3 }); + expect(result).toBe(3); + }); + }); + + describe("round function", () => { + it("should round to the nearest integer", async () => { + const result = await evaluate("@round(4.3)"); + expect(result).toBe(4); + }); + + it("should round up for values >= .5", async () => { + const result = await evaluate("@round(4.5)"); + expect(result).toBe(5); + }); + + it("should handle negative numbers", async () => { + const result = await evaluate("@round(-3.7)"); + expect(result).toBe(-4); + }); + + it("should handle negative numbers with .5", async () => { + const result = await evaluate("@round(-3.5)"); + expect(result).toBe(-3); + }); + }); + + describe("sqrt function", () => { + it("should return the square root of a positive number", async () => { + const result = await evaluate("@sqrt(16)"); + expect(result).toBe(4); + }); + + it("should handle non-perfect squares", async () => { + const result = await evaluate("@sqrt(2)"); + expect(result).toBeCloseTo(1.4142, 4); + }); + + it("should handle zero", async () => { + const result = await evaluate("@sqrt(0)"); + expect(result).toBe(0); + }); + + it("should return NaN for negative numbers", async () => { + const result = await evaluate("@sqrt(-4)"); + expect(result).toBeNaN(); + }); + }); + + describe("pow function", () => { + it("should return the base raised to the exponent", async () => { + const result = await evaluate("@pow(2, 3)"); + expect(result).toBe(8); + }); + + it("should handle fractional exponents", async () => { + const result = await evaluate("@pow(4, 0.5)"); + expect(result).toBe(2); + }); + + it("should handle negative exponents", async () => { + const result = await evaluate("@pow(2, -2)"); + expect(result).toBe(0.25); + }); + + it("should handle zero base with positive exponent", async () => { + const result = await evaluate("@pow(0, 5)"); + expect(result).toBe(0); + }); + + it("should handle zero base with zero exponent", async () => { + const result = await evaluate("@pow(0, 0)"); + expect(result).toBe(1); // This is the mathematical convention + }); + + it("should handle variables in context", async () => { + const result = await evaluate("@pow(base, exponent)", { + base: 3, + exponent: 4, + }); + expect(result).toBe(81); + }); + }); + + describe("Combined math functions", () => { + it("should allow nesting of math functions", async () => { + const result = await evaluate("@round(@sqrt(@pow(x, 2) + @pow(y, 2)))", { + x: 3, + y: 4, + }); + expect(result).toBe(5); // sqrt(3² + 4²) = sqrt(25) = 5 + }); + + it("should work with expressions as arguments", async () => { + const result = await evaluate("@max(@abs(x), @abs(y), @abs(z))", { + x: -5, + y: 3, + z: -8, + }); + expect(result).toBe(8); + }); + + it("should handle complex mathematical expressions", async () => { + const result = await evaluate( + "@pow(@floor(x / y), 2) + @ceil(@sqrt(z))", + { x: 10, y: 3, z: 15 }, + ); + expect(result).toBe(13); // pow(floor(10/3), 2) + ceil(sqrt(15)) = 3² + ceil(3.87) = 9 + 4 = 13 + }); + }); }); diff --git a/tests/parser.test.ts b/tests/parser.test.ts index c33ed01..e467bb4 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -3,357 +3,357 @@ import { NodeType, parse } from "../src/parser"; import { tokenize } from "../src/tokenizer"; describe("Parser", () => { - function parseExpression(input: string) { - const tokens = tokenize(input); - return parse(tokens); - } + function parseExpression(input: string) { + const tokens = tokenize(input); + return parse(tokens); + } - describe("Literals", () => { - it("should parse number literals", () => { - const ast = parseExpression("42"); - expect(ast).toEqual({ - type: NodeType.Program, - body: { - type: NodeType.Literal, - value: 42, - }, - }); - }); + describe("Literals", () => { + it("should parse number literals", () => { + const ast = parseExpression("42"); + expect(ast).toEqual({ + type: NodeType.Program, + body: { + type: NodeType.Literal, + value: 42, + }, + }); + }); - it("should parse string literals", () => { - const ast = parseExpression('"hello"'); - expect(ast).toEqual({ - type: NodeType.Program, - body: { - type: NodeType.Literal, - value: "hello", - }, - }); - }); + it("should parse string literals", () => { + const ast = parseExpression('"hello"'); + expect(ast).toEqual({ + type: NodeType.Program, + body: { + type: NodeType.Literal, + value: "hello", + }, + }); + }); - it("should parse boolean literals", () => { - const ast = parseExpression("true"); - expect(ast).toEqual({ - type: NodeType.Program, - body: { - type: NodeType.Literal, - value: true, - }, - }); - }); + it("should parse boolean literals", () => { + const ast = parseExpression("true"); + expect(ast).toEqual({ + type: NodeType.Program, + body: { + type: NodeType.Literal, + value: true, + }, + }); + }); - it("should parse null literal", () => { - const ast = parseExpression("null"); - expect(ast).toEqual({ - type: NodeType.Program, - body: { - type: NodeType.Literal, - value: null, - }, - }); - }); - }); + it("should parse null literal", () => { + const ast = parseExpression("null"); + expect(ast).toEqual({ + type: NodeType.Program, + body: { + type: NodeType.Literal, + value: null, + }, + }); + }); + }); - describe("Member Expressions", () => { - it("should parse dot notation", () => { - const ast = parseExpression("data.value"); - expect(ast).toEqual({ - type: NodeType.Program, - body: { - type: NodeType.MemberExpression, - object: { - type: NodeType.Identifier, - name: "data", - }, - property: { - type: NodeType.Identifier, - name: "value", - }, - computed: false, - }, - }); - }); + describe("Member Expressions", () => { + it("should parse dot notation", () => { + const ast = parseExpression("data.value"); + expect(ast).toEqual({ + type: NodeType.Program, + body: { + type: NodeType.MemberExpression, + object: { + type: NodeType.Identifier, + name: "data", + }, + property: { + type: NodeType.Identifier, + name: "value", + }, + computed: false, + }, + }); + }); - it("should parse bracket notation", () => { - const ast = parseExpression('data["value"]'); - expect(ast).toEqual({ - type: NodeType.Program, - body: { - type: NodeType.MemberExpression, - object: { - type: NodeType.Identifier, - name: "data", - }, - property: { - type: NodeType.Literal, - value: "value", - }, - computed: true, - }, - }); - }); - }); + it("should parse bracket notation", () => { + const ast = parseExpression('data["value"]'); + expect(ast).toEqual({ + type: NodeType.Program, + body: { + type: NodeType.MemberExpression, + object: { + type: NodeType.Identifier, + name: "data", + }, + property: { + type: NodeType.Literal, + value: "value", + }, + computed: true, + }, + }); + }); + }); - describe("Function Calls", () => { - it("should parse function calls without arguments", () => { - const ast = parseExpression("@sum()"); - expect(ast).toEqual({ - type: NodeType.Program, - body: { - type: NodeType.CallExpression, - callee: { - type: NodeType.Identifier, - name: "sum", - }, - arguments: [], - }, - }); - }); + describe("Function Calls", () => { + it("should parse function calls without arguments", () => { + const ast = parseExpression("@sum()"); + expect(ast).toEqual({ + type: NodeType.Program, + body: { + type: NodeType.CallExpression, + callee: { + type: NodeType.Identifier, + name: "sum", + }, + arguments: [], + }, + }); + }); - it("should parse function calls with multiple arguments", () => { - const ast = parseExpression("@max(a, b, 42)"); - expect(ast).toEqual({ - type: NodeType.Program, - body: { - type: NodeType.CallExpression, - callee: { - type: NodeType.Identifier, - name: "max", - }, - arguments: [ - { - type: NodeType.Identifier, - name: "a", - }, - { - type: NodeType.Identifier, - name: "b", - }, - { - type: NodeType.Literal, - value: 42, - }, - ], - }, - }); - }); - }); + it("should parse function calls with multiple arguments", () => { + const ast = parseExpression("@max(a, b, 42)"); + expect(ast).toEqual({ + type: NodeType.Program, + body: { + type: NodeType.CallExpression, + callee: { + type: NodeType.Identifier, + name: "max", + }, + arguments: [ + { + type: NodeType.Identifier, + name: "a", + }, + { + type: NodeType.Identifier, + name: "b", + }, + { + type: NodeType.Literal, + value: 42, + }, + ], + }, + }); + }); + }); - describe("Binary Expressions", () => { - it("should parse arithmetic expressions", () => { - const ast = parseExpression("a + b * c"); - expect(ast).toEqual({ - type: NodeType.Program, - body: { - type: NodeType.BinaryExpression, - operator: "+", - left: { - type: NodeType.Identifier, - name: "a", - }, - right: { - type: NodeType.BinaryExpression, - operator: "*", - left: { - type: NodeType.Identifier, - name: "b", - }, - right: { - type: NodeType.Identifier, - name: "c", - }, - }, - }, - }); - }); + describe("Binary Expressions", () => { + it("should parse arithmetic expressions", () => { + const ast = parseExpression("a + b * c"); + expect(ast).toEqual({ + type: NodeType.Program, + body: { + type: NodeType.BinaryExpression, + operator: "+", + left: { + type: NodeType.Identifier, + name: "a", + }, + right: { + type: NodeType.BinaryExpression, + operator: "*", + left: { + type: NodeType.Identifier, + name: "b", + }, + right: { + type: NodeType.Identifier, + name: "c", + }, + }, + }, + }); + }); - it("should parse comparison expressions", () => { - const ast = parseExpression("a > b"); - expect(ast).toEqual({ - type: NodeType.Program, - body: { - type: NodeType.BinaryExpression, - operator: ">", - left: { - type: NodeType.Identifier, - name: "a", - }, - right: { - type: NodeType.Identifier, - name: "b", - }, - }, - }); - }); + it("should parse comparison expressions", () => { + const ast = parseExpression("a > b"); + expect(ast).toEqual({ + type: NodeType.Program, + body: { + type: NodeType.BinaryExpression, + operator: ">", + left: { + type: NodeType.Identifier, + name: "a", + }, + right: { + type: NodeType.Identifier, + name: "b", + }, + }, + }); + }); - it("should parse logical expressions", () => { - const ast = parseExpression("a && b || c"); - expect(ast).toEqual({ - type: NodeType.Program, - body: { - type: NodeType.BinaryExpression, - operator: "||", - left: { - type: NodeType.BinaryExpression, - operator: "&&", - left: { - type: NodeType.Identifier, - name: "a", - }, - right: { - type: NodeType.Identifier, - name: "b", - }, - }, - right: { - type: NodeType.Identifier, - name: "c", - }, - }, - }); - }); - }); + it("should parse logical expressions", () => { + const ast = parseExpression("a && b || c"); + expect(ast).toEqual({ + type: NodeType.Program, + body: { + type: NodeType.BinaryExpression, + operator: "||", + left: { + type: NodeType.BinaryExpression, + operator: "&&", + left: { + type: NodeType.Identifier, + name: "a", + }, + right: { + type: NodeType.Identifier, + name: "b", + }, + }, + right: { + type: NodeType.Identifier, + name: "c", + }, + }, + }); + }); + }); - describe("Unary Expressions", () => { - it("should parse unary expressions", () => { - const ast = parseExpression("!a"); - expect(ast).toEqual({ - type: NodeType.Program, - body: { - type: NodeType.UnaryExpression, - operator: "!", - argument: { - type: NodeType.Identifier, - name: "a", - }, - prefix: true, - }, - }); - }); - }); + describe("Unary Expressions", () => { + it("should parse unary expressions", () => { + const ast = parseExpression("!a"); + expect(ast).toEqual({ + type: NodeType.Program, + body: { + type: NodeType.UnaryExpression, + operator: "!", + argument: { + type: NodeType.Identifier, + name: "a", + }, + prefix: true, + }, + }); + }); + }); - describe("Conditional Expressions", () => { - it("should parse ternary expressions", () => { - const ast = parseExpression("a ? b : c"); - expect(ast).toEqual({ - type: NodeType.Program, - body: { - type: NodeType.ConditionalExpression, - test: { - type: NodeType.Identifier, - name: "a", - }, - consequent: { - type: NodeType.Identifier, - name: "b", - }, - alternate: { - type: NodeType.Identifier, - name: "c", - }, - }, - }); - }); + describe("Conditional Expressions", () => { + it("should parse ternary expressions", () => { + const ast = parseExpression("a ? b : c"); + expect(ast).toEqual({ + type: NodeType.Program, + body: { + type: NodeType.ConditionalExpression, + test: { + type: NodeType.Identifier, + name: "a", + }, + consequent: { + type: NodeType.Identifier, + name: "b", + }, + alternate: { + type: NodeType.Identifier, + name: "c", + }, + }, + }); + }); - it("should parse nested ternary expressions", () => { - const ast = parseExpression("a ? b : c ? d : e"); - expect(ast).toEqual({ - type: NodeType.Program, - body: { - type: NodeType.ConditionalExpression, - test: { - type: NodeType.Identifier, - name: "a", - }, - consequent: { - type: NodeType.Identifier, - name: "b", - }, - alternate: { - type: NodeType.ConditionalExpression, - test: { - type: NodeType.Identifier, - name: "c", - }, - consequent: { - type: NodeType.Identifier, - name: "d", - }, - alternate: { - type: NodeType.Identifier, - name: "e", - }, - }, - }, - }); - }); - }); + it("should parse nested ternary expressions", () => { + const ast = parseExpression("a ? b : c ? d : e"); + expect(ast).toEqual({ + type: NodeType.Program, + body: { + type: NodeType.ConditionalExpression, + test: { + type: NodeType.Identifier, + name: "a", + }, + consequent: { + type: NodeType.Identifier, + name: "b", + }, + alternate: { + type: NodeType.ConditionalExpression, + test: { + type: NodeType.Identifier, + name: "c", + }, + consequent: { + type: NodeType.Identifier, + name: "d", + }, + alternate: { + type: NodeType.Identifier, + name: "e", + }, + }, + }, + }); + }); + }); - describe("Complex Expressions", () => { - it("should parse complex expressions", () => { - const ast = parseExpression("a + b * c > d ? e : f"); - expect(ast).toEqual({ - type: NodeType.Program, - body: { - type: NodeType.ConditionalExpression, - test: { - type: NodeType.BinaryExpression, - operator: ">", - left: { - type: NodeType.BinaryExpression, - operator: "+", - left: { - type: NodeType.Identifier, - name: "a", - }, - right: { - type: NodeType.BinaryExpression, - operator: "*", - left: { - type: NodeType.Identifier, - name: "b", - }, - right: { - type: NodeType.Identifier, - name: "c", - }, - }, - }, - right: { - type: NodeType.Identifier, - name: "d", - }, - }, - consequent: { - type: NodeType.Identifier, - name: "e", - }, - alternate: { - type: NodeType.Identifier, - name: "f", - }, - }, - }); - }); - }); + describe("Complex Expressions", () => { + it("should parse complex expressions", () => { + const ast = parseExpression("a + b * c > d ? e : f"); + expect(ast).toEqual({ + type: NodeType.Program, + body: { + type: NodeType.ConditionalExpression, + test: { + type: NodeType.BinaryExpression, + operator: ">", + left: { + type: NodeType.BinaryExpression, + operator: "+", + left: { + type: NodeType.Identifier, + name: "a", + }, + right: { + type: NodeType.BinaryExpression, + operator: "*", + left: { + type: NodeType.Identifier, + name: "b", + }, + right: { + type: NodeType.Identifier, + name: "c", + }, + }, + }, + right: { + type: NodeType.Identifier, + name: "d", + }, + }, + consequent: { + type: NodeType.Identifier, + name: "e", + }, + alternate: { + type: NodeType.Identifier, + name: "f", + }, + }, + }); + }); + }); - describe("Error Handling", () => { - it("should throw error for unexpected token", () => { - expect(() => parseExpression("a +")).toThrow("Unexpected end of input"); - }); + describe("Error Handling", () => { + it("should throw error for unexpected token", () => { + expect(() => parseExpression("a +")).toThrow("Unexpected end of input"); + }); - it("should throw error for invalid property access", () => { - expect(() => parseExpression("a.")).toThrow("Expected property name"); - }); + it("should throw error for invalid property access", () => { + expect(() => parseExpression("a.")).toThrow("Expected property name"); + }); - it("should throw error for unclosed bracket notation", () => { - expect(() => parseExpression("a[b")).toThrow("Expected closing bracket"); - }); + it("should throw error for unclosed bracket notation", () => { + expect(() => parseExpression("a[b")).toThrow("Expected closing bracket"); + }); - it("should throw error for invalid ternary expression", () => { - expect(() => parseExpression("a ? b")).toThrow( - "Expected : in conditional expression", - ); - }); - }); + it("should throw error for invalid ternary expression", () => { + expect(() => parseExpression("a ? b")).toThrow( + "Expected : in conditional expression", + ); + }); + }); }); diff --git a/tests/template-dynamic.test.ts b/tests/template-dynamic.test.ts index 2d14b3e..cbb71bb 100644 --- a/tests/template-dynamic.test.ts +++ b/tests/template-dynamic.test.ts @@ -2,474 +2,474 @@ import { describe, expect, it } from "vitest"; import { evaluate, register } from "../src"; describe("Dynamic Template Capabilities", () => { - // Helper function to evaluate expressions with custom functions - async function evaluateExpr( - expression: string, - context = {}, - functions = {}, - ) { - // Register any custom functions - Object.entries(functions).forEach(([name, fn]) => { - register(name, fn as any); - }); - - // Evaluate the expression - return await evaluate(expression, context); - } - - describe("Nested Property Access", () => { - const context = { - user: { - profile: { - details: { - preferences: { - theme: "dark", - fontSize: 16, - notifications: { - email: true, - push: false, - frequency: "daily", - }, - }, - address: { - city: "Shanghai", - country: "China", - coordinates: [121.4737, 31.2304], - }, - }, - lastLogin: "2025-03-10", - }, - permissions: ["read", "write", "admin"], - active: true, - }, - settings: { - global: { - language: "zh-CN", - timezone: "Asia/Shanghai", - }, - }, - stats: { - visits: 42, - actions: 128, - performance: { - average: 95.7, - history: [94.2, 95.1, 95.7, 96.3, 97.0], - }, - }, - }; - - it("should access deeply nested properties", async () => { - expect( - await evaluateExpr("user.profile.details.preferences.theme", context), - ).toBe("dark"); - expect( - await evaluateExpr( - "user.profile.details.address.coordinates[0]", - context, - ), - ).toBe(121.4737); - expect( - await evaluateExpr( - "user.profile.details.preferences.notifications.frequency", - context, - ), - ).toBe("daily"); - }); - - it("should handle bracket notation with string literals", async () => { - expect( - await evaluateExpr( - 'user["profile"]["details"]["preferences"]["fontSize"]', - context, - ), - ).toBe(16); - }); - - it("should handle mixed dot and bracket notation", async () => { - expect( - await evaluateExpr( - 'user.profile["details"].preferences["notifications"].push', - context, - ), - ).toBe(false); - expect( - await evaluateExpr('user["profile"].details["address"].city', context), - ).toBe("Shanghai"); - }); - }); - - describe("Dynamic Property Access", () => { - const context = { - data: { - key1: "value1", - key2: "value2", - key3: "value3", - }, - keys: ["key1", "key2", "key3"], - selectedKey: "key2", - config: { - mapping: { - field1: "key1", - field2: "key2", - field3: "key3", - }, - selected: "field2", - }, - }; - - it("should access properties using dynamic keys", async () => { - expect(await evaluateExpr("data[selectedKey]", context)).toBe("value2"); - expect(await evaluateExpr("data[keys[0]]", context)).toBe("value1"); - expect(await evaluateExpr("data[keys[2]]", context)).toBe("value3"); - }); - - it("should handle nested dynamic property access", async () => { - expect( - await evaluateExpr("data[config.mapping[config.selected]]", context), - ).toBe("value2"); - expect(await evaluateExpr("data[config.mapping.field1]", context)).toBe( - "value1", - ); - }); - }); - - describe("Conditional Logic", () => { - const context = { - user: { - role: "admin", - verified: true, - age: 30, - subscription: "premium", - }, - thresholds: { - age: 18, - premium: 25, - }, - features: { - basic: ["read", "comment"], - premium: ["read", "comment", "publish", "moderate"], - admin: ["read", "comment", "publish", "moderate", "manage"], - }, - }; - - it("should evaluate simple conditional expressions", async () => { - expect( - await evaluateExpr( - "user.role === 'admin' ? 'Administrator' : 'User'", - context, - ), - ).toBe("Administrator"); - expect( - await evaluateExpr( - "user.verified ? 'Verified' : 'Unverified'", - context, - ), - ).toBe("Verified"); - }); - - it("should handle nested conditional expressions", async () => { - const expr = - "user.role === 'admin' ? 'Admin Access' : (user.subscription === 'premium' ? 'Premium Access' : 'Basic Access')"; - expect(await evaluateExpr(expr, context)).toBe("Admin Access"); - - const context2: any = { - ...context, - user: { ...context.user, role: "user" }, - }; - expect(await evaluateExpr(expr, context2)).toBe("Premium Access"); - - const context3: any = { - ...context2, - user: { ...context2.user, subscription: "basic" }, - }; - expect(await evaluateExpr(expr, context3)).toBe("Basic Access"); - }); - - it("should combine conditional logic with property access", async () => { - const expr = - "user.role === 'admin' ? features.admin : (user.subscription === 'premium' ? features.premium : features.basic)"; - expect(((await evaluateExpr(expr, context)) as any)[4]).toBe("manage"); // admin features include 'manage' - - const context2: any = { - ...context, - user: { ...context.user, role: "user" }, - }; - expect(((await evaluateExpr(expr, context2)) as any)[3]).toBe("moderate"); // premium features include 'moderate' - - const context3: any = { - ...context2, - user: { ...context2.user, subscription: "basic" }, - }; - expect(((await evaluateExpr(expr, context3)) as any).length).toBe(2); // basic features have 2 items - }); - }); - - describe("Template String Interpolation", () => { - // Define custom functions for string operations - const functions = { - concat: (...strings: string[]) => strings.join(""), - formatDate: (date: string, format = "YYYY-MM-DD") => { - // Simple date formatter (in real implementation, use a proper date library) - const d = new Date(date); - if (format === "YYYY-MM-DD") { - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; - } - if (format === "DD/MM/YYYY") { - return `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}`; - } - return date; // Default fallback - }, - }; - - const context = { - user: { - name: "张三", - email: "zhangsan@example.com", - joinDate: "2024-01-15", - lastLogin: "2025-03-10T14:30:00", - plan: "premium", - usage: { - storage: 42.5, - bandwidth: 128.7, - }, - }, - company: { - name: "示例公司", - address: "上海市浦东新区", - }, - locale: "zh-CN", - }; - - it("should support basic string concatenation", async () => { - const expr = - "@concat('Welcome, ', user.name, '! Your plan is ', user.plan)"; - expect(await evaluateExpr(expr, context, functions)).toBe( - "Welcome, 张三! Your plan is premium", - ); - }); - - it("should support template string interpolation", async () => { - const template = - "'Dear ' + user.name + ', Thank you for being a ' + user.plan + ' member since ' + user.joinDate + '. Your current storage usage is ' + user.usage.storage + 'GB.'"; - const result = await evaluateExpr(template, context, functions); - expect(result).toBe( - "Dear 张三, Thank you for being a premium member since 2024-01-15. Your current storage usage is 42.5GB.", - ); - }); - }); - - describe("Complex Business Logic", () => { - const context = { - order: { - id: "ORD-12345", - customer: { - id: "CUST-789", - name: "李四", - type: "vip", - memberSince: "2020-05-10", - loyaltyPoints: 1250, - }, - items: [ - { - id: "PROD-001", - name: "商品A", - price: 100, - quantity: 2, - category: "electronics", - }, - { - id: "PROD-002", - name: "商品B", - price: 50, - quantity: 1, - category: "books", - }, - { - id: "PROD-003", - name: "商品C", - price: 200, - quantity: 3, - category: "electronics", - }, - ], - shipping: { - method: "express", - address: { - city: "北京", - province: "北京市", - country: "中国", - }, - cost: 20, - }, - payment: { - method: "credit_card", - status: "completed", - }, - date: "2025-03-01", - status: "processing", - }, - pricing: { - discounts: { - vip: 0.1, // 10% off for VIP customers - bulk: 0.05, // 5% off for bulk orders (>= 5 items) - categories: { - electronics: 0.08, // 8% off for electronics - books: 0.15, // 15% off for books - }, - }, - shipping: { - standard: 10, - express: 20, - international: { - standard: 50, - express: 80, - }, - }, - tax: { - domestic: 0.13, // 13% tax for domestic orders - international: 0.2, // 20% tax for international orders - }, - }, - config: { - loyaltyPointsPerDollar: 0.5, - minimumForFreeShipping: 500, - }, - }; - - // Define custom functions for business logic - const functions = { - calculateSubtotal: (items: any[]) => { - return items.reduce((sum, item) => sum + item.price * item.quantity, 0); - }, - calculateDiscount: (order: any, pricing: any) => { - const subtotal = functions.calculateSubtotal(order.items); - let discount = 0; - - // Customer type discount - if (order.customer.type === "vip") { - discount += subtotal * pricing.discounts.vip; - } - - // Bulk order discount - const totalItems = order.items.reduce( - (sum: number, item: any) => sum + item.quantity, - 0, - ); - if (totalItems >= 5) { - discount += subtotal * pricing.discounts.bulk; - } - - // Category-specific discounts (applied to eligible items only) - const categoryDiscounts = order.items.reduce( - (sum: number, item: any) => { - const categoryDiscount = - pricing.discounts.categories[item.category] || 0; - return sum + item.price * item.quantity * categoryDiscount; - }, - 0, - ); - - discount += categoryDiscounts; - - return Math.min(discount, subtotal * 0.25); // Cap discount at 25% of subtotal - }, - calculateTax: (order: any, pricing: any) => { - const subtotal = functions.calculateSubtotal(order.items); - const discount = functions.calculateDiscount(order, pricing); - const taxableAmount = subtotal - discount; - - const taxRate = - order.shipping.address.country === "中国" - ? pricing.tax.domestic - : pricing.tax.international; - - return taxableAmount * taxRate; - }, - calculateTotal: (order: any, pricing: any, config: any) => { - const subtotal = functions.calculateSubtotal(order.items); - const discount = functions.calculateDiscount(order, pricing); - const tax = functions.calculateTax(order, pricing); - - // Determine shipping cost - let shippingCost = 20; - if (subtotal - discount < config.minimumForFreeShipping) { - const isInternational = order.shipping.address.country !== "中国"; - if (isInternational) { - shippingCost = - pricing.shipping.international[order.shipping.method]; - } else { - shippingCost = pricing.shipping[order.shipping.method]; - } - } - - return subtotal - discount + tax + shippingCost; - }, - calculateLoyaltyPoints: (order: any, config: any) => { - const subtotal = functions.calculateSubtotal(order.items); - return Math.floor(subtotal * config.loyaltyPointsPerDollar); - }, - formatCurrency: (amount: number, currency = "CNY") => { - return new Intl.NumberFormat("zh-CN", { - style: "currency", - currency, - }).format(amount); - }, - }; - - it("should calculate order subtotal", async () => { - const expr = "@calculateSubtotal(order.items)"; - const result = await evaluateExpr(expr, context, functions); - expect(result).toBe(850); // (100*2) + (50*1) + (200*3) = 200 + 50 + 600 = 850 - }); - - it("should calculate appropriate discounts", async () => { - const expr = "@calculateDiscount(order, pricing)"; - const result = await evaluateExpr(expr, context, functions); - - // Expected discounts: - // - VIP discount: 850 * 0.1 = 85 - // - Bulk discount: 850 * 0.05 = 42.5 (total quantity = 6 items) - // - Category discounts: (200*2 + 600) * 0.08 + 50 * 0.15 = 64 + 7.5 = 71.5 - // Total discount: 85 + 42.5 + 71.5 = 199 - // But capped at 25% of subtotal: 850 * 0.25 = 212.5 - // So expected discount is 199 - expect(result).toBeCloseTo(199, 0); - }); - - it("should calculate final order total", async () => { - const expr = "@calculateTotal(order, pricing, config)"; - const result = await evaluateExpr(expr, context, functions); - - // Subtotal: 850 - // Discount: 199 - // Taxable amount: 651 - // Tax (13%): 84.63 - // Shipping: 20 (express, not free because below 500 after discount) - // Total: 850 - 199 + 84.63 + 20 = 755.63 - expect(result).toBeCloseTo(755.63, 2); - }); - - it("should calculate earned loyalty points", async () => { - const expr = "@calculateLoyaltyPoints(order, config)"; - const result = await evaluateExpr(expr, context, functions); - - // Subtotal: 850 - // Points per dollar: 0.5 - // Earned points: 850 * 0.5 = 425 - expect(result).toBe(425); - }); - - it("should format currency values", async () => { - const expr = "@formatCurrency(@calculateTotal(order, pricing, config))"; - const result = await evaluateExpr(expr, context, functions); - - // This test may vary based on locale implementation, but should contain the correct amount - expect(result).toContain("755"); - }); - - it("should handle complex conditional business logic", async () => { - // Determine shipping method and estimate based on order details - const expr = ` + // Helper function to evaluate expressions with custom functions + async function evaluateExpr( + expression: string, + context = {}, + functions = {}, + ) { + // Register any custom functions + Object.entries(functions).forEach(([name, fn]) => { + register(name, fn as any); + }); + + // Evaluate the expression + return await evaluate(expression, context); + } + + describe("Nested Property Access", () => { + const context = { + user: { + profile: { + details: { + preferences: { + theme: "dark", + fontSize: 16, + notifications: { + email: true, + push: false, + frequency: "daily", + }, + }, + address: { + city: "Shanghai", + country: "China", + coordinates: [121.4737, 31.2304], + }, + }, + lastLogin: "2025-03-10", + }, + permissions: ["read", "write", "admin"], + active: true, + }, + settings: { + global: { + language: "zh-CN", + timezone: "Asia/Shanghai", + }, + }, + stats: { + visits: 42, + actions: 128, + performance: { + average: 95.7, + history: [94.2, 95.1, 95.7, 96.3, 97.0], + }, + }, + }; + + it("should access deeply nested properties", async () => { + expect( + await evaluateExpr("user.profile.details.preferences.theme", context), + ).toBe("dark"); + expect( + await evaluateExpr( + "user.profile.details.address.coordinates[0]", + context, + ), + ).toBe(121.4737); + expect( + await evaluateExpr( + "user.profile.details.preferences.notifications.frequency", + context, + ), + ).toBe("daily"); + }); + + it("should handle bracket notation with string literals", async () => { + expect( + await evaluateExpr( + 'user["profile"]["details"]["preferences"]["fontSize"]', + context, + ), + ).toBe(16); + }); + + it("should handle mixed dot and bracket notation", async () => { + expect( + await evaluateExpr( + 'user.profile["details"].preferences["notifications"].push', + context, + ), + ).toBe(false); + expect( + await evaluateExpr('user["profile"].details["address"].city', context), + ).toBe("Shanghai"); + }); + }); + + describe("Dynamic Property Access", () => { + const context = { + data: { + key1: "value1", + key2: "value2", + key3: "value3", + }, + keys: ["key1", "key2", "key3"], + selectedKey: "key2", + config: { + mapping: { + field1: "key1", + field2: "key2", + field3: "key3", + }, + selected: "field2", + }, + }; + + it("should access properties using dynamic keys", async () => { + expect(await evaluateExpr("data[selectedKey]", context)).toBe("value2"); + expect(await evaluateExpr("data[keys[0]]", context)).toBe("value1"); + expect(await evaluateExpr("data[keys[2]]", context)).toBe("value3"); + }); + + it("should handle nested dynamic property access", async () => { + expect( + await evaluateExpr("data[config.mapping[config.selected]]", context), + ).toBe("value2"); + expect(await evaluateExpr("data[config.mapping.field1]", context)).toBe( + "value1", + ); + }); + }); + + describe("Conditional Logic", () => { + const context = { + user: { + role: "admin", + verified: true, + age: 30, + subscription: "premium", + }, + thresholds: { + age: 18, + premium: 25, + }, + features: { + basic: ["read", "comment"], + premium: ["read", "comment", "publish", "moderate"], + admin: ["read", "comment", "publish", "moderate", "manage"], + }, + }; + + it("should evaluate simple conditional expressions", async () => { + expect( + await evaluateExpr( + "user.role === 'admin' ? 'Administrator' : 'User'", + context, + ), + ).toBe("Administrator"); + expect( + await evaluateExpr( + "user.verified ? 'Verified' : 'Unverified'", + context, + ), + ).toBe("Verified"); + }); + + it("should handle nested conditional expressions", async () => { + const expr = + "user.role === 'admin' ? 'Admin Access' : (user.subscription === 'premium' ? 'Premium Access' : 'Basic Access')"; + expect(await evaluateExpr(expr, context)).toBe("Admin Access"); + + const context2: any = { + ...context, + user: { ...context.user, role: "user" }, + }; + expect(await evaluateExpr(expr, context2)).toBe("Premium Access"); + + const context3: any = { + ...context2, + user: { ...context2.user, subscription: "basic" }, + }; + expect(await evaluateExpr(expr, context3)).toBe("Basic Access"); + }); + + it("should combine conditional logic with property access", async () => { + const expr = + "user.role === 'admin' ? features.admin : (user.subscription === 'premium' ? features.premium : features.basic)"; + expect(((await evaluateExpr(expr, context)) as any)[4]).toBe("manage"); // admin features include 'manage' + + const context2: any = { + ...context, + user: { ...context.user, role: "user" }, + }; + expect(((await evaluateExpr(expr, context2)) as any)[3]).toBe("moderate"); // premium features include 'moderate' + + const context3: any = { + ...context2, + user: { ...context2.user, subscription: "basic" }, + }; + expect(((await evaluateExpr(expr, context3)) as any).length).toBe(2); // basic features have 2 items + }); + }); + + describe("Template String Interpolation", () => { + // Define custom functions for string operations + const functions = { + concat: (...strings: string[]) => strings.join(""), + formatDate: (date: string, format = "YYYY-MM-DD") => { + // Simple date formatter (in real implementation, use a proper date library) + const d = new Date(date); + if (format === "YYYY-MM-DD") { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + } + if (format === "DD/MM/YYYY") { + return `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}`; + } + return date; // Default fallback + }, + }; + + const context = { + user: { + name: "张三", + email: "zhangsan@example.com", + joinDate: "2024-01-15", + lastLogin: "2025-03-10T14:30:00", + plan: "premium", + usage: { + storage: 42.5, + bandwidth: 128.7, + }, + }, + company: { + name: "示例公司", + address: "上海市浦东新区", + }, + locale: "zh-CN", + }; + + it("should support basic string concatenation", async () => { + const expr = + "@concat('Welcome, ', user.name, '! Your plan is ', user.plan)"; + expect(await evaluateExpr(expr, context, functions)).toBe( + "Welcome, 张三! Your plan is premium", + ); + }); + + it("should support template string interpolation", async () => { + const template = + "'Dear ' + user.name + ', Thank you for being a ' + user.plan + ' member since ' + user.joinDate + '. Your current storage usage is ' + user.usage.storage + 'GB.'"; + const result = await evaluateExpr(template, context, functions); + expect(result).toBe( + "Dear 张三, Thank you for being a premium member since 2024-01-15. Your current storage usage is 42.5GB.", + ); + }); + }); + + describe("Complex Business Logic", () => { + const context = { + order: { + id: "ORD-12345", + customer: { + id: "CUST-789", + name: "李四", + type: "vip", + memberSince: "2020-05-10", + loyaltyPoints: 1250, + }, + items: [ + { + id: "PROD-001", + name: "商品A", + price: 100, + quantity: 2, + category: "electronics", + }, + { + id: "PROD-002", + name: "商品B", + price: 50, + quantity: 1, + category: "books", + }, + { + id: "PROD-003", + name: "商品C", + price: 200, + quantity: 3, + category: "electronics", + }, + ], + shipping: { + method: "express", + address: { + city: "北京", + province: "北京市", + country: "中国", + }, + cost: 20, + }, + payment: { + method: "credit_card", + status: "completed", + }, + date: "2025-03-01", + status: "processing", + }, + pricing: { + discounts: { + vip: 0.1, // 10% off for VIP customers + bulk: 0.05, // 5% off for bulk orders (>= 5 items) + categories: { + electronics: 0.08, // 8% off for electronics + books: 0.15, // 15% off for books + }, + }, + shipping: { + standard: 10, + express: 20, + international: { + standard: 50, + express: 80, + }, + }, + tax: { + domestic: 0.13, // 13% tax for domestic orders + international: 0.2, // 20% tax for international orders + }, + }, + config: { + loyaltyPointsPerDollar: 0.5, + minimumForFreeShipping: 500, + }, + }; + + // Define custom functions for business logic + const functions = { + calculateSubtotal: (items: any[]) => { + return items.reduce((sum, item) => sum + item.price * item.quantity, 0); + }, + calculateDiscount: (order: any, pricing: any) => { + const subtotal = functions.calculateSubtotal(order.items); + let discount = 0; + + // Customer type discount + if (order.customer.type === "vip") { + discount += subtotal * pricing.discounts.vip; + } + + // Bulk order discount + const totalItems = order.items.reduce( + (sum: number, item: any) => sum + item.quantity, + 0, + ); + if (totalItems >= 5) { + discount += subtotal * pricing.discounts.bulk; + } + + // Category-specific discounts (applied to eligible items only) + const categoryDiscounts = order.items.reduce( + (sum: number, item: any) => { + const categoryDiscount = + pricing.discounts.categories[item.category] || 0; + return sum + item.price * item.quantity * categoryDiscount; + }, + 0, + ); + + discount += categoryDiscounts; + + return Math.min(discount, subtotal * 0.25); // Cap discount at 25% of subtotal + }, + calculateTax: (order: any, pricing: any) => { + const subtotal = functions.calculateSubtotal(order.items); + const discount = functions.calculateDiscount(order, pricing); + const taxableAmount = subtotal - discount; + + const taxRate = + order.shipping.address.country === "中国" + ? pricing.tax.domestic + : pricing.tax.international; + + return taxableAmount * taxRate; + }, + calculateTotal: (order: any, pricing: any, config: any) => { + const subtotal = functions.calculateSubtotal(order.items); + const discount = functions.calculateDiscount(order, pricing); + const tax = functions.calculateTax(order, pricing); + + // Determine shipping cost + let shippingCost = 20; + if (subtotal - discount < config.minimumForFreeShipping) { + const isInternational = order.shipping.address.country !== "中国"; + if (isInternational) { + shippingCost = + pricing.shipping.international[order.shipping.method]; + } else { + shippingCost = pricing.shipping[order.shipping.method]; + } + } + + return subtotal - discount + tax + shippingCost; + }, + calculateLoyaltyPoints: (order: any, config: any) => { + const subtotal = functions.calculateSubtotal(order.items); + return Math.floor(subtotal * config.loyaltyPointsPerDollar); + }, + formatCurrency: (amount: number, currency = "CNY") => { + return new Intl.NumberFormat("zh-CN", { + style: "currency", + currency, + }).format(amount); + }, + }; + + it("should calculate order subtotal", async () => { + const expr = "@calculateSubtotal(order.items)"; + const result = await evaluateExpr(expr, context, functions); + expect(result).toBe(850); // (100*2) + (50*1) + (200*3) = 200 + 50 + 600 = 850 + }); + + it("should calculate appropriate discounts", async () => { + const expr = "@calculateDiscount(order, pricing)"; + const result = await evaluateExpr(expr, context, functions); + + // Expected discounts: + // - VIP discount: 850 * 0.1 = 85 + // - Bulk discount: 850 * 0.05 = 42.5 (total quantity = 6 items) + // - Category discounts: (200*2 + 600) * 0.08 + 50 * 0.15 = 64 + 7.5 = 71.5 + // Total discount: 85 + 42.5 + 71.5 = 199 + // But capped at 25% of subtotal: 850 * 0.25 = 212.5 + // So expected discount is 199 + expect(result).toBeCloseTo(199, 0); + }); + + it("should calculate final order total", async () => { + const expr = "@calculateTotal(order, pricing, config)"; + const result = await evaluateExpr(expr, context, functions); + + // Subtotal: 850 + // Discount: 199 + // Taxable amount: 651 + // Tax (13%): 84.63 + // Shipping: 20 (express, not free because below 500 after discount) + // Total: 850 - 199 + 84.63 + 20 = 755.63 + expect(result).toBeCloseTo(755.63, 2); + }); + + it("should calculate earned loyalty points", async () => { + const expr = "@calculateLoyaltyPoints(order, config)"; + const result = await evaluateExpr(expr, context, functions); + + // Subtotal: 850 + // Points per dollar: 0.5 + // Earned points: 850 * 0.5 = 425 + expect(result).toBe(425); + }); + + it("should format currency values", async () => { + const expr = "@formatCurrency(@calculateTotal(order, pricing, config))"; + const result = await evaluateExpr(expr, context, functions); + + // This test may vary based on locale implementation, but should contain the correct amount + expect(result).toContain("755"); + }); + + it("should handle complex conditional business logic", async () => { + // Determine shipping method and estimate based on order details + const expr = ` order.shipping.address.country !== "中国" ? @formatCurrency(pricing.shipping.international[order.shipping.method]) : (@calculateSubtotal(order.items) - @calculateDiscount(order, pricing) >= config.minimumForFreeShipping ? @@ -477,29 +477,29 @@ describe("Dynamic Template Capabilities", () => { @formatCurrency(pricing.shipping[order.shipping.method])) `; - const result = await evaluateExpr(expr, context, functions); - // The order is domestic (China) and below free shipping threshold, so should show express shipping cost (¥20) - expect(result).toBe("免费配送"); - - // Test with order above free shipping threshold - const largeOrder = { - ...context, - order: { - ...context.order, - items: [ - { - id: "PROD-004", - name: "商品D", - price: 600, - quantity: 1, - category: "electronics", - }, - ], - }, - }; - - const resultLargeOrder = await evaluateExpr(expr, largeOrder, functions); - expect(resultLargeOrder).toBe("¥20.00"); // Should be free shipping - }); - }); + const result = await evaluateExpr(expr, context, functions); + // The order is domestic (China) and below free shipping threshold, so should show express shipping cost (¥20) + expect(result).toBe("免费配送"); + + // Test with order above free shipping threshold + const largeOrder = { + ...context, + order: { + ...context.order, + items: [ + { + id: "PROD-004", + name: "商品D", + price: 600, + quantity: 1, + category: "electronics", + }, + ], + }, + }; + + const resultLargeOrder = await evaluateExpr(expr, largeOrder, functions); + expect(resultLargeOrder).toBe("¥20.00"); // Should be free shipping + }); + }); }); diff --git a/tests/tokenizer.test.ts b/tests/tokenizer.test.ts index c2de774..85a5b02 100644 --- a/tests/tokenizer.test.ts +++ b/tests/tokenizer.test.ts @@ -2,198 +2,198 @@ import { describe, expect, it } from "vitest"; import { type Token, TokenType, tokenize } from "../src/tokenizer"; describe("Tokenizer", () => { - describe("Basic Literals", () => { - it("should tokenize string literals", () => { - const input = "\"hello\" 'world'"; - const expected: Token[] = [ - { type: TokenType.STRING, value: "hello" }, - { type: TokenType.STRING, value: "world" }, - ]; - expect(tokenize(input)).toEqual(expected); - }); + describe("Basic Literals", () => { + it("should tokenize string literals", () => { + const input = "\"hello\" 'world'"; + const expected: Token[] = [ + { type: TokenType.STRING, value: "hello" }, + { type: TokenType.STRING, value: "world" }, + ]; + expect(tokenize(input)).toEqual(expected); + }); - it("should handle escaped quotes in strings", () => { - const input = '"hello \\"world\\""'; - const expected: Token[] = [ - { type: TokenType.STRING, value: 'hello "world"' }, - ]; - expect(tokenize(input)).toEqual(expected); - }); + it("should handle escaped quotes in strings", () => { + const input = '"hello \\"world\\""'; + const expected: Token[] = [ + { type: TokenType.STRING, value: 'hello "world"' }, + ]; + expect(tokenize(input)).toEqual(expected); + }); - it("should tokenize numbers", () => { - const input = "42 -3.14 0.5"; - const expected: Token[] = [ - { type: TokenType.NUMBER, value: "42" }, - { type: TokenType.NUMBER, value: "-3.14" }, - { type: TokenType.NUMBER, value: "0.5" }, - ]; - expect(tokenize(input)).toEqual(expected); - }); + it("should tokenize numbers", () => { + const input = "42 -3.14 0.5"; + const expected: Token[] = [ + { type: TokenType.NUMBER, value: "42" }, + { type: TokenType.NUMBER, value: "-3.14" }, + { type: TokenType.NUMBER, value: "0.5" }, + ]; + expect(tokenize(input)).toEqual(expected); + }); - it("should tokenize boolean and null", () => { - const input = "true false null"; - const expected: Token[] = [ - { type: TokenType.BOOLEAN, value: "true" }, - { type: TokenType.BOOLEAN, value: "false" }, - { type: TokenType.NULL, value: "null" }, - ]; - expect(tokenize(input)).toEqual(expected); - }); - }); + it("should tokenize boolean and null", () => { + const input = "true false null"; + const expected: Token[] = [ + { type: TokenType.BOOLEAN, value: "true" }, + { type: TokenType.BOOLEAN, value: "false" }, + { type: TokenType.NULL, value: "null" }, + ]; + expect(tokenize(input)).toEqual(expected); + }); + }); - describe("Operators", () => { - it("should tokenize arithmetic operators", () => { - const input = "a + b - c * d / e % f"; - const expected: Token[] = [ - { type: TokenType.IDENTIFIER, value: "a" }, - { type: TokenType.OPERATOR, value: "+" }, - { type: TokenType.IDENTIFIER, value: "b" }, - { type: TokenType.OPERATOR, value: "-" }, - { type: TokenType.IDENTIFIER, value: "c" }, - { type: TokenType.OPERATOR, value: "*" }, - { type: TokenType.IDENTIFIER, value: "d" }, - { type: TokenType.OPERATOR, value: "/" }, - { type: TokenType.IDENTIFIER, value: "e" }, - { type: TokenType.OPERATOR, value: "%" }, - { type: TokenType.IDENTIFIER, value: "f" }, - ]; - expect(tokenize(input)).toEqual(expected); - }); + describe("Operators", () => { + it("should tokenize arithmetic operators", () => { + const input = "a + b - c * d / e % f"; + const expected: Token[] = [ + { type: TokenType.IDENTIFIER, value: "a" }, + { type: TokenType.OPERATOR, value: "+" }, + { type: TokenType.IDENTIFIER, value: "b" }, + { type: TokenType.OPERATOR, value: "-" }, + { type: TokenType.IDENTIFIER, value: "c" }, + { type: TokenType.OPERATOR, value: "*" }, + { type: TokenType.IDENTIFIER, value: "d" }, + { type: TokenType.OPERATOR, value: "/" }, + { type: TokenType.IDENTIFIER, value: "e" }, + { type: TokenType.OPERATOR, value: "%" }, + { type: TokenType.IDENTIFIER, value: "f" }, + ]; + expect(tokenize(input)).toEqual(expected); + }); - it("should tokenize comparison operators", () => { - const input = "a === b !== c > d < e >= f <= g"; - const expected: Token[] = [ - { type: TokenType.IDENTIFIER, value: "a" }, - { type: TokenType.OPERATOR, value: "===" }, - { type: TokenType.IDENTIFIER, value: "b" }, - { type: TokenType.OPERATOR, value: "!==" }, - { type: TokenType.IDENTIFIER, value: "c" }, - { type: TokenType.OPERATOR, value: ">" }, - { type: TokenType.IDENTIFIER, value: "d" }, - { type: TokenType.OPERATOR, value: "<" }, - { type: TokenType.IDENTIFIER, value: "e" }, - { type: TokenType.OPERATOR, value: ">=" }, - { type: TokenType.IDENTIFIER, value: "f" }, - { type: TokenType.OPERATOR, value: "<=" }, - { type: TokenType.IDENTIFIER, value: "g" }, - ]; - expect(tokenize(input)).toEqual(expected); - }); + it("should tokenize comparison operators", () => { + const input = "a === b !== c > d < e >= f <= g"; + const expected: Token[] = [ + { type: TokenType.IDENTIFIER, value: "a" }, + { type: TokenType.OPERATOR, value: "===" }, + { type: TokenType.IDENTIFIER, value: "b" }, + { type: TokenType.OPERATOR, value: "!==" }, + { type: TokenType.IDENTIFIER, value: "c" }, + { type: TokenType.OPERATOR, value: ">" }, + { type: TokenType.IDENTIFIER, value: "d" }, + { type: TokenType.OPERATOR, value: "<" }, + { type: TokenType.IDENTIFIER, value: "e" }, + { type: TokenType.OPERATOR, value: ">=" }, + { type: TokenType.IDENTIFIER, value: "f" }, + { type: TokenType.OPERATOR, value: "<=" }, + { type: TokenType.IDENTIFIER, value: "g" }, + ]; + expect(tokenize(input)).toEqual(expected); + }); - it("should tokenize logical operators", () => { - const input = "a && b || !c"; - const expected: Token[] = [ - { type: TokenType.IDENTIFIER, value: "a" }, - { type: TokenType.OPERATOR, value: "&&" }, - { type: TokenType.IDENTIFIER, value: "b" }, - { type: TokenType.OPERATOR, value: "||" }, - { type: TokenType.OPERATOR, value: "!" }, - { type: TokenType.IDENTIFIER, value: "c" }, - ]; - expect(tokenize(input)).toEqual(expected); - }); - }); + it("should tokenize logical operators", () => { + const input = "a && b || !c"; + const expected: Token[] = [ + { type: TokenType.IDENTIFIER, value: "a" }, + { type: TokenType.OPERATOR, value: "&&" }, + { type: TokenType.IDENTIFIER, value: "b" }, + { type: TokenType.OPERATOR, value: "||" }, + { type: TokenType.OPERATOR, value: "!" }, + { type: TokenType.IDENTIFIER, value: "c" }, + ]; + expect(tokenize(input)).toEqual(expected); + }); + }); - describe("Property Access", () => { - it("should tokenize dot notation", () => { - const input = "data.value.nested"; - const expected: Token[] = [ - { type: TokenType.IDENTIFIER, value: "data" }, - { type: TokenType.DOT, value: "." }, - { type: TokenType.IDENTIFIER, value: "value" }, - { type: TokenType.DOT, value: "." }, - { type: TokenType.IDENTIFIER, value: "nested" }, - ]; - expect(tokenize(input)).toEqual(expected); - }); + describe("Property Access", () => { + it("should tokenize dot notation", () => { + const input = "data.value.nested"; + const expected: Token[] = [ + { type: TokenType.IDENTIFIER, value: "data" }, + { type: TokenType.DOT, value: "." }, + { type: TokenType.IDENTIFIER, value: "value" }, + { type: TokenType.DOT, value: "." }, + { type: TokenType.IDENTIFIER, value: "nested" }, + ]; + expect(tokenize(input)).toEqual(expected); + }); - it("should tokenize bracket notation", () => { - const input = 'data["value"]'; - const expected: Token[] = [ - { type: TokenType.IDENTIFIER, value: "data" }, - { type: TokenType.BRACKET_LEFT, value: "[" }, - { type: TokenType.STRING, value: "value" }, - { type: TokenType.BRACKET_RIGHT, value: "]" }, - ]; - expect(tokenize(input)).toEqual(expected); - }); - }); + it("should tokenize bracket notation", () => { + const input = 'data["value"]'; + const expected: Token[] = [ + { type: TokenType.IDENTIFIER, value: "data" }, + { type: TokenType.BRACKET_LEFT, value: "[" }, + { type: TokenType.STRING, value: "value" }, + { type: TokenType.BRACKET_RIGHT, value: "]" }, + ]; + expect(tokenize(input)).toEqual(expected); + }); + }); - describe("Function Calls", () => { - it("should tokenize predefined functions", () => { - const input = "@sum(values)"; - const expected: Token[] = [ - { type: TokenType.FUNCTION, value: "sum" }, - { type: TokenType.PAREN_LEFT, value: "(" }, - { type: TokenType.IDENTIFIER, value: "values" }, - { type: TokenType.PAREN_RIGHT, value: ")" }, - ]; - expect(tokenize(input)).toEqual(expected); - }); + describe("Function Calls", () => { + it("should tokenize predefined functions", () => { + const input = "@sum(values)"; + const expected: Token[] = [ + { type: TokenType.FUNCTION, value: "sum" }, + { type: TokenType.PAREN_LEFT, value: "(" }, + { type: TokenType.IDENTIFIER, value: "values" }, + { type: TokenType.PAREN_RIGHT, value: ")" }, + ]; + expect(tokenize(input)).toEqual(expected); + }); - it("should tokenize function calls with multiple arguments", () => { - const input = "@max(a, b, c)"; - const expected: Token[] = [ - { type: TokenType.FUNCTION, value: "max" }, - { type: TokenType.PAREN_LEFT, value: "(" }, - { type: TokenType.IDENTIFIER, value: "a" }, - { type: TokenType.COMMA, value: "," }, - { type: TokenType.IDENTIFIER, value: "b" }, - { type: TokenType.COMMA, value: "," }, - { type: TokenType.IDENTIFIER, value: "c" }, - { type: TokenType.PAREN_RIGHT, value: ")" }, - ]; - expect(tokenize(input)).toEqual(expected); - }); - }); + it("should tokenize function calls with multiple arguments", () => { + const input = "@max(a, b, c)"; + const expected: Token[] = [ + { type: TokenType.FUNCTION, value: "max" }, + { type: TokenType.PAREN_LEFT, value: "(" }, + { type: TokenType.IDENTIFIER, value: "a" }, + { type: TokenType.COMMA, value: "," }, + { type: TokenType.IDENTIFIER, value: "b" }, + { type: TokenType.COMMA, value: "," }, + { type: TokenType.IDENTIFIER, value: "c" }, + { type: TokenType.PAREN_RIGHT, value: ")" }, + ]; + expect(tokenize(input)).toEqual(expected); + }); + }); - describe("Conditional Expressions", () => { - it("should tokenize ternary expressions", () => { - const input = "condition ? trueValue : falseValue"; - const expected: Token[] = [ - { type: TokenType.IDENTIFIER, value: "condition" }, - { type: TokenType.QUESTION, value: "?" }, - { type: TokenType.IDENTIFIER, value: "trueValue" }, - { type: TokenType.COLON, value: ":" }, - { type: TokenType.IDENTIFIER, value: "falseValue" }, - ]; - expect(tokenize(input)).toEqual(expected); - }); - }); + describe("Conditional Expressions", () => { + it("should tokenize ternary expressions", () => { + const input = "condition ? trueValue : falseValue"; + const expected: Token[] = [ + { type: TokenType.IDENTIFIER, value: "condition" }, + { type: TokenType.QUESTION, value: "?" }, + { type: TokenType.IDENTIFIER, value: "trueValue" }, + { type: TokenType.COLON, value: ":" }, + { type: TokenType.IDENTIFIER, value: "falseValue" }, + ]; + expect(tokenize(input)).toEqual(expected); + }); + }); - describe("Complex Expressions", () => { - it("should tokenize complex nested expressions", () => { - const input = '@sum(data.values) > 0 ? data["status"] : "inactive"'; - const expected: Token[] = [ - { type: TokenType.FUNCTION, value: "sum" }, - { type: TokenType.PAREN_LEFT, value: "(" }, - { type: TokenType.IDENTIFIER, value: "data" }, - { type: TokenType.DOT, value: "." }, - { type: TokenType.IDENTIFIER, value: "values" }, - { type: TokenType.PAREN_RIGHT, value: ")" }, - { type: TokenType.OPERATOR, value: ">" }, - { type: TokenType.NUMBER, value: "0" }, - { type: TokenType.QUESTION, value: "?" }, - { type: TokenType.IDENTIFIER, value: "data" }, - { type: TokenType.BRACKET_LEFT, value: "[" }, - { type: TokenType.STRING, value: "status" }, - { type: TokenType.BRACKET_RIGHT, value: "]" }, - { type: TokenType.COLON, value: ":" }, - { type: TokenType.STRING, value: "inactive" }, - ]; - expect(tokenize(input)).toEqual(expected); - }); - }); + describe("Complex Expressions", () => { + it("should tokenize complex nested expressions", () => { + const input = '@sum(data.values) > 0 ? data["status"] : "inactive"'; + const expected: Token[] = [ + { type: TokenType.FUNCTION, value: "sum" }, + { type: TokenType.PAREN_LEFT, value: "(" }, + { type: TokenType.IDENTIFIER, value: "data" }, + { type: TokenType.DOT, value: "." }, + { type: TokenType.IDENTIFIER, value: "values" }, + { type: TokenType.PAREN_RIGHT, value: ")" }, + { type: TokenType.OPERATOR, value: ">" }, + { type: TokenType.NUMBER, value: "0" }, + { type: TokenType.QUESTION, value: "?" }, + { type: TokenType.IDENTIFIER, value: "data" }, + { type: TokenType.BRACKET_LEFT, value: "[" }, + { type: TokenType.STRING, value: "status" }, + { type: TokenType.BRACKET_RIGHT, value: "]" }, + { type: TokenType.COLON, value: ":" }, + { type: TokenType.STRING, value: "inactive" }, + ]; + expect(tokenize(input)).toEqual(expected); + }); + }); - describe("Error Handling", () => { - it("should throw error for unterminated string", () => { - const input = '"unclosed string'; - expect(() => tokenize(input)).toThrow("Unterminated string"); - }); + describe("Error Handling", () => { + it("should throw error for unterminated string", () => { + const input = '"unclosed string'; + expect(() => tokenize(input)).toThrow("Unterminated string"); + }); - it("should throw error for unexpected character", () => { - const input = "a # b"; - expect(() => tokenize(input)).toThrow("Unexpected character: #"); - }); - }); + it("should throw error for unexpected character", () => { + const input = "a # b"; + expect(() => tokenize(input)).toThrow("Unexpected character: #"); + }); + }); }); diff --git a/tsconfig.json b/tsconfig.json index 1ae6859..546799b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,10 @@ { - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "declaration": true, - "strict": true, - "skipLibCheck": true - }, - "include": ["src/**/*.ts"] + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "declaration": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts index 46e2f1e..3d93de4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,19 +1,19 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ - test: { - environment: "node", - globals: true, - include: ["**/*.test.ts"], - exclude: ["**/*.d.ts", "node_modules/**"], - coverage: { - provider: "v8", - reporter: ["text", "json", "html"], - include: ["src/**/*.ts"], - exclude: ["**/*.d.ts", "**/*.test.ts", "node_modules/**"], - }, - benchmark: { - include: ["**/*.bench.ts"], - }, - }, + test: { + environment: "node", + globals: true, + include: ["**/*.test.ts"], + exclude: ["**/*.d.ts", "node_modules/**"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["src/**/*.ts"], + exclude: ["**/*.d.ts", "**/*.test.ts", "node_modules/**"], + }, + benchmark: { + include: ["**/*.bench.ts"], + }, + }, });