├── .github
└── workflows
│ └── build.yml
├── .gitignore
├── .npmignore
├── .npmrc
├── LICENSE
├── README.md
├── bench
└── expr.bench.ts
├── biome.json
├── package.json
├── rollup.config.mjs
├── src
├── compile.ts
├── error.ts
├── evaluate.ts
├── functions.ts
├── index.ts
├── interpreter.ts
├── parser.ts
└── tokenizer.ts
├── tests
├── api-integration.test.ts
├── coverage-improvement.test.ts
├── edge-cases.test.ts
├── interpreter.test.ts
├── math-functions.test.ts
├── parser.test.ts
├── template-dynamic.test.ts
└── tokenizer.test.ts
├── tsconfig.json
└── vitest.config.ts
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: build
2 |
3 | on:
4 | push:
5 | branches: [main, master]
6 | pull_request:
7 | branches: [main, master]
8 |
9 | jobs:
10 | build-and-test:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v4
15 |
16 | - name: Setup pnpm
17 | uses: pnpm/action-setup@v2
18 | with:
19 | version: latest
20 |
21 | - name: Setup Node.js
22 | uses: actions/setup-node@v4
23 | with:
24 | node-version: "18"
25 | registry: "https://registry.npmjs.org"
26 |
27 | - name: Install dependencies
28 | run: pnpm install
29 |
30 | - name: Check code formatting
31 | run: pnpm biome check .
32 |
33 | - name: Run tests
34 | run: pnpm test
35 |
36 | - name: Build package
37 | run: pnpm run build
38 |
39 | - name: Run benchmarks
40 | run: pnpm run benchmark
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Optional stylelint cache
58 | .stylelintcache
59 |
60 | # Microbundle cache
61 | .rpt2_cache/
62 | .rts2_cache_cjs/
63 | .rts2_cache_es/
64 | .rts2_cache_umd/
65 |
66 | # Optional REPL history
67 | .node_repl_history
68 |
69 | # Output of 'npm pack'
70 | *.tgz
71 |
72 | # Yarn Integrity file
73 | .yarn-integrity
74 |
75 | # dotenv environment variable files
76 | .env
77 | .env.development.local
78 | .env.test.local
79 | .env.production.local
80 | .env.local
81 |
82 | # parcel-bundler cache (https://parceljs.org/)
83 | .cache
84 | .parcel-cache
85 |
86 | # Next.js build output
87 | .next
88 | out
89 |
90 | # Nuxt.js build / generate output
91 | .nuxt
92 | dist
93 |
94 | # Gatsby files
95 | .cache/
96 | # Comment in the public line in if your project uses Gatsby and not Next.js
97 | # https://nextjs.org/blog/next-9-1#public-directory-support
98 | # public
99 |
100 | # vuepress build output
101 | .vuepress/dist
102 |
103 | # vuepress v2.x temp and cache directory
104 | .temp
105 | .cache
106 |
107 | # Docusaurus cache and generated files
108 | .docusaurus
109 |
110 | # Serverless directories
111 | .serverless/
112 |
113 | # FuseBox cache
114 | .fusebox/
115 |
116 | # DynamoDB Local files
117 | .dynamodb/
118 |
119 | # TernJS port file
120 | .tern-port
121 |
122 | # Stores VSCode versions used for testing VSCode extensions
123 | .vscode-test
124 |
125 | # yarn v2
126 | .yarn/cache
127 | .yarn/unplugged
128 | .yarn/build-state.yml
129 | .yarn/install-state.gz
130 | .pnp.*
131 |
132 | # lock
133 | yarn.lock
134 | pnpm-lock.yaml
135 | package-lock.json
136 | bun.lockb
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | /*
2 | !/dist
3 | !LICENSE.txt
4 | !README.md
5 | !package.json
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | registry=https://registry.npmjs.org/
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 bqxbqx
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
@antv/expr: Mathematical Expression Parser
4 |
5 | Lightweight JavaScript expression parser and evaluator, safety and high-performance. 🚀
6 |
7 | 
8 | [](https://github.com/antvis/expr/actions/workflows/build.yml)
9 | [](https://www.npmjs.com/package/@antv/expr)
10 | [](https://www.npmjs.com/package/@antv/expr)
11 |
12 |
13 |
14 | Used to parse a _mathematical expressions_ to _JavaScript function_ safely. For example, in [@antv/g2](https://github.com/antvis/expr), we can set the style with an expressions.
15 |
16 | ```ts
17 | {
18 | // Equivalent to function: `d => d.value > 100 ? 'red' : 'green'`
19 | fill: "{ d.value > 100 ? 'red' : 'green' }",
20 | }
21 | ```
22 |
23 |
24 | ## ✨ Features
25 |
26 | - 🔒 **Secure by default** - No access to global objects or prototype chain, does not use `eval` or `new Function`.
27 | - 🚀 **High performance** - Supports pre-compilation of expressions for improved performance with repeated evaluations.
28 | - 🛠️ **Extensible** - Register custom functions to easily extend functionality.
29 | - 🪩 **Lightweight** - Zero dependencies, small footprint, before gzip it was less than `8 Kb`.
30 |
31 |
32 | ## 📥 Installation
33 |
34 | ```bash
35 | npm install @antv/expr
36 | # or
37 | yarn add @antv/expr
38 | # or
39 | pnpm add @antv/expr
40 | ```
41 |
42 |
43 | ## 🔨 Usage
44 |
45 | - [Synchronous Expression Evaluation](#synchronous-expression-evaluation)
46 | - [Pre-compiling Expressions](#pre-compiling-expressions)
47 | - [Registering and Calling Functions](#registering-and-calling-functions)
48 | - [Variable References](#variable-references)
49 | - [Arithmetic Operations](#arithmetic-operations)
50 | - [Comparison and Logical Operations](#comparison-and-logical-operations)
51 | - [Conditional (Ternary) Expressions](#conditional-ternary-expressions)
52 | - [Timeout Handling](#timeout-handling)
53 |
54 | ### Synchronous Expression Evaluation
55 |
56 | ```typescript
57 | import { evaluate } from '@antv/expr';
58 |
59 | // Basic evaluation
60 | const result = evaluate('x + y', { x: 10, y: 20 }); // returns 30
61 |
62 | // Using dot notation and array access
63 | const data = {
64 | values: [1, 2, 3],
65 | status: 'active'
66 | };
67 |
68 | const result = evaluate('data.values[0] + data.values[1]', { data }); // returns 3
69 | ```
70 |
71 | ### Pre-compiling Expressions
72 |
73 | ```typescript
74 | import { compile } from '@antv/expr';
75 |
76 | // Compile an expression
77 | const evaluator = compile('price * quantity');
78 | const result1 = evaluator({ price: 10, quantity: 5 }); // returns 50
79 | const result2 = evaluator({ price: 20, quantity: 3 }); // returns 60
80 | ```
81 |
82 | ### Registering and Calling Functions
83 |
84 | ```typescript
85 | import { register, evaluate } from '@antv/expr';
86 |
87 | // Register functions
88 | register('formatCurrency', (amount) => `$${amount.toFixed(2)}`);
89 |
90 | // Function call with arguments
91 | const result = evaluate('@max(a, b, c)', { a: 5, b: 9, c: 2 }); // returns 9
92 |
93 | // Expression as function arguments
94 | const result = evaluate('@formatCurrency(price * quantity)', {
95 | price: 10.5, quantity: 3
96 | }); // returns '$31.50'
97 | ```
98 | Build-in Functions: `abs`, `ceil`, `floor`, `round`, `sqrt`, `pow`, `max`, `min`.
99 |
100 | ### Variable References
101 |
102 | ```typescript
103 | // Simple variable reference
104 | const result = evaluate('x', { x: 42 }); // returns 42
105 |
106 | // Nested property access with dot notation
107 | const result = evaluate('user.profile.name', {
108 | user: { profile: { name: 'John' } }
109 | }); // returns 'John'
110 |
111 | // Array access with bracket notation
112 | const result = evaluate('items[0]', { items: [10, 20, 30] }); // returns 10
113 |
114 | // Mixed dot and bracket notation
115 | const result = evaluate('data.items[0].value', {
116 | data: { items: [{ value: 42 }] }
117 | }); // returns 42
118 | ```
119 |
120 | ### Arithmetic Operations
121 |
122 | ```typescript
123 | // Basic arithmetic
124 | const result = evaluate('a + b * c', { a: 5, b: 3, c: 2 }); // returns 11
125 |
126 | // Using parentheses for grouping
127 | const result = evaluate('(a + b) * c', { a: 5, b: 3, c: 2 }); // returns 16
128 |
129 | // Modulo operation
130 | const result = evaluate('a % b', { a: 10, b: 3 }); // returns 1
131 | ```
132 |
133 | ### Comparison and Logical Operations
134 |
135 | ```typescript
136 | // Comparison operators
137 | const result = evaluate('age >= 18', { age: 20 }); // returns true
138 |
139 | // Logical AND
140 | const result = evaluate('isActive && !isDeleted', {
141 | isActive: true, isDeleted: false
142 | }); // returns true
143 |
144 | // Logical OR
145 | const result = evaluate('status === "active" || status === "pending"', {
146 | status: 'pending'
147 | }); // returns true
148 | ```
149 |
150 | ### Conditional (Ternary) Expressions
151 |
152 | ```typescript
153 | // Simple ternary expression
154 | const result = evaluate('age >= 18 ? "adult" : "minor"', {
155 | age: 20
156 | }); // returns 'adult'
157 |
158 | // Nested ternary expressions
159 | const result = evaluate('score >= 90 ? "A" : score >= 80 ? "B" : "C"', {
160 | score: 85
161 | }); // returns 'B'
162 | ```
163 |
164 | ### Timeout Handling
165 |
166 | You can implement timeout handling by wrapping your evaluation in a `Promise.race` with a timeout:
167 |
168 | ```typescript
169 | import { evaluate } from "@antv/expr";
170 |
171 | // Create a function that evaluates with a timeout
172 | function evaluateWithTimeout(expr, context, timeoutMs) {
173 | const evaluationPromise = new Promise((resolve) => {
174 | resolve(evaluate(expr, context));
175 | });
176 |
177 | const timeoutPromise = new Promise((_, reject) => {
178 | setTimeout(
179 | () => reject(new Error(`Evaluation timed out after ${timeoutMs}ms`)),
180 | timeoutMs,
181 | );
182 | });
183 |
184 | return Promise.race([evaluationPromise, timeoutPromise]);
185 | }
186 | ```
187 |
188 |
189 | ## 🚀Benchmarks
190 |
191 | Performance comparison of different evaluation methods: (baseline: new Function)
192 |
193 | | Expression Type | new Function vs evaluate after compile | new Function vs evaluate without compile | new Function vs [expr-eval](https://www.npmjs.com/package/expr-eval?activeTab=readme) Parser |
194 | |-----------------------|----------------------------------------|------------------------------------------|----------------------------------|
195 | | Simple Expressions | 1.59x faster | 6.36x faster | 23.94x faster |
196 | | Medium Expressions | 2.16x faster | 9.81x faster | 37.81x faster |
197 | | Complex Expressions | 1.59x faster | 4.89x faster | 32.74x faster |
198 |
199 | ```mermaid
200 | gantt
201 | title Performance Comparison (Baseline: new Function) * 100
202 | dateFormat X
203 | axisFormat %s
204 |
205 | section Simple
206 | expr evaluate after compile :done, 0, 159
207 | expr evaluate without compile :done, 0, 636
208 | expr-eval Parser :done, 0, 2394
209 |
210 | section Medium
211 | expr evaluate after compile :done, 0, 216
212 | expr evaluate without compile :done, 0, 981
213 | expr-eval Parser :done, 0, 3781
214 |
215 | section Complex
216 | expr evaluate after compile :done, 0, 159
217 | expr evaluate without compile :done, 0, 489
218 | expr-eval Parser :done, 0, 3274
219 | ```
220 |
221 |
222 | ## 📮API Reference
223 |
224 | #### `evaluate(expression: string, context?: object): any`
225 |
226 | Synchronously evaluates an expression and returns the result.
227 |
228 | - `expression`: The expression string to evaluate
229 | - `context`: An object containing variables used in the expression (optional)
230 | - Returns: The result of the expression evaluation
231 |
232 | #### `compile(expression: string): (context?: object) => any`
233 |
234 | Synchronously compiles an expression, returning a function that can be used multiple times.
235 |
236 | - `expression`: The expression string to compile
237 | - Returns: A function that accepts a context object and returns the evaluation result
238 |
239 | #### `register(name: string, fn: Function): void`
240 |
241 | Registers a custom function that can be used in expressions.
242 |
243 | - `name`: Function name (used with @ prefix in expressions)
244 | - `fn`: Function implementation
245 |
246 | All evaluation errors throw an `ExpressionError` type exception with detailed error information.
247 |
248 |
249 | ## License
250 |
251 | MIT
252 |
--------------------------------------------------------------------------------
/bench/expr.bench.ts:
--------------------------------------------------------------------------------
1 | import { Parser } from "expr-eval";
2 | import { bench, describe } from "vitest";
3 | import { compile, evaluate, register } from "../dist/index.esm.js";
4 |
5 | const context = {
6 | user: {
7 | name: "John",
8 | age: 30,
9 | isAdmin: true,
10 | scores: [85, 90, 78, 92],
11 | address: {
12 | city: "New York",
13 | zip: "10001",
14 | },
15 | },
16 | products: [
17 | { id: 1, name: "Laptop", price: 1200 },
18 | { id: 2, name: "Phone", price: 800 },
19 | { id: 3, name: "Tablet", price: 500 },
20 | ],
21 | calculateTotal: (items: any[]) => {
22 | const total = items.reduce((sum, item) => sum + item.price, 0);
23 | return total;
24 | },
25 | applyDiscount: (total: number, percentage: number) => {
26 | const value = total * (1 - percentage / 100);
27 | return value;
28 | },
29 | };
30 |
31 | const simpleExpression = "user.age + 5";
32 | const mediumExpression = 'user.scores[2] > 80 ? "Good" : "Needs improvement"';
33 | const complexExpression =
34 | '@applyDiscount(@calculateTotal(products), 10) > 2000 ? "High value" : "Standard"';
35 |
36 | const complexExpression2 =
37 | 'applyDiscount(calculateTotal(products), 10) > 2000 ? "High value" : "Standard"';
38 |
39 | const simpleExpressionCompiler = compile(simpleExpression);
40 | const mediumExpressionCompiler = compile(mediumExpression);
41 | const complexExpressionCompiler = compile(complexExpression);
42 |
43 | register("calculateTotal", context.calculateTotal);
44 | register("applyDiscount", context.applyDiscount);
45 |
46 | const parser = new Parser();
47 | parser.functions.calculateTotal = context.calculateTotal;
48 | parser.functions.applyDiscount = context.applyDiscount;
49 |
50 | const newFunctionSimple = new Function(
51 | "context",
52 | `with(context) { return ${simpleExpression}; }`,
53 | );
54 | const newFunctionMedium = new Function(
55 | "context",
56 | `with(context) { return ${mediumExpression}; }`,
57 | );
58 | const newFunctionComplex = new Function(
59 | "context",
60 | `with(context) { return ${complexExpression2}; }`,
61 | );
62 |
63 | describe("Simple Expression Benchmarks", () => {
64 | bench("evaluate after compile (baseline) only interpreter", () => {
65 | simpleExpressionCompiler(context);
66 | });
67 |
68 | bench("new Function (vs evaluate)", () => {
69 | newFunctionSimple(context);
70 | });
71 |
72 | bench(
73 | "evaluate without compile (vs evaluate) tokenize + parse + interpreter",
74 | () => {
75 | evaluate(simpleExpression, context);
76 | },
77 | );
78 |
79 | bench("expr-eval Parser (vs evaluate)", () => {
80 | // @ts-ignore
81 | Parser.evaluate(simpleExpression, context);
82 | });
83 | });
84 |
85 | describe("Medium Expression Benchmarks", () => {
86 | bench("evaluate after compile (baseline) only interpreter", () => {
87 | mediumExpressionCompiler(context);
88 | });
89 |
90 | bench("new Function (vs evaluate)", () => {
91 | newFunctionMedium(context);
92 | });
93 |
94 | bench(
95 | "evaluate without compile (vs evaluate) tokenize + parse + interpreter",
96 | () => {
97 | evaluate(mediumExpression, context);
98 | },
99 | );
100 |
101 | bench("expr-eval Parser (vs evaluate)", () => {
102 | // @ts-ignore
103 | Parser.evaluate(mediumExpression, context);
104 | });
105 | });
106 |
107 | describe("Complex Expression Benchmarks", () => {
108 | bench("evaluate after compile (baseline) only interpreter", () => {
109 | complexExpressionCompiler(context);
110 | });
111 |
112 | bench("new Function (vs evaluate)", () => {
113 | newFunctionComplex(context);
114 | });
115 |
116 | bench(
117 | "evaluate without compile (vs evaluate) tokenize + parse + interpreter",
118 | () => {
119 | evaluate(complexExpression2, context);
120 | },
121 | );
122 |
123 | bench("expr-eval Parser (vs evaluate)", () => {
124 | // @ts-ignore
125 | parser.evaluate(complexExpression2, context);
126 | });
127 | });
128 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3 | "vcs": {
4 | "enabled": false,
5 | "clientKind": "git",
6 | "useIgnoreFile": false
7 | },
8 | "files": {
9 | "ignoreUnknown": false,
10 | "ignore": ["dist", "node_modules", "coverage"]
11 | },
12 | "formatter": {
13 | "enabled": true,
14 | "indentStyle": "space",
15 | "indentWidth": 2
16 | },
17 | "organizeImports": {
18 | "enabled": true
19 | },
20 | "linter": {
21 | "enabled": true,
22 | "rules": {
23 | "recommended": true
24 | },
25 | "ignore": ["node_modules", "dist", "tests", "bench", "coverage"]
26 | },
27 | "javascript": {
28 | "formatter": {
29 | "quoteStyle": "double"
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@antv/expr",
3 | "version": "1.0.1",
4 | "description": "A secure, high-performance expression evaluator for dynamic chart rendering",
5 | "main": "dist/index.cjs.js",
6 | "module": "dist/index.esm.js",
7 | "types": "dist/index.d.ts",
8 | "files": ["dist", "LICENSE", "README.md", "package.json"],
9 | "scripts": {
10 | "build": "rollup -c && npm run size",
11 | "test": "vitest run --coverage",
12 | "size": "limit-size",
13 | "benchmark": "vitest bench",
14 | "prepublishOnly": "pnpm run test && pnpm run build"
15 | },
16 | "keywords": [
17 | "expression",
18 | "evaluator",
19 | "parser",
20 | "secure",
21 | "antv",
22 | "chart",
23 | "expr"
24 | ],
25 | "devDependencies": {
26 | "@biomejs/biome": "1.9.4",
27 | "@rollup/plugin-node-resolve": "^16.0.0",
28 | "@rollup/plugin-terser": "^0.4.4",
29 | "@rollup/plugin-typescript": "^12.1.2",
30 | "@vitest/coverage-v8": "^3.0.8",
31 | "expr-eval": "^2.0.2",
32 | "limit-size": "^0.1.4",
33 | "rollup": "^4.34.6",
34 | "tslib": "^2.8.1",
35 | "vitest": "^3.0.8"
36 | },
37 | "limit-size": [
38 | {
39 | "path": "dist/index.cjs.js",
40 | "limit": "8 Kb"
41 | },
42 | {
43 | "path": "dist/index.cjs.js",
44 | "limit": "3 Kb",
45 | "gzip": true
46 | }
47 | ],
48 | "repository": {
49 | "type": "git",
50 | "url": "https://github.com/antvis/expr"
51 | },
52 | "license": "MIT"
53 | }
54 |
--------------------------------------------------------------------------------
/rollup.config.mjs:
--------------------------------------------------------------------------------
1 | import { nodeResolve } from "@rollup/plugin-node-resolve";
2 | import terser from "@rollup/plugin-terser";
3 | import typescript from "@rollup/plugin-typescript";
4 |
5 | export default [
6 | {
7 | input: "src/index.ts",
8 | output: [
9 | {
10 | file: "dist/index.esm.js",
11 | format: "esm",
12 | },
13 | {
14 | file: "dist/index.cjs.js",
15 | format: "cjs",
16 | },
17 | ],
18 | plugins: [
19 | nodeResolve(),
20 | typescript({
21 | tsconfig: "./tsconfig.json",
22 | outDir: "dist",
23 | }),
24 | terser({
25 | compress: {
26 | drop_console: true,
27 | },
28 | output: {
29 | comments: false,
30 | },
31 | }),
32 | ],
33 | },
34 | ];
35 |
--------------------------------------------------------------------------------
/src/compile.ts:
--------------------------------------------------------------------------------
1 | import { getFunctions } from "./functions";
2 | import {
3 | type Context,
4 | createInterpreterState,
5 | evaluateAst,
6 | } from "./interpreter";
7 | import { parse } from "./parser";
8 | import { tokenize } from "./tokenizer";
9 |
10 | /**
11 | * Compile an expression into a reusable function
12 | * @param expression - The expression to compile
13 | * @returns A function that evaluates the expression with a given context
14 | */
15 | export function compile(
16 | expression: string,
17 | // biome-ignore lint/suspicious/noExplicitAny:
18 | ): (context?: Context) => any {
19 | const tokens = tokenize(expression);
20 | const ast = parse(tokens);
21 | const interpreterState = createInterpreterState({}, getFunctions());
22 |
23 | // Return a function that can be called with different contexts
24 | // biome-ignore lint/suspicious/noExplicitAny: Return type depends on the expression
25 | return (context: Context = {}): any => {
26 | return evaluateAst(ast, interpreterState, context);
27 | };
28 | }
29 |
--------------------------------------------------------------------------------
/src/error.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Error class for expression parsing errors.
3 | */
4 | export class ExpressionError extends Error {
5 | constructor(
6 | message: string,
7 | public readonly position?: number,
8 | public readonly token?: string,
9 | ) {
10 | super(message);
11 | this.name = "ExpressionError";
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/evaluate.ts:
--------------------------------------------------------------------------------
1 | import { compile } from "./compile";
2 | import type { Context } from "./interpreter";
3 |
4 | /**
5 | * Evaluate an expression with a given context
6 | * @param expression - The expression to evaluate
7 | * @param context - The context to use for evaluation
8 | * @returns The result of evaluating the expression
9 | */
10 | export function evaluate(
11 | expression: string,
12 | context: Context = {},
13 | // biome-ignore lint/suspicious/noExplicitAny: Return type depends on the expression
14 | ): any {
15 | return compile(expression)(context);
16 | }
17 |
--------------------------------------------------------------------------------
/src/functions.ts:
--------------------------------------------------------------------------------
1 | // Global registry for functions that can be used in expressions
2 | // biome-ignore lint/suspicious/noExplicitAny: Function registry needs to support any function type
3 | type ExpressionFunction = (...args: any[]) => any;
4 |
5 | // Register some common Math functions by default
6 | const exprGlobalFunctions: Record = {
7 | abs: Math.abs,
8 | ceil: Math.ceil,
9 | floor: Math.floor,
10 | max: Math.max,
11 | min: Math.min,
12 | round: Math.round,
13 | sqrt: Math.sqrt,
14 | pow: Math.pow,
15 | };
16 |
17 | /**
18 | * Register a function to be used in expressions with the @ prefix
19 | * @param name - The name of the function to register
20 | * @param fn - The function implementation
21 | */
22 | export function register(name: string, fn: ExpressionFunction): void {
23 | exprGlobalFunctions[name] = fn;
24 | }
25 |
26 | /**
27 | * Get all the registered functions
28 | * @returns
29 | */
30 | export function getFunctions(): Record {
31 | return exprGlobalFunctions;
32 | }
33 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { register } from "./functions";
2 | export { compile } from "./compile";
3 | export { evaluate } from "./evaluate";
4 | export { ExpressionError } from "./error";
5 |
--------------------------------------------------------------------------------
/src/interpreter.ts:
--------------------------------------------------------------------------------
1 | import { ExpressionError } from "./error";
2 | import {
3 | type BinaryExpression,
4 | type CallExpression,
5 | type ConditionalExpression,
6 | type Expression,
7 | type Identifier,
8 | type Literal,
9 | type MemberExpression,
10 | NodeType,
11 | type Program,
12 | type UnaryExpression,
13 | } from "./parser";
14 |
15 | // biome-ignore lint/suspicious/noExplicitAny:
16 | export type Context = Record;
17 | // biome-ignore lint/suspicious/noExplicitAny:
18 | export type Functions = Record any>;
19 |
20 | /**
21 | * InterpreterState represents the current state of interpretation
22 | * @property context - Variables and values available during evaluation
23 | * @property functions - Functions available for calling during evaluation
24 | */
25 | interface InterpreterState {
26 | context: Context;
27 | functions: Functions;
28 | }
29 |
30 | /**
31 | * Creates a new interpreter state with the provided context and functions
32 | * @param context - Initial variable context
33 | * @param functions - Available functions
34 | * @returns A new interpreter state
35 | */
36 | export const createInterpreterState = (
37 | context: Context = {},
38 | functions: Functions = {},
39 | ): InterpreterState => {
40 | return {
41 | context,
42 | functions,
43 | };
44 | };
45 |
46 | /**
47 | * Evaluates an AST and returns the result
48 | * @param ast - The AST to evaluate
49 | * @param state - Current interpreter state
50 | * @param context - Optional context to override the default context
51 | * @returns The result of evaluation
52 | * @example
53 | * const ast = parse(tokens);
54 | * const result = evaluate(ast, state);
55 | */
56 | export const evaluateAst = (
57 | ast: Program,
58 | state: InterpreterState,
59 | context?: Context,
60 | ): unknown => {
61 | let evaluationState = state;
62 | if (context) {
63 | evaluationState = {
64 | ...state,
65 | context: { ...state.context, ...context },
66 | };
67 | }
68 |
69 | // Define all evaluation functions within the closure to access evaluationState
70 | /**
71 | * Evaluates a literal value
72 | * @param node - Literal node
73 | * @returns The literal value
74 | * @example "hello" → "hello"
75 | * @example 42 → 42
76 | */
77 | const evaluateLiteral = (node: Literal): number | string | boolean | null => {
78 | return node.value;
79 | };
80 |
81 | /**
82 | * Evaluates an identifier by looking up its value in the context
83 | * @param node - Identifier node
84 | * @returns The value from context
85 | * @example data → context.data
86 | */
87 | const evaluateIdentifier = (node: Identifier): unknown => {
88 | if (!(node.name in evaluationState.context)) {
89 | throw new ExpressionError(`Undefined variable: ${node.name}`);
90 | }
91 | return evaluationState.context[node.name];
92 | };
93 |
94 | /**
95 | * Evaluates a member expression (property access)
96 | * @param node - MemberExpression node
97 | * @returns The accessed property value
98 | * @example data.value → context.data.value
99 | * @example data["value"] → context.data["value"]
100 | */
101 | const evaluateMemberExpression = (node: MemberExpression): unknown => {
102 | const object = evaluateNode(node.object);
103 | if (object == null) {
104 | throw new ExpressionError("Cannot access property of null or undefined");
105 | }
106 |
107 | const property = node.computed
108 | ? evaluateNode(node.property)
109 | : (node.property as Identifier).name;
110 |
111 | // biome-ignore lint/suspicious/noExplicitAny:
112 | return (object as any)[property as string | number];
113 | };
114 |
115 | /**
116 | * Evaluates a function call
117 | * @param node - CallExpression node
118 | * @returns The function result
119 | * @example @sum(1, 2) → functions.sum(1, 2)
120 | */
121 | const evaluateCallExpression = (node: CallExpression): unknown => {
122 | const func = evaluationState.functions[node.callee.name];
123 | if (!func) {
124 | throw new ExpressionError(`Undefined function: ${node.callee.name}`);
125 | }
126 |
127 | const args = node.arguments.map((arg) => evaluateNode(arg));
128 | return func(...args);
129 | };
130 |
131 | /**
132 | * Evaluates a binary expression
133 | * @param node - BinaryExpression node
134 | * @returns The result of the binary operation
135 | * @example a + b → context.a + context.b
136 | * @example x > y → context.x > context.y
137 | */
138 | const evaluateBinaryExpression = (node: BinaryExpression): unknown => {
139 | // Implement short-circuit evaluation for logical operators
140 | if (node.operator === "&&") {
141 | const left = evaluateNode(node.left);
142 | // If left side is falsy, return it immediately without evaluating right side
143 | if (!left) return left;
144 | // Otherwise evaluate and return right side
145 | return evaluateNode(node.right);
146 | }
147 |
148 | if (node.operator === "||") {
149 | const left = evaluateNode(node.left);
150 | // If left side is truthy, return it immediately without evaluating right side
151 | if (left) return left;
152 | // Otherwise evaluate and return right side
153 | return evaluateNode(node.right);
154 | }
155 |
156 | // For other operators, evaluate both sides normally
157 | const left = evaluateNode(node.left);
158 | const right = evaluateNode(node.right);
159 |
160 | switch (node.operator) {
161 | case "+":
162 | // For addition, handle both numeric addition and string concatenation
163 | // biome-ignore lint/suspicious/noExplicitAny:
164 | return (left as any) + (right as any);
165 | case "-":
166 | return (left as number) - (right as number);
167 | case "*":
168 | return (left as number) * (right as number);
169 | case "/":
170 | return (left as number) / (right as number);
171 | case "%":
172 | return (left as number) % (right as number);
173 | case "===":
174 | return left === right;
175 | case "!==":
176 | return left !== right;
177 | case ">":
178 | return (left as number) > (right as number);
179 | case ">=":
180 | return (left as number) >= (right as number);
181 | case "<":
182 | return (left as number) < (right as number);
183 | case "<=":
184 | return (left as number) <= (right as number);
185 | default:
186 | throw new ExpressionError(`Unknown operator: ${node.operator}`);
187 | }
188 | };
189 |
190 | /**
191 | * Evaluates a unary expression
192 | * @param node - UnaryExpression node
193 | * @returns The result of the unary operation
194 | * @example !valid → !context.valid
195 | * @example -num → -context.num
196 | */
197 | const evaluateUnaryExpression = (node: UnaryExpression): unknown => {
198 | const argument = evaluateNode(node.argument);
199 |
200 | if (node.prefix) {
201 | switch (node.operator) {
202 | case "!":
203 | return !argument;
204 | case "-":
205 | if (typeof argument !== "number") {
206 | throw new ExpressionError(
207 | `Cannot apply unary - to non-number: ${argument}`,
208 | );
209 | }
210 | return -argument;
211 | default:
212 | throw new ExpressionError(`Unknown operator: ${node.operator}`);
213 | }
214 | }
215 | // Currently we don't support postfix operators
216 | throw new ExpressionError(
217 | `Postfix operators are not supported: ${node.operator}`,
218 | );
219 | };
220 |
221 | /**
222 | * Evaluates a conditional (ternary) expression
223 | * @param node - ConditionalExpression node
224 | * @returns The result of the conditional expression
225 | * @example a ? b : c → context.a ? context.b : context.c
226 | */
227 | const evaluateConditionalExpression = (
228 | node: ConditionalExpression,
229 | ): unknown => {
230 | const test = evaluateNode(node.test);
231 | return test ? evaluateNode(node.consequent) : evaluateNode(node.alternate);
232 | };
233 |
234 | /**
235 | * Evaluates a single AST node
236 | * @param node - The node to evaluate
237 | * @returns The result of evaluation
238 | */
239 | const evaluateNode = (node: Expression): unknown => {
240 | switch (node.type) {
241 | case NodeType.Literal:
242 | return evaluateLiteral(node);
243 | case NodeType.Identifier:
244 | return evaluateIdentifier(node);
245 | case NodeType.MemberExpression:
246 | return evaluateMemberExpression(node);
247 | case NodeType.CallExpression:
248 | return evaluateCallExpression(node);
249 | case NodeType.BinaryExpression:
250 | return evaluateBinaryExpression(node);
251 | case NodeType.UnaryExpression:
252 | return evaluateUnaryExpression(node);
253 | case NodeType.ConditionalExpression:
254 | return evaluateConditionalExpression(node);
255 | default:
256 | throw new ExpressionError(
257 | `Evaluation error: Unsupported node type: ${(node as Expression).type}`,
258 | );
259 | }
260 | };
261 |
262 | // Start evaluation with the root node
263 | return evaluateNode(ast.body);
264 | };
265 |
--------------------------------------------------------------------------------
/src/parser.ts:
--------------------------------------------------------------------------------
1 | import { ExpressionError } from "./error";
2 | import { type Token, TokenType } from "./tokenizer";
3 |
4 | /**
5 | * All possible node types in the Abstract Syntax Tree (AST)
6 | * - Program: Root node of the AST
7 | * - Literal: Constants (numbers, strings, booleans, null)
8 | * - Identifier: Variable and property names
9 | * - MemberExpression: Property access (dot or bracket notation)
10 | * - CallExpression: Function invocation
11 | * - BinaryExpression: Operations with two operands
12 | * - UnaryExpression: Operations with one operand
13 | * - ConditionalExpression: Ternary operator expressions
14 | */
15 | export enum NodeType {
16 | Program = 0,
17 | Literal = 1,
18 | Identifier = 2,
19 | MemberExpression = 3,
20 | CallExpression = 4,
21 | BinaryExpression = 5,
22 | UnaryExpression = 6,
23 | ConditionalExpression = 7,
24 | }
25 |
26 | /**
27 | * Base interface for all AST nodes
28 | * Every node must have a type property identifying its kind
29 | */
30 | export interface Node {
31 | type: NodeType;
32 | }
33 |
34 | /**
35 | * Root node of the AST
36 | * Contains a single expression as its body
37 | */
38 | export interface Program extends Node {
39 | type: NodeType.Program;
40 | body: Expression;
41 | }
42 |
43 | /**
44 | * Base interface for all expression nodes
45 | * All expressions are nodes that can produce a value
46 | */
47 | export type Expression =
48 | | Literal
49 | | Identifier
50 | | MemberExpression
51 | | CallExpression
52 | | BinaryExpression
53 | | UnaryExpression
54 | | ConditionalExpression;
55 |
56 | /**
57 | * Represents literal values in the code
58 | * Examples: 42, "hello", true, null
59 | */
60 | export interface Literal extends Node {
61 | type: NodeType.Literal;
62 | value: string | number | boolean | null; // The actual value
63 | }
64 |
65 | /**
66 | * Represents identifiers/names in the code
67 | * Examples: variable names, property names
68 | */
69 | export interface Identifier extends Node {
70 | type: NodeType.Identifier;
71 | name: string;
72 | }
73 |
74 | /**
75 | * Represents property access expressions
76 | * Examples:
77 | * - obj.prop (computed: false)
78 | * - obj["prop"] (computed: true)
79 | */
80 | export interface MemberExpression extends Node {
81 | type: NodeType.MemberExpression;
82 | object: Expression; // The object being accessed
83 | property: Expression; // The property being accessed
84 | computed: boolean; // true for obj["prop"], false for obj.prop
85 | }
86 |
87 | /**
88 | * Represents function calls
89 | * Example: @sum(a, b)
90 | */
91 | export interface CallExpression extends Node {
92 | type: NodeType.CallExpression;
93 | callee: Identifier; // Function name
94 | arguments: Expression[]; // Array of argument expressions
95 | }
96 |
97 | /**
98 | * Represents operations with two operands
99 | * Examples: a + b, x * y, foo === bar
100 | */
101 | export interface BinaryExpression extends Node {
102 | type: NodeType.BinaryExpression;
103 | operator: string; // The operator (+, -, *, /, etc.)
104 | left: Expression; // Left-hand operand
105 | right: Expression; // Right-hand operand
106 | }
107 |
108 | /**
109 | * Represents operations with a single operand
110 | * Example: !valid
111 | */
112 | export interface UnaryExpression extends Node {
113 | type: NodeType.UnaryExpression;
114 | operator: string; // The operator (!, -, etc.)
115 | argument: Expression; // The operand
116 | prefix: boolean; // true for prefix operators, false for postfix
117 | }
118 |
119 | /**
120 | * Represents ternary conditional expressions
121 | * Example: condition ? trueValue : falseValue
122 | */
123 | export interface ConditionalExpression extends Node {
124 | type: NodeType.ConditionalExpression;
125 | test: Expression; // The condition
126 | consequent: Expression; // Value if condition is true
127 | alternate: Expression; // Value if condition is false
128 | }
129 |
130 | // Operator precedence lookup table for O(1) access
131 | const OPERATOR_PRECEDENCE = new Map([
132 | ["||", 2],
133 | ["&&", 3],
134 | ["===", 4],
135 | ["!==", 4],
136 | [">", 5],
137 | [">=", 5],
138 | ["<", 5],
139 | ["<=", 5],
140 | ["+", 6],
141 | ["-", 6],
142 | ["*", 7],
143 | ["/", 7],
144 | ["%", 7],
145 | ["!", 8],
146 | ]);
147 |
148 | // Pre-create common AST nodes for reuse
149 | const NULL_LITERAL: Literal = {
150 | type: NodeType.Literal,
151 | value: null,
152 | };
153 |
154 | const TRUE_LITERAL: Literal = {
155 | type: NodeType.Literal,
156 | value: true,
157 | };
158 |
159 | const FALSE_LITERAL: Literal = {
160 | type: NodeType.Literal,
161 | value: false,
162 | };
163 |
164 | /**
165 | * Parse tokens into an AST
166 | * Time: O(n) - single pass through tokens
167 | * Space: O(d) - recursive depth of expression tree
168 | * @param tokens - Array of tokens from the tokenizer
169 | * @returns AST representing the expression
170 | */
171 | export const parse = (tokens: Token[]): Program => {
172 | // Use closure to encapsulate the parser state
173 | let current = 0;
174 | const length = tokens.length;
175 |
176 | /**
177 | * Returns the current token without consuming it
178 | * @returns The current token or null if at end of input
179 | */
180 | const peek = (): Token | null => {
181 | if (current >= length) return null;
182 | return tokens[current];
183 | };
184 |
185 | /**
186 | * Consumes and returns the current token, advancing the parser position
187 | * @returns The consumed token
188 | */
189 | const consume = (): Token => {
190 | return tokens[current++];
191 | };
192 |
193 | /**
194 | * Checks if the current token matches the expected type
195 | * @param type - The token type to match
196 | * @returns boolean indicating if current token matches
197 | */
198 | const match = (type: TokenType): boolean => {
199 | const token = peek();
200 | return token !== null && token.type === type;
201 | };
202 |
203 | /**
204 | * Gets operator precedence
205 | * @param token - The token to check
206 | * @returns Precedence level (-1 to 9) or -1 if not an operator
207 | */
208 | const getOperatorPrecedence = (token: Token): number => {
209 | if (token.type === TokenType.OPERATOR) {
210 | return OPERATOR_PRECEDENCE.get(token.value) || -1;
211 | }
212 |
213 | if (token.type === TokenType.DOT || token.type === TokenType.BRACKET_LEFT) {
214 | return 9; // Highest precedence for member access
215 | }
216 |
217 | if (token.type === TokenType.QUESTION) {
218 | return 1; // Make it higher than -1 but lower than other operators
219 | }
220 |
221 | return -1;
222 | };
223 |
224 | /**
225 | * Parses member access expressions
226 | * @param object - The object expression being accessed
227 | * @returns MemberExpression node
228 | */
229 | const parseMemberExpression = (object: Expression): MemberExpression => {
230 | const token = consume(); // consume . or [
231 | let property: Expression;
232 | let computed: boolean;
233 |
234 | if (token.type === TokenType.DOT) {
235 | if (!match(TokenType.IDENTIFIER)) {
236 | const token = peek();
237 | throw new ExpressionError(
238 | "Expected property name",
239 | current,
240 | token ? token.value : "",
241 | );
242 | }
243 | const identifierToken = consume();
244 | property = {
245 | type: NodeType.Identifier,
246 | name: identifierToken.value,
247 | };
248 | computed = false;
249 | } else {
250 | // BRACKET_LEFT
251 | property = parseExpression(0);
252 |
253 | if (!match(TokenType.BRACKET_RIGHT)) {
254 | const token = peek();
255 | throw new ExpressionError(
256 | "Expected closing bracket",
257 | current,
258 | token ? token.value : "",
259 | );
260 | }
261 | consume(); // consume ]
262 | computed = true;
263 | }
264 |
265 | return {
266 | type: NodeType.MemberExpression,
267 | object,
268 | property,
269 | computed,
270 | };
271 | };
272 |
273 | /**
274 | * Parses function call expressions
275 | * @returns CallExpression node
276 | */
277 | const parseCallExpression = (): CallExpression => {
278 | const token = consume(); // consume FUNCTION token
279 | const args: Expression[] = [];
280 |
281 | if (!match(TokenType.PAREN_LEFT)) {
282 | const token = peek();
283 | throw new ExpressionError(
284 | "Expected opening parenthesis after function name",
285 | current,
286 | token ? token.value : "",
287 | );
288 | }
289 | consume(); // consume (
290 |
291 | // Parse arguments
292 | while (true) {
293 | // First check for right parenthesis
294 | if (match(TokenType.PAREN_RIGHT)) {
295 | consume(); // consume )
296 | break;
297 | }
298 |
299 | // Then check for end of input before doing anything else
300 | if (!peek()) {
301 | const token = peek();
302 | throw new ExpressionError(
303 | "Expected closing parenthesis",
304 | current,
305 | token ? token.value : "",
306 | );
307 | }
308 |
309 | // If we have arguments already, we need a comma
310 | if (args.length > 0) {
311 | if (!match(TokenType.COMMA)) {
312 | const token = peek();
313 | throw new ExpressionError(
314 | "Expected comma between function arguments",
315 | current,
316 | token ? token.value : "",
317 | );
318 | }
319 | consume(); // consume ,
320 | }
321 |
322 | const arg = parseExpression(0);
323 | args.push(arg);
324 | }
325 |
326 | return {
327 | type: NodeType.CallExpression,
328 | callee: {
329 | type: NodeType.Identifier,
330 | name: token.value,
331 | },
332 | arguments: args,
333 | };
334 | };
335 |
336 | /**
337 | * Parses primary expressions (literals, identifiers, parenthesized expressions)
338 | * @returns Expression node
339 | */
340 | const parsePrimary = (): Expression => {
341 | const token = peek();
342 | if (!token)
343 | throw new ExpressionError(
344 | "Unexpected end of input",
345 | current,
346 | "",
347 | );
348 |
349 | // Handle unary operators
350 | if (
351 | token.type === TokenType.OPERATOR &&
352 | (token.value === "!" || token.value === "-")
353 | ) {
354 | consume(); // consume operator
355 | const argument = parsePrimary();
356 | return {
357 | type: NodeType.UnaryExpression,
358 | operator: token.value,
359 | argument,
360 | prefix: true,
361 | };
362 | }
363 |
364 | switch (token.type) {
365 | case TokenType.NUMBER: {
366 | consume(); // consume number
367 | return {
368 | type: NodeType.Literal,
369 | value: Number(token.value),
370 | };
371 | }
372 |
373 | case TokenType.STRING: {
374 | consume(); // consume string
375 | return {
376 | type: NodeType.Literal,
377 | value: token.value,
378 | };
379 | }
380 |
381 | case TokenType.BOOLEAN: {
382 | consume(); // consume boolean
383 | return token.value === "true" ? TRUE_LITERAL : FALSE_LITERAL;
384 | }
385 |
386 | case TokenType.NULL: {
387 | consume(); // consume null
388 | return NULL_LITERAL;
389 | }
390 |
391 | case TokenType.IDENTIFIER: {
392 | consume(); // consume identifier
393 | return {
394 | type: NodeType.Identifier,
395 | name: token.value,
396 | };
397 | }
398 |
399 | case TokenType.FUNCTION:
400 | return parseCallExpression();
401 |
402 | case TokenType.PAREN_LEFT: {
403 | consume(); // consume (
404 | const expr = parseExpression(0);
405 | if (!match(TokenType.PAREN_RIGHT)) {
406 | const token = peek();
407 | throw new ExpressionError(
408 | "Expected closing parenthesis",
409 | current,
410 | token ? token.value : "",
411 | );
412 | }
413 | consume(); // consume )
414 | return expr;
415 | }
416 |
417 | default:
418 | throw new ExpressionError(
419 | `Unexpected token: ${token.type}`,
420 | current,
421 | token.value,
422 | );
423 | }
424 | };
425 |
426 | /**
427 | * Parses expressions with operator precedence
428 | * @param precedence - Current precedence level
429 | * @returns Expression node
430 | */
431 | const parseExpression = (precedence = 0): Expression => {
432 | let left = parsePrimary();
433 |
434 | while (current < length) {
435 | const token = tokens[current]; // Inline peek() for performance
436 | const nextPrecedence = getOperatorPrecedence(token);
437 |
438 | if (nextPrecedence <= precedence) break;
439 |
440 | if (token.type === TokenType.QUESTION) {
441 | consume(); // consume ?
442 | const consequent = parseExpression(0);
443 | if (!match(TokenType.COLON)) {
444 | const token = peek();
445 | throw new ExpressionError(
446 | "Expected : in conditional expression",
447 | current,
448 | token ? token.value : "",
449 | );
450 | }
451 | consume(); // consume :
452 | const alternate = parseExpression(0);
453 | left = {
454 | type: NodeType.ConditionalExpression,
455 | test: left,
456 | consequent,
457 | alternate,
458 | };
459 | continue;
460 | }
461 |
462 | if (token.type === TokenType.OPERATOR) {
463 | consume(); // consume operator
464 | const right = parseExpression(nextPrecedence);
465 | left = {
466 | type: NodeType.BinaryExpression,
467 | operator: token.value,
468 | left,
469 | right,
470 | };
471 | continue;
472 | }
473 |
474 | if (
475 | token.type === TokenType.DOT ||
476 | token.type === TokenType.BRACKET_LEFT
477 | ) {
478 | left = parseMemberExpression(left);
479 | continue;
480 | }
481 |
482 | break;
483 | }
484 |
485 | return left;
486 | };
487 |
488 | // Start parsing from the initial state
489 | const expression = parseExpression();
490 | return {
491 | type: NodeType.Program,
492 | body: expression,
493 | };
494 | };
495 |
--------------------------------------------------------------------------------
/src/tokenizer.ts:
--------------------------------------------------------------------------------
1 | import { ExpressionError } from "./error";
2 |
3 | // token type enum
4 | export enum TokenType {
5 | STRING = 0,
6 | NUMBER = 1,
7 | BOOLEAN = 2,
8 | NULL = 3,
9 | IDENTIFIER = 4,
10 | OPERATOR = 5,
11 | FUNCTION = 6,
12 | DOT = 7,
13 | BRACKET_LEFT = 8,
14 | BRACKET_RIGHT = 9,
15 | PAREN_LEFT = 10,
16 | PAREN_RIGHT = 11,
17 | COMMA = 12,
18 | QUESTION = 13,
19 | COLON = 14,
20 | DOLLAR = 15,
21 | }
22 |
23 | // Character code constants for faster comparison
24 | const CHAR_0 = 48; // '0'
25 | const CHAR_9 = 57; // '9'
26 | const CHAR_A = 65; // 'A'
27 | const CHAR_Z = 90; // 'Z'
28 | const CHAR_a = 97; // 'a'
29 | const CHAR_z = 122; // 'z'
30 | const CHAR_UNDERSCORE = 95; // '_'
31 | const CHAR_DOT = 46; // '.'
32 | const CHAR_MINUS = 45; // '-'
33 | const CHAR_PLUS = 43; // '+'
34 | const CHAR_MULTIPLY = 42; // '*'
35 | const CHAR_DIVIDE = 47; // '/'
36 | const CHAR_MODULO = 37; // '%'
37 | const CHAR_EXCLAMATION = 33; // '!'
38 | const CHAR_AMPERSAND = 38; // '&'
39 | const CHAR_PIPE = 124; // '|'
40 | const CHAR_EQUAL = 61; // '='
41 | const CHAR_LESS_THAN = 60; // '<'
42 | const CHAR_GREATER_THAN = 62; // '>'
43 | const CHAR_QUESTION = 63; // '?'
44 | const CHAR_COLON = 58; // ':'
45 | const CHAR_COMMA = 44; // ','
46 | const CHAR_BRACKET_LEFT = 91; // '['
47 | const CHAR_BRACKET_RIGHT = 93; // ']'
48 | const CHAR_PAREN_LEFT = 40; // '('
49 | const CHAR_PAREN_RIGHT = 41; // ')'
50 | const CHAR_DOLLAR = 36; // '$'
51 | const CHAR_AT = 64; // '@'
52 | const CHAR_DOUBLE_QUOTE = 34; // '"'
53 | const CHAR_SINGLE_QUOTE = 39; // '\''
54 | const CHAR_BACKSLASH = 92; // '\\'
55 | const CHAR_SPACE = 32; // ' '
56 | const CHAR_TAB = 9; // '\t'
57 | const CHAR_NEWLINE = 10; // '\n'
58 | const CHAR_CARRIAGE_RETURN = 13; // '\r'
59 |
60 | // Use a Set for faster lookups
61 | const WHITESPACE_CHARS = new Set([
62 | CHAR_SPACE,
63 | CHAR_TAB,
64 | CHAR_NEWLINE,
65 | CHAR_CARRIAGE_RETURN,
66 | ]);
67 | const OPERATOR_START_CHARS = new Set([
68 | CHAR_PLUS,
69 | CHAR_MINUS,
70 | CHAR_MULTIPLY,
71 | CHAR_DIVIDE,
72 | CHAR_MODULO,
73 | CHAR_EXCLAMATION,
74 | CHAR_AMPERSAND,
75 | CHAR_PIPE,
76 | CHAR_EQUAL,
77 | CHAR_LESS_THAN,
78 | CHAR_GREATER_THAN,
79 | ]);
80 |
81 | // Token type lookup maps for common tokens
82 | const KEYWORDS = new Map([
83 | ["true", TokenType.BOOLEAN],
84 | ["false", TokenType.BOOLEAN],
85 | ["null", TokenType.NULL],
86 | ]);
87 |
88 | // Operator to token type mapping (sorted by length for optimization)
89 | const OPERATOR_TOKENS = new Map([
90 | // 3-character operators
91 | ["===", true],
92 | ["!==", true],
93 |
94 | // 2-character operators
95 | ["<=", true],
96 | [">=", true],
97 | ["&&", true],
98 | ["||", true],
99 |
100 | // 1-character operators
101 | ["+", true],
102 | ["-", true],
103 | ["*", true],
104 | ["/", true],
105 | ["%", true],
106 | ["!", true],
107 | ["<", true],
108 | [">", true],
109 | ]);
110 |
111 | // Single character token map for O(1) lookup
112 | const SINGLE_CHAR_TOKENS = new Map([
113 | [CHAR_DOT, TokenType.DOT],
114 | [CHAR_BRACKET_LEFT, TokenType.BRACKET_LEFT],
115 | [CHAR_BRACKET_RIGHT, TokenType.BRACKET_RIGHT],
116 | [CHAR_PAREN_LEFT, TokenType.PAREN_LEFT],
117 | [CHAR_PAREN_RIGHT, TokenType.PAREN_RIGHT],
118 | [CHAR_COMMA, TokenType.COMMA],
119 | [CHAR_QUESTION, TokenType.QUESTION],
120 | [CHAR_COLON, TokenType.COLON],
121 | [CHAR_DOLLAR, TokenType.DOLLAR],
122 | ]);
123 |
124 | /**
125 | * Token represents a single unit in the expression
126 | * @property type - The category of the token
127 | * @property value - The actual string value of the token
128 | */
129 | export interface Token {
130 | type: TokenType;
131 | value: string;
132 | }
133 |
134 | // Pre-allocate token objects for single character tokens to reduce object creation
135 | const CHAR_TOKEN_CACHE = new Map();
136 | for (const [code, type] of SINGLE_CHAR_TOKENS.entries()) {
137 | CHAR_TOKEN_CACHE.set(code, { type, value: String.fromCharCode(code) });
138 | }
139 |
140 | /**
141 | * Check if a character code is a digit (0-9)
142 | */
143 | function isDigit(code: number): boolean {
144 | return code >= CHAR_0 && code <= CHAR_9;
145 | }
146 |
147 | /**
148 | * Check if a character code is a letter (a-z, A-Z) or underscore
149 | */
150 | function isAlpha(code: number): boolean {
151 | return (
152 | (code >= CHAR_a && code <= CHAR_z) ||
153 | (code >= CHAR_A && code <= CHAR_Z) ||
154 | code === CHAR_UNDERSCORE
155 | );
156 | }
157 |
158 | /**
159 | * Check if a character code is alphanumeric (a-z, A-Z, 0-9) or underscore
160 | */
161 | function isAlphaNumeric(code: number): boolean {
162 | return isAlpha(code) || isDigit(code);
163 | }
164 |
165 | /**
166 | * Check if a character code is whitespace
167 | */
168 | function isWhitespace(code: number): boolean {
169 | return WHITESPACE_CHARS.has(code);
170 | }
171 |
172 | /**
173 | * Check if a character code can start an operator
174 | */
175 | function isOperatorStart(code: number): boolean {
176 | return OPERATOR_START_CHARS.has(code);
177 | }
178 |
179 | /**
180 | * Converts an input expression string into an array of tokens
181 | * Processes the input character by character, identifying tokens based on patterns
182 | *
183 | * Time Complexity: O(n) where n is the length of input string
184 | * Space Complexity: O(n) for storing the tokens array
185 | *
186 | * @param expr - The input expression string to tokenize
187 | * @returns Array of Token objects
188 | * @throws Error for unexpected or invalid characters
189 | */
190 | export const tokenize = (expr: string): Token[] => {
191 | const input = expr;
192 | const length = input.length;
193 | // Pre-allocate tokens array with estimated capacity to avoid resizing
194 | const tokens: Token[] = new Array(Math.ceil(length / 3));
195 | let tokenCount = 0;
196 | let pos = 0;
197 |
198 | /**
199 | * Reads a string literal token, handling escape sequences
200 | * @returns String token
201 | * @throws Error for unterminated strings
202 | */
203 |
204 | function readString(quoteChar: number): Token {
205 | const start = pos + 1; // Skip opening quote
206 | pos++;
207 | let value = "";
208 | let hasEscape = false;
209 |
210 | while (pos < length) {
211 | const char = input.charCodeAt(pos);
212 | if (char === quoteChar) {
213 | // If no escape sequences, use substring directly
214 | if (!hasEscape) {
215 | value = input.substring(start, pos);
216 | }
217 | pos++; // Skip closing quote
218 | return { type: TokenType.STRING, value };
219 | }
220 | if (char === CHAR_BACKSLASH) {
221 | // Handle escape sequence
222 | if (!hasEscape) {
223 | // First escape encountered, copy characters so far
224 | value = input.substring(start, pos);
225 | hasEscape = true;
226 | }
227 | pos++;
228 | value += input[pos];
229 | } else if (hasEscape) {
230 | // Only append if we're building the escaped string
231 | value += input[pos];
232 | }
233 | pos++;
234 | }
235 |
236 | throw new ExpressionError(
237 | `Unterminated string starting with ${String.fromCharCode(quoteChar)}`,
238 | pos,
239 | input.substring(Math.max(0, pos - 10), pos),
240 | );
241 | }
242 |
243 | /**
244 | * Reads a numeric token, handling integers, decimals, and negative numbers
245 | * @returns Number token
246 | */
247 | function readNumber(): Token {
248 | const start = pos;
249 |
250 | // Handle negative sign if present
251 | if (input.charCodeAt(pos) === CHAR_MINUS) {
252 | pos++;
253 | }
254 |
255 | // Read digits before decimal point
256 | while (pos < length && isDigit(input.charCodeAt(pos))) {
257 | pos++;
258 | }
259 |
260 | // Handle decimal point and digits after it
261 | if (pos < length && input.charCodeAt(pos) === CHAR_DOT) {
262 | pos++;
263 | while (pos < length && isDigit(input.charCodeAt(pos))) {
264 | pos++;
265 | }
266 | }
267 |
268 | const value = input.slice(start, pos);
269 | return { type: TokenType.NUMBER, value };
270 | }
271 |
272 | /**
273 | * Reads a function name token after @ symbol
274 | * @returns Function token
275 | */
276 | function readFunction(): Token {
277 | pos++; // Skip @ symbol
278 | const start = pos;
279 |
280 | // First character must be a letter or underscore
281 | if (pos < length && isAlpha(input.charCodeAt(pos))) {
282 | pos++;
283 |
284 | // Subsequent characters can be alphanumeric
285 | while (pos < length && isAlphaNumeric(input.charCodeAt(pos))) {
286 | pos++;
287 | }
288 | }
289 |
290 | const value = input.slice(start, pos);
291 | return { type: TokenType.FUNCTION, value };
292 | }
293 |
294 | /**
295 | * Reads an identifier token, also handling boolean and null literals
296 | * @returns Identifier, boolean, or null token
297 | */
298 | function readIdentifier(): Token {
299 | const start = pos++; // First character already checked
300 |
301 | // Read remaining characters
302 | while (pos < length && isAlphaNumeric(input.charCodeAt(pos))) {
303 | pos++;
304 | }
305 |
306 | const value = input.slice(start, pos);
307 |
308 | // Check if it's a keyword (true, false, null)
309 | const keywordType = KEYWORDS.get(value);
310 | if (keywordType) {
311 | return { type: keywordType, value };
312 | }
313 |
314 | return { type: TokenType.IDENTIFIER, value };
315 | }
316 |
317 | /**
318 | * Reads an operator token, checking multi-character operators first
319 | * @returns Operator token
320 | */
321 | function readOperator(): Token {
322 | // Try to match 3-character operators
323 | if (pos + 2 < length) {
324 | const op3 = input.substring(pos, pos + 3);
325 | if (OPERATOR_TOKENS.has(op3)) {
326 | pos += 3;
327 | return { type: TokenType.OPERATOR, value: op3 };
328 | }
329 | }
330 |
331 | // Try to match 2-character operators
332 | if (pos + 1 < length) {
333 | const op2 = input.substring(pos, pos + 2);
334 | if (OPERATOR_TOKENS.has(op2)) {
335 | pos += 2;
336 | return { type: TokenType.OPERATOR, value: op2 };
337 | }
338 | }
339 |
340 | // Try to match 1-character operators
341 | const op1 = input[pos];
342 | if (OPERATOR_TOKENS.has(op1)) {
343 | pos++;
344 | return { type: TokenType.OPERATOR, value: op1 };
345 | }
346 |
347 | throw new ExpressionError(
348 | `Unknown operator at position ${pos}: ${input.substring(pos, pos + 1)}`,
349 | pos,
350 | input.substring(Math.max(0, pos - 10), pos),
351 | );
352 | }
353 |
354 | // Main tokenization loop
355 | while (pos < length) {
356 | const charCode = input.charCodeAt(pos);
357 |
358 | // Fast path for whitespace
359 | if (isWhitespace(charCode)) {
360 | pos++;
361 | continue;
362 | }
363 |
364 | // Fast path for single-character tokens
365 | const cachedToken = CHAR_TOKEN_CACHE.get(charCode);
366 | if (cachedToken) {
367 | tokens[tokenCount++] = cachedToken;
368 | pos++;
369 | continue;
370 | }
371 |
372 | // Handle string literals
373 | if (charCode === CHAR_DOUBLE_QUOTE || charCode === CHAR_SINGLE_QUOTE) {
374 | tokens[tokenCount++] = readString(charCode);
375 | continue;
376 | }
377 |
378 | // Handle numbers (including negative numbers)
379 | if (
380 | isDigit(charCode) ||
381 | (charCode === CHAR_MINUS &&
382 | pos + 1 < length &&
383 | isDigit(input.charCodeAt(pos + 1)))
384 | ) {
385 | tokens[tokenCount++] = readNumber();
386 | continue;
387 | }
388 |
389 | // Handle function calls starting with @
390 | if (charCode === CHAR_AT) {
391 | tokens[tokenCount++] = readFunction();
392 | continue;
393 | }
394 |
395 | // Handle identifiers (including keywords)
396 | if (isAlpha(charCode)) {
397 | tokens[tokenCount++] = readIdentifier();
398 | continue;
399 | }
400 |
401 | // Handle operators
402 | if (isOperatorStart(charCode)) {
403 | tokens[tokenCount++] = readOperator();
404 | continue;
405 | }
406 |
407 | // If we get here, we have an unexpected character
408 | throw new ExpressionError(
409 | `Unexpected character: ${input[pos]}`,
410 | pos,
411 | input.substring(Math.max(0, pos - 10), pos),
412 | );
413 | }
414 |
415 | // Trim the tokens array to the actual number of tokens
416 | return tokenCount === tokens.length ? tokens : tokens.slice(0, tokenCount);
417 | };
418 |
--------------------------------------------------------------------------------
/tests/api-integration.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { ExpressionError, compile, evaluate, register } from "../src";
3 |
4 | describe("API Integration Tests", () => {
5 | describe("evaluate function", () => {
6 | it("should evaluate simple expressions", () => {
7 | expect(evaluate("42")).toBe(42);
8 | expect(evaluate("'hello'")).toBe("hello");
9 | expect(evaluate("true")).toBe(true);
10 | expect(evaluate("null")).toBe(null);
11 | });
12 |
13 | it("should evaluate expressions with context", () => {
14 | const context = { x: 10, y: 5 };
15 | expect(evaluate("x + y", context)).toBe(15);
16 | expect(evaluate("x - y", context)).toBe(5);
17 | expect(evaluate("x * y", context)).toBe(50);
18 | expect(evaluate("x / y", context)).toBe(2);
19 | });
20 |
21 | it("should throw ExpressionError for invalid expressions", () => {
22 | expect(() => evaluate("")).toThrow(ExpressionError);
23 | expect(() => evaluate("x + y")).toThrow(ExpressionError);
24 | });
25 | });
26 |
27 | describe("compile function", () => {
28 | it("should create a function that can be called with different contexts", () => {
29 | const expr = compile("x + y");
30 | expect(expr({ x: 10, y: 5 })).toBe(15);
31 | expect(expr({ x: 20, y: 30 })).toBe(50);
32 | });
33 |
34 | it("should throw when compiling invalid expressions", () => {
35 | expect(() => compile("")).toThrow(ExpressionError);
36 | });
37 | });
38 |
39 | describe("register function", () => {
40 | it("should register custom functions that can be used in expressions", () => {
41 | register("sum", (...args) => args.reduce((a, b) => a + b, 0));
42 | expect(evaluate("@sum(1, 2, 3)")).toBe(6);
43 | });
44 |
45 | it("should allow registered functions to be used in compiled expressions", () => {
46 | register("multiply", (a, b) => a * b);
47 | const expr = compile("@multiply(x, y)");
48 | expect(expr({ x: 10, y: 5 })).toBe(50);
49 | });
50 | });
51 |
52 | describe("Variable References", () => {
53 | it("should handle nested property access", () => {
54 | const context = {
55 | user: { profile: { name: "John", age: 30 } },
56 | };
57 | expect(evaluate("user.profile.name", context)).toBe("John");
58 | expect(evaluate("user.profile.age", context)).toBe(30);
59 | });
60 |
61 | it("should handle array access", () => {
62 | const context = { items: [10, 20, 30] };
63 | expect(evaluate("items[0]", context)).toBe(10);
64 | expect(evaluate("items[1]", context)).toBe(20);
65 | expect(evaluate("items[2]", context)).toBe(30);
66 | });
67 |
68 | it("should handle mixed dot and bracket notation", () => {
69 | const context = { data: { items: [{ value: 42 }] } };
70 | expect(evaluate("data.items[0].value", context)).toBe(42);
71 | expect(evaluate("data['items'][0]['value']", context)).toBe(42);
72 | });
73 | });
74 |
75 | describe("Arithmetic Operations", () => {
76 | const context = { a: 10, b: 3, c: 2 };
77 |
78 | it("should handle basic arithmetic", () => {
79 | expect(evaluate("a + b", context)).toBe(13);
80 | expect(evaluate("a - b", context)).toBe(7);
81 | expect(evaluate("a * b", context)).toBe(30);
82 | expect(evaluate("a / b", context)).toBe(10 / 3);
83 | });
84 |
85 | it("should handle operator precedence", () => {
86 | expect(evaluate("a + b * c", context)).toBe(16); // 10 + (3 * 2)
87 | expect(evaluate("(a + b) * c", context)).toBe(26); // (10 + 3) * 2
88 | });
89 |
90 | it("should handle modulo operation", () => {
91 | expect(evaluate("a % b", context)).toBe(1); // 10 % 3 = 1
92 | });
93 | });
94 |
95 | describe("Comparison and Logical Operations", () => {
96 | const context = {
97 | age: 20,
98 | status: "active",
99 | isAdmin: true,
100 | isDeleted: false,
101 | };
102 |
103 | it("should handle comparison operators", () => {
104 | expect(evaluate("age >= 18", context)).toBe(true);
105 | expect(evaluate("age < 18", context)).toBe(false);
106 | expect(evaluate("age === 20", context)).toBe(true);
107 | expect(evaluate("age !== 21", context)).toBe(true);
108 | });
109 |
110 | it("should handle logical operators", () => {
111 | expect(evaluate("isAdmin && !isDeleted", context)).toBe(true);
112 | expect(evaluate("isAdmin || isDeleted", context)).toBe(true);
113 | expect(evaluate("!isAdmin", context)).toBe(false);
114 | });
115 |
116 | it("should handle combined logical expressions", () => {
117 | expect(
118 | evaluate("(age >= 18 && status === 'active') || isAdmin", context),
119 | ).toBe(true);
120 | expect(evaluate("age < 18 && status === 'active'", context)).toBe(false);
121 | });
122 | });
123 |
124 | describe("Conditional (Ternary) Expressions", () => {
125 | const context = { age: 20, score: 85 };
126 |
127 | it("should handle simple ternary expressions", () => {
128 | expect(evaluate("age >= 18 ? 'adult' : 'minor'", context)).toBe("adult");
129 | expect(evaluate("age < 18 ? 'minor' : 'adult'", context)).toBe("adult");
130 | });
131 |
132 | it("should handle nested ternary expressions", () => {
133 | const expr = "score >= 90 ? 'A' : score >= 80 ? 'B' : 'C'";
134 | expect(evaluate(expr, context)).toBe("B");
135 | });
136 | });
137 |
138 | describe("Error Handling", () => {
139 | it("should provide detailed error information", () => {
140 | expect(() => evaluate("x +")).toThrow(ExpressionError);
141 | });
142 | });
143 |
144 | describe("Security Features", () => {
145 | it("should prevent access to global objects", () => {
146 | // Testing that we can't access window/global objects
147 | expect(() => evaluate("window")).toThrow(ExpressionError);
148 | expect(() => evaluate("global")).toThrow(ExpressionError);
149 | });
150 | });
151 | });
152 |
--------------------------------------------------------------------------------
/tests/coverage-improvement.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it, vi } from "vitest";
2 | import { ExpressionError, compile, evaluate, register } from "../src";
3 | import { createInterpreterState, evaluateAst } from "../src/interpreter";
4 | import { NodeType, parse } from "../src/parser";
5 | import { TokenType, tokenize } from "../src/tokenizer";
6 |
7 | describe("Coverage Improvement Tests", () => {
8 | describe("Expression Error Handling", () => {
9 | it("should handle non-ExpressionError errors during evaluation", () => {
10 | // Mock the evaluate function to throw a generic error
11 | const originalEvaluate = vi
12 | .spyOn(console, "error")
13 | .mockImplementation(() => {
14 | throw new Error("Generic error");
15 | });
16 |
17 | expect(() => evaluate("a + b", {})).toThrow();
18 |
19 | // Restore the original function
20 | originalEvaluate.mockRestore();
21 | });
22 |
23 | it("should handle unknown errors during evaluation", () => {
24 | // Mock the evaluate function to throw a non-Error object
25 | const originalEvaluate = vi
26 | .spyOn(console, "error")
27 | .mockImplementation(() => {
28 | throw "Not an error object";
29 | });
30 |
31 | expect(() => evaluate("a + b", {})).toThrow();
32 |
33 | // Restore the original function
34 | originalEvaluate.mockRestore();
35 | });
36 |
37 | it("should handle empty expressions", () => {
38 | expect(() => evaluate("")).toThrow("Unexpected end of input");
39 | });
40 |
41 | it("should compile expressions correctly", () => {
42 | const compiled = compile("a + b");
43 | expect(compiled).toBeDefined();
44 | expect(typeof compiled).toBe("function");
45 | });
46 | });
47 |
48 | describe("Tokenizer Edge Cases", () => {
49 | it("should handle negative numbers in expressions", () => {
50 | const tokens = tokenize("-42.5");
51 |
52 | expect(tokens).toHaveLength(1);
53 | expect(tokens[0]).toEqual({ type: TokenType.NUMBER, value: "-42.5" });
54 | });
55 |
56 | it("should handle function names with underscores", () => {
57 | const tokens = tokenize("@calculate_total(a, b)");
58 |
59 | expect(tokens).toHaveLength(6);
60 | expect(tokens[0]).toEqual({
61 | type: TokenType.FUNCTION,
62 | value: "calculate_total",
63 | });
64 | });
65 | });
66 |
67 | describe("Parser Edge Cases", () => {
68 | it("should throw error for missing comma between function arguments", () => {
69 | // Create function call missing comma "@func(a b)"
70 | const tokens = tokenize("@func(a b)");
71 |
72 | expect(() => parse(tokens)).toThrow(
73 | "Expected comma between function arguments",
74 | );
75 | });
76 |
77 | it("should throw error for unclosed function call", () => {
78 | // Create unclosed function call "@func(a, b"
79 | const tokens = tokenize("@func(a, b");
80 |
81 | expect(() => parse(tokens)).toThrow("Expected closing parenthesis");
82 | });
83 |
84 | it("should handle complex member expressions", () => {
85 | // Test complex member expression "obj.prop[index].nested"
86 | const tokens = tokenize("obj.prop[index].nested");
87 | const ast = parse(tokens);
88 |
89 | expect(ast.type).toBe(NodeType.Program);
90 | expect(ast.body.type).toBe(NodeType.MemberExpression);
91 | });
92 | });
93 |
94 | describe("Interpreter Edge Cases", () => {
95 | it("should handle null values in member expressions", () => {
96 | const interpreterState = createInterpreterState();
97 | const ast: any = {
98 | type: NodeType.Program,
99 | body: {
100 | type: NodeType.MemberExpression,
101 | object: {
102 | type: NodeType.Literal,
103 | value: null,
104 | raw: "null",
105 | },
106 | property: {
107 | type: NodeType.Identifier,
108 | name: "prop",
109 | },
110 | computed: false,
111 | },
112 | };
113 |
114 | expect(() => evaluateAst(ast, interpreterState, {})).toThrow(
115 | "Cannot access property of null",
116 | );
117 | });
118 |
119 | it("should handle undefined functions in call expressions", () => {
120 | const interpreterState = createInterpreterState();
121 | const ast: any = {
122 | type: NodeType.Program,
123 | body: {
124 | type: NodeType.CallExpression,
125 | callee: {
126 | type: NodeType.Identifier,
127 | name: "undefinedFunc",
128 | },
129 | arguments: [],
130 | },
131 | };
132 |
133 | expect(() => evaluateAst(ast, interpreterState, {})).toThrow(
134 | "Undefined function",
135 | );
136 | });
137 |
138 | it("should handle unsupported unary operators", () => {
139 | const interpreterState = createInterpreterState();
140 | const ast: any = {
141 | type: NodeType.Program,
142 | body: {
143 | type: NodeType.UnaryExpression,
144 | operator: "~", //
145 | argument: {
146 | type: NodeType.Literal,
147 | value: 5,
148 | raw: "5",
149 | },
150 | },
151 | };
152 |
153 | expect(() => evaluateAst(ast, interpreterState, {})).toThrow(
154 | "Postfix operators are not supported: ~",
155 | );
156 | });
157 | });
158 | });
159 |
--------------------------------------------------------------------------------
/tests/edge-cases.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { ExpressionError, compile, evaluate, register } from "../src";
3 |
4 | describe("Edge Cases and Advanced Scenarios", () => {
5 | describe("Empty and Invalid Inputs", () => {
6 | it("should handle empty expressions", () => {
7 | expect(() => evaluate("")).toThrow(ExpressionError);
8 | expect(() => evaluate(" ")).toThrow(ExpressionError);
9 | });
10 |
11 | it("should handle invalid syntax", () => {
12 | expect(() => evaluate("x +")).toThrow(ExpressionError);
13 | expect(() => evaluate("(x + y")).toThrow(ExpressionError);
14 | expect(() => evaluate("x + y)")).toThrow(ExpressionError);
15 | });
16 |
17 | it("should handle invalid tokens", () => {
18 | expect(() => evaluate("x $ y")).toThrow(ExpressionError);
19 | expect(() => evaluate("#invalid")).toThrow(ExpressionError);
20 | });
21 | });
22 |
23 | describe("Type Coercion", () => {
24 | it("should handle string concatenation", () => {
25 | expect(evaluate("'hello' + ' world'")).toBe("hello world");
26 | expect(evaluate("'value: ' + 42")).toBe("value: 42");
27 | expect(evaluate("'is true: ' + true")).toBe("is true: true");
28 | });
29 |
30 | it("should handle boolean coercion in logical operations", () => {
31 | expect(evaluate("0 && 'anything'")).toBe(0);
32 | expect(evaluate("1 && 'something'")).toBe("something");
33 | expect(evaluate("'' || 'fallback'")).toBe("fallback");
34 | expect(evaluate("'value' || 'fallback'")).toBe("value");
35 | });
36 |
37 | it("should handle numeric coercion", () => {
38 | expect(evaluate("'5' - 2")).toBe(3);
39 | expect(evaluate("'10' / '2'")).toBe(5);
40 | expect(evaluate("'3' * 4")).toBe(12);
41 | });
42 | });
43 |
44 | describe("Deep Nesting", () => {
45 | it("should handle deeply nested expressions", () => {
46 | const expr = "(x + y) * z / 2";
47 | expect(evaluate(expr, { x: 1, y: 2, z: 3 })).toBe(4.5); // ((1+2)*3/2) = 4.5
48 | });
49 |
50 | it("should handle deeply nested object access", () => {
51 | const context = {
52 | a: { b: { c: { d: { e: { value: 42 } } } } },
53 | };
54 | expect(evaluate("a.b.c.d.e.value", context)).toBe(42);
55 | });
56 |
57 | it("should handle deeply nested array access", () => {
58 | const context = {
59 | matrix: [[[[5]]]],
60 | };
61 | expect(evaluate("matrix[0][0][0][0]", context)).toBe(5);
62 | });
63 | });
64 |
65 | describe("Large Numbers and Precision", () => {
66 | it("should handle large integers", () => {
67 | expect(evaluate("1000000000 * 1000000000")).toBe(1000000000000000000);
68 | });
69 |
70 | it("should handle floating point precision", () => {
71 | // JavaScript floating point precision issues
72 | expect(evaluate("0.1 + 0.2")).toBeCloseTo(0.3);
73 | expect(evaluate("0.1 + 0.2 === 0.3")).toBe(false); // JS behavior
74 | });
75 | });
76 |
77 | describe("Complex Function Usage", () => {
78 | it("should support nested function calls", () => {
79 | register("inner", (a, b) => a + b);
80 | register("outer", (a, b) => a * b);
81 | expect(evaluate("@outer(@inner(x, y), z)", { x: 2, y: 3, z: 4 })).toBe(
82 | 20,
83 | ); // (2+3)*4 = 20
84 | });
85 |
86 | it("should handle function calls with complex expressions as arguments", () => {
87 | register("calculate", (a, b, c) => a + b + c);
88 | expect(
89 | evaluate("@calculate(x + y, z * 2, w ? 1 : 0)", {
90 | x: 1,
91 | y: 2,
92 | z: 3,
93 | w: true,
94 | }),
95 | ).toBe(10); // (1+2) + (3*2) + 1 = 10
96 | });
97 | });
98 |
99 | describe("Context Manipulation", () => {
100 | it("should not modify the original context", () => {
101 | const context = { x: 5, y: 10 };
102 | evaluate("x + y", context);
103 | expect(context).toEqual({ x: 5, y: 10 }); // Context should be unchanged
104 | });
105 |
106 | it("should handle undefined context values", () => {
107 | expect(() => evaluate("x + 5", { y: 10 })).toThrow();
108 | expect(() => evaluate("x.y.z", { x: {} })).toThrow();
109 | });
110 | });
111 |
112 | describe("Performance Considerations", () => {
113 | it("should benefit from pre-compilation", () => {
114 | const expr = compile("x + y");
115 |
116 | // This is more of a conceptual test since we can't easily measure performance in a unit test
117 | // But we can verify that the compiled expression works correctly
118 | expect(expr({ x: 1, y: 2 })).toBe(3);
119 | expect(expr({ x: 10, y: 20 })).toBe(30);
120 | });
121 | });
122 |
123 | describe("Error Cases", () => {
124 | it("should handle division by zero", () => {
125 | expect(evaluate("10 / 0")).toBe(Number.POSITIVE_INFINITY);
126 | expect(evaluate("-10 / 0")).toBe(Number.NEGATIVE_INFINITY);
127 | });
128 |
129 | it("should handle invalid property access", () => {
130 | expect(() => evaluate("null.property")).toThrow();
131 | expect(() => evaluate("undefined.property")).toThrow();
132 | });
133 |
134 | it("should handle invalid array access", () => {
135 | const context = { arr: [1, 2, 3] };
136 | expect(evaluate("arr[10]", context)).toBe(undefined);
137 | expect(evaluate("arr['invalid']", context)).toBe(undefined);
138 | });
139 | });
140 |
141 | describe("Security Edge Cases", () => {
142 | it("should prevent access to global objects even with tricky expressions", () => {
143 | // These should throw errors or return undefined, not expose global objects
144 | expect(() => evaluate("this")).toThrow();
145 | });
146 | });
147 | });
148 |
--------------------------------------------------------------------------------
/tests/interpreter.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { createInterpreterState, evaluateAst } from "../src/interpreter";
3 | import { parse } from "../src/parser";
4 | import { tokenize } from "../src/tokenizer";
5 |
6 | describe("Interpreter", () => {
7 | function evaluateExpression(input: string, context = {}, functions = {}) {
8 | const tokens = tokenize(input);
9 | const ast = parse(tokens);
10 | const interpreterState = createInterpreterState({}, functions);
11 | return evaluateAst(ast, interpreterState, context);
12 | }
13 |
14 | describe("Literals", () => {
15 | it("should evaluate number literals", () => {
16 | expect(evaluateExpression("42")).toBe(42);
17 | });
18 |
19 | it("should evaluate string literals", () => {
20 | expect(evaluateExpression('"hello"')).toBe("hello");
21 | });
22 |
23 | it("should evaluate boolean literals", () => {
24 | expect(evaluateExpression("true")).toBe(true);
25 | expect(evaluateExpression("false")).toBe(false);
26 | });
27 |
28 | it("should evaluate null", () => {
29 | expect(evaluateExpression("null")).toBe(null);
30 | });
31 | });
32 |
33 | describe("Member Expressions", () => {
34 | const context = {
35 | data: {
36 | value: 42,
37 | nested: {
38 | array: [1, 2, 3],
39 | },
40 | },
41 | };
42 |
43 | it("should evaluate dot notation", () => {
44 | expect(evaluateExpression("data.value", context)).toBe(42);
45 | });
46 |
47 | it("should evaluate bracket notation", () => {
48 | expect(evaluateExpression('data["value"]', context)).toBe(42);
49 | });
50 |
51 | it("should evaluate nested access", () => {
52 | expect(evaluateExpression("data.nested.array[1]", context)).toBe(2);
53 | });
54 | });
55 |
56 | describe("Function Calls", () => {
57 | const functions = {
58 | sum: (...args: number[]) => args.reduce((a, b) => a + b, 0),
59 | max: Math.max,
60 | };
61 |
62 | it("should evaluate function calls", () => {
63 | expect(evaluateExpression("@sum(1, 2, 3)", {}, functions)).toBe(6);
64 | });
65 |
66 | it("should evaluate nested expressions in arguments", () => {
67 | const context = { x: 1, y: 2 };
68 | expect(evaluateExpression("@max(x, y, 3)", context, functions)).toBe(3);
69 | });
70 | });
71 |
72 | describe("Binary Expressions", () => {
73 | const context = { a: 5, b: 3 };
74 |
75 | it("should evaluate arithmetic operators", () => {
76 | expect(evaluateExpression("a + b", context)).toBe(8);
77 | expect(evaluateExpression("a - b", context)).toBe(2);
78 | expect(evaluateExpression("a * b", context)).toBe(15);
79 | expect(evaluateExpression("a / b", context)).toBe(5 / 3);
80 | });
81 |
82 | it("should evaluate comparison operators", () => {
83 | expect(evaluateExpression("a > b", context)).toBe(true);
84 | expect(evaluateExpression("a === b", context)).toBe(false);
85 | });
86 |
87 | it("should evaluate logical operators", () => {
88 | expect(evaluateExpression("true && false")).toBe(false);
89 | expect(evaluateExpression("true || false")).toBe(true);
90 | });
91 | });
92 |
93 | describe("Conditional Expressions", () => {
94 | it("should evaluate simple conditionals", () => {
95 | expect(evaluateExpression("true ? 1 : 2")).toBe(1);
96 | expect(evaluateExpression("false ? 1 : 2")).toBe(2);
97 | });
98 |
99 | it("should evaluate nested conditionals", () => {
100 | const input = "true ? false ? 1 : 2 : 3";
101 | expect(evaluateExpression(input)).toBe(2);
102 | });
103 | });
104 |
105 | describe("Complex Expressions", () => {
106 | const context = {
107 | data: {
108 | values: [1, 2, 3],
109 | status: "active",
110 | },
111 | };
112 |
113 | const functions = {
114 | sum: (arr: number[]) => arr.reduce((a, b) => a + b, 0),
115 | };
116 |
117 | it("should evaluate complex expressions", () => {
118 | const input = '@sum(data.values) > 5 ? data["status"] : "inactive"';
119 | expect(evaluateExpression(input, context, functions)).toBe("active");
120 | });
121 | });
122 |
123 | describe("Error Handling", () => {
124 | it("should throw for undefined variables", () => {
125 | expect(() => evaluateExpression("unknownVar")).toThrow(
126 | "Undefined variable",
127 | );
128 | });
129 |
130 | it("should throw for undefined functions", () => {
131 | expect(() => evaluateExpression("@unknown()")).toThrow(
132 | "Undefined function",
133 | );
134 | });
135 |
136 | it("should throw for null property access", () => {
137 | const context = { data: null };
138 | expect(() => evaluateExpression("data.value", context)).toThrow(
139 | "Cannot access property of null",
140 | );
141 | });
142 | });
143 |
144 | describe("Logical Operators", () => {
145 | it("should implement short-circuit evaluation for &&", () => {
146 | expect(evaluateExpression("false && true")).toBe(false);
147 | expect(evaluateExpression("true && true")).toBe(true);
148 | expect(evaluateExpression("data && data.value", { data: null })).toBe(
149 | null,
150 | );
151 | });
152 |
153 | it("should implement short-circuit evaluation for ||", () => {
154 | expect(evaluateExpression("true || true")).toBe(true);
155 | expect(evaluateExpression("false || true")).toBe(true);
156 | });
157 | });
158 | });
159 |
--------------------------------------------------------------------------------
/tests/math-functions.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { evaluate } from "../src";
3 |
4 | describe("Default Math Functions", () => {
5 | describe("abs function", () => {
6 | it("should return the absolute value of a number", async () => {
7 | const result = await evaluate("@abs(-5)");
8 | expect(result).toBe(5);
9 | });
10 |
11 | it("should handle zero", async () => {
12 | const result = await evaluate("@abs(0)");
13 | expect(result).toBe(0);
14 | });
15 |
16 | it("should handle positive numbers", async () => {
17 | const result = await evaluate("@abs(10)");
18 | expect(result).toBe(10);
19 | });
20 | });
21 |
22 | describe("ceil function", () => {
23 | it("should round up to the nearest integer", async () => {
24 | const result = await evaluate("@ceil(4.3)");
25 | expect(result).toBe(5);
26 | });
27 |
28 | it("should not change integers", async () => {
29 | const result = await evaluate("@ceil(7)");
30 | expect(result).toBe(7);
31 | });
32 |
33 | it("should handle negative numbers", async () => {
34 | const result = await evaluate("@ceil(-3.7)");
35 | expect(result).toBe(-3);
36 | });
37 | });
38 |
39 | describe("floor function", () => {
40 | it("should round down to the nearest integer", async () => {
41 | const result = await evaluate("@floor(4.9)");
42 | expect(result).toBe(4);
43 | });
44 |
45 | it("should not change integers", async () => {
46 | const result = await evaluate("@floor(7)");
47 | expect(result).toBe(7);
48 | });
49 |
50 | it("should handle negative numbers", async () => {
51 | const result = await evaluate("@floor(-3.1)");
52 | expect(result).toBe(-4);
53 | });
54 | });
55 |
56 | describe("max function", () => {
57 | it("should return the largest of two numbers", async () => {
58 | const result = await evaluate("@max(5, 10)");
59 | expect(result).toBe(10);
60 | });
61 |
62 | it("should handle multiple arguments", async () => {
63 | const result = await evaluate("@max(5, 10, 3, 8, 15, 2)");
64 | expect(result).toBe(15);
65 | });
66 |
67 | it("should handle negative numbers", async () => {
68 | const result = await evaluate("@max(-5, -10, -3)");
69 | expect(result).toBe(-3);
70 | });
71 |
72 | it("should handle variables in context", async () => {
73 | const result = await evaluate("@max(a, b, c)", { a: 5, b: 10, c: 3 });
74 | expect(result).toBe(10);
75 | });
76 | });
77 |
78 | describe("min function", () => {
79 | it("should return the smallest of two numbers", async () => {
80 | const result = await evaluate("@min(5, 10)");
81 | expect(result).toBe(5);
82 | });
83 |
84 | it("should handle multiple arguments", async () => {
85 | const result = await evaluate("@min(5, 10, 3, 8, 15, 2)");
86 | expect(result).toBe(2);
87 | });
88 |
89 | it("should handle negative numbers", async () => {
90 | const result = await evaluate("@min(-5, -10, -3)");
91 | expect(result).toBe(-10);
92 | });
93 |
94 | it("should handle variables in context", async () => {
95 | const result = await evaluate("@min(a, b, c)", { a: 5, b: 10, c: 3 });
96 | expect(result).toBe(3);
97 | });
98 | });
99 |
100 | describe("round function", () => {
101 | it("should round to the nearest integer", async () => {
102 | const result = await evaluate("@round(4.3)");
103 | expect(result).toBe(4);
104 | });
105 |
106 | it("should round up for values >= .5", async () => {
107 | const result = await evaluate("@round(4.5)");
108 | expect(result).toBe(5);
109 | });
110 |
111 | it("should handle negative numbers", async () => {
112 | const result = await evaluate("@round(-3.7)");
113 | expect(result).toBe(-4);
114 | });
115 |
116 | it("should handle negative numbers with .5", async () => {
117 | const result = await evaluate("@round(-3.5)");
118 | expect(result).toBe(-3);
119 | });
120 | });
121 |
122 | describe("sqrt function", () => {
123 | it("should return the square root of a positive number", async () => {
124 | const result = await evaluate("@sqrt(16)");
125 | expect(result).toBe(4);
126 | });
127 |
128 | it("should handle non-perfect squares", async () => {
129 | const result = await evaluate("@sqrt(2)");
130 | expect(result).toBeCloseTo(1.4142, 4);
131 | });
132 |
133 | it("should handle zero", async () => {
134 | const result = await evaluate("@sqrt(0)");
135 | expect(result).toBe(0);
136 | });
137 |
138 | it("should return NaN for negative numbers", async () => {
139 | const result = await evaluate("@sqrt(-4)");
140 | expect(result).toBeNaN();
141 | });
142 | });
143 |
144 | describe("pow function", () => {
145 | it("should return the base raised to the exponent", async () => {
146 | const result = await evaluate("@pow(2, 3)");
147 | expect(result).toBe(8);
148 | });
149 |
150 | it("should handle fractional exponents", async () => {
151 | const result = await evaluate("@pow(4, 0.5)");
152 | expect(result).toBe(2);
153 | });
154 |
155 | it("should handle negative exponents", async () => {
156 | const result = await evaluate("@pow(2, -2)");
157 | expect(result).toBe(0.25);
158 | });
159 |
160 | it("should handle zero base with positive exponent", async () => {
161 | const result = await evaluate("@pow(0, 5)");
162 | expect(result).toBe(0);
163 | });
164 |
165 | it("should handle zero base with zero exponent", async () => {
166 | const result = await evaluate("@pow(0, 0)");
167 | expect(result).toBe(1); // This is the mathematical convention
168 | });
169 |
170 | it("should handle variables in context", async () => {
171 | const result = await evaluate("@pow(base, exponent)", {
172 | base: 3,
173 | exponent: 4,
174 | });
175 | expect(result).toBe(81);
176 | });
177 | });
178 |
179 | describe("Combined math functions", () => {
180 | it("should allow nesting of math functions", async () => {
181 | const result = await evaluate("@round(@sqrt(@pow(x, 2) + @pow(y, 2)))", {
182 | x: 3,
183 | y: 4,
184 | });
185 | expect(result).toBe(5); // sqrt(3² + 4²) = sqrt(25) = 5
186 | });
187 |
188 | it("should work with expressions as arguments", async () => {
189 | const result = await evaluate("@max(@abs(x), @abs(y), @abs(z))", {
190 | x: -5,
191 | y: 3,
192 | z: -8,
193 | });
194 | expect(result).toBe(8);
195 | });
196 |
197 | it("should handle complex mathematical expressions", async () => {
198 | const result = await evaluate(
199 | "@pow(@floor(x / y), 2) + @ceil(@sqrt(z))",
200 | { x: 10, y: 3, z: 15 },
201 | );
202 | expect(result).toBe(13); // pow(floor(10/3), 2) + ceil(sqrt(15)) = 3² + ceil(3.87) = 9 + 4 = 13
203 | });
204 | });
205 | });
206 |
--------------------------------------------------------------------------------
/tests/parser.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { NodeType, parse } from "../src/parser";
3 | import { tokenize } from "../src/tokenizer";
4 |
5 | describe("Parser", () => {
6 | function parseExpression(input: string) {
7 | const tokens = tokenize(input);
8 | return parse(tokens);
9 | }
10 |
11 | describe("Literals", () => {
12 | it("should parse number literals", () => {
13 | const ast = parseExpression("42");
14 | expect(ast).toEqual({
15 | type: NodeType.Program,
16 | body: {
17 | type: NodeType.Literal,
18 | value: 42,
19 | },
20 | });
21 | });
22 |
23 | it("should parse string literals", () => {
24 | const ast = parseExpression('"hello"');
25 | expect(ast).toEqual({
26 | type: NodeType.Program,
27 | body: {
28 | type: NodeType.Literal,
29 | value: "hello",
30 | },
31 | });
32 | });
33 |
34 | it("should parse boolean literals", () => {
35 | const ast = parseExpression("true");
36 | expect(ast).toEqual({
37 | type: NodeType.Program,
38 | body: {
39 | type: NodeType.Literal,
40 | value: true,
41 | },
42 | });
43 | });
44 |
45 | it("should parse null literal", () => {
46 | const ast = parseExpression("null");
47 | expect(ast).toEqual({
48 | type: NodeType.Program,
49 | body: {
50 | type: NodeType.Literal,
51 | value: null,
52 | },
53 | });
54 | });
55 | });
56 |
57 | describe("Member Expressions", () => {
58 | it("should parse dot notation", () => {
59 | const ast = parseExpression("data.value");
60 | expect(ast).toEqual({
61 | type: NodeType.Program,
62 | body: {
63 | type: NodeType.MemberExpression,
64 | object: {
65 | type: NodeType.Identifier,
66 | name: "data",
67 | },
68 | property: {
69 | type: NodeType.Identifier,
70 | name: "value",
71 | },
72 | computed: false,
73 | },
74 | });
75 | });
76 |
77 | it("should parse bracket notation", () => {
78 | const ast = parseExpression('data["value"]');
79 | expect(ast).toEqual({
80 | type: NodeType.Program,
81 | body: {
82 | type: NodeType.MemberExpression,
83 | object: {
84 | type: NodeType.Identifier,
85 | name: "data",
86 | },
87 | property: {
88 | type: NodeType.Literal,
89 | value: "value",
90 | },
91 | computed: true,
92 | },
93 | });
94 | });
95 | });
96 |
97 | describe("Function Calls", () => {
98 | it("should parse function calls without arguments", () => {
99 | const ast = parseExpression("@sum()");
100 | expect(ast).toEqual({
101 | type: NodeType.Program,
102 | body: {
103 | type: NodeType.CallExpression,
104 | callee: {
105 | type: NodeType.Identifier,
106 | name: "sum",
107 | },
108 | arguments: [],
109 | },
110 | });
111 | });
112 |
113 | it("should parse function calls with multiple arguments", () => {
114 | const ast = parseExpression("@max(a, b, 42)");
115 | expect(ast).toEqual({
116 | type: NodeType.Program,
117 | body: {
118 | type: NodeType.CallExpression,
119 | callee: {
120 | type: NodeType.Identifier,
121 | name: "max",
122 | },
123 | arguments: [
124 | {
125 | type: NodeType.Identifier,
126 | name: "a",
127 | },
128 | {
129 | type: NodeType.Identifier,
130 | name: "b",
131 | },
132 | {
133 | type: NodeType.Literal,
134 | value: 42,
135 | },
136 | ],
137 | },
138 | });
139 | });
140 | });
141 |
142 | describe("Binary Expressions", () => {
143 | it("should parse arithmetic expressions", () => {
144 | const ast = parseExpression("a + b * c");
145 | expect(ast).toEqual({
146 | type: NodeType.Program,
147 | body: {
148 | type: NodeType.BinaryExpression,
149 | operator: "+",
150 | left: {
151 | type: NodeType.Identifier,
152 | name: "a",
153 | },
154 | right: {
155 | type: NodeType.BinaryExpression,
156 | operator: "*",
157 | left: {
158 | type: NodeType.Identifier,
159 | name: "b",
160 | },
161 | right: {
162 | type: NodeType.Identifier,
163 | name: "c",
164 | },
165 | },
166 | },
167 | });
168 | });
169 |
170 | it("should parse comparison expressions", () => {
171 | const ast = parseExpression("a > b");
172 | expect(ast).toEqual({
173 | type: NodeType.Program,
174 | body: {
175 | type: NodeType.BinaryExpression,
176 | operator: ">",
177 | left: {
178 | type: NodeType.Identifier,
179 | name: "a",
180 | },
181 | right: {
182 | type: NodeType.Identifier,
183 | name: "b",
184 | },
185 | },
186 | });
187 | });
188 |
189 | it("should parse logical expressions", () => {
190 | const ast = parseExpression("a && b || c");
191 | expect(ast).toEqual({
192 | type: NodeType.Program,
193 | body: {
194 | type: NodeType.BinaryExpression,
195 | operator: "||",
196 | left: {
197 | type: NodeType.BinaryExpression,
198 | operator: "&&",
199 | left: {
200 | type: NodeType.Identifier,
201 | name: "a",
202 | },
203 | right: {
204 | type: NodeType.Identifier,
205 | name: "b",
206 | },
207 | },
208 | right: {
209 | type: NodeType.Identifier,
210 | name: "c",
211 | },
212 | },
213 | });
214 | });
215 | });
216 |
217 | describe("Unary Expressions", () => {
218 | it("should parse unary expressions", () => {
219 | const ast = parseExpression("!a");
220 | expect(ast).toEqual({
221 | type: NodeType.Program,
222 | body: {
223 | type: NodeType.UnaryExpression,
224 | operator: "!",
225 | argument: {
226 | type: NodeType.Identifier,
227 | name: "a",
228 | },
229 | prefix: true,
230 | },
231 | });
232 | });
233 | });
234 |
235 | describe("Conditional Expressions", () => {
236 | it("should parse ternary expressions", () => {
237 | const ast = parseExpression("a ? b : c");
238 | expect(ast).toEqual({
239 | type: NodeType.Program,
240 | body: {
241 | type: NodeType.ConditionalExpression,
242 | test: {
243 | type: NodeType.Identifier,
244 | name: "a",
245 | },
246 | consequent: {
247 | type: NodeType.Identifier,
248 | name: "b",
249 | },
250 | alternate: {
251 | type: NodeType.Identifier,
252 | name: "c",
253 | },
254 | },
255 | });
256 | });
257 |
258 | it("should parse nested ternary expressions", () => {
259 | const ast = parseExpression("a ? b : c ? d : e");
260 | expect(ast).toEqual({
261 | type: NodeType.Program,
262 | body: {
263 | type: NodeType.ConditionalExpression,
264 | test: {
265 | type: NodeType.Identifier,
266 | name: "a",
267 | },
268 | consequent: {
269 | type: NodeType.Identifier,
270 | name: "b",
271 | },
272 | alternate: {
273 | type: NodeType.ConditionalExpression,
274 | test: {
275 | type: NodeType.Identifier,
276 | name: "c",
277 | },
278 | consequent: {
279 | type: NodeType.Identifier,
280 | name: "d",
281 | },
282 | alternate: {
283 | type: NodeType.Identifier,
284 | name: "e",
285 | },
286 | },
287 | },
288 | });
289 | });
290 | });
291 |
292 | describe("Complex Expressions", () => {
293 | it("should parse complex expressions", () => {
294 | const ast = parseExpression("a + b * c > d ? e : f");
295 | expect(ast).toEqual({
296 | type: NodeType.Program,
297 | body: {
298 | type: NodeType.ConditionalExpression,
299 | test: {
300 | type: NodeType.BinaryExpression,
301 | operator: ">",
302 | left: {
303 | type: NodeType.BinaryExpression,
304 | operator: "+",
305 | left: {
306 | type: NodeType.Identifier,
307 | name: "a",
308 | },
309 | right: {
310 | type: NodeType.BinaryExpression,
311 | operator: "*",
312 | left: {
313 | type: NodeType.Identifier,
314 | name: "b",
315 | },
316 | right: {
317 | type: NodeType.Identifier,
318 | name: "c",
319 | },
320 | },
321 | },
322 | right: {
323 | type: NodeType.Identifier,
324 | name: "d",
325 | },
326 | },
327 | consequent: {
328 | type: NodeType.Identifier,
329 | name: "e",
330 | },
331 | alternate: {
332 | type: NodeType.Identifier,
333 | name: "f",
334 | },
335 | },
336 | });
337 | });
338 | });
339 |
340 | describe("Error Handling", () => {
341 | it("should throw error for unexpected token", () => {
342 | expect(() => parseExpression("a +")).toThrow("Unexpected end of input");
343 | });
344 |
345 | it("should throw error for invalid property access", () => {
346 | expect(() => parseExpression("a.")).toThrow("Expected property name");
347 | });
348 |
349 | it("should throw error for unclosed bracket notation", () => {
350 | expect(() => parseExpression("a[b")).toThrow("Expected closing bracket");
351 | });
352 |
353 | it("should throw error for invalid ternary expression", () => {
354 | expect(() => parseExpression("a ? b")).toThrow(
355 | "Expected : in conditional expression",
356 | );
357 | });
358 | });
359 | });
360 |
--------------------------------------------------------------------------------
/tests/template-dynamic.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { evaluate, register } from "../src";
3 |
4 | describe("Dynamic Template Capabilities", () => {
5 | // Helper function to evaluate expressions with custom functions
6 | async function evaluateExpr(
7 | expression: string,
8 | context = {},
9 | functions = {},
10 | ) {
11 | // Register any custom functions
12 | Object.entries(functions).forEach(([name, fn]) => {
13 | register(name, fn as any);
14 | });
15 |
16 | // Evaluate the expression
17 | return await evaluate(expression, context);
18 | }
19 |
20 | describe("Nested Property Access", () => {
21 | const context = {
22 | user: {
23 | profile: {
24 | details: {
25 | preferences: {
26 | theme: "dark",
27 | fontSize: 16,
28 | notifications: {
29 | email: true,
30 | push: false,
31 | frequency: "daily",
32 | },
33 | },
34 | address: {
35 | city: "Shanghai",
36 | country: "China",
37 | coordinates: [121.4737, 31.2304],
38 | },
39 | },
40 | lastLogin: "2025-03-10",
41 | },
42 | permissions: ["read", "write", "admin"],
43 | active: true,
44 | },
45 | settings: {
46 | global: {
47 | language: "zh-CN",
48 | timezone: "Asia/Shanghai",
49 | },
50 | },
51 | stats: {
52 | visits: 42,
53 | actions: 128,
54 | performance: {
55 | average: 95.7,
56 | history: [94.2, 95.1, 95.7, 96.3, 97.0],
57 | },
58 | },
59 | };
60 |
61 | it("should access deeply nested properties", async () => {
62 | expect(
63 | await evaluateExpr("user.profile.details.preferences.theme", context),
64 | ).toBe("dark");
65 | expect(
66 | await evaluateExpr(
67 | "user.profile.details.address.coordinates[0]",
68 | context,
69 | ),
70 | ).toBe(121.4737);
71 | expect(
72 | await evaluateExpr(
73 | "user.profile.details.preferences.notifications.frequency",
74 | context,
75 | ),
76 | ).toBe("daily");
77 | });
78 |
79 | it("should handle bracket notation with string literals", async () => {
80 | expect(
81 | await evaluateExpr(
82 | 'user["profile"]["details"]["preferences"]["fontSize"]',
83 | context,
84 | ),
85 | ).toBe(16);
86 | });
87 |
88 | it("should handle mixed dot and bracket notation", async () => {
89 | expect(
90 | await evaluateExpr(
91 | 'user.profile["details"].preferences["notifications"].push',
92 | context,
93 | ),
94 | ).toBe(false);
95 | expect(
96 | await evaluateExpr('user["profile"].details["address"].city', context),
97 | ).toBe("Shanghai");
98 | });
99 | });
100 |
101 | describe("Dynamic Property Access", () => {
102 | const context = {
103 | data: {
104 | key1: "value1",
105 | key2: "value2",
106 | key3: "value3",
107 | },
108 | keys: ["key1", "key2", "key3"],
109 | selectedKey: "key2",
110 | config: {
111 | mapping: {
112 | field1: "key1",
113 | field2: "key2",
114 | field3: "key3",
115 | },
116 | selected: "field2",
117 | },
118 | };
119 |
120 | it("should access properties using dynamic keys", async () => {
121 | expect(await evaluateExpr("data[selectedKey]", context)).toBe("value2");
122 | expect(await evaluateExpr("data[keys[0]]", context)).toBe("value1");
123 | expect(await evaluateExpr("data[keys[2]]", context)).toBe("value3");
124 | });
125 |
126 | it("should handle nested dynamic property access", async () => {
127 | expect(
128 | await evaluateExpr("data[config.mapping[config.selected]]", context),
129 | ).toBe("value2");
130 | expect(await evaluateExpr("data[config.mapping.field1]", context)).toBe(
131 | "value1",
132 | );
133 | });
134 | });
135 |
136 | describe("Conditional Logic", () => {
137 | const context = {
138 | user: {
139 | role: "admin",
140 | verified: true,
141 | age: 30,
142 | subscription: "premium",
143 | },
144 | thresholds: {
145 | age: 18,
146 | premium: 25,
147 | },
148 | features: {
149 | basic: ["read", "comment"],
150 | premium: ["read", "comment", "publish", "moderate"],
151 | admin: ["read", "comment", "publish", "moderate", "manage"],
152 | },
153 | };
154 |
155 | it("should evaluate simple conditional expressions", async () => {
156 | expect(
157 | await evaluateExpr(
158 | "user.role === 'admin' ? 'Administrator' : 'User'",
159 | context,
160 | ),
161 | ).toBe("Administrator");
162 | expect(
163 | await evaluateExpr(
164 | "user.verified ? 'Verified' : 'Unverified'",
165 | context,
166 | ),
167 | ).toBe("Verified");
168 | });
169 |
170 | it("should handle nested conditional expressions", async () => {
171 | const expr =
172 | "user.role === 'admin' ? 'Admin Access' : (user.subscription === 'premium' ? 'Premium Access' : 'Basic Access')";
173 | expect(await evaluateExpr(expr, context)).toBe("Admin Access");
174 |
175 | const context2: any = {
176 | ...context,
177 | user: { ...context.user, role: "user" },
178 | };
179 | expect(await evaluateExpr(expr, context2)).toBe("Premium Access");
180 |
181 | const context3: any = {
182 | ...context2,
183 | user: { ...context2.user, subscription: "basic" },
184 | };
185 | expect(await evaluateExpr(expr, context3)).toBe("Basic Access");
186 | });
187 |
188 | it("should combine conditional logic with property access", async () => {
189 | const expr =
190 | "user.role === 'admin' ? features.admin : (user.subscription === 'premium' ? features.premium : features.basic)";
191 | expect(((await evaluateExpr(expr, context)) as any)[4]).toBe("manage"); // admin features include 'manage'
192 |
193 | const context2: any = {
194 | ...context,
195 | user: { ...context.user, role: "user" },
196 | };
197 | expect(((await evaluateExpr(expr, context2)) as any)[3]).toBe("moderate"); // premium features include 'moderate'
198 |
199 | const context3: any = {
200 | ...context2,
201 | user: { ...context2.user, subscription: "basic" },
202 | };
203 | expect(((await evaluateExpr(expr, context3)) as any).length).toBe(2); // basic features have 2 items
204 | });
205 | });
206 |
207 | describe("Template String Interpolation", () => {
208 | // Define custom functions for string operations
209 | const functions = {
210 | concat: (...strings: string[]) => strings.join(""),
211 | formatDate: (date: string, format = "YYYY-MM-DD") => {
212 | // Simple date formatter (in real implementation, use a proper date library)
213 | const d = new Date(date);
214 | if (format === "YYYY-MM-DD") {
215 | return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
216 | }
217 | if (format === "DD/MM/YYYY") {
218 | return `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}`;
219 | }
220 | return date; // Default fallback
221 | },
222 | };
223 |
224 | const context = {
225 | user: {
226 | name: "张三",
227 | email: "zhangsan@example.com",
228 | joinDate: "2024-01-15",
229 | lastLogin: "2025-03-10T14:30:00",
230 | plan: "premium",
231 | usage: {
232 | storage: 42.5,
233 | bandwidth: 128.7,
234 | },
235 | },
236 | company: {
237 | name: "示例公司",
238 | address: "上海市浦东新区",
239 | },
240 | locale: "zh-CN",
241 | };
242 |
243 | it("should support basic string concatenation", async () => {
244 | const expr =
245 | "@concat('Welcome, ', user.name, '! Your plan is ', user.plan)";
246 | expect(await evaluateExpr(expr, context, functions)).toBe(
247 | "Welcome, 张三! Your plan is premium",
248 | );
249 | });
250 |
251 | it("should support template string interpolation", async () => {
252 | const template =
253 | "'Dear ' + user.name + ', Thank you for being a ' + user.plan + ' member since ' + user.joinDate + '. Your current storage usage is ' + user.usage.storage + 'GB.'";
254 | const result = await evaluateExpr(template, context, functions);
255 | expect(result).toBe(
256 | "Dear 张三, Thank you for being a premium member since 2024-01-15. Your current storage usage is 42.5GB.",
257 | );
258 | });
259 | });
260 |
261 | describe("Complex Business Logic", () => {
262 | const context = {
263 | order: {
264 | id: "ORD-12345",
265 | customer: {
266 | id: "CUST-789",
267 | name: "李四",
268 | type: "vip",
269 | memberSince: "2020-05-10",
270 | loyaltyPoints: 1250,
271 | },
272 | items: [
273 | {
274 | id: "PROD-001",
275 | name: "商品A",
276 | price: 100,
277 | quantity: 2,
278 | category: "electronics",
279 | },
280 | {
281 | id: "PROD-002",
282 | name: "商品B",
283 | price: 50,
284 | quantity: 1,
285 | category: "books",
286 | },
287 | {
288 | id: "PROD-003",
289 | name: "商品C",
290 | price: 200,
291 | quantity: 3,
292 | category: "electronics",
293 | },
294 | ],
295 | shipping: {
296 | method: "express",
297 | address: {
298 | city: "北京",
299 | province: "北京市",
300 | country: "中国",
301 | },
302 | cost: 20,
303 | },
304 | payment: {
305 | method: "credit_card",
306 | status: "completed",
307 | },
308 | date: "2025-03-01",
309 | status: "processing",
310 | },
311 | pricing: {
312 | discounts: {
313 | vip: 0.1, // 10% off for VIP customers
314 | bulk: 0.05, // 5% off for bulk orders (>= 5 items)
315 | categories: {
316 | electronics: 0.08, // 8% off for electronics
317 | books: 0.15, // 15% off for books
318 | },
319 | },
320 | shipping: {
321 | standard: 10,
322 | express: 20,
323 | international: {
324 | standard: 50,
325 | express: 80,
326 | },
327 | },
328 | tax: {
329 | domestic: 0.13, // 13% tax for domestic orders
330 | international: 0.2, // 20% tax for international orders
331 | },
332 | },
333 | config: {
334 | loyaltyPointsPerDollar: 0.5,
335 | minimumForFreeShipping: 500,
336 | },
337 | };
338 |
339 | // Define custom functions for business logic
340 | const functions = {
341 | calculateSubtotal: (items: any[]) => {
342 | return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
343 | },
344 | calculateDiscount: (order: any, pricing: any) => {
345 | const subtotal = functions.calculateSubtotal(order.items);
346 | let discount = 0;
347 |
348 | // Customer type discount
349 | if (order.customer.type === "vip") {
350 | discount += subtotal * pricing.discounts.vip;
351 | }
352 |
353 | // Bulk order discount
354 | const totalItems = order.items.reduce(
355 | (sum: number, item: any) => sum + item.quantity,
356 | 0,
357 | );
358 | if (totalItems >= 5) {
359 | discount += subtotal * pricing.discounts.bulk;
360 | }
361 |
362 | // Category-specific discounts (applied to eligible items only)
363 | const categoryDiscounts = order.items.reduce(
364 | (sum: number, item: any) => {
365 | const categoryDiscount =
366 | pricing.discounts.categories[item.category] || 0;
367 | return sum + item.price * item.quantity * categoryDiscount;
368 | },
369 | 0,
370 | );
371 |
372 | discount += categoryDiscounts;
373 |
374 | return Math.min(discount, subtotal * 0.25); // Cap discount at 25% of subtotal
375 | },
376 | calculateTax: (order: any, pricing: any) => {
377 | const subtotal = functions.calculateSubtotal(order.items);
378 | const discount = functions.calculateDiscount(order, pricing);
379 | const taxableAmount = subtotal - discount;
380 |
381 | const taxRate =
382 | order.shipping.address.country === "中国"
383 | ? pricing.tax.domestic
384 | : pricing.tax.international;
385 |
386 | return taxableAmount * taxRate;
387 | },
388 | calculateTotal: (order: any, pricing: any, config: any) => {
389 | const subtotal = functions.calculateSubtotal(order.items);
390 | const discount = functions.calculateDiscount(order, pricing);
391 | const tax = functions.calculateTax(order, pricing);
392 |
393 | // Determine shipping cost
394 | let shippingCost = 20;
395 | if (subtotal - discount < config.minimumForFreeShipping) {
396 | const isInternational = order.shipping.address.country !== "中国";
397 | if (isInternational) {
398 | shippingCost =
399 | pricing.shipping.international[order.shipping.method];
400 | } else {
401 | shippingCost = pricing.shipping[order.shipping.method];
402 | }
403 | }
404 |
405 | return subtotal - discount + tax + shippingCost;
406 | },
407 | calculateLoyaltyPoints: (order: any, config: any) => {
408 | const subtotal = functions.calculateSubtotal(order.items);
409 | return Math.floor(subtotal * config.loyaltyPointsPerDollar);
410 | },
411 | formatCurrency: (amount: number, currency = "CNY") => {
412 | return new Intl.NumberFormat("zh-CN", {
413 | style: "currency",
414 | currency,
415 | }).format(amount);
416 | },
417 | };
418 |
419 | it("should calculate order subtotal", async () => {
420 | const expr = "@calculateSubtotal(order.items)";
421 | const result = await evaluateExpr(expr, context, functions);
422 | expect(result).toBe(850); // (100*2) + (50*1) + (200*3) = 200 + 50 + 600 = 850
423 | });
424 |
425 | it("should calculate appropriate discounts", async () => {
426 | const expr = "@calculateDiscount(order, pricing)";
427 | const result = await evaluateExpr(expr, context, functions);
428 |
429 | // Expected discounts:
430 | // - VIP discount: 850 * 0.1 = 85
431 | // - Bulk discount: 850 * 0.05 = 42.5 (total quantity = 6 items)
432 | // - Category discounts: (200*2 + 600) * 0.08 + 50 * 0.15 = 64 + 7.5 = 71.5
433 | // Total discount: 85 + 42.5 + 71.5 = 199
434 | // But capped at 25% of subtotal: 850 * 0.25 = 212.5
435 | // So expected discount is 199
436 | expect(result).toBeCloseTo(199, 0);
437 | });
438 |
439 | it("should calculate final order total", async () => {
440 | const expr = "@calculateTotal(order, pricing, config)";
441 | const result = await evaluateExpr(expr, context, functions);
442 |
443 | // Subtotal: 850
444 | // Discount: 199
445 | // Taxable amount: 651
446 | // Tax (13%): 84.63
447 | // Shipping: 20 (express, not free because below 500 after discount)
448 | // Total: 850 - 199 + 84.63 + 20 = 755.63
449 | expect(result).toBeCloseTo(755.63, 2);
450 | });
451 |
452 | it("should calculate earned loyalty points", async () => {
453 | const expr = "@calculateLoyaltyPoints(order, config)";
454 | const result = await evaluateExpr(expr, context, functions);
455 |
456 | // Subtotal: 850
457 | // Points per dollar: 0.5
458 | // Earned points: 850 * 0.5 = 425
459 | expect(result).toBe(425);
460 | });
461 |
462 | it("should format currency values", async () => {
463 | const expr = "@formatCurrency(@calculateTotal(order, pricing, config))";
464 | const result = await evaluateExpr(expr, context, functions);
465 |
466 | // This test may vary based on locale implementation, but should contain the correct amount
467 | expect(result).toContain("755");
468 | });
469 |
470 | it("should handle complex conditional business logic", async () => {
471 | // Determine shipping method and estimate based on order details
472 | const expr = `
473 | order.shipping.address.country !== "中国" ?
474 | @formatCurrency(pricing.shipping.international[order.shipping.method]) :
475 | (@calculateSubtotal(order.items) - @calculateDiscount(order, pricing) >= config.minimumForFreeShipping ?
476 | "免费配送" :
477 | @formatCurrency(pricing.shipping[order.shipping.method]))
478 | `;
479 |
480 | const result = await evaluateExpr(expr, context, functions);
481 | // The order is domestic (China) and below free shipping threshold, so should show express shipping cost (¥20)
482 | expect(result).toBe("免费配送");
483 |
484 | // Test with order above free shipping threshold
485 | const largeOrder = {
486 | ...context,
487 | order: {
488 | ...context.order,
489 | items: [
490 | {
491 | id: "PROD-004",
492 | name: "商品D",
493 | price: 600,
494 | quantity: 1,
495 | category: "electronics",
496 | },
497 | ],
498 | },
499 | };
500 |
501 | const resultLargeOrder = await evaluateExpr(expr, largeOrder, functions);
502 | expect(resultLargeOrder).toBe("¥20.00"); // Should be free shipping
503 | });
504 | });
505 | });
506 |
--------------------------------------------------------------------------------
/tests/tokenizer.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { type Token, TokenType, tokenize } from "../src/tokenizer";
3 |
4 | describe("Tokenizer", () => {
5 | describe("Basic Literals", () => {
6 | it("should tokenize string literals", () => {
7 | const input = "\"hello\" 'world'";
8 | const expected: Token[] = [
9 | { type: TokenType.STRING, value: "hello" },
10 | { type: TokenType.STRING, value: "world" },
11 | ];
12 | expect(tokenize(input)).toEqual(expected);
13 | });
14 |
15 | it("should handle escaped quotes in strings", () => {
16 | const input = '"hello \\"world\\""';
17 | const expected: Token[] = [
18 | { type: TokenType.STRING, value: 'hello "world"' },
19 | ];
20 | expect(tokenize(input)).toEqual(expected);
21 | });
22 |
23 | it("should tokenize numbers", () => {
24 | const input = "42 -3.14 0.5";
25 | const expected: Token[] = [
26 | { type: TokenType.NUMBER, value: "42" },
27 | { type: TokenType.NUMBER, value: "-3.14" },
28 | { type: TokenType.NUMBER, value: "0.5" },
29 | ];
30 | expect(tokenize(input)).toEqual(expected);
31 | });
32 |
33 | it("should tokenize boolean and null", () => {
34 | const input = "true false null";
35 | const expected: Token[] = [
36 | { type: TokenType.BOOLEAN, value: "true" },
37 | { type: TokenType.BOOLEAN, value: "false" },
38 | { type: TokenType.NULL, value: "null" },
39 | ];
40 | expect(tokenize(input)).toEqual(expected);
41 | });
42 | });
43 |
44 | describe("Operators", () => {
45 | it("should tokenize arithmetic operators", () => {
46 | const input = "a + b - c * d / e % f";
47 | const expected: Token[] = [
48 | { type: TokenType.IDENTIFIER, value: "a" },
49 | { type: TokenType.OPERATOR, value: "+" },
50 | { type: TokenType.IDENTIFIER, value: "b" },
51 | { type: TokenType.OPERATOR, value: "-" },
52 | { type: TokenType.IDENTIFIER, value: "c" },
53 | { type: TokenType.OPERATOR, value: "*" },
54 | { type: TokenType.IDENTIFIER, value: "d" },
55 | { type: TokenType.OPERATOR, value: "/" },
56 | { type: TokenType.IDENTIFIER, value: "e" },
57 | { type: TokenType.OPERATOR, value: "%" },
58 | { type: TokenType.IDENTIFIER, value: "f" },
59 | ];
60 | expect(tokenize(input)).toEqual(expected);
61 | });
62 |
63 | it("should tokenize comparison operators", () => {
64 | const input = "a === b !== c > d < e >= f <= g";
65 | const expected: Token[] = [
66 | { type: TokenType.IDENTIFIER, value: "a" },
67 | { type: TokenType.OPERATOR, value: "===" },
68 | { type: TokenType.IDENTIFIER, value: "b" },
69 | { type: TokenType.OPERATOR, value: "!==" },
70 | { type: TokenType.IDENTIFIER, value: "c" },
71 | { type: TokenType.OPERATOR, value: ">" },
72 | { type: TokenType.IDENTIFIER, value: "d" },
73 | { type: TokenType.OPERATOR, value: "<" },
74 | { type: TokenType.IDENTIFIER, value: "e" },
75 | { type: TokenType.OPERATOR, value: ">=" },
76 | { type: TokenType.IDENTIFIER, value: "f" },
77 | { type: TokenType.OPERATOR, value: "<=" },
78 | { type: TokenType.IDENTIFIER, value: "g" },
79 | ];
80 | expect(tokenize(input)).toEqual(expected);
81 | });
82 |
83 | it("should tokenize logical operators", () => {
84 | const input = "a && b || !c";
85 | const expected: Token[] = [
86 | { type: TokenType.IDENTIFIER, value: "a" },
87 | { type: TokenType.OPERATOR, value: "&&" },
88 | { type: TokenType.IDENTIFIER, value: "b" },
89 | { type: TokenType.OPERATOR, value: "||" },
90 | { type: TokenType.OPERATOR, value: "!" },
91 | { type: TokenType.IDENTIFIER, value: "c" },
92 | ];
93 | expect(tokenize(input)).toEqual(expected);
94 | });
95 | });
96 |
97 | describe("Property Access", () => {
98 | it("should tokenize dot notation", () => {
99 | const input = "data.value.nested";
100 | const expected: Token[] = [
101 | { type: TokenType.IDENTIFIER, value: "data" },
102 | { type: TokenType.DOT, value: "." },
103 | { type: TokenType.IDENTIFIER, value: "value" },
104 | { type: TokenType.DOT, value: "." },
105 | { type: TokenType.IDENTIFIER, value: "nested" },
106 | ];
107 | expect(tokenize(input)).toEqual(expected);
108 | });
109 |
110 | it("should tokenize bracket notation", () => {
111 | const input = 'data["value"]';
112 | const expected: Token[] = [
113 | { type: TokenType.IDENTIFIER, value: "data" },
114 | { type: TokenType.BRACKET_LEFT, value: "[" },
115 | { type: TokenType.STRING, value: "value" },
116 | { type: TokenType.BRACKET_RIGHT, value: "]" },
117 | ];
118 | expect(tokenize(input)).toEqual(expected);
119 | });
120 | });
121 |
122 | describe("Function Calls", () => {
123 | it("should tokenize predefined functions", () => {
124 | const input = "@sum(values)";
125 | const expected: Token[] = [
126 | { type: TokenType.FUNCTION, value: "sum" },
127 | { type: TokenType.PAREN_LEFT, value: "(" },
128 | { type: TokenType.IDENTIFIER, value: "values" },
129 | { type: TokenType.PAREN_RIGHT, value: ")" },
130 | ];
131 | expect(tokenize(input)).toEqual(expected);
132 | });
133 |
134 | it("should tokenize function calls with multiple arguments", () => {
135 | const input = "@max(a, b, c)";
136 | const expected: Token[] = [
137 | { type: TokenType.FUNCTION, value: "max" },
138 | { type: TokenType.PAREN_LEFT, value: "(" },
139 | { type: TokenType.IDENTIFIER, value: "a" },
140 | { type: TokenType.COMMA, value: "," },
141 | { type: TokenType.IDENTIFIER, value: "b" },
142 | { type: TokenType.COMMA, value: "," },
143 | { type: TokenType.IDENTIFIER, value: "c" },
144 | { type: TokenType.PAREN_RIGHT, value: ")" },
145 | ];
146 | expect(tokenize(input)).toEqual(expected);
147 | });
148 | });
149 |
150 | describe("Conditional Expressions", () => {
151 | it("should tokenize ternary expressions", () => {
152 | const input = "condition ? trueValue : falseValue";
153 | const expected: Token[] = [
154 | { type: TokenType.IDENTIFIER, value: "condition" },
155 | { type: TokenType.QUESTION, value: "?" },
156 | { type: TokenType.IDENTIFIER, value: "trueValue" },
157 | { type: TokenType.COLON, value: ":" },
158 | { type: TokenType.IDENTIFIER, value: "falseValue" },
159 | ];
160 | expect(tokenize(input)).toEqual(expected);
161 | });
162 | });
163 |
164 | describe("Complex Expressions", () => {
165 | it("should tokenize complex nested expressions", () => {
166 | const input = '@sum(data.values) > 0 ? data["status"] : "inactive"';
167 | const expected: Token[] = [
168 | { type: TokenType.FUNCTION, value: "sum" },
169 | { type: TokenType.PAREN_LEFT, value: "(" },
170 | { type: TokenType.IDENTIFIER, value: "data" },
171 | { type: TokenType.DOT, value: "." },
172 | { type: TokenType.IDENTIFIER, value: "values" },
173 | { type: TokenType.PAREN_RIGHT, value: ")" },
174 | { type: TokenType.OPERATOR, value: ">" },
175 | { type: TokenType.NUMBER, value: "0" },
176 | { type: TokenType.QUESTION, value: "?" },
177 | { type: TokenType.IDENTIFIER, value: "data" },
178 | { type: TokenType.BRACKET_LEFT, value: "[" },
179 | { type: TokenType.STRING, value: "status" },
180 | { type: TokenType.BRACKET_RIGHT, value: "]" },
181 | { type: TokenType.COLON, value: ":" },
182 | { type: TokenType.STRING, value: "inactive" },
183 | ];
184 | expect(tokenize(input)).toEqual(expected);
185 | });
186 | });
187 |
188 | describe("Error Handling", () => {
189 | it("should throw error for unterminated string", () => {
190 | const input = '"unclosed string';
191 | expect(() => tokenize(input)).toThrow("Unterminated string");
192 | });
193 |
194 | it("should throw error for unexpected character", () => {
195 | const input = "a # b";
196 | expect(() => tokenize(input)).toThrow("Unexpected character: #");
197 | });
198 | });
199 | });
200 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "module": "ESNext",
5 | "declaration": true,
6 | "strict": true,
7 | "skipLibCheck": true
8 | },
9 | "include": ["src/**/*.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vitest/config";
2 |
3 | export default defineConfig({
4 | test: {
5 | environment: "node",
6 | globals: true,
7 | include: ["**/*.test.ts"],
8 | exclude: ["**/*.d.ts", "node_modules/**"],
9 | coverage: {
10 | provider: "v8",
11 | reporter: ["text", "json", "html"],
12 | include: ["src/**/*.ts"],
13 | exclude: ["**/*.d.ts", "**/*.test.ts", "node_modules/**"],
14 | },
15 | benchmark: {
16 | include: ["**/*.bench.ts"],
17 | },
18 | },
19 | });
20 |
--------------------------------------------------------------------------------