├── .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 | ![gzip size](https://img.badgesize.io/https://unpkg.com/@antv/expr/dist/index.esm.js?compression=gzip) 8 | [![Build Status](https://github.com/antvis/expr/actions/workflows/build.yml/badge.svg)](https://github.com/antvis/expr/actions/workflows/build.yml) 9 | [![npm Version](https://img.shields.io/npm/v/@antv/expr.svg)](https://www.npmjs.com/package/@antv/expr) 10 | [![npm Download](https://img.shields.io/npm/dm/@antv/expr.svg)](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 | --------------------------------------------------------------------------------