├── .npmignore ├── src ├── lib │ ├── range.js │ ├── is-scalar.js │ ├── Levenshtein.js │ └── addcslashes.js ├── Provider │ ├── AbstractProvider.js │ ├── __tests__ │ │ ├── DateProvider.test.js │ │ ├── ArrayProvider.test.js │ │ ├── BasicProvider.test.js │ │ └── StringProvider.test.js │ ├── DateProvider.js │ ├── ArrayProvider.js │ ├── BasicProvider.js │ └── StringProvider.js ├── LogicException.js ├── SerializedParsedExpression.js ├── Expression.js ├── Node │ ├── NullCoalescedNameNode.js │ ├── ArgumentsNode.js │ ├── NameNode.js │ ├── ConditionalNode.js │ ├── __tests__ │ │ ├── ArgumentsNode.test.js │ │ ├── NullCoalescedNameNode.test.js │ │ ├── NameNode.test.js │ │ ├── Node.test.js │ │ ├── FunctionNode.test.js │ │ ├── ArrayNode.test.js │ │ ├── UnaryNode.test.js │ │ ├── ConstantNode.test.js │ │ ├── ConditionalNode.test.js │ │ ├── GetAttrNode.test.js │ │ └── BinaryNode.test.js │ ├── UnaryNode.js │ ├── NullCoalesceNode.js │ ├── FunctionNode.js │ ├── ConstantNode.js │ ├── Node.js │ ├── ArrayNode.js │ ├── GetAttrNode.js │ └── BinaryNode.js ├── index.js ├── SyntaxError.js ├── __tests__ │ ├── ParsedExpression.test.js │ ├── ExpressionLanguage.lint.test.js │ ├── ExpressionFunction.test.js │ ├── Lexer.test.js │ └── Parser.test.js ├── defaultCustomFunctions.js ├── ExpressionFunction.js ├── Compiler.js ├── TokenStream.js ├── Cache │ └── ArrayAdapter.js ├── ParsedExpression.js ├── ExpressionLanguage.js └── Lexer.js ├── .gitignore ├── tsconfig.json ├── .babelrc ├── jest-reporter.cjs ├── LICENSE.txt ├── rollup.config.js ├── package.json ├── .github ├── actions │ └── version-check │ │ └── action.yml └── workflows │ └── npm-publish.yml ├── examples └── browser-usage.html ├── types └── index.d.ts └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | .idea 2 | /src 3 | -------------------------------------------------------------------------------- /src/lib/range.js: -------------------------------------------------------------------------------- 1 | export function range (start, end) { 2 | let result = []; 3 | for (let i = start; i <= end; i++) { 4 | result.push(i); 5 | } 6 | return result; 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDEs 2 | .vscode 3 | .idea 4 | 5 | ###> Mac OS ### 6 | .DS_Store 7 | ###< Mac OS ### 8 | 9 | /lib 10 | /dist 11 | /node_modules 12 | npm-debug.log 13 | yarn-error.log 14 | -------------------------------------------------------------------------------- /src/Provider/AbstractProvider.js: -------------------------------------------------------------------------------- 1 | export default class AbstractProvider { 2 | getFunctions() { 3 | throw new Error("getFunctions must be implemented by " + this.name); 4 | }; 5 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "noEmit": true, 5 | "strict": true 6 | }, 7 | "files": [ 8 | "types/index.d.ts", 9 | "types/test-types.ts" 10 | ] 11 | } -------------------------------------------------------------------------------- /src/LogicException.js: -------------------------------------------------------------------------------- 1 | export default class LogicException extends Error { 2 | constructor(message) { 3 | super(message); 4 | this.name = "LogicException"; 5 | } 6 | 7 | toString() { 8 | return `${this.name}: ${this.message}`; 9 | } 10 | } -------------------------------------------------------------------------------- /src/SerializedParsedExpression.js: -------------------------------------------------------------------------------- 1 | 2 | export default class SerializedParsedExpression { 3 | constructor(expression, nodes) { 4 | this.expression = expression; 5 | this.nodes = nodes; 6 | } 7 | 8 | getNodes = () => { 9 | return JSON.parse(this.nodes); 10 | } 11 | 12 | } -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": ["@babel/plugin-proposal-class-properties"] 13 | } 14 | -------------------------------------------------------------------------------- /src/Expression.js: -------------------------------------------------------------------------------- 1 | export default class Expression { 2 | constructor(expression) { 3 | this.expression = expression; 4 | } 5 | 6 | /** 7 | * Gets the expression. 8 | * @returns {string} The expression 9 | */ 10 | toString() { 11 | return this.expression; 12 | } 13 | } -------------------------------------------------------------------------------- /src/lib/is-scalar.js: -------------------------------------------------------------------------------- 1 | export function is_scalar(mixedVar) { // eslint-disable-line camelcase 2 | // discuss at: https://locutus.io/php/is_scalar/ 3 | // original by: Paulo Freitas 4 | // example 1: is_scalar(186.31) 5 | // returns 1: true 6 | // example 2: is_scalar({0: 'Kevin van Zonneveld'}) 7 | // returns 2: false 8 | 9 | return (/boolean|number|string/).test(typeof mixedVar); 10 | } -------------------------------------------------------------------------------- /src/Node/NullCoalescedNameNode.js: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | 3 | export default class NullCoalescedNameNode extends Node { 4 | constructor(name) { 5 | super({}, {name}); 6 | this.name = 'NullCoalescedNameNode'; 7 | } 8 | 9 | compile = (compiler) => { 10 | compiler.raw(this.attributes.name + " ?? null"); 11 | } 12 | 13 | evaluate = (functions, values) => { 14 | return null; 15 | } 16 | 17 | toArray = () => { 18 | return [this.attributes.name + " ?? null"]; 19 | } 20 | } -------------------------------------------------------------------------------- /src/Node/ArgumentsNode.js: -------------------------------------------------------------------------------- 1 | import ArrayNode from "./ArrayNode"; 2 | 3 | export default class ArgumentsNode extends ArrayNode { 4 | constructor() { 5 | super(); 6 | this.name = "ArgumentsNode"; 7 | } 8 | 9 | compile = (compiler) => { 10 | this.compileArguments(compiler, false); 11 | }; 12 | 13 | toArray = () => { 14 | let array = []; 15 | for (let pair of this.getKeyValuePairs()) { 16 | array.push(pair.value); 17 | array.push(", "); 18 | } 19 | array.pop(); 20 | 21 | return array; 22 | } 23 | } -------------------------------------------------------------------------------- /src/Node/NameNode.js: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | 3 | export default class NameNode extends Node { 4 | constructor(name) { 5 | super({}, {name: name}); 6 | this.name = 'NameNode'; 7 | } 8 | 9 | compile = (compiler) => { 10 | compiler.raw(this.attributes.name); 11 | }; 12 | 13 | evaluate = (functions, values) => { 14 | //console.log(`Checking for value of "${this.attributes.name}"`); 15 | let value = values[this.attributes.name]; 16 | //console.log(`Value: ${value}`); 17 | return value; 18 | }; 19 | 20 | toArray = () => { 21 | return [this.attributes.name]; 22 | } 23 | } -------------------------------------------------------------------------------- /src/Provider/__tests__/DateProvider.test.js: -------------------------------------------------------------------------------- 1 | import ExpressionLanguage from "../../ExpressionLanguage"; 2 | import DateProvider from "../DateProvider"; 3 | import date from "locutus/php/datetime/date"; 4 | import strtotime from "locutus/php/datetime/strtotime"; 5 | 6 | test('date evaluate', () => { 7 | let el = new ExpressionLanguage(null, [new DateProvider()]); 8 | let result = el.evaluate('date("Y-m-d")'); 9 | expect(result).toBe(date("Y-m-d")); 10 | }); 11 | 12 | test('strtotime evaluate', () => { 13 | let el = new ExpressionLanguage(null, [new DateProvider()]); 14 | let result = el.evaluate('strtotime("yesterday")'); 15 | expect(result).toBe(strtotime("yesterday")); 16 | }); 17 | -------------------------------------------------------------------------------- /jest-reporter.cjs: -------------------------------------------------------------------------------- 1 | class AssertionReporter { 2 | constructor() { 3 | this.assertionCount = 0; 4 | } 5 | onTestResult(_, testResult) { 6 | let count = 0; 7 | testResult.testResults.forEach(tr => { 8 | count += tr.numPassingAsserts; 9 | this.assertionCount += tr.numPassingAsserts; 10 | }); 11 | //console.log(`✅ Passed expectations in this file: ${count}`); 12 | } 13 | onRunComplete() { 14 | console.log(""); 15 | console.log("------"); 16 | // Make the "Passed expectations:" label bold and the count green 17 | // Bold: \x1b[1m ... \x1b[22m (reset bold) 18 | // Green: \x1b[32m ... \x1b[39m (reset foreground color) 19 | console.log(`\x1b[1mPassed expectations: \x1b[32m${this.assertionCount}\x1b[39m\x1b[22m`); 20 | } 21 | } 22 | 23 | module.exports = AssertionReporter; -------------------------------------------------------------------------------- /src/Node/ConditionalNode.js: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | 3 | export default class ConditionalNode extends Node { 4 | constructor(expr1, expr2, expr3) { 5 | super({ 6 | expr1: expr1, expr2: expr2, expr3: expr3 7 | }); 8 | this.name = 'ConditionalNode'; 9 | } 10 | 11 | compile = (compiler) => { 12 | compiler.raw('((') 13 | .compile(this.nodes.expr1) 14 | .raw(') ? (') 15 | .compile(this.nodes.expr2) 16 | .raw(') : (') 17 | .compile(this.nodes.expr3) 18 | .raw('))'); 19 | }; 20 | 21 | evaluate = (functions, values) => { 22 | if (this.nodes.expr1.evaluate(functions, values)) { 23 | return this.nodes.expr2.evaluate(functions, values); 24 | } 25 | 26 | return this.nodes.expr3.evaluate(functions, values); 27 | }; 28 | 29 | toArray = () => { 30 | return ['(', this.nodes.expr1, ' ? ', this.nodes.expr2, ' : ', this.nodes.expr3, ')']; 31 | }; 32 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import ExpressionLanguage from "./ExpressionLanguage"; 2 | import {tokenize} from "./Lexer"; 3 | import Parser, {IGNORE_UNKNOWN_VARIABLES, IGNORE_UNKNOWN_FUNCTIONS} from "./Parser"; 4 | import ExpressionFunction from "./ExpressionFunction"; 5 | import Compiler from "./Compiler"; 6 | import ArrayAdapter from "./Cache/ArrayAdapter"; 7 | import AbstractProvider from "./Provider/AbstractProvider"; 8 | import BasicProvider from "./Provider/BasicProvider"; 9 | import StringProvider from "./Provider/StringProvider"; 10 | import ArrayProvider from "./Provider/ArrayProvider"; 11 | import DateProvider from "./Provider/DateProvider"; 12 | 13 | export default ExpressionLanguage; 14 | 15 | export { 16 | ExpressionLanguage, 17 | Parser, 18 | IGNORE_UNKNOWN_VARIABLES, 19 | IGNORE_UNKNOWN_FUNCTIONS, 20 | tokenize, 21 | ExpressionFunction, 22 | Compiler, 23 | ArrayAdapter, 24 | AbstractProvider, 25 | BasicProvider, 26 | StringProvider, 27 | ArrayProvider, 28 | DateProvider 29 | } 30 | -------------------------------------------------------------------------------- /src/Node/__tests__/ArgumentsNode.test.js: -------------------------------------------------------------------------------- 1 | import ArgumentsNode from "../ArgumentsNode"; 2 | import ConstantNode from "../ConstantNode"; 3 | import Compiler from "../../Compiler"; 4 | 5 | function getCompileData() { 6 | return [ 7 | ['"a", "b"', getArrayNode()] 8 | ] 9 | } 10 | 11 | function getDumpData() { 12 | return [ 13 | ['"a", "b"', getArrayNode()] 14 | ] 15 | } 16 | 17 | function getArrayNode() { 18 | let arr = createArrayNode(); 19 | arr.addElement(new ConstantNode("a")); 20 | arr.addElement(new ConstantNode("b")); 21 | return arr; 22 | } 23 | 24 | function createArrayNode() { 25 | return new ArgumentsNode(); 26 | } 27 | 28 | test('compile ArgumentsNode', () => { 29 | for (let compileParams of getCompileData()) { 30 | let compiler = new Compiler({}); 31 | compileParams[1].compile(compiler); 32 | expect(compiler.getSource()).toBe(compileParams[0]); 33 | } 34 | }); 35 | 36 | test('dump ArgumentsNode', () => { 37 | for (let dumpParams of getDumpData()) { 38 | expect(dumpParams[1].dump()).toBe(dumpParams[0]); 39 | } 40 | }); 41 | 42 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Kekoa Dev LLC 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. -------------------------------------------------------------------------------- /src/Node/UnaryNode.js: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | 3 | export default class UnaryNode extends Node { 4 | static operators = { 5 | '!': '!', 6 | 'not': '!', 7 | '+': '+', 8 | '-': '-', 9 | '~': '~' 10 | }; 11 | 12 | constructor(operator, node) { 13 | super({node: node}, {operator: operator}); 14 | this.name = 'UnaryNode'; 15 | } 16 | 17 | compile = (compiler) => { 18 | compiler.raw('(') 19 | .raw(UnaryNode.operators[this.attributes.operator]) 20 | .compile(this.nodes.node) 21 | .raw(')'); 22 | }; 23 | 24 | evaluate = (functions, values) => { 25 | let value = this.nodes.node.evaluate(functions, values); 26 | switch(this.attributes.operator) { 27 | case 'not': 28 | case '!': 29 | return !value; 30 | case '-': 31 | return -value; 32 | case '~': 33 | return ~value; 34 | } 35 | 36 | return value; 37 | }; 38 | 39 | toArray = () => { 40 | return ['(', this.attributes.operator + " ", this.nodes.node, ')']; 41 | } 42 | } -------------------------------------------------------------------------------- /src/Provider/DateProvider.js: -------------------------------------------------------------------------------- 1 | import AbstractProvider from "./AbstractProvider"; 2 | import ExpressionFunction from "../ExpressionFunction"; 3 | import date from "locutus/php/datetime/date"; 4 | import strtotime from "locutus/php/datetime/strtotime"; 5 | 6 | export default class DateProvider extends AbstractProvider { 7 | getFunctions() { 8 | return [ 9 | new ExpressionFunction('date', function(format, timestamp) { 10 | let remaining = ""; 11 | if (timestamp) { 12 | remaining = `, ${timestamp}`; 13 | } 14 | return `date(${format}${remaining})`; 15 | }, function(values, format, timestamp) { 16 | return date(format, timestamp); 17 | }), 18 | new ExpressionFunction('strtotime', function(str, now) { 19 | let remaining = ""; 20 | if (now) { 21 | remaining = `, ${now}`; 22 | } 23 | return `strtotime(${str}${remaining})`; 24 | }, function (values, str, now) { 25 | return strtotime(str, now); 26 | }) 27 | ] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/Levenshtein.js: -------------------------------------------------------------------------------- 1 | export const getEditDistance = function(a, b){ 2 | if(a.length === 0) return b.length; 3 | if(b.length === 0) return a.length; 4 | 5 | let matrix = []; 6 | 7 | // increment along the first column of each row 8 | let i; 9 | for(i = 0; i <= b.length; i++){ 10 | matrix[i] = [i]; 11 | } 12 | 13 | // increment each column in the first row 14 | let j; 15 | for(j = 0; j <= a.length; j++){ 16 | if (matrix[0] === undefined) { 17 | matrix[0] = []; 18 | } 19 | matrix[0][j] = j; 20 | } 21 | 22 | // Fill in the rest of the matrix 23 | for(i = 1; i <= b.length; i++){ 24 | for(j = 1; j <= a.length; j++){ 25 | if(b.charAt(i-1) === a.charAt(j-1)){ 26 | matrix[i][j] = matrix[i-1][j-1]; 27 | } else { 28 | matrix[i][j] = Math.min(matrix[i-1][j-1] + 1, // substitution 29 | Math.min(matrix[i][j-1] + 1, // insertion 30 | matrix[i-1][j] + 1)); // deletion 31 | } 32 | } 33 | } 34 | 35 | if (matrix[b.length] === undefined) { 36 | matrix[b.length] = []; 37 | } 38 | return matrix[b.length][a.length]; 39 | }; -------------------------------------------------------------------------------- /src/Node/NullCoalesceNode.js: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | import GetAttrNode from "./GetAttrNode"; 3 | 4 | export default class NullCoalesceNode extends Node { 5 | constructor(expr1, expr2) { 6 | super({expr1: expr1, expr2: expr2}); 7 | this.name = 'NullCoalesceNode'; 8 | } 9 | 10 | compile = (compiler) => { 11 | compiler.raw('((') 12 | .compile(this.nodes.expr1) 13 | .raw(") ?? (") 14 | .compile(this.nodes.expr2) 15 | .raw("))"); 16 | } 17 | 18 | evaluate = (functions, values) => { 19 | if (this.nodes.expr1 instanceof GetAttrNode) { 20 | this._addNullCoalesceAttributeToGetAttrNodes(this.nodes.expr1); 21 | } 22 | 23 | return this.nodes.expr1.evaluate(functions, values) ?? this.nodes.expr2.evaluate(functions, values); 24 | } 25 | 26 | toArray = () => { 27 | return ['(', this.nodes.expr1, ') ?? (', this.nodes.expr2, ')']; 28 | } 29 | 30 | _addNullCoalesceAttributeToGetAttrNodes = (node) => { 31 | if (!node instanceof GetAttrNode) { 32 | return; 33 | } 34 | 35 | node.attributes.is_null_coalesce = true; 36 | for (let oneNode of Object.values(node.nodes)) { 37 | this._addNullCoalesceAttributeToGetAttrNodes(oneNode) 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /src/SyntaxError.js: -------------------------------------------------------------------------------- 1 | import {getEditDistance} from "./lib/Levenshtein"; 2 | 3 | export default class SyntaxError extends Error { 4 | constructor(message, cursor, expression, subject, proposals) { 5 | super(message); 6 | this.name = "SyntaxError"; 7 | this.cursor = cursor; 8 | this.expression = expression; 9 | this.subject = subject; 10 | this.proposals = proposals; 11 | } 12 | 13 | toString() { 14 | 15 | let message = `${this.name}: ${this.message} around position ${this.cursor}`; 16 | if (this.expression) { 17 | message = message + ` for expression \`${this.expression}\``; 18 | } 19 | message += "."; 20 | 21 | if (this.subject && this.proposals) { 22 | let minScore = Number.MAX_SAFE_INTEGER, 23 | guess = null; 24 | for (let proposal of this.proposals) { 25 | let distance = getEditDistance(this.subject, proposal); 26 | if (distance < minScore) { 27 | guess = proposal; 28 | minScore = distance; 29 | } 30 | } 31 | 32 | if (guess !== null && minScore < 3) { 33 | message += ` Did you mean "${guess}"?`; 34 | } 35 | } 36 | 37 | return message; 38 | } 39 | } -------------------------------------------------------------------------------- /src/__tests__/ParsedExpression.test.js: -------------------------------------------------------------------------------- 1 | import ConstantNode from "../Node/ConstantNode"; 2 | import ParsedExpression from "../ParsedExpression"; 3 | import {Parser, tokenize} from "../index"; 4 | 5 | test('serialize ParsedExpression', () => { 6 | let expression = new ParsedExpression('25', new ConstantNode(25)); 7 | 8 | let serialized = JSON.stringify(expression); 9 | let unserialized = ParsedExpression.fromJSON(serialized); 10 | 11 | expect(unserialized.expression).toEqual(expression.expression); 12 | expect(unserialized.nodes.name).toEqual(expression.nodes.name); 13 | expect(unserialized.nodes.attributes).toMatchObject(expression.nodes.attributes); 14 | }); 15 | 16 | test('serialize more complex ParsedExpression', () => { 17 | let expressionString = "25 + 30"; 18 | let parser = new Parser(); 19 | let nodes = parser.parse(tokenize(expressionString)); 20 | let expression = new ParsedExpression(expressionString, nodes); 21 | 22 | let serialized = JSON.stringify(expression); 23 | let unserialized = ParsedExpression.fromJSON(serialized); 24 | expect(unserialized.expression).toEqual(expressionString); 25 | expect(unserialized.nodes.length).toEqual(expression.nodes.length); 26 | for (let i = 0; i < unserialized.nodes.length; i++) { 27 | expect(unserialized.nodes[i]).toEqual(nodes[i]); 28 | } 29 | }); -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { babel } from '@rollup/plugin-babel'; 2 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import { terser } from 'rollup-plugin-terser'; 5 | import pkg from './package.json'; 6 | 7 | export default [ 8 | // UMD build (for browsers) 9 | { 10 | input: 'src/index.js', 11 | output: [ 12 | { 13 | name: 'ExpressionLanguage', 14 | file: 'dist/expression-language.js', 15 | format: 'umd', 16 | exports: 'named', 17 | globals: { 18 | 'locutus': 'locutus' 19 | } 20 | }, 21 | { 22 | name: 'ExpressionLanguage', 23 | file: pkg.browser, 24 | format: 'umd', 25 | exports: 'named', 26 | plugins: [terser()], 27 | globals: { 28 | 'locutus': 'locutus' 29 | } 30 | } 31 | ], 32 | external: ['locutus'], 33 | plugins: [ 34 | nodeResolve({ 35 | browser: true 36 | }), 37 | commonjs(), 38 | babel({ 39 | babelHelpers: 'bundled', 40 | exclude: 'node_modules/**', 41 | presets: [ 42 | ['@babel/preset-env', { 43 | targets: { 44 | browsers: ['last 2 versions', 'not dead', '> 0.5%'] 45 | } 46 | }] 47 | ], 48 | plugins: ['@babel/plugin-proposal-class-properties'] 49 | }) 50 | ] 51 | } 52 | ]; -------------------------------------------------------------------------------- /src/Node/__tests__/NullCoalescedNameNode.test.js: -------------------------------------------------------------------------------- 1 | import NullCoalescedNameNode from "../NullCoalescedNameNode"; 2 | import Compiler from "../../Compiler"; 3 | 4 | function getEvaluateData() { 5 | return [ 6 | [null, new NullCoalescedNameNode('foo'), {}] 7 | ]; 8 | } 9 | 10 | function getCompileData() { 11 | return [ 12 | ['foo ?? null', new NullCoalescedNameNode('foo')], 13 | ]; 14 | } 15 | 16 | function getDumpData() { 17 | return [ 18 | ['foo ?? null', new NullCoalescedNameNode('foo')], 19 | ] 20 | } 21 | 22 | test('evaluate NullCoalescedNameNode', () => { 23 | for (let evaluateParams of getEvaluateData()) { 24 | let evaluated = evaluateParams[1].evaluate({}, evaluateParams[2]); 25 | if (evaluateParams[0] !== null && typeof evaluateParams[0] === "object") { 26 | expect(evaluated).toMatchObject(evaluateParams[0]); 27 | } 28 | else { 29 | expect(evaluated).toBe(evaluateParams[0]); 30 | } 31 | } 32 | }); 33 | 34 | test('compile NullCoalescedNameNode', () => { 35 | for (let compileParams of getCompileData()) { 36 | let compiler = new Compiler({}); 37 | compileParams[1].compile(compiler); 38 | expect(compiler.getSource()).toBe(compileParams[0]); 39 | } 40 | }); 41 | 42 | test('dump NullCoalescedNameNode', () => { 43 | for (let dumpParams of getDumpData()) { 44 | expect(dumpParams[1].dump()).toBe(dumpParams[0]); 45 | } 46 | }); -------------------------------------------------------------------------------- /src/Node/__tests__/NameNode.test.js: -------------------------------------------------------------------------------- 1 | import NameNode from "../NameNode"; 2 | import Compiler from "../../Compiler"; 3 | 4 | function getEvaluateData() { 5 | return [ 6 | ['bar', new NameNode('foo'), {foo: 'bar'}], 7 | ]; 8 | } 9 | function getCompileData() 10 | { 11 | return [ 12 | ['foo', new NameNode('foo')], 13 | ]; 14 | } 15 | function getDumpData() 16 | { 17 | return [ 18 | ['foo', new NameNode('foo')], 19 | ]; 20 | } 21 | 22 | test('evaluate NameNode', () => { 23 | for (let evaluateParams of getEvaluateData()) { 24 | //console.log("Evaluating: ", evaluateParams); 25 | let evaluated = evaluateParams[1].evaluate(evaluateParams[3]||{}, evaluateParams[2]); 26 | //console.log("Evaluated: ", evaluated); 27 | if (evaluateParams[0] !== null && typeof evaluateParams[0] === "object") { 28 | expect(evaluated).toMatchObject(evaluateParams[0]); 29 | } 30 | else { 31 | expect(evaluated).toBe(evaluateParams[0]); 32 | } 33 | } 34 | }); 35 | 36 | test('compile NameNode', () => { 37 | for (let compileParams of getCompileData()) { 38 | let compiler = new Compiler({}); 39 | compileParams[1].compile(compiler); 40 | expect(compiler.getSource()).toBe(compileParams[0]); 41 | } 42 | }); 43 | 44 | test('dump NameNode', () => { 45 | for (let dumpParams of getDumpData()) { 46 | expect(dumpParams[1].dump()).toBe(dumpParams[0]); 47 | } 48 | }); -------------------------------------------------------------------------------- /src/Node/FunctionNode.js: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | 3 | export default class FunctionNode extends Node { 4 | constructor(name, _arguments) { 5 | //console.log("Creating function node: ", name, _arguments); 6 | super({fnArguments: _arguments}, {name: name}); 7 | this.name = 'FunctionNode'; 8 | } 9 | 10 | compile = (compiler) => { 11 | let _arguments = []; 12 | for (let node of Object.values(this.nodes.fnArguments.nodes)) { 13 | _arguments.push(compiler.subcompile(node)); 14 | } 15 | 16 | let fn = compiler.getFunction(this.attributes.name); 17 | 18 | compiler.raw(fn.compiler.apply(null, _arguments)); 19 | }; 20 | 21 | evaluate = (functions, values) => { 22 | let _arguments = [values]; 23 | for (let node of Object.values(this.nodes.fnArguments.nodes)) { 24 | //console.log("Testing: ", node, functions, values); 25 | _arguments.push(node.evaluate(functions, values)); 26 | } 27 | 28 | return functions[this.attributes.name]['evaluator'].apply(null, _arguments); 29 | }; 30 | 31 | toArray = () => { 32 | let array = []; 33 | array.push(this.attributes.name); 34 | 35 | for (let node of Object.values(this.nodes.fnArguments.nodes)) { 36 | array.push(', '); 37 | array.push(node); 38 | } 39 | 40 | array[1] = '('; 41 | array.push(')'); 42 | 43 | return array; 44 | } 45 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expression-language", 3 | "version": "2.5.1", 4 | "description": "Javascript implementation of symfony/expression-language", 5 | "main": "lib/index.js", 6 | "types": "dist/index.d.ts", 7 | "browser": "dist/expression-language.min.js", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/jameskfry/expression-language.git" 11 | }, 12 | "scripts": { 13 | "build": "rm -rf lib && babel src -d lib", 14 | "build:browser": "rollup -c && cp types/index.d.ts dist/index.d.ts", 15 | "build:all": "npm run build && npm run build:browser", 16 | "prepublishOnly": "npm run build:all", 17 | "test": "jest" 18 | }, 19 | "jest": { 20 | "testPathIgnorePatterns": [ 21 | "/lib" 22 | ], 23 | "reporters": ["default", "/jest-reporter.cjs"] 24 | }, 25 | "keywords": [ 26 | "expression", 27 | "language" 28 | ], 29 | "author": "James K Fry", 30 | "license": "MIT", 31 | "dependencies": { 32 | "locutus": "^2.0.11" 33 | }, 34 | "devDependencies": { 35 | "@babel/cli": "^7.7.7", 36 | "@babel/core": "^7.7.7", 37 | "@babel/plugin-proposal-class-properties": "^7.7.4", 38 | "@babel/plugin-transform-modules-commonjs": "^7.7.5", 39 | "@babel/plugin-transform-runtime": "^7.6.2", 40 | "@babel/preset-env": "^7.7.7", 41 | "@rollup/plugin-babel": "^5.3.1", 42 | "@rollup/plugin-commonjs": "^22.0.2", 43 | "@rollup/plugin-node-resolve": "^14.1.0", 44 | "jest": "^30.0.5", 45 | "rollup": "^2.79.1", 46 | "rollup-plugin-terser": "^7.0.2" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/__tests__/ExpressionLanguage.lint.test.js: -------------------------------------------------------------------------------- 1 | import {IGNORE_UNKNOWN_FUNCTIONS, IGNORE_UNKNOWN_VARIABLES} from "../Parser"; 2 | import ExpressionLanguage from "../ExpressionLanguage"; 3 | 4 | /** 5 | * Tests for ExpressionLanguage.lint and using flags through the high-level API 6 | */ 7 | describe('ExpressionLanguage.lint and flags', () => { 8 | test('lint passes for valid expression', () => { 9 | const el = new ExpressionLanguage(); 10 | el.lint('foo["some_key"].callFunction(a ? b)', ['foo', 'a', 'b']); 11 | }); 12 | 13 | test('lint throws by default for unknown variable and function', () => { 14 | const el = new ExpressionLanguage(); 15 | expect(() => el.lint('myFn(foo)')).toThrow(); 16 | }); 17 | 18 | test('lint allows unknown variables when flag is set', () => { 19 | const el = new ExpressionLanguage(); 20 | el.lint('foo.bar', [], IGNORE_UNKNOWN_VARIABLES); 21 | }); 22 | 23 | test('lint allows unknown functions when flag is set', () => { 24 | const el = new ExpressionLanguage(); 25 | el.lint('foo()', [], IGNORE_UNKNOWN_FUNCTIONS); 26 | }); 27 | 28 | test('lint allows both unknown function and variable when both flags set', () => { 29 | const el = new ExpressionLanguage(); 30 | el.lint('foo(bar)', [], IGNORE_UNKNOWN_FUNCTIONS | IGNORE_UNKNOWN_VARIABLES); 31 | }); 32 | 33 | test('lint supports deprecated null names by converting to IGNORE_UNKNOWN_VARIABLES', () => { 34 | const el = new ExpressionLanguage(); 35 | // Should not throw because names === null maps to IGNORE_UNKNOWN_VARIABLES internally 36 | el.lint('foo.bar', null, 0); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/defaultCustomFunctions.js: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | 3 | export const isString = s => { 4 | return typeof s === "string"; 5 | }; 6 | 7 | export const strLen = s => { 8 | if (isString(s)) { 9 | return s.length; 10 | } 11 | return 0; 12 | }; 13 | 14 | export const isEmail = s => { 15 | if (isString(s)) { 16 | return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); 17 | } 18 | 19 | return false; 20 | }; 21 | 22 | export const isPhone = s => { 23 | if (isString(s)) { 24 | if (s.substring(0, 2) === "+1") { 25 | s = s.substring(2); 26 | } 27 | return /^\d{10}$/.test(s.replace(/\D/g, "")); 28 | } 29 | 30 | return false; 31 | }; 32 | 33 | export const isNull = s => { 34 | return s === null; 35 | }; 36 | 37 | export const isCurrency = s => { 38 | return /(?=.*?\d)^\$?(([1-9]\d{0,2}(,\d{3})*)|\d+)?(\.\d{1,2})?$/.test(s); 39 | }; 40 | 41 | export const now = () => { 42 | return moment(); 43 | }; 44 | 45 | export const dateFormat = (m, format) => { 46 | return m.format(format); 47 | }; 48 | 49 | export const year = m => { 50 | return dateFormat(m, "YYYY"); 51 | }; 52 | 53 | export const date = m => { 54 | return dateFormat(m, "YYYY-MM-DD"); 55 | }; 56 | 57 | export const string = s => { 58 | if (s.toString !== undefined) { 59 | return s.toString(); 60 | } 61 | 62 | return ""; 63 | }; 64 | 65 | export const int = s => { 66 | return parseInt(s); 67 | }; 68 | 69 | let defaultCustomFunctions = { 70 | isString, 71 | strLen, 72 | isEmail, 73 | isPhone, 74 | isNull, 75 | isCurrency, 76 | now, 77 | dateFormat, 78 | year, 79 | date, 80 | string, 81 | int 82 | }; 83 | 84 | export default defaultCustomFunctions; -------------------------------------------------------------------------------- /src/Node/__tests__/Node.test.js: -------------------------------------------------------------------------------- 1 | import Node from "../Node"; 2 | import ConstantNode from "../ConstantNode"; 3 | import Compiler from "../../Compiler"; 4 | 5 | test("toString", () => { 6 | let node = new Node([new ConstantNode('foo')]); 7 | expect(node.toString()).toBe("Node(\n ConstantNode(value: 'foo')\n)"); 8 | }); 9 | 10 | test("serialization", () => { 11 | let node = new Node({foo: 'bar'}, {bar: 'foo'}); 12 | let serialized = JSON.stringify(node); 13 | let unserialized = Object.assign(new Node, JSON.parse(serialized)); 14 | expect(unserialized).toBeInstanceOf(Node); 15 | expect(unserialized.toString()).toEqual(node.toString()); 16 | }); 17 | 18 | test('compileActuallyCompilesAllNodes', () => { 19 | const compiler = new Compiler({}); 20 | const nodes = []; 21 | for (let i = 0; i < 10; i++) { 22 | nodes.push({ 23 | compile: jest.fn(), 24 | toString: () => 'MockNode()' 25 | }); 26 | } 27 | const parent = new Node(nodes); 28 | parent.compile(compiler); 29 | for (const child of nodes) { 30 | expect(child.compile).toHaveBeenCalledTimes(1); 31 | expect(child.compile).toHaveBeenCalledWith(compiler); 32 | } 33 | }); 34 | 35 | test('evaluateActuallyEvaluatesAllNodes', () => { 36 | const nodes = []; 37 | for (let i = 1; i <= 3; i++) { 38 | nodes.push({ 39 | evaluate: jest.fn().mockReturnValue(i), 40 | toString: () => 'MockNode()' 41 | }); 42 | } 43 | const parent = new Node(nodes); 44 | const functions = {}; 45 | const values = {}; 46 | const result = parent.evaluate(functions, values); 47 | expect(result).toEqual([1, 2, 3]); 48 | for (const child of nodes) { 49 | expect(child.evaluate).toHaveBeenCalledTimes(1); 50 | expect(child.evaluate).toHaveBeenCalledWith(functions, values); 51 | } 52 | }); 53 | -------------------------------------------------------------------------------- /src/Node/ConstantNode.js: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | 3 | export default class ConstantNode extends Node { 4 | constructor(value, isIdentifier = false, isNullSafe = false) { 5 | super({}, {value: value}); 6 | this.isIdentifier = isIdentifier; 7 | this.isNullSafe = isNullSafe; 8 | this.name = 'ConstantNode'; 9 | } 10 | 11 | compile = (compiler) => { 12 | compiler.repr(this.attributes.value, this.isIdentifier); 13 | }; 14 | 15 | evaluate = (functions, values) => { 16 | return this.attributes.value; 17 | }; 18 | 19 | toArray = () => { 20 | let array = [], 21 | value = this.attributes.value; 22 | 23 | if (this.isIdentifier) { 24 | array.push(value); 25 | } 26 | else if (true === value) { 27 | array.push('true'); 28 | } 29 | else if (false === value) { 30 | array.push('false'); 31 | } 32 | else if (null === value) { 33 | array.push('null'); 34 | } 35 | else if (typeof value === "number") { 36 | array.push(value); 37 | } 38 | else if (typeof value === "string") { 39 | array.push(this.dumpString(value)); 40 | } 41 | else if (Array.isArray(value)) { 42 | for (let v of value) { 43 | array.push(','); 44 | array.push(new ConstantNode(v)); 45 | } 46 | array[0] = '['; 47 | array.push(']'); 48 | } 49 | else if (this.isHash(value)) { 50 | for (let k of Object.keys(value)) { 51 | array.push(', '); 52 | array.push(new ConstantNode(k)); 53 | array.push(': '); 54 | array.push(new ConstantNode(value[k])); 55 | } 56 | array[0] = '{'; 57 | array.push('}'); 58 | } 59 | 60 | return array; 61 | }; 62 | } -------------------------------------------------------------------------------- /.github/actions/version-check/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Package Version Check' 2 | description: 'Checks if the package version has changed compared to the published version' 3 | inputs: 4 | package-name: 5 | description: 'Name of the package to check' 6 | required: true 7 | outputs: 8 | current-version: 9 | description: 'Current version from package.json' 10 | value: ${{ steps.version-compare.outputs['current-version'] }} 11 | published-version: 12 | description: 'Latest published version from npm' 13 | value: ${{ steps.version-compare.outputs['published-version'] }} 14 | changed: 15 | description: 'Whether the version has changed (true/false)' 16 | value: ${{ steps.version-compare.outputs.changed }} 17 | runs: 18 | using: "composite" 19 | steps: 20 | - name: Get current version 21 | id: package-version 22 | shell: bash 23 | run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT 24 | 25 | - name: Check published version 26 | id: npm-version 27 | shell: bash 28 | run: | 29 | PUBLISHED_VERSION=$(npm view ${{ inputs.package-name }} version 2>/dev/null || echo "0.0.0") 30 | echo "published=$PUBLISHED_VERSION" >> $GITHUB_OUTPUT 31 | 32 | - name: Compare versions 33 | id: version-compare 34 | shell: bash 35 | run: | 36 | if [ "${{ steps.package-version.outputs.version }}" != "${{ steps.npm-version.outputs.published }}" ]; then 37 | echo "changed=true" >> $GITHUB_OUTPUT 38 | echo "Package version changed from ${{ steps.npm-version.outputs.published }} to ${{ steps.package-version.outputs.version }}" 39 | else 40 | echo "changed=false" >> $GITHUB_OUTPUT 41 | echo "Package version unchanged: ${{ steps.package-version.outputs.version }}" 42 | fi 43 | echo "current-version=${{ steps.package-version.outputs.version }}" >> $GITHUB_OUTPUT 44 | echo "published-version=${{ steps.npm-version.outputs.published }}" >> $GITHUB_OUTPUT -------------------------------------------------------------------------------- /src/Provider/__tests__/ArrayProvider.test.js: -------------------------------------------------------------------------------- 1 | import ExpressionLanguage from "../../ExpressionLanguage"; 2 | import ArrayProvider from "../ArrayProvider"; 3 | 4 | test('implode evaluate', () => { 5 | let el = new ExpressionLanguage(null, [new ArrayProvider()]); 6 | let result = el.evaluate('implode(". ", ["check", "this", "out"])'); 7 | expect(result).toBe("check. this. out"); 8 | }); 9 | 10 | test('implode compile', () => { 11 | let el = new ExpressionLanguage(null, [new ArrayProvider()]); 12 | let result = el.compile('implode(". ", ["check", "this", "out"])'); 13 | expect(result).toBe('implode(". ", ["check", "this", "out"])'); 14 | }); 15 | 16 | test('count evaluate', () => { 17 | let el = new ExpressionLanguage(null, [new ArrayProvider()]); 18 | let result = el.evaluate('count(["1", "2", "3"])'); 19 | expect(result).toBe(3); 20 | 21 | let result2 = el.evaluate('count(["1", "2", "3", ["4", "5"]], "COUNT_RECURSIVE")'); 22 | expect(result2).toBe(6); // Counts array as one, then contents individually 23 | }); 24 | 25 | test('count compile', () => { 26 | let el = new ExpressionLanguage(null, [new ArrayProvider()]); 27 | let result = el.compile('count(["1", "2", "3"])'); 28 | expect(result).toBe('count(["1", "2", "3"])'); 29 | 30 | let result2 = el.compile('count(["1", "2", "3"], "COUNT_RECURSIVE")'); 31 | expect(result2).toBe('count(["1", "2", "3"], "COUNT_RECURSIVE")'); 32 | }); 33 | 34 | test('array_intersect evaluate', () => { 35 | let el = new ExpressionLanguage(null, [new ArrayProvider()]); 36 | let result = el.evaluate('array_intersect(["1", "2", "3"], ["1", "2", "3"], ["2", "3"])'); 37 | expect(result).toMatchObject(["2", "3"]); 38 | }); 39 | 40 | test('array_intersect compile', () => { 41 | let el = new ExpressionLanguage(null, [new ArrayProvider()]); 42 | let result = el.compile('array_intersect(["1", "2", "3"], ["1", "2", "3"], ["2", "3"])'); 43 | expect(result).toBe('array_intersect(["1", "2", "3"], ["1", "2", "3"], ["2", "3"])'); 44 | }); 45 | -------------------------------------------------------------------------------- /src/Node/__tests__/FunctionNode.test.js: -------------------------------------------------------------------------------- 1 | import ConstantNode from "../ConstantNode"; 2 | import FunctionNode from "../FunctionNode"; 3 | import Node from "../Node"; 4 | import Compiler from "../../Compiler"; 5 | 6 | function getEvaluateData() { 7 | return [ 8 | ['bar', new FunctionNode('foo', new Node([new ConstantNode('bar')])), {}, {foo: getCallables()}] 9 | ]; 10 | } 11 | 12 | function getCompileData() { 13 | return [ 14 | ['foo("bar")', new FunctionNode('foo', new Node([new ConstantNode('bar')])), {foo: getCallables()}], 15 | ]; 16 | } 17 | 18 | function getDumpData() { 19 | return [ 20 | ['foo("bar")', new FunctionNode('foo', new Node([new ConstantNode('bar')])), {foo: getCallables()}], 21 | ]; 22 | } 23 | 24 | function getCallables() { 25 | return { 26 | 'compiler': (arg) => { 27 | return `foo(${arg})`; 28 | }, 29 | 'evaluator': (variables, arg) => { 30 | return arg; 31 | } 32 | }; 33 | } 34 | 35 | test('evaluate FunctionNode', () => { 36 | for (let evaluateParams of getEvaluateData()) { 37 | //console.log("Evaluating: ", evaluateParams); 38 | let evaluated = evaluateParams[1].evaluate(evaluateParams[3], evaluateParams[2]); 39 | //console.log("Evaluated: ", evaluated); 40 | if (evaluateParams[0] !== null && typeof evaluateParams[0] === "object") { 41 | expect(evaluated).toMatchObject(evaluateParams[0]); 42 | } else { 43 | expect(evaluated).toBe(evaluateParams[0]); 44 | } 45 | } 46 | }); 47 | 48 | test('compile FunctionNode', () => { 49 | for (let compileParams of getCompileData()) { 50 | let compiler = new Compiler(compileParams[2]); 51 | compileParams[1].compile(compiler); 52 | expect(compiler.getSource()).toBe(compileParams[0]); 53 | } 54 | }); 55 | 56 | test('dump FunctionNode', () => { 57 | for (let dumpParams of getDumpData()) { 58 | expect(dumpParams[1].dump()).toBe(dumpParams[0]); 59 | } 60 | }); -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npm 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | id-token: write 10 | contents: read 11 | 12 | jobs: 13 | publish-npm: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Set up Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: '22' 25 | registry-url: 'https://registry.npmjs.org/' 26 | 27 | - name: Upgrade NPM to latest 28 | run: npm install -g npm@latest 29 | 30 | - name: Install dependencies 31 | run: npm ci 32 | 33 | - name: Run tests 34 | run: npm test 35 | 36 | - name: Check version change 37 | id: version-check 38 | uses: ./.github/actions/version-check 39 | with: 40 | package-name: 'expression-language' 41 | 42 | - name: Build and publish package 43 | if: steps.version-check.outputs.changed == 'true' 44 | run: npm publish 45 | 46 | - name: Generate changelog 47 | id: changelog 48 | if: steps.version-check.outputs.changed == 'true' 49 | uses: metcalfc/changelog-generator@v4.6.2 50 | with: 51 | myToken: ${{ secrets.GITHUB_TOKEN }} 52 | 53 | - name: Create GitHub Release 54 | if: steps.version-check.outputs.changed == 'true' 55 | id: create_release 56 | uses: softprops/action-gh-release@v2 57 | with: 58 | tag_name: v${{ steps.version-check.outputs['current-version'] }} 59 | name: Release v${{ steps.version-check.outputs['current-version'] }} 60 | body: | 61 | ## Changes in this Release 62 | 63 | ${{ steps.changelog.outputs.changelog }} 64 | files: | 65 | dist/expression-language.js 66 | dist/expression-language.min.js 67 | dist/index.d.ts 68 | draft: false 69 | prerelease: false 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | -------------------------------------------------------------------------------- /src/Provider/ArrayProvider.js: -------------------------------------------------------------------------------- 1 | import ExpressionFunction from "../ExpressionFunction"; 2 | import AbstractProvider from "./AbstractProvider"; 3 | import array_intersect from "locutus/php/array/array_intersect"; 4 | import count from "locutus/php/array/count"; 5 | import implode from "locutus/php/strings/implode"; 6 | 7 | export default class ArrayProvider extends AbstractProvider { 8 | getFunctions() { 9 | return [ 10 | implodeFn, 11 | countFn, 12 | arrayIntersectFn 13 | ]; 14 | } 15 | } 16 | 17 | export const implodeFn = new ExpressionFunction( 18 | 'implode', 19 | function compiler(glue, pieces) { 20 | //console.log("compile implode: ", pieces, glue, typeof pieces); 21 | return `implode(${glue}, ${pieces})`; 22 | }, 23 | function evaluator(values, glue, pieces) { 24 | return implode(glue, pieces); 25 | } 26 | ); 27 | 28 | export const countFn = new ExpressionFunction( 29 | 'count', 30 | function compiler(mixedVar, mode) { 31 | let remaining = ''; 32 | if (mode) { 33 | remaining = `, ${mode}`; 34 | } 35 | return `count(${mixedVar}${remaining})`; 36 | }, 37 | function evaluator(values, mixedVar, mode) { 38 | return count(mixedVar, mode); 39 | } 40 | ); 41 | 42 | export const arrayIntersectFn = new ExpressionFunction( 43 | 'array_intersect', 44 | function compiler(arr1, ...rest) { 45 | let remaining = ''; 46 | if (rest.length > 0) { 47 | remaining = ", " + rest.join(", "); 48 | } 49 | return `array_intersect(${arr1}${remaining})`; 50 | }, 51 | function evaluator(values) { 52 | let newArgs = [], 53 | allArrays = true; 54 | for (let i = 1; i < arguments.length; i++) { 55 | newArgs.push(arguments[i]); 56 | if (!Array.isArray(arguments[i])) { 57 | allArrays = false; 58 | } 59 | } 60 | let res = array_intersect.apply(null, newArgs); 61 | 62 | if (allArrays) { 63 | return Object.values(res); 64 | } 65 | return res; 66 | } 67 | ); 68 | -------------------------------------------------------------------------------- /src/Node/__tests__/ArrayNode.test.js: -------------------------------------------------------------------------------- 1 | import ArrayNode from "../ArrayNode"; 2 | import ConstantNode from "../ConstantNode"; 3 | import Compiler from "../../Compiler"; 4 | 5 | function getEvaluateData() { 6 | return [ 7 | [{b: 'a', "0": "b"}, getArrayNode()] 8 | ] 9 | } 10 | 11 | function getCompileData() { 12 | return [ 13 | ['{"b": "a", "0": "b"}', getArrayNode()] 14 | ] 15 | } 16 | 17 | function getDumpData() { 18 | let arrOne = createArrayNode(); 19 | arrOne.addElement(new ConstantNode("c"), new ConstantNode('a"b')); 20 | arrOne.addElement(new ConstantNode("d"), new ConstantNode('a\\b')); 21 | 22 | let arrTwo = createArrayNode(); 23 | arrTwo.addElement(new ConstantNode('c')); 24 | arrTwo.addElement(new ConstantNode('d')); 25 | return [ 26 | ['{"0": "b", "b": "a"}', getArrayNode()], 27 | ['{"a\\"b": "c", "a\\\\b": "d"}', arrOne], 28 | ['["c", "d"]', arrTwo] 29 | ]; 30 | } 31 | 32 | function getArrayNode() { 33 | let arr = createArrayNode(); 34 | arr.addElement(new ConstantNode("a"), new ConstantNode("b")); 35 | arr.addElement(new ConstantNode("b"), new ConstantNode("0")); 36 | return arr; 37 | } 38 | 39 | function createArrayNode() { 40 | return new ArrayNode(); 41 | } 42 | 43 | test('evaluate ArrayNode', () => { 44 | for (let evaluateParams of getEvaluateData()) { 45 | //console.log("Evaluating: ", evaluateParams); 46 | let evaluated = evaluateParams[1].evaluate({}, {}); 47 | //console.log("Evaluated: ", evaluated); 48 | if (evaluateParams[0] !== null && typeof evaluateParams[0] === "object") { 49 | expect(evaluated).toMatchObject(evaluateParams[0]); 50 | } 51 | else { 52 | expect(evaluated).toBe(evaluateParams[0]); 53 | } 54 | } 55 | }); 56 | 57 | test('compile ArrayNode', () => { 58 | for (let compileParams of getCompileData()) { 59 | let compiler = new Compiler({}); 60 | compileParams[1].compile(compiler); 61 | expect(compiler.getSource()).toBe(compileParams[0]); 62 | } 63 | }); 64 | 65 | test('dump ArrayNode', () => { 66 | for (let dumpParams of getDumpData()) { 67 | expect(dumpParams[1].dump()).toBe(dumpParams[0]); 68 | } 69 | }); -------------------------------------------------------------------------------- /src/Node/__tests__/UnaryNode.test.js: -------------------------------------------------------------------------------- 1 | import ConstantNode from "../ConstantNode"; 2 | import UnaryNode from "../UnaryNode"; 3 | import Compiler from "../../Compiler"; 4 | 5 | function getEvaluateData() 6 | { 7 | return [ 8 | [-1, new UnaryNode('-', new ConstantNode(1))], 9 | [3, new UnaryNode('+', new ConstantNode(3))], 10 | [false, new UnaryNode('!', new ConstantNode(true))], 11 | [false, new UnaryNode('not', new ConstantNode(true))], 12 | [-6, new UnaryNode('~', new ConstantNode(5))], 13 | ]; 14 | } 15 | function getCompileData() 16 | { 17 | return [ 18 | ['(-1)', new UnaryNode('-', new ConstantNode(1))], 19 | ['(+3)', new UnaryNode('+', new ConstantNode(3))], 20 | ['(!true)', new UnaryNode('!', new ConstantNode(true))], 21 | ['(!true)', new UnaryNode('not', new ConstantNode(true))], 22 | ['(~5)', new UnaryNode('~', new ConstantNode(5))], 23 | ]; 24 | } 25 | function getDumpData() 26 | { 27 | return [ 28 | ['(- 1)', new UnaryNode('-', new ConstantNode(1))], 29 | ['(+ 3)', new UnaryNode('+', new ConstantNode(3))], 30 | ['(! true)', new UnaryNode('!', new ConstantNode(true))], 31 | ['(not true)', new UnaryNode('not', new ConstantNode(true))], 32 | ['(~ 5)', new UnaryNode('~', new ConstantNode(5))], 33 | ]; 34 | } 35 | 36 | test('evaluate UnaryNode', () => { 37 | for (let evaluateParams of getEvaluateData()) { 38 | //console.log("Evaluating: ", evaluateParams); 39 | let evaluated = evaluateParams[1].evaluate(evaluateParams[3]||{}, evaluateParams[2]); 40 | //console.log("Evaluated: ", evaluated); 41 | if (evaluateParams[0] !== null && typeof evaluateParams[0] === "object") { 42 | expect(evaluated).toMatchObject(evaluateParams[0]); 43 | } 44 | else { 45 | expect(evaluated).toBe(evaluateParams[0]); 46 | } 47 | } 48 | }); 49 | 50 | test('compile UnaryNode', () => { 51 | for (let compileParams of getCompileData()) { 52 | let compiler = new Compiler({}); 53 | compileParams[1].compile(compiler); 54 | expect(compiler.getSource()).toBe(compileParams[0]); 55 | } 56 | }); 57 | 58 | test('dump UnaryNode', () => { 59 | for (let dumpParams of getDumpData()) { 60 | expect(dumpParams[1].dump()).toBe(dumpParams[0]); 61 | } 62 | }); -------------------------------------------------------------------------------- /src/Node/Node.js: -------------------------------------------------------------------------------- 1 | import {is_scalar} from "../lib/is-scalar"; 2 | import {addcslashes} from "../lib/addcslashes"; 3 | 4 | export default class Node { 5 | constructor(nodes = {}, attributes = {}) { 6 | this.name = 'Node'; 7 | this.nodes = nodes; 8 | this.attributes = attributes; 9 | } 10 | 11 | toString() { 12 | let attributes = []; 13 | for (let name of Object.keys(this.attributes)) { 14 | let oneAttribute = 'null'; 15 | if (this.attributes[name]) { 16 | oneAttribute = this.attributes[name].toString(); 17 | } 18 | attributes.push(`${name}: '${oneAttribute}'`); 19 | } 20 | 21 | let repr = [this.name + "(" + attributes.join(", ")]; 22 | 23 | if (this.nodes.length > 0) { 24 | for (let node of Object.values(this.nodes)) { 25 | let lines = node.toString().split("\n"); 26 | for(let line of lines) { 27 | repr.push(" " + line); 28 | } 29 | } 30 | repr.push(")"); 31 | } 32 | else { 33 | repr[0] += ")"; 34 | } 35 | 36 | return repr.join("\n"); 37 | } 38 | 39 | 40 | 41 | compile = (compiler) => { 42 | for (let node of Object.values(this.nodes)) { 43 | node.compile(compiler); 44 | } 45 | }; 46 | 47 | evaluate = (functions, values) => { 48 | let results = []; 49 | for (let node of Object.values(this.nodes)) { 50 | results.push(node.evaluate(functions, values)); 51 | } 52 | 53 | return results; 54 | }; 55 | 56 | toArray = () => { 57 | throw new Error(`Dumping a "${this.name}" instance is not supported yet.`); 58 | } 59 | 60 | dump = () => { 61 | let dump = ""; 62 | 63 | for (let v of this.toArray()) { 64 | dump += is_scalar(v) ? v : v.dump(); 65 | } 66 | 67 | return dump; 68 | }; 69 | 70 | dumpString = (value) => { 71 | return `"${addcslashes(value, "\0\t\"\\")}"`; 72 | }; 73 | 74 | isHash = (value) => { 75 | let expectedKey = 0; 76 | 77 | for (let key of Object.keys(value)) { 78 | key = parseInt(key); 79 | if (key !== expectedKey++) { 80 | return true; 81 | } 82 | } 83 | return false; 84 | }; 85 | } -------------------------------------------------------------------------------- /src/Node/__tests__/ConstantNode.test.js: -------------------------------------------------------------------------------- 1 | import ConstantNode from "../ConstantNode"; 2 | import Compiler from "../../Compiler"; 3 | 4 | function getEvaluateData() { 5 | return [ 6 | [false, new ConstantNode(false)], 7 | [true, new ConstantNode(true)], 8 | [null, new ConstantNode(null)], 9 | [3, new ConstantNode(3)], 10 | [3.3, new ConstantNode(3.3)], 11 | ['foo', new ConstantNode('foo')], 12 | [{one: 1, b: 'a'}, new ConstantNode({one: 1, b: 'a'})] 13 | ] 14 | } 15 | 16 | function getCompileData() { 17 | return [ 18 | ['false', new ConstantNode(false)], 19 | ['true', new ConstantNode(true)], 20 | ['null', new ConstantNode(null)], 21 | ['3', new ConstantNode(3)], 22 | ['3.3', new ConstantNode(3.3)], 23 | ['"foo"', new ConstantNode('foo')], 24 | ['{\"one\":1, \"b\":"a"}', new ConstantNode({one: 1, b: 'a'})] 25 | ]; 26 | } 27 | 28 | function getDumpData() { 29 | return [ 30 | ['false', new ConstantNode(false)], 31 | ['true', new ConstantNode(true)], 32 | ['null', new ConstantNode(null)], 33 | ['3', new ConstantNode(3)], 34 | ['3.3', new ConstantNode(3.3)], 35 | ['"foo"', new ConstantNode('foo')], 36 | ['foo', new ConstantNode('foo', true)], 37 | ['{"one": 1}', new ConstantNode({one: 1})], 38 | ['{\"one\": 1, "c": true, \"b\": "a"}', new ConstantNode({one: 1, c: true, b: 'a'})], 39 | ['{"a\\"b": "c", "a\\\\b": "d"}', new ConstantNode({'a"b': 'c', 'a\\b': "d"})], 40 | ['["c","d"]', new ConstantNode(["c", "d"])], 41 | ['{"a": ["b"]}', new ConstantNode({a: ["b"]})] 42 | ] 43 | } 44 | 45 | test('evaluate ConstantNode', () => { 46 | for (let evaluateParams of getEvaluateData()) { 47 | if (evaluateParams[0] !== null && typeof evaluateParams[0] === "object") { 48 | expect(evaluateParams[1].evaluate({}, {})).toMatchObject(evaluateParams[0]); 49 | } 50 | else { 51 | expect(evaluateParams[1].evaluate({}, {})).toBe(evaluateParams[0]); 52 | } 53 | } 54 | }); 55 | 56 | test('compile ConstantNode', () => { 57 | for (let compileParams of getCompileData()) { 58 | let compiler = new Compiler({}); 59 | compileParams[1].compile(compiler); 60 | expect(compiler.getSource()).toBe(compileParams[0]); 61 | } 62 | }); 63 | 64 | test('dump ConstantNode', () => { 65 | for (let dumpParams of getDumpData()) { 66 | expect(dumpParams[1].dump()).toBe(dumpParams[0]); 67 | } 68 | }); -------------------------------------------------------------------------------- /src/Provider/__tests__/BasicProvider.test.js: -------------------------------------------------------------------------------- 1 | import ExpressionLanguage from "../../ExpressionLanguage"; 2 | import BasicProvider from "../BasicProvider"; 3 | 4 | test('isset evaluate', () => { 5 | let el = new ExpressionLanguage(null, [new BasicProvider()]); 6 | let result = el.evaluate("isset(\"foo['bar']\")", {foo: {bar: 'yep'}}); 7 | expect(result).toBe(true); 8 | 9 | let result2 = el.evaluate('isset(\'foo["bar"]\')', {foo: {bar: 'yep'}}); 10 | expect(result2).toBe(true); 11 | }); 12 | 13 | test('isset short circuit', () => { 14 | let el = new ExpressionLanguage(null, [new BasicProvider()]); 15 | let result = el.evaluate("isset(\"foo['bar']\") or foo['baz'] == 'yep'", {foo: {bar: 'yep'}}); 16 | expect(result).toBe(true); 17 | 18 | let result2 = el.evaluate("isset(\"foo['bar']\") and foo['bar'] == 'yep'", {foo: {baz: 'yep'}}); 19 | expect(result2).toBe(false); 20 | }); 21 | 22 | test('isset deep resolution', () => { 23 | let el = new ExpressionLanguage(null, [new BasicProvider()]); 24 | let result = el.evaluate("isset(\"foo['bar']['buzz']\") and foo['bar']['buzz'] == 'yep'", {foo: {bar: {buzz: 'yep'}}}); 25 | expect(result).toBe(true); 26 | 27 | let result2 = el.evaluate("isset(\"foo['bar']['buzz']\") and foo['bar']['buzz'] == 'yeppers'", {foo: {bar: {buzz: 'yep'}}}); 28 | expect(result2).toBe(false); 29 | }); 30 | 31 | test('isset array resolution', () => { 32 | let el = new ExpressionLanguage(null, [new BasicProvider()]); 33 | let result = el.evaluate("isset(\"foo[0]['buzz']\") and foo[0]['buzz'] == 'yep'", {foo: [{buzz: 'yep'}]}); 34 | expect(result).toBe(true); 35 | }); 36 | 37 | test('isset with dot notation', () => { 38 | let el = new ExpressionLanguage(null, [new BasicProvider()]); 39 | let result = el.evaluate("isset(\"foo.bar\") and foo.bar == 'yep'", {foo: {bar: 'yep'}}); 40 | expect(result).toBe(true); 41 | 42 | let result2 = el.evaluate("isset(\"foo.bar.buzz\") and foo.bar.buzz == 'yep'", {foo: {bar: {buzz: 'yep'}}}); 43 | expect(result2).toBe(true); 44 | }); 45 | 46 | test('isset with ! operator', () => { 47 | let el = new ExpressionLanguage(null, [new BasicProvider()]); 48 | let result = el.evaluate("!isset(\"foo.baz\") and foo.bar == 'yep'", {foo: {bar: 'yep'}}); 49 | expect(result).toBe(true); 50 | }); 51 | 52 | test('isset with not operator', () => { 53 | let el = new ExpressionLanguage(null, [new BasicProvider()]); 54 | let result = el.evaluate("not isset(\"foo.baz\") and foo.bar == 'yep'", {foo: {bar: 'yep'}}); 55 | expect(result).toBe(true); 56 | }); 57 | -------------------------------------------------------------------------------- /src/ExpressionFunction.js: -------------------------------------------------------------------------------- 1 | export default class ExpressionFunction { 2 | constructor(name, compiler, evaluator) { 3 | this.name = name; 4 | this.compiler = compiler; 5 | this.evaluator = evaluator; 6 | } 7 | 8 | getName = () => { 9 | return this.name; 10 | }; 11 | 12 | getCompiler = () => { 13 | return this.compiler; 14 | }; 15 | 16 | getEvaluator = () => { 17 | return this.evaluator; 18 | } 19 | 20 | /** 21 | * Creates an ExpressionFunction from a JavaScript function name (string path). 22 | * 23 | * - Supports dotted paths on globalThis (e.g., 'Math.max'). 24 | * - If a dotted path is provided and expressionFunctionName is not defined, 25 | * an error is thrown (mirrors PHP namespaced constraint). 26 | * 27 | * @param {string} javascriptFunctionName The JS function name or dotted path on globalThis 28 | * @param {string|null} expressionFunctionName Optional expression function name (default: last segment of the JS name) 29 | * @returns {ExpressionFunction} 30 | */ 31 | static fromJavascript(javascriptFunctionName, expressionFunctionName = null) { 32 | if (typeof javascriptFunctionName !== 'string' || javascriptFunctionName.length === 0) { 33 | throw new TypeError('A JavaScript function name (string) must be provided.'); 34 | } 35 | 36 | const fnPath = javascriptFunctionName.replace(/^\/+/, ''); 37 | const parts = fnPath.split('.'); 38 | 39 | // Resolve the function from globalThis following dotted path 40 | let ctx = (typeof globalThis !== 'undefined') ? globalThis : (typeof window !== 'undefined' ? window : (typeof global !== 'undefined' ? global : {})); 41 | let resolved = ctx; 42 | for (const segment of parts) { 43 | if (resolved == null) break; 44 | resolved = resolved[segment]; 45 | } 46 | 47 | if (typeof resolved !== 'function') { 48 | throw new Error(`JavaScript function "${fnPath}" does not exist.`); 49 | } 50 | 51 | if (!expressionFunctionName && parts.length > 1) { 52 | throw new Error(`An expression function name must be defined when JavaScript function "${fnPath}" is namespaced.`); 53 | } 54 | 55 | const compiler = (...args) => `${fnPath}(${args.join(', ')})`; 56 | const evaluator = (p, ...args) => resolved(...args); 57 | 58 | const name = expressionFunctionName || parts[parts.length - 1]; 59 | return new this(name, compiler, evaluator); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Provider/__tests__/StringProvider.test.js: -------------------------------------------------------------------------------- 1 | import ExpressionLanguage from "../../ExpressionLanguage"; 2 | import StringProvider from "../StringProvider"; 3 | 4 | test('strtolower evaluate', () => { 5 | let el = new ExpressionLanguage(null, [new StringProvider()]); 6 | let result = el.evaluate('strtolower("TESTING")'); 7 | expect(result).toBe("testing"); 8 | }); 9 | 10 | test('strtolower compile', () => { 11 | let el = new ExpressionLanguage(null, [new StringProvider()]); 12 | let result = el.compile('strtolower("TESTING")'); 13 | expect(result).toBe('strtolower("TESTING")'); 14 | }); 15 | 16 | test('strtoupper evaluate', () => { 17 | let el = new ExpressionLanguage(null, [new StringProvider()]); 18 | let result = el.evaluate('strtoupper("testing")'); 19 | expect(result).toBe("TESTING"); 20 | }); 21 | 22 | test('explode evaluate', () => { 23 | let el = new ExpressionLanguage(null, [new StringProvider()]); 24 | let result = el.evaluate('explode(" ", "check this out")'); 25 | expect(result).toMatchObject(["check", "this", "out"]); 26 | }); 27 | 28 | test('explode evaluate with complex string', () => { 29 | let el = new ExpressionLanguage(null, [new StringProvider()]); 30 | let result = el.evaluate('explode(" .*&3 ", "check .*&3 this .*&3 out")'); 31 | expect(result).toMatchObject(["check", "this", "out"]); 32 | 33 | let result2 = el.evaluate('explode(" .*&3 ", "check .*&3 this .*&3 out")'); 34 | expect(result2).toMatchObject(["check ", " this ", " out"]); 35 | }); 36 | 37 | test('explode compile', () => { 38 | let el = new ExpressionLanguage(null, [new StringProvider()]); 39 | let result = el.compile('explode(". ", "check this out")'); 40 | expect(result).toBe('explode(". ", "check this out", null)'); 41 | }); 42 | 43 | test('strlen evaluate', () => { 44 | let el = new ExpressionLanguage(null, [new StringProvider()]); 45 | let result = el.evaluate('strlen("Hats are cool")'); 46 | expect(result).toBe(13); 47 | }); 48 | 49 | test('substr evaluate', () => { 50 | let el = new ExpressionLanguage(null, [new StringProvider()]); 51 | let result = el.evaluate('substr("Hats are cool", 0, 3)'); 52 | expect(result).toBe('Hat'); 53 | }); 54 | 55 | test('stristr evaluate', () => { 56 | let el = new ExpressionLanguage(null, [new StringProvider()]); 57 | let result = el.evaluate('stristr("Hats are cool", "Are")'); 58 | expect(result).toBe('are cool'); 59 | }); 60 | 61 | test('strstr evaluate', () => { 62 | let el = new ExpressionLanguage(null, [new StringProvider()]); 63 | let result = el.evaluate('strstr("Hats are cool", "are")'); 64 | expect(result).toBe('are cool'); 65 | 66 | let result2 = el.evaluate('strstr("Hats are cool", "Are")'); 67 | expect(result2).toBe(false); 68 | }); 69 | -------------------------------------------------------------------------------- /src/__tests__/ExpressionFunction.test.js: -------------------------------------------------------------------------------- 1 | import ExpressionFunction from "../ExpressionFunction"; 2 | 3 | // Helper to define a temporary global function for testing 4 | function defineGlobalFn(name, fn) { 5 | const parts = name.split('.'); 6 | let ctx = globalThis; 7 | for (let i = 0; i < parts.length - 1; i++) { 8 | const seg = parts[i]; 9 | if (!ctx[seg]) ctx[seg] = {}; // create namespace as needed 10 | ctx = ctx[seg]; 11 | } 12 | ctx[parts[parts.length - 1]] = fn; 13 | return () => { 14 | // cleanup 15 | ctx[parts[parts.length - 1]] = undefined; 16 | }; 17 | } 18 | 19 | describe('ExpressionFunction.fromJavascript', () => { 20 | test('creates function from non-namespaced global function with default name and works (compile/evaluate)', () => { 21 | const cleanup = defineGlobalFn('myTestFn', (a, b) => a + b); 22 | try { 23 | const ef = ExpressionFunction.fromJavascript('myTestFn'); 24 | expect(ef.getName()).toBe('myTestFn'); 25 | 26 | const compiler = ef.getCompiler(); 27 | const code = compiler('1', '2'); 28 | expect(code).toBe('myTestFn(1, 2)'); 29 | 30 | const evaluator = ef.getEvaluator(); 31 | expect(evaluator({}, 1, 2)).toBe(3); 32 | } finally { 33 | cleanup(); 34 | } 35 | }); 36 | 37 | test('creates function from namespaced path (Math.max) when explicit expression name is provided', () => { 38 | const ef = ExpressionFunction.fromJavascript('Math.max', 'max'); 39 | expect(ef.getName()).toBe('max'); 40 | 41 | const compiler = ef.getCompiler(); 42 | expect(compiler('1', '2', '3')).toBe('Math.max(1, 2, 3)'); 43 | 44 | const evaluator = ef.getEvaluator(); 45 | expect(evaluator({}, 1, 3, 2)).toBe(3); 46 | }); 47 | 48 | test('throws if function does not exist', () => { 49 | expect(() => ExpressionFunction.fromJavascript('nonExistentFnXYZ')).toThrow( 50 | 'JavaScript function "nonExistentFnXYZ" does not exist.' 51 | ); 52 | }); 53 | 54 | test('throws if namespaced path provided without expression name', () => { 55 | expect(() => ExpressionFunction.fromJavascript('Math.max')).toThrow( 56 | 'An expression function name must be defined when JavaScript function "Math.max" is namespaced.' 57 | ); 58 | }); 59 | 60 | test('throws TypeError for invalid or empty name', () => { 61 | expect(() => ExpressionFunction.fromJavascript('')).toThrow(TypeError); 62 | expect(() => ExpressionFunction.fromJavascript(123)).toThrow(TypeError); 63 | expect(() => ExpressionFunction.fromJavascript(null)).toThrow(TypeError); 64 | expect(() => ExpressionFunction.fromJavascript(undefined)).toThrow(TypeError); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/Provider/BasicProvider.js: -------------------------------------------------------------------------------- 1 | import ExpressionFunction from "../ExpressionFunction"; 2 | import AbstractProvider from "./AbstractProvider"; 3 | 4 | export default class ArrayProvider extends AbstractProvider { 5 | getFunctions() { 6 | return [ 7 | issetFn 8 | ]; 9 | } 10 | } 11 | 12 | export const issetFn = new ExpressionFunction( 13 | 'isset', 14 | function compiler(variable) { 15 | return `isset(${variable})`; 16 | }, 17 | function evaluator(values, variable) { 18 | let baseName = "", 19 | parts = [], 20 | gathering = "", 21 | gathered = ""; 22 | 23 | for (let i = 0; i < variable.length; i++) { 24 | let char = variable[i]; 25 | if (char === "]") { 26 | gathering = ""; 27 | parts.push({type: 'array', index: gathered.replace(/"/g, "").replace(/'/g, "")}); 28 | gathered = ""; 29 | continue; 30 | } 31 | if (char === "[") { 32 | gathering = "array"; 33 | gathered = ""; 34 | continue; 35 | } 36 | if (gathering === "object" && (!/[A-z0-9_]/.test(char) || i === variable.length - 1)) { 37 | let lastChar = false; 38 | if (i === variable.length - 1) { 39 | gathered += char; 40 | lastChar = true; 41 | } 42 | gathering = ""; 43 | parts.push({type: 'object', attribute: gathered}); 44 | gathered = ""; 45 | 46 | if (lastChar) { 47 | continue; 48 | } 49 | } 50 | if (char === ".") { 51 | gathering = "object"; 52 | gathered = ""; 53 | continue; 54 | } 55 | if (gathering) { 56 | gathered += char; 57 | } else { 58 | baseName += char; 59 | } 60 | } 61 | 62 | if (parts.length > 0) { 63 | //console.log("Parts: ", parts); 64 | if (values[baseName] !== undefined) { 65 | let baseVar = values[baseName]; 66 | for (let part of parts) { 67 | if (part.type === "array") { 68 | if (baseVar[part.index] === undefined) { 69 | return false; 70 | } 71 | baseVar = baseVar[part.index]; 72 | } 73 | if (part.type === "object") { 74 | if (baseVar[part.attribute] === undefined) { 75 | return false; 76 | } 77 | baseVar = baseVar[part.attribute]; 78 | } 79 | } 80 | 81 | return true; 82 | } 83 | 84 | return false; 85 | } else { 86 | return values[baseName] !== undefined; 87 | } 88 | } 89 | ); 90 | -------------------------------------------------------------------------------- /src/Provider/StringProvider.js: -------------------------------------------------------------------------------- 1 | import ExpressionFunction from "../ExpressionFunction"; 2 | import AbstractProvider from "./AbstractProvider"; 3 | import explode from "locutus/php/strings/explode"; 4 | import strlen from "locutus/php/strings/strlen"; 5 | import strtolower from "locutus/php/strings/strtolower"; 6 | import strtoupper from "locutus/php/strings/strtoupper"; 7 | import substr from "locutus/php/strings/substr"; 8 | import strstr from "locutus/php/strings/strstr"; 9 | import stristr from "locutus/php/strings/stristr"; 10 | 11 | export default class StringProvider extends AbstractProvider { 12 | getFunctions() { 13 | return [ 14 | new ExpressionFunction('strtolower', (str) => { 15 | return 'strtolower(' + str + ')'; 16 | }, (args, str) => { 17 | return strtolower(str); 18 | }), 19 | new ExpressionFunction('strtoupper', (str) => { 20 | return 'strtoupper(' + str + ')'; 21 | }, (args, str) => { 22 | return strtoupper(str); 23 | }), 24 | new ExpressionFunction('explode', (delimiter, string, limit='null') => { 25 | return `explode(${delimiter}, ${string}, ${limit})`; 26 | }, (values, delimiter, string, limit=null) => { 27 | return explode(delimiter, string, limit); 28 | }), 29 | new ExpressionFunction('strlen', function compiler(str) { 30 | return `strlen(${str});`; 31 | }, function evaluator(values, str) { 32 | return strlen(str); 33 | }), 34 | new ExpressionFunction('strstr', function compiler(haystack, needle, before_needle) { 35 | let remaining = ''; 36 | if (before_needle) { 37 | remaining = `, ${before_needle}`; 38 | } 39 | return `strstr(${haystack}, ${needle}${remaining});`; 40 | }, function evaluator(values, haystack, needle, before_needle) { 41 | return strstr(haystack, needle, before_needle); 42 | }), 43 | new ExpressionFunction('stristr', function compiler(haystack, needle, before_needle) { 44 | let remaining = ''; 45 | if (before_needle) { 46 | remaining = `, ${before_needle}`; 47 | } 48 | return `stristr(${haystack}, ${needle}${remaining});`; 49 | }, function evaluator(values, haystack, needle, before_needle) { 50 | return stristr(haystack, needle, before_needle); 51 | }), 52 | new ExpressionFunction('substr', function compiler(str, start, length) { 53 | let remaining = ''; 54 | if (length) { 55 | remaining = `, ${length}`; 56 | } 57 | return `substr(${str}, ${start}${remaining});`; 58 | }, function evaluator(values, str, start, length) { 59 | return substr(str, start, length); 60 | }) 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /examples/browser-usage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Expression Language Browser Example 7 | 31 | 32 | 33 |

Expression Language Browser Example

34 | 35 |
36 |

Basic Example

37 |
let result = expressionLanguage.evaluate('1 + 1');
38 |

Result:

39 |
40 | 41 |
42 |

Multiple Clauses Example

43 |
let result = expressionLanguage.evaluate('a > 0 && b != a', { a: 1, b: 2 });
44 |

Result:

45 |
46 | 47 |
48 |

Object and Array Access Example

49 |
let expression = 'a[2] === "three" and b.myMethod(a[1]) === "bar two"';
50 | let values = {
51 |     a: ["one", "two", "three"], 
52 |     b: {
53 |         myProperty: "foo", 
54 |         myMethod: function(word) {
55 |             return "bar " + word;
56 |         }
57 |     }
58 | };
59 | let result = expressionLanguage.evaluate(expression, values);
60 |

Result:

61 |
62 | 63 | 64 | 65 | 66 | 92 | 93 | -------------------------------------------------------------------------------- /src/Compiler.js: -------------------------------------------------------------------------------- 1 | import {addcslashes} from "./lib/addcslashes"; 2 | 3 | export default class Compiler { 4 | 5 | constructor(functions) { 6 | this.source = ''; 7 | this.functions = functions; 8 | } 9 | 10 | getFunction = (name) => { 11 | return this.functions[name]; 12 | }; 13 | 14 | /** 15 | * Gets the current javascript code after compilation. 16 | * 17 | * @returns {string} The javascript code 18 | */ 19 | getSource = () => { 20 | return this.source; 21 | }; 22 | 23 | reset = () => { 24 | this.source = ''; 25 | 26 | return this; 27 | }; 28 | 29 | /** 30 | * Compiles a node 31 | * 32 | * @param {Node} node 33 | * @returns {Compiler} 34 | */ 35 | compile = (node) => { 36 | node.compile(this); 37 | return this; 38 | }; 39 | 40 | subcompile = (node) => { 41 | let current = this.source; 42 | this.source = ''; 43 | 44 | node.compile(this); 45 | 46 | let source = this.source; 47 | this.source = current; 48 | 49 | return source; 50 | }; 51 | 52 | /** 53 | * Adds a raw string to the compiled code. 54 | * 55 | * @param {string} str The string 56 | * @returns {Compiler} 57 | */ 58 | raw = (str) => { 59 | this.source += str; 60 | return this; 61 | }; 62 | 63 | /** 64 | * Adds a quoted string to the compiled code. 65 | * @param {string} value The string 66 | * @returns {Compiler} 67 | */ 68 | string = (value) => { 69 | this.source += '"' + addcslashes(value, "\0\t\"\$\\") + '"'; 70 | return this; 71 | }; 72 | 73 | /** 74 | * Returns a javascript representation of a given value. 75 | * @param {int|float|null|boolean|Object|Array|string} value The value to convert 76 | * @param {boolean} isIdentifier 77 | * @returns {Compiler} 78 | */ 79 | repr = (value, isIdentifier = false) => { 80 | // Integer or Float 81 | if (isIdentifier) { 82 | this.raw(value); 83 | } 84 | else if (Number.isInteger(value) || (+value === value && (!isFinite(value) || !!(value % 1)))) { 85 | this.raw(value); 86 | } 87 | else if (null === value) { 88 | this.raw('null'); 89 | } 90 | else if (typeof value === 'boolean') { 91 | this.raw(value ? 'true' : 'false'); 92 | } 93 | else if (typeof value === 'object') { 94 | this.raw('{'); 95 | let first = true; 96 | for (let oneKey of Object.keys(value)) { 97 | if (!first) { 98 | this.raw(', '); 99 | } 100 | first = false; 101 | this.repr(oneKey); 102 | this.raw(':'); 103 | this.repr(value[oneKey]); 104 | } 105 | this.raw('}'); 106 | } 107 | else if (Array.isArray(value)) { 108 | this.raw('['); 109 | let first = true; 110 | for (let oneValue of value) { 111 | if (!first) { 112 | this.raw(', '); 113 | } 114 | first = false; 115 | this.repr(oneValue); 116 | } 117 | this.raw(']'); 118 | } 119 | else { 120 | this.string(value); 121 | } 122 | 123 | return this; 124 | }; 125 | } -------------------------------------------------------------------------------- /src/Node/ArrayNode.js: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | import ConstantNode from "./ConstantNode"; 3 | 4 | export default class ArrayNode extends Node { 5 | constructor() { 6 | super(); 7 | this.name = "ArrayNode"; 8 | this.type = "Array"; 9 | this.index = -1; 10 | this.keyIndex = -1; 11 | } 12 | 13 | addElement = (value, key = null) => { 14 | if (null === key) { 15 | key = new ConstantNode(++this.index); 16 | } 17 | else { 18 | if (this.type === 'Array') { 19 | this.type = 'Object'; 20 | } 21 | } 22 | 23 | this.nodes[(++this.keyIndex).toString()] = key; 24 | this.nodes[(++this.keyIndex).toString()] = value; 25 | }; 26 | 27 | compile = (compiler) => { 28 | if (this.type === 'Object') { 29 | compiler.raw('{'); 30 | } 31 | else { 32 | compiler.raw('['); 33 | } 34 | this.compileArguments(compiler, this.type !== "Array"); 35 | if (this.type === 'Object') { 36 | compiler.raw('}'); 37 | } 38 | else { 39 | compiler.raw(']'); 40 | } 41 | }; 42 | 43 | evaluate = (functions, values) => { 44 | let result; 45 | if (this.type === 'Array') { 46 | result = []; 47 | for (let pair of this.getKeyValuePairs()) { 48 | result.push(pair.value.evaluate(functions, values)); 49 | } 50 | } 51 | else { 52 | result = {}; 53 | for (let pair of this.getKeyValuePairs()) { 54 | result[pair.key.evaluate(functions, values)] = pair.value.evaluate(functions, values); 55 | } 56 | } 57 | 58 | return result; 59 | }; 60 | 61 | toArray = () => { 62 | let value = {}; 63 | for (let pair of this.getKeyValuePairs()) { 64 | value[pair.key.attributes.value] = pair.value; 65 | } 66 | 67 | let array = []; 68 | 69 | if (this.isHash(value)) { 70 | for (let k of Object.keys(value)) { 71 | array.push(', '); 72 | array.push(new ConstantNode(k)); 73 | array.push(': '); 74 | array.push(value[k]); 75 | } 76 | array[0] = '{'; 77 | array.push('}'); 78 | } 79 | else { 80 | for (let v of Object.values(value)) { 81 | array.push(', '); 82 | array.push(v); 83 | } 84 | array[0] = '['; 85 | array.push(']'); 86 | } 87 | 88 | return array; 89 | }; 90 | 91 | getKeyValuePairs = () => { 92 | let pairs = []; 93 | let nodes = Object.values(this.nodes); 94 | let i,j,pair,chunk = 2; 95 | for (i=0,j=nodes.length; i { 104 | let first = true; 105 | for (let pair of this.getKeyValuePairs()) { 106 | if (!first) { 107 | compiler.raw(', '); 108 | } 109 | first = false; 110 | 111 | if (withKeys) { 112 | compiler.compile(pair.key) 113 | .raw(': '); 114 | } 115 | 116 | compiler.compile(pair.value); 117 | } 118 | }; 119 | } -------------------------------------------------------------------------------- /src/Node/__tests__/ConditionalNode.test.js: -------------------------------------------------------------------------------- 1 | import ConditionalNode from "../ConditionalNode"; 2 | import ConstantNode from "../ConstantNode"; 3 | import Compiler from "../../Compiler"; 4 | 5 | function getEvaluateData() 6 | { 7 | return [ 8 | [1, new ConditionalNode(new ConstantNode(true), new ConstantNode(1), new ConstantNode(2))], 9 | [2, new ConditionalNode(new ConstantNode(false), new ConstantNode(1), new ConstantNode(2))], 10 | // Shorthand: condition ? 'yes' => condition ? 'yes' : '' 11 | ['yes', new ConditionalNode(new ConstantNode(true), new ConstantNode('yes'), new ConstantNode(''))], 12 | ['', new ConditionalNode(new ConstantNode(false), new ConstantNode('yes'), new ConstantNode(''))], 13 | // Elvis-like: a ? b => a ? a : b 14 | ['left', new ConditionalNode(new ConstantNode('left'), new ConstantNode('left'), new ConstantNode('right'))], 15 | ['right', new ConditionalNode(new ConstantNode(false), new ConstantNode(false), new ConstantNode('right'))], 16 | ]; 17 | } 18 | 19 | function getCompileData() 20 | { 21 | return [ 22 | ['((true) ? (1) : (2))', new ConditionalNode(new ConstantNode(true), new ConstantNode(1), new ConstantNode(2))], 23 | ['((false) ? (1) : (2))', new ConditionalNode(new ConstantNode(false), new ConstantNode(1), new ConstantNode(2))], 24 | ['((true) ? ("yes") : (""))', new ConditionalNode(new ConstantNode(true), new ConstantNode('yes'), new ConstantNode(''))], 25 | ['((false) ? ("yes") : (""))', new ConditionalNode(new ConstantNode(false), new ConstantNode('yes'), new ConstantNode(''))], 26 | ['(("left") ? ("left") : ("right"))', new ConditionalNode(new ConstantNode('left'), new ConstantNode('left'), new ConstantNode('right'))], 27 | ['((false) ? (false) : ("right"))', new ConditionalNode(new ConstantNode(false), new ConstantNode(false), new ConstantNode('right'))], 28 | ]; 29 | } 30 | 31 | function getDumpData() 32 | { 33 | return [ 34 | ['(true ? 1 : 2)', new ConditionalNode(new ConstantNode(true), new ConstantNode(1), new ConstantNode(2))], 35 | ['(false ? 1 : 2)', new ConditionalNode(new ConstantNode(false), new ConstantNode(1), new ConstantNode(2))], 36 | ['(true ? "yes" : "")', new ConditionalNode(new ConstantNode(true), new ConstantNode('yes'), new ConstantNode(''))], 37 | ['(false ? "yes" : "")', new ConditionalNode(new ConstantNode(false), new ConstantNode('yes'), new ConstantNode(''))], 38 | ['("left" ? "left" : "right")', new ConditionalNode(new ConstantNode('left'), new ConstantNode('left'), new ConstantNode('right'))], 39 | ['(false ? false : "right")', new ConditionalNode(new ConstantNode(false), new ConstantNode(false), new ConstantNode('right'))], 40 | ]; 41 | } 42 | 43 | test('evaluate ConditionalNode', () => { 44 | for (let evaluateParams of getEvaluateData()) { 45 | //console.log("Evaluating: ", evaluateParams); 46 | let evaluated = evaluateParams[1].evaluate({}, {}); 47 | //console.log("Evaluated: ", evaluated); 48 | if (evaluateParams[0] !== null && typeof evaluateParams[0] === "object") { 49 | expect(evaluated).toMatchObject(evaluateParams[0]); 50 | } 51 | else { 52 | expect(evaluated).toBe(evaluateParams[0]); 53 | } 54 | } 55 | }); 56 | 57 | test('compile ConditionalNode', () => { 58 | for (let compileParams of getCompileData()) { 59 | let compiler = new Compiler({}); 60 | compileParams[1].compile(compiler); 61 | expect(compiler.getSource()).toBe(compileParams[0]); 62 | } 63 | }); 64 | 65 | test('dump ConditionalNode', () => { 66 | for (let dumpParams of getDumpData()) { 67 | expect(dumpParams[1].dump()).toBe(dumpParams[0]); 68 | } 69 | }); -------------------------------------------------------------------------------- /src/Node/__tests__/GetAttrNode.test.js: -------------------------------------------------------------------------------- 1 | import GetAttrNode from "../GetAttrNode"; 2 | import ArrayNode from "../ArrayNode"; 3 | import ConstantNode from "../ConstantNode"; 4 | import NameNode from "../NameNode"; 5 | import Compiler from "../../Compiler"; 6 | import ArgumentsNode from "../ArgumentsNode"; 7 | 8 | function getArrayNode() { 9 | let arr = new ArrayNode(); 10 | arr.addElement(new ConstantNode('a'), new ConstantNode('b')); 11 | arr.addElement(new ConstantNode('b')); 12 | 13 | return arr; 14 | } 15 | 16 | class Obj { 17 | foo = 'bar'; 18 | fooFn = () => { 19 | return 'baz'; 20 | } 21 | } 22 | 23 | function getEvaluateData() { 24 | return [ 25 | ['b', new GetAttrNode(new NameNode('foo'), new ConstantNode('0'), getArrayNode(), GetAttrNode.ARRAY_CALL), { 26 | foo: { 27 | b: 'a', 28 | '0': 'b' 29 | } 30 | }], 31 | ['a', new GetAttrNode(new NameNode('foo'), new ConstantNode('b'), getArrayNode(), GetAttrNode.ARRAY_CALL), { 32 | foo: { 33 | b: 'a', 34 | '0': 'b' 35 | } 36 | }], 37 | 38 | ['bar', new GetAttrNode(new NameNode('foo'), new ConstantNode('foo'), getArrayNode(), GetAttrNode.PROPERTY_CALL), {foo: new Obj()}], 39 | 40 | ['baz', new GetAttrNode(new NameNode('foo'), new ConstantNode('fooFn'), getArrayNode(), GetAttrNode.METHOD_CALL), {foo: new Obj()}], 41 | ['a', new GetAttrNode(new NameNode('foo'), new NameNode('index'), getArrayNode(), GetAttrNode.ARRAY_CALL), { 42 | foo: { 43 | b: 'a', 44 | '0': 'b' 45 | }, 46 | index: 'b' 47 | }], 48 | ]; 49 | } 50 | 51 | function getCompileData() { 52 | return [ 53 | ['foo[0]', new GetAttrNode(new NameNode('foo'), new ConstantNode(0), getArrayNode(), GetAttrNode.ARRAY_CALL)], 54 | ['foo["b"]', new GetAttrNode(new NameNode('foo'), new ConstantNode('b'), getArrayNode(), GetAttrNode.ARRAY_CALL)], 55 | 56 | ['foo.foo', new GetAttrNode(new NameNode('foo'), new ConstantNode('foo'), getArrayNode(), GetAttrNode.PROPERTY_CALL), {foo: new Obj()}], 57 | 58 | ['foo.fooFn({"b": "a", 0: "b"})', new GetAttrNode(new NameNode('foo'), new ConstantNode('fooFn'), getArrayNode(), GetAttrNode.METHOD_CALL), {foo: new Obj()} 59 | ], 60 | ['foo[index]', new GetAttrNode(new NameNode('foo'), new NameNode('index'), getArrayNode(), GetAttrNode.ARRAY_CALL)], 61 | ]; 62 | } 63 | 64 | function getDumpData() { 65 | return [ 66 | ['foo[0]', new GetAttrNode(new NameNode('foo'), new ConstantNode(0), getArrayNode(), GetAttrNode.ARRAY_CALL)], 67 | ['foo["b"]', new GetAttrNode(new NameNode('foo'), new ConstantNode('b'), getArrayNode(), GetAttrNode.ARRAY_CALL)], 68 | 69 | ['foo.foo', new GetAttrNode(new NameNode('foo'), new NameNode('foo'), getArrayNode(), GetAttrNode.PROPERTY_CALL), {foo: new Obj()}], 70 | 71 | ['foo.fooFn({"0": "b", "b": "a"})', new GetAttrNode(new NameNode('foo'), new NameNode('fooFn'), getArrayNode(), GetAttrNode.METHOD_CALL), {foo: new Obj()} 72 | ], 73 | ['foo[index]', new GetAttrNode(new NameNode('foo'), new NameNode('index'), getArrayNode(), GetAttrNode.ARRAY_CALL)], 74 | ['foo?.fooFn()', new GetAttrNode(new NameNode('foo'), new ConstantNode('fooFn', true, true), new ArgumentsNode(), GetAttrNode.METHOD_CALL)] 75 | ]; 76 | } 77 | 78 | test('evaluate GetAttrNode', () => { 79 | for (let evaluateParams of getEvaluateData()) { 80 | //console.log("Evaluating: ", evaluateParams); 81 | let evaluated = evaluateParams[1].evaluate(evaluateParams[3]||{}, evaluateParams[2]); 82 | //console.log("Evaluated: ", evaluated); 83 | if (evaluateParams[0] !== null && typeof evaluateParams[0] === "object") { 84 | expect(evaluated).toMatchObject(evaluateParams[0]); 85 | } 86 | else { 87 | expect(evaluated).toBe(evaluateParams[0]); 88 | } 89 | } 90 | }); 91 | 92 | test('compile GetAttrNode', () => { 93 | for (let compileParams of getCompileData()) { 94 | let compiler = new Compiler({}); 95 | compileParams[1].compile(compiler); 96 | expect(compiler.getSource()).toBe(compileParams[0]); 97 | } 98 | }); 99 | 100 | test('dump GetAttrNode', () => { 101 | for (let dumpParams of getDumpData()) { 102 | expect(dumpParams[1].dump()).toBe(dumpParams[0]); 103 | } 104 | }); -------------------------------------------------------------------------------- /src/TokenStream.js: -------------------------------------------------------------------------------- 1 | import SyntaxError from "./SyntaxError"; 2 | 3 | export class TokenStream { 4 | constructor(expression, tokens) { 5 | this.expression = expression; 6 | this.position = 0; 7 | this.tokens = tokens; 8 | } 9 | 10 | get current() { 11 | return this.tokens[this.position]; 12 | } 13 | 14 | get last() { 15 | return this.tokens[this.position - 1]; 16 | } 17 | 18 | toString() { 19 | return this.tokens.join("\n"); 20 | } 21 | 22 | next = () => { 23 | this.position += 1; 24 | 25 | if (this.tokens[this.position] === undefined) { 26 | throw new SyntaxError("Unexpected end of expression", this.last.cursor, this.expression); 27 | } 28 | }; 29 | 30 | expect = (type, value, message) => { 31 | let token = this.current; 32 | if (!token.test(type, value)) { 33 | let compiledMessage = ""; 34 | if (message) { 35 | compiledMessage = message + ". "; 36 | } 37 | let valueMessage = ""; 38 | if (value) { 39 | valueMessage = ` with value "${value}"`; 40 | } 41 | compiledMessage += `Unexpected token "${token.type}" of value "${token.value}" ("${type}" expected${valueMessage})`; 42 | 43 | throw new SyntaxError(compiledMessage, token.cursor, this.expression); 44 | } 45 | this.next(); 46 | }; 47 | 48 | isEOF = () => { 49 | return Token.EOF_TYPE === this.current.type; 50 | }; 51 | 52 | isEqualTo = (ts) => { 53 | if (ts === null || 54 | ts === undefined || 55 | !ts instanceof TokenStream) { 56 | return false; 57 | } 58 | 59 | if (ts.tokens.length !== this.tokens.length) { 60 | return false; 61 | } 62 | 63 | let tsStartPosition = ts.position; 64 | ts.position = 0; 65 | let allTokensMatch = true; 66 | for (let token of this.tokens) { 67 | let match = ts.current.isEqualTo(token); 68 | if (!match) { 69 | allTokensMatch = false; 70 | break; 71 | } 72 | if (ts.position < ts.tokens.length - 1) { 73 | ts.next(); 74 | } 75 | } 76 | ts.position = tsStartPosition; 77 | 78 | return allTokensMatch; 79 | }; 80 | 81 | diff = (ts) => { 82 | let diff = []; 83 | if (!this.isEqualTo(ts)) { 84 | let index = 0; 85 | let tsStartPosition = ts.position; 86 | ts.position = 0; 87 | for (let token of this.tokens) { 88 | let tokenDiff = token.diff(ts.current); 89 | if (tokenDiff.length > 0) { 90 | diff.push({index: index, diff: tokenDiff}); 91 | } 92 | if (ts.position < ts.tokens.length - 1) { 93 | ts.next(); 94 | } 95 | } 96 | ts.position = tsStartPosition; 97 | } 98 | return diff; 99 | }; 100 | } 101 | 102 | 103 | export class Token { 104 | static EOF_TYPE = 'end of expression'; 105 | static NAME_TYPE = 'name'; 106 | static NUMBER_TYPE = 'number'; 107 | static STRING_TYPE = 'string'; 108 | static OPERATOR_TYPE = 'operator'; 109 | static PUNCTUATION_TYPE = 'punctuation'; 110 | 111 | constructor(type, value, cursor) { 112 | this.value = value; 113 | this.type = type; 114 | this.cursor = cursor; 115 | } 116 | 117 | test = (type, value = null) => { 118 | return this.type === type && (null === value || this.value === value); 119 | }; 120 | 121 | toString() { 122 | return `${this.cursor} [${this.type}] ${this.value}`; 123 | } 124 | 125 | isEqualTo = (t) => { 126 | if (t === null || t === undefined || !t instanceof Token) { 127 | return false; 128 | } 129 | 130 | return t.value == this.value && t.type === this.type && t.cursor === this.cursor; 131 | }; 132 | 133 | diff = (t) => { 134 | let diff = []; 135 | if (!this.isEqualTo(t)) { 136 | if (t.value !== this.value) { 137 | diff.push(`Value: ${t.value} != ${this.value}`); 138 | } 139 | if (t.cursor !== this.cursor) { 140 | diff.push(`Cursor: ${t.cursor} != ${this.cursor}`); 141 | } 142 | if (t.type !== this.type) { 143 | diff.push(`Type: ${t.type} != ${this.type}`); 144 | } 145 | } 146 | return diff; 147 | }; 148 | } -------------------------------------------------------------------------------- /src/Node/GetAttrNode.js: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | import ConstantNode from "./ConstantNode"; 3 | 4 | export default class GetAttrNode extends Node { 5 | static PROPERTY_CALL = 1; 6 | static METHOD_CALL = 2; 7 | static ARRAY_CALL = 3; 8 | 9 | constructor(node, attribute, fnArguments, type) { 10 | super( 11 | {node: node, attribute: attribute, fnArguments: fnArguments}, 12 | {type: type, is_null_coalesce: false, is_short_circuited: false} 13 | ); 14 | this.name = 'GetAttrNode'; 15 | } 16 | 17 | compile = (compiler) => { 18 | const nullSafe = this.nodes.attribute instanceof ConstantNode && this.nodes.attribute.isNullSafe; 19 | switch(this.attributes.type) { 20 | case GetAttrNode.PROPERTY_CALL: 21 | compiler.compile(this.nodes.node) 22 | .raw(nullSafe ? '?.' : '.') 23 | .raw(this.nodes.attribute.attributes.value); 24 | break; 25 | case GetAttrNode.METHOD_CALL: 26 | compiler.compile(this.nodes.node) 27 | .raw(nullSafe ? '?.' : '.') 28 | .raw(this.nodes.attribute.attributes.value) 29 | .raw('(') 30 | .compile(this.nodes.fnArguments) 31 | .raw(')'); 32 | break; 33 | case GetAttrNode.ARRAY_CALL: 34 | compiler.compile(this.nodes.node) 35 | .raw('[') 36 | .compile(this.nodes.attribute) 37 | .raw(']'); 38 | break; 39 | } 40 | }; 41 | 42 | evaluate = (functions, values) => { 43 | switch(this.attributes.type) { 44 | case GetAttrNode.PROPERTY_CALL: 45 | let obj = this.nodes.node.evaluate(functions, values); 46 | if (null === obj && (this.nodes.attribute.isNullSafe || this.attributes.is_null_coalesce)) { 47 | this.attributes.is_short_circuited = true; 48 | return null; 49 | } 50 | if (null === obj && this.isShortCircuited()) { 51 | return null; 52 | } 53 | 54 | if (typeof obj !== "object") { 55 | throw new Error(`Unable to get property "${property}" on a non-object: ` + (typeof obj)); 56 | } 57 | 58 | let property = this.nodes.attribute.attributes.value; 59 | if (this.attributes.is_null_coalesce) { 60 | return obj[property] ?? null; 61 | } 62 | 63 | return obj[property]; 64 | case GetAttrNode.METHOD_CALL: 65 | let obj2 = this.nodes.node.evaluate(functions, values); 66 | 67 | if (null === obj2 && this.nodes.attribute.isNullSafe) { 68 | this.attributes.is_short_circuited = true; 69 | 70 | return null; 71 | } 72 | 73 | if (null === obj2 && this.isShortCircuited()) { 74 | return null; 75 | } 76 | 77 | let method = this.nodes.attribute.attributes.value; 78 | 79 | if (typeof obj2 !== 'object') { 80 | throw new Error(`Unable to call method "${method}" on a non-object: ` + (typeof obj2)); 81 | } 82 | if (obj2[method] === undefined) { 83 | throw new Error(`Method "${method}" is undefined on object.`); 84 | } 85 | if (typeof obj2[method] != 'function') { 86 | throw new Error(`Method "${method}" is not a function on object.`); 87 | } 88 | 89 | let evaluatedArgs = this.nodes.fnArguments.evaluate(functions, values); 90 | return obj2[method].apply(null, evaluatedArgs); 91 | case GetAttrNode.ARRAY_CALL: 92 | let array = this.nodes.node.evaluate(functions, values); 93 | if (null === array && this.isShortCircuited()) { 94 | return null; 95 | } 96 | 97 | if (!Array.isArray(array) && typeof array !== 'object' && !(null === array && this.attributes.is_null_coalesce)) { 98 | throw new Error(`Unable to get an item on a non-array: ` + typeof array); 99 | } 100 | 101 | if (this.attributes.is_null_coalesce) { 102 | if (!array) { 103 | return null; 104 | } 105 | return array[this.nodes.attribute.evaluate(functions, values)] ?? null; 106 | } 107 | 108 | return array[this.nodes.attribute.evaluate(functions, values)]; 109 | } 110 | }; 111 | 112 | isShortCircuited() { 113 | return this.attributes.is_short_circuited || (this.nodes.node instanceof GetAttrNode && this.nodes.node.isShortCircuited()); 114 | } 115 | 116 | toArray = () => { 117 | const nullSafe = this.nodes.attribute instanceof ConstantNode && this.nodes.attribute.isNullSafe; 118 | switch(this.attributes.type) { 119 | case GetAttrNode.PROPERTY_CALL: 120 | return [this.nodes.node, (nullSafe ? "?." : "."), this.nodes.attribute]; 121 | case GetAttrNode.METHOD_CALL: 122 | return [this.nodes.node, (nullSafe ? "?." : "."), this.nodes.attribute, '(', this.nodes.fnArguments, ')']; 123 | case GetAttrNode.ARRAY_CALL: 124 | return [this.nodes.node, '[', this.nodes.attribute, ']']; 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Node/BinaryNode.js: -------------------------------------------------------------------------------- 1 | import Node from "./Node"; 2 | import {range} from "../lib/range"; 3 | 4 | export default class BinaryNode extends Node { 5 | 6 | static regex_expression = /\/(.+)\/(.*)/; 7 | 8 | static operators = { 9 | '~': '.', 10 | 'and': '&&', 11 | 'or': '||', 12 | 'xor': 'xor', 13 | '<<': '<<', 14 | '>>': '>>' 15 | }; 16 | 17 | static functions = { 18 | '**': 'Math.pow', 19 | '..': 'range', 20 | 'in': 'includes', 21 | 'not in': '!includes' 22 | }; 23 | 24 | constructor(operator, left, right) { 25 | super({left: left, right: right}, {operator: operator}); 26 | this.name = "BinaryNode"; 27 | } 28 | 29 | compile = (compiler) => { 30 | let operator = this.attributes.operator; 31 | 32 | if ('matches' === operator) { 33 | compiler.compile(this.nodes.right) 34 | .raw(".test(") 35 | .compile(this.nodes.left) 36 | .raw(")"); 37 | return; 38 | } else if ('contains' === operator) { 39 | compiler.raw('(') 40 | .compile(this.nodes.left) 41 | .raw(".toString().toLowerCase().includes(") 42 | .compile(this.nodes.right) 43 | .raw(".toString().toLowerCase())"); 44 | 45 | return; 46 | } else if ('starts with' === operator) { 47 | compiler.raw('(') 48 | .compile(this.nodes.left) 49 | .raw(".toString().toLowerCase().startsWith(") 50 | .compile(this.nodes.right) 51 | .raw(".toString().toLowerCase())"); 52 | 53 | return; 54 | } else if ('ends with' === operator) { 55 | compiler.raw('(') 56 | .compile(this.nodes.left) 57 | .raw(".toString().toLowerCase().endsWith(") 58 | .compile(this.nodes.right) 59 | .raw(".toString().toLowerCase())"); 60 | 61 | return; 62 | } 63 | 64 | if (BinaryNode.functions[operator] !== undefined) { 65 | compiler.raw(`${BinaryNode.functions[operator]}(`) 66 | .compile(this.nodes.left) 67 | .raw(", ") 68 | .compile(this.nodes.right) 69 | .raw(")"); 70 | 71 | return 72 | } 73 | 74 | if (BinaryNode.operators[operator] !== undefined) { 75 | operator = BinaryNode.operators[operator]; 76 | } 77 | 78 | compiler.raw("(") 79 | .compile(this.nodes.left) 80 | .raw(' ') 81 | .raw(operator) 82 | .raw(' ') 83 | .compile(this.nodes.right) 84 | .raw(")"); 85 | }; 86 | 87 | evaluate = (functions, values) => { 88 | let operator = this.attributes.operator, 89 | left = this.nodes.left.evaluate(functions, values); 90 | 91 | //console.log("Evaluating: ", left, operator, right); 92 | 93 | if (BinaryNode.functions[operator] !== undefined) { 94 | let right = this.nodes.right.evaluate(functions, values); 95 | switch(operator) { 96 | case 'not in': 97 | return right.indexOf(left) === -1; 98 | case 'in': 99 | return right.indexOf(left) >= 0; 100 | case '..': 101 | return range(left, right); 102 | case '**': 103 | return Math.pow(left, right); 104 | } 105 | } 106 | 107 | let right = null; 108 | switch(operator) { 109 | case 'or': 110 | case '||': 111 | if (!left) { 112 | right = this.nodes.right.evaluate(functions, values); 113 | } 114 | return left || right; 115 | case 'and': 116 | case '&&': 117 | if (left) { 118 | right = this.nodes.right.evaluate(functions, values); 119 | } 120 | return left && right; 121 | case 'xor': 122 | right = this.nodes.right.evaluate(functions, values); 123 | return ((right && !left) || (left && !right)); 124 | case '<<': 125 | right = this.nodes.right.evaluate(functions, values); 126 | return left << right; 127 | case '>>': 128 | right = this.nodes.right.evaluate(functions, values); 129 | return left >> right; 130 | default: 131 | } 132 | 133 | right = this.nodes.right.evaluate(functions, values); 134 | 135 | switch(operator) { 136 | case '|': 137 | return left | right; 138 | case '^': 139 | return left ^ right; 140 | case '&': 141 | return left & right; 142 | case '==': 143 | return left == right; 144 | case '===': 145 | return left === right; 146 | case '!=': 147 | return left != right; 148 | case '!==': 149 | return left !== right; 150 | case '<': 151 | return left < right; 152 | case '>': 153 | return left > right; 154 | case '>=': 155 | return left >= right; 156 | case '<=': 157 | return left <= right; 158 | case 'not in': 159 | return right.indexOf(left) === -1; 160 | case 'in': 161 | return right.indexOf(left) >= 0; 162 | case '+': 163 | return left + right; 164 | case '-': 165 | return left - right; 166 | case '~': 167 | return left.toString() + right.toString(); 168 | case '*': 169 | return left * right; 170 | case '/': 171 | return left / right; 172 | case '%': 173 | return left % right; 174 | case 'matches': 175 | if (left === null || left === undefined) { 176 | return false; 177 | } 178 | let res = right.match(BinaryNode.regex_expression); 179 | let regexp = new RegExp(res[1], res[2]); 180 | return regexp.test(left); 181 | case 'contains': 182 | return left.toString().toLowerCase().includes(right.toString().toLowerCase()); 183 | case 'starts with': 184 | return left.toString().toLowerCase().startsWith(right.toString().toLowerCase()); 185 | case 'ends with': 186 | return left.toString().toLowerCase().endsWith(right.toString().toLowerCase()); 187 | } 188 | }; 189 | 190 | toArray = () => { 191 | return ["(", this.nodes.left, ' ' + this.attributes.operator + ' ', this.nodes.right, ")"]; 192 | } 193 | 194 | } -------------------------------------------------------------------------------- /src/__tests__/Lexer.test.js: -------------------------------------------------------------------------------- 1 | import {tokenize} from "../Lexer"; 2 | import {Token, TokenStream} from "../TokenStream"; 3 | 4 | function getTokenizeData() { 5 | return [ 6 | [ 7 | [new Token(Token.NAME_TYPE, 'a', 3)], 8 | ' a ', 9 | ], 10 | [ 11 | [new Token(Token.NAME_TYPE, 'a', 1)], 12 | 'a', 13 | ], 14 | [ 15 | [new Token(Token.STRING_TYPE, 'foo', 1)], 16 | '"foo"', 17 | ], 18 | [ 19 | [new Token(Token.NUMBER_TYPE, '3', 1)], 20 | '3', 21 | ], 22 | [ 23 | [new Token(Token.OPERATOR_TYPE, '+', 1)], 24 | '+', 25 | ], 26 | [ 27 | [new Token(Token.PUNCTUATION_TYPE, '.', 1)], 28 | '.', 29 | ], 30 | [ 31 | [ 32 | new Token(Token.PUNCTUATION_TYPE, '(', 1), 33 | new Token(Token.NUMBER_TYPE, '3', 2), 34 | new Token(Token.OPERATOR_TYPE, '+', 4), 35 | new Token(Token.NUMBER_TYPE, '5', 6), 36 | new Token(Token.PUNCTUATION_TYPE, ')', 7), 37 | new Token(Token.OPERATOR_TYPE, '~', 9), 38 | new Token(Token.NAME_TYPE, 'foo', 11), 39 | new Token(Token.PUNCTUATION_TYPE, '(', 14), 40 | new Token(Token.STRING_TYPE, 'bar', 15), 41 | new Token(Token.PUNCTUATION_TYPE, ')', 20), 42 | new Token(Token.PUNCTUATION_TYPE, '.', 21), 43 | new Token(Token.NAME_TYPE, 'baz', 22), 44 | new Token(Token.PUNCTUATION_TYPE, '[', 25), 45 | new Token(Token.NUMBER_TYPE, '4', 26), 46 | new Token(Token.PUNCTUATION_TYPE, ']', 27), 47 | new Token(Token.OPERATOR_TYPE, '-', 29), 48 | new Token(Token.NUMBER_TYPE, 1990, 31), 49 | new Token(Token.OPERATOR_TYPE, '+', 39), 50 | new Token(Token.OPERATOR_TYPE, '~', 41), 51 | new Token(Token.NAME_TYPE, 'qux', 42), 52 | ], 53 | '(3 + 5) ~ foo("bar").baz[4] - 1.99E+3 + ~qux', 54 | ], 55 | [ 56 | [ 57 | new Token(Token.NUMBER_TYPE, 0.01, 1) 58 | ], 59 | '1e-2' 60 | ], 61 | [ 62 | [ 63 | new Token(Token.NUMBER_TYPE, 1000000, 1) 64 | ], 65 | '1_000_000' 66 | ], 67 | [ 68 | [new Token(Token.OPERATOR_TYPE, '..', 1)], 69 | '..', 70 | ], 71 | [ 72 | [ 73 | new Token(Token.NUMBER_TYPE, 23, 1), 74 | new Token(Token.OPERATOR_TYPE, '..', 3), 75 | new Token(Token.NUMBER_TYPE, 26, 5), 76 | ], 77 | '23..26', 78 | ], 79 | [ 80 | [new Token(Token.OPERATOR_TYPE, '!', 1)], 81 | '!', 82 | ], 83 | [ 84 | [new Token(Token.STRING_TYPE, '#foo', 1)], 85 | "'#foo'", 86 | ], 87 | [ 88 | [new Token(Token.STRING_TYPE, '#foo', 1)], 89 | '"#foo"', 90 | ], 91 | [ 92 | [new Token(Token.STRING_TYPE, 'foo["bar"]', 1)], 93 | "'foo[\"bar\"]'" 94 | ], 95 | [ 96 | [ 97 | new Token(Token.NAME_TYPE, 'foo', 1), 98 | new Token(Token.PUNCTUATION_TYPE, '.', 4), 99 | new Token(Token.NAME_TYPE, 'not', 5), 100 | new Token(Token.OPERATOR_TYPE, 'in', 9), 101 | new Token(Token.PUNCTUATION_TYPE, '[', 12), 102 | new Token(Token.NAME_TYPE, 'bar', 13), 103 | new Token(Token.PUNCTUATION_TYPE, ']', 16), 104 | ], 105 | 'foo.not in [bar]', 106 | ], 107 | [ 108 | [new Token(Token.NUMBER_TYPE, 0.787, 1)], 109 | '0.787', 110 | ], 111 | [ 112 | [new Token(Token.NUMBER_TYPE, 0.1234, 1)], 113 | '.1234', 114 | ], 115 | [ 116 | [new Token(Token.NUMBER_TYPE, 188165.1178, 1)], 117 | '188_165.1_178', 118 | ], 119 | [ 120 | [ 121 | new Token(Token.OPERATOR_TYPE, '-', 1), 122 | new Token(Token.NUMBER_TYPE, 7189000000.0, 2), 123 | ], 124 | '-.7_189e+10', 125 | ], 126 | [ 127 | [ 128 | new Token(Token.NUMBER_TYPE, 65536, 1), 129 | ], 130 | '65536 /* this is 2^16 */', 131 | ], 132 | [ 133 | [ 134 | new Token(Token.NUMBER_TYPE, 2, 1), 135 | new Token(Token.OPERATOR_TYPE, '*', 21), 136 | new Token(Token.NUMBER_TYPE, 4, 23), 137 | ], 138 | '2 /* /* comment1 */ * 4', 139 | ], 140 | [ 141 | [ 142 | new Token(Token.STRING_TYPE, '/* this is', 1), 143 | new Token(Token.OPERATOR_TYPE, '~', 14), 144 | new Token(Token.STRING_TYPE, 'not a comment */', 16), 145 | ], 146 | '"/* this is" ~ "not a comment */"', 147 | ], 148 | [ 149 | [ 150 | new Token(Token.STRING_TYPE, '/* this is not a comment */', 1), 151 | ], 152 | '"/* this is not a comment */"', 153 | ], 154 | [ 155 | [ 156 | new Token(Token.NAME_TYPE, 'foo', 1), 157 | new Token(Token.OPERATOR_TYPE, 'xor', 5), 158 | new Token(Token.NAME_TYPE, 'bar', 9), 159 | ], 160 | 'foo xor bar', 161 | ], 162 | [ 163 | [ 164 | new Token(Token.PUNCTUATION_TYPE, '\\', 1) 165 | ], 166 | '\\\\' 167 | ] 168 | ]; 169 | } 170 | 171 | test('tokenize throws error with message', () => { 172 | let expression = "service(faulty.expression.example').dummyMethod()"; 173 | try { 174 | tokenize(expression); 175 | expect(true).toBe(false).message("An error should have been thrown."); 176 | } 177 | catch(err) { 178 | expect(err.toString()).toContain('Unexpected character "\'"') 179 | } 180 | }); 181 | 182 | test('tokenize throws error on unclosed brace', () => { 183 | let expression = "service(unclosed.expression.dummyMethod()"; 184 | try { 185 | tokenize(expression); 186 | expect(true).toBe(false).message("An error should have been thrown."); 187 | } 188 | catch(err) { 189 | expect(err.toString()).toContain('Unclosed "("'); 190 | } 191 | }); 192 | 193 | test('tokenize', () => { 194 | let data = getTokenizeData(); 195 | for (let tokenizeData of data) { 196 | let tokens = tokenizeData[0], 197 | expression = tokenizeData[1]; 198 | tokens.push(new Token(Token.EOF_TYPE, null, expression.length + 1)); 199 | 200 | //console.log("Testing: ", expression); 201 | 202 | let generatedStream = tokenize(expression), 203 | expectedStream = new TokenStream(expression, tokens); 204 | 205 | //console.log("Diff: " + JSON.stringify(generatedStream.diff(expectedStream))); 206 | 207 | expect(generatedStream.isEqualTo(expectedStream)) 208 | .toBe(true); 209 | } 210 | }); 211 | -------------------------------------------------------------------------------- /src/Cache/ArrayAdapter.js: -------------------------------------------------------------------------------- 1 | export default class ArrayAdapter { 2 | 3 | constructor(defaultLifetime = 0) { 4 | this.defaultLifetime = defaultLifetime; 5 | this.values = {}; 6 | this.expiries = {}; 7 | } 8 | 9 | createCacheItem = (key, value, isHit) => { 10 | let item = new CacheItem(); 11 | item.key = key; 12 | item.value = value; 13 | item.isHit = isHit; 14 | item.defaultLifetime = this.defaultLifetime; 15 | 16 | return item; 17 | }; 18 | 19 | get = (key, callback, beta = null, metadata = null) => { 20 | let item = this.getItem(key); 21 | if (!item.isHit) { 22 | let save = true; 23 | this.save(item.set(callback(item, save))); 24 | } 25 | return item.get(); 26 | }; 27 | 28 | getItem = (key) => { 29 | let isHit = this.hasItem(key), 30 | value = null; 31 | if (!isHit) { 32 | this.values[key] = null; 33 | } 34 | else { 35 | value = this.values[key]; 36 | } 37 | let f = this.createCacheItem; 38 | 39 | return f(key, value, isHit); 40 | }; 41 | 42 | getItems = (keys) => { 43 | for (let key of keys) { 44 | if (typeof key !== "string" && !this.expiries[key]) { 45 | CacheItem.validateKey(key); 46 | } 47 | } 48 | 49 | return this.generateItems(keys, ((new Date).getTime() / 1000), this.createCacheItem); 50 | }; 51 | 52 | deleteItems = (keys) => { 53 | for (let key of keys) { 54 | this.deleteItem(key); 55 | } 56 | 57 | return true; 58 | }; 59 | 60 | save = (item) => { 61 | if (!item instanceof CacheItem) { 62 | return false; 63 | } 64 | 65 | if (item.expiry !== null && item.expiry <= ((new Date).getTime() / 1000)) { 66 | this.deleteItem(item.key); 67 | 68 | return true; 69 | } 70 | if (null === item.expiry && 0 < item.defaultLifetime) { 71 | item.expiry = ((new Date()).getTime() / 1000) + item.defaultLifetime; 72 | } 73 | this.values[item.key] = item.value; 74 | this.expiries[item.key] = item.expiry || Number.MAX_SAFE_INTEGER; 75 | 76 | return true; 77 | }; 78 | 79 | saveDeferred = (item) => { 80 | return this.save(item); 81 | }; 82 | 83 | commit = () => { 84 | return true; 85 | }; 86 | 87 | delete = (key) => { 88 | return this.deleteItem(key); 89 | }; 90 | 91 | getValues = () => { 92 | return this.values; 93 | }; 94 | 95 | hasItem = (key) => { 96 | if (typeof key === "string" && this.expiries[key] && this.expiries[key] > ((new Date).getTime() / 1000)) { 97 | return true; 98 | } 99 | CacheItem.validateKey(key); 100 | 101 | return !!this.expiries[key] && !this.deleteItem(key); 102 | }; 103 | 104 | clear = () => { 105 | this.values = {}; 106 | this.expiries = {}; 107 | return true; 108 | }; 109 | 110 | deleteItem = (key) => { 111 | if (typeof key !== "string" || !this.expiries[key]) { 112 | CacheItem.validateKey(key); 113 | } 114 | delete this.values[key]; 115 | delete this.expiries[key]; 116 | 117 | return true; 118 | }; 119 | 120 | reset = () => { 121 | this.clear(); 122 | }; 123 | 124 | generateItems = (keys, now, f) => { 125 | let generated = []; 126 | for (let key of keys) { 127 | let value = null; 128 | let isHit = !!this.expiries[key]; 129 | if (!isHit && (this.expiries[key] > now || !this.deleteItem(key))) { 130 | this.values[key] = null; 131 | } 132 | else { 133 | value = this.values[key]; 134 | } 135 | 136 | generated[key] = f(key, value, isHit); 137 | } 138 | 139 | return generated; 140 | }; 141 | } 142 | 143 | export class CacheItem { 144 | static METADATA_EXPIRY_OFFSET = 1527506807; 145 | static RESERVED_CHARACTERS = ["{", "}", "(", ")", "/", "\\", "@", ":"]; 146 | 147 | constructor() { 148 | this.key = null; 149 | this.value = null; 150 | this.isHit = false; 151 | this.expiry = null; 152 | this.defaultLifetime = null; 153 | this.metadata = {}; 154 | this.newMetadata = {}; 155 | this.innerItem = null; 156 | this.poolHash = null; 157 | this.isTaggable = false; 158 | } 159 | 160 | getKey = () => { 161 | return this.key; 162 | }; 163 | 164 | get = () => { 165 | return this.value; 166 | }; 167 | 168 | set = (value) => { 169 | this.value = value; 170 | return this; 171 | }; 172 | 173 | expiresAt = (expiration) => { 174 | if (null === expiration) { 175 | this.expiry = this.defaultLifetime > 0 ? ((Date.now() / 1000) + this.defaultLifetime) : null; 176 | } 177 | else if (expiration instanceof Date) { 178 | this.expiry = (expiration.getTime() / 1000); 179 | } 180 | else { 181 | throw new Error(`Expiration date must be instance of Date or be null, "${(expiration.name)}" given`) 182 | } 183 | 184 | return this; 185 | }; 186 | 187 | expiresAfter = (time) => { 188 | if (null === time) { 189 | this.expiry = this.defaultLifetime > 0 ? ((Date.now() / 1000) + this.defaultLifetime) : null; 190 | } 191 | else if (Number.isInteger(time)) { 192 | this.expiry = ((new Date).getTime() / 1000) + time; 193 | } 194 | else { 195 | throw new Error(`Expiration date must be an integer or be null, "${(time.name)}" given`) 196 | } 197 | 198 | return this; 199 | }; 200 | 201 | tag = (tags) => { 202 | if (!this.isTaggable) { 203 | throw new Error(`Cache item "${this.key}" comes from a non tag-aware pool: you cannot tag it.`); 204 | } 205 | if (!Array.isArray(tags)) { 206 | tags = [tags]; 207 | } 208 | 209 | for (let tag of tags) { 210 | if (typeof tag !== "string") { 211 | throw new Error(`Cache tag must by a string, "${(typeof tag)}" given.`); 212 | } 213 | if (this.newMetadata.tags[tag]) { 214 | if (tag === '') { 215 | throw new Error("Cache tag length must be greater than zero"); 216 | } 217 | } 218 | this.newMetadata.tags[tag] = tag; 219 | } 220 | 221 | return this; 222 | }; 223 | 224 | getMetadata = () => { 225 | return this.metadata; 226 | }; 227 | 228 | static validateKey = (key) => { 229 | if (typeof key !== "string") { 230 | throw new Error(`Cache key must be string, "${(typeof key)}" given.`); 231 | } 232 | if ('' === key) { 233 | throw new Error("Cache key length must be greater than zero"); 234 | } 235 | for (let reserved of CacheItem.RESERVED_CHARACTERS) { 236 | if (key.indexOf(reserved) >= 0) { 237 | throw new Error(`Cache key "${key}" contains reserved character "${reserved}".`); 238 | } 239 | } 240 | 241 | return key; 242 | }; 243 | } -------------------------------------------------------------------------------- /src/lib/addcslashes.js: -------------------------------------------------------------------------------- 1 | export function addcslashes (str, charlist) { 2 | // discuss at: https://locutus.io/php/addcslashes/ 3 | // original by: Brett Zamir (https://brett-zamir.me) 4 | // note 1: We show double backslashes in the return value example 5 | // note 1: code below because a JavaScript string will not 6 | // note 1: render them as backslashes otherwise 7 | // example 1: addcslashes('foo[ ]', 'A..z'); // Escape all ASCII within capital A to lower z range, including square brackets 8 | // returns 1: "\\f\\o\\o\\[ \\]" 9 | // example 2: addcslashes("zoo['.']", 'z..A'); // Only escape z, period, and A here since not a lower-to-higher range 10 | // returns 2: "\\zoo['\\.']" 11 | // _example 3: addcslashes("@a\u0000\u0010\u00A9", "\0..\37!@\177..\377"); // Escape as octals those specified and less than 32 (0x20) or greater than 126 (0x7E), but not otherwise 12 | // _returns 3: '\\@a\\000\\020\\302\\251' 13 | // _example 4: addcslashes("\u0020\u007E", "\40..\175"); // Those between 32 (0x20 or 040) and 126 (0x7E or 0176) decimal value will be backslashed if specified (not octalized) 14 | // _returns 4: '\\ ~' 15 | // _example 5: addcslashes("\r\u0007\n", '\0..\37'); // Recognize C escape sequences if specified 16 | // _returns 5: "\\r\\a\\n" 17 | // _example 6: addcslashes("\r\u0007\n", '\0'); // Do not recognize C escape sequences if not specified 18 | // _returns 6: "\r\u0007\n" 19 | 20 | var target = '' 21 | var chrs = [] 22 | var i = 0 23 | var j = 0 24 | var c = '' 25 | var next = '' 26 | var rangeBegin = '' 27 | var rangeEnd = '' 28 | var chr = '' 29 | var begin = 0 30 | var end = 0 31 | var octalLength = 0 32 | var postOctalPos = 0 33 | var cca = 0 34 | var escHexGrp = [] 35 | var encoded = '' 36 | var percentHex = /%([\dA-Fa-f]+)/g 37 | 38 | var _pad = function (n, c) { 39 | if ((n = n + '').length < c) { 40 | return new Array(++c - n.length).join('0') + n 41 | } 42 | return n 43 | } 44 | 45 | for (i = 0; i < charlist.length; i++) { 46 | c = charlist.charAt(i) 47 | next = charlist.charAt(i + 1) 48 | if (c === '\\' && next && (/\d/).test(next)) { 49 | // Octal 50 | rangeBegin = charlist.slice(i + 1).match(/^\d+/)[0] 51 | octalLength = rangeBegin.length 52 | postOctalPos = i + octalLength + 1 53 | if (charlist.charAt(postOctalPos) + charlist.charAt(postOctalPos + 1) === '..') { 54 | // Octal begins range 55 | begin = rangeBegin.charCodeAt(0) 56 | if ((/\\\d/).test(charlist.charAt(postOctalPos + 2) + charlist.charAt(postOctalPos + 3))) { 57 | // Range ends with octal 58 | rangeEnd = charlist.slice(postOctalPos + 3).match(/^\d+/)[0] 59 | // Skip range end backslash 60 | i += 1 61 | } else if (charlist.charAt(postOctalPos + 2)) { 62 | // Range ends with character 63 | rangeEnd = charlist.charAt(postOctalPos + 2) 64 | } else { 65 | throw new Error('Range with no end point') 66 | } 67 | end = rangeEnd.charCodeAt(0) 68 | if (end > begin) { 69 | // Treat as a range 70 | for (j = begin; j <= end; j++) { 71 | chrs.push(String.fromCharCode(j)) 72 | } 73 | } else { 74 | // Supposed to treat period, begin and end as individual characters only, not a range 75 | chrs.push('.', rangeBegin, rangeEnd) 76 | } 77 | // Skip dots and range end (already skipped range end backslash if present) 78 | i += rangeEnd.length + 2 79 | } else { 80 | // Octal is by itself 81 | chr = String.fromCharCode(parseInt(rangeBegin, 8)) 82 | chrs.push(chr) 83 | } 84 | // Skip range begin 85 | i += octalLength 86 | } else if (next + charlist.charAt(i + 2) === '..') { 87 | // Character begins range 88 | rangeBegin = c 89 | begin = rangeBegin.charCodeAt(0) 90 | if ((/\\\d/).test(charlist.charAt(i + 3) + charlist.charAt(i + 4))) { 91 | // Range ends with octal 92 | rangeEnd = charlist.slice(i + 4).match(/^\d+/)[0] 93 | // Skip range end backslash 94 | i += 1 95 | } else if (charlist.charAt(i + 3)) { 96 | // Range ends with character 97 | rangeEnd = charlist.charAt(i + 3) 98 | } else { 99 | throw new Error('Range with no end point') 100 | } 101 | end = rangeEnd.charCodeAt(0) 102 | if (end > begin) { 103 | // Treat as a range 104 | for (j = begin; j <= end; j++) { 105 | chrs.push(String.fromCharCode(j)) 106 | } 107 | } else { 108 | // Supposed to treat period, begin and end as individual characters only, not a range 109 | chrs.push('.', rangeBegin, rangeEnd) 110 | } 111 | // Skip dots and range end (already skipped range end backslash if present) 112 | i += rangeEnd.length + 2 113 | } else { 114 | // Character is by itself 115 | chrs.push(c) 116 | } 117 | } 118 | 119 | for (i = 0; i < str.length; i++) { 120 | c = str.charAt(i) 121 | if (chrs.indexOf(c) !== -1) { 122 | target += '\\' 123 | cca = c.charCodeAt(0) 124 | if (cca < 32 || cca > 126) { 125 | // Needs special escaping 126 | switch (c) { 127 | case '\n': 128 | target += 'n' 129 | break 130 | case '\t': 131 | target += 't' 132 | break 133 | case '\u000D': 134 | target += 'r' 135 | break 136 | case '\u0007': 137 | target += 'a' 138 | break 139 | case '\v': 140 | target += 'v' 141 | break 142 | case '\b': 143 | target += 'b' 144 | break 145 | case '\f': 146 | target += 'f' 147 | break 148 | default: 149 | // target += _pad(cca.toString(8), 3);break; // Sufficient for UTF-16 150 | encoded = encodeURIComponent(c) 151 | 152 | // 3-length-padded UTF-8 octets 153 | if ((escHexGrp = percentHex.exec(encoded)) !== null) { 154 | // already added a slash above: 155 | target += _pad(parseInt(escHexGrp[1], 16).toString(8), 3) 156 | } 157 | while ((escHexGrp = percentHex.exec(encoded)) !== null) { 158 | target += '\\' + _pad(parseInt(escHexGrp[1], 16).toString(8), 3) 159 | } 160 | break 161 | } 162 | } else { 163 | // Perform regular backslashed escaping 164 | target += c 165 | } 166 | } else { 167 | // Just add the character unescaped 168 | target += c 169 | } 170 | } 171 | 172 | return target 173 | } -------------------------------------------------------------------------------- /src/ParsedExpression.js: -------------------------------------------------------------------------------- 1 | import Expression from "./Expression"; 2 | import Node from "./Node/Node"; 3 | import ConstantNode from "./Node/ConstantNode"; 4 | import NameNode from "./Node/NameNode"; 5 | import FunctionNode from "./Node/FunctionNode"; 6 | import UnaryNode from "./Node/UnaryNode"; 7 | import BinaryNode from "./Node/BinaryNode"; 8 | import GetAttrNode from "./Node/GetAttrNode"; 9 | import ArrayNode from "./Node/ArrayNode"; 10 | import ArgumentsNode from "./Node/ArgumentsNode"; 11 | import ConditionalNode from "./Node/ConditionalNode"; 12 | import NullCoalesceNode from "./Node/NullCoalesceNode"; 13 | import NullCoalescedNameNode from "./Node/NullCoalescedNameNode"; 14 | 15 | export default class ParsedExpression extends Expression { 16 | constructor(expression, nodes) { 17 | super(expression); 18 | this.nodes = nodes; 19 | } 20 | 21 | getNodes = () => { 22 | return this.nodes; 23 | } 24 | 25 | static fromJSON(json) { 26 | const obj = typeof json === 'string' ? JSON.parse(json) : json; 27 | 28 | const buildNode = (n) => { 29 | if (n === null || n === undefined) { 30 | return n; 31 | } 32 | // If it's already an instance (unlikely when parsing from plain JSON), return as-is 33 | if (n instanceof Node) { 34 | return n; 35 | } 36 | // If it doesn't look like a Node, return as-is 37 | if (typeof n !== 'object' || !n.name) { 38 | return n; 39 | } 40 | 41 | switch (n.name) { 42 | case 'ConstantNode': { 43 | return new ConstantNode(n.attributes?.value, !!n.isIdentifier, !!n.isNullSafe); 44 | } 45 | case 'NameNode': { 46 | return new NameNode(n.attributes?.name); 47 | } 48 | case 'NullCoalescedNameNode': { 49 | return new NullCoalescedNameNode(n.attributes?.name); 50 | } 51 | case 'UnaryNode': { 52 | return new UnaryNode(n.attributes?.operator, buildNode(n.nodes?.node)); 53 | } 54 | case 'BinaryNode': { 55 | return new BinaryNode(n.attributes?.operator, buildNode(n.nodes?.left), buildNode(n.nodes?.right)); 56 | } 57 | case 'ConditionalNode': { 58 | return new ConditionalNode(buildNode(n.nodes?.expr1), buildNode(n.nodes?.expr2), buildNode(n.nodes?.expr3)); 59 | } 60 | case 'NullCoalesceNode': { 61 | return new NullCoalesceNode(buildNode(n.nodes?.expr1), buildNode(n.nodes?.expr2)); 62 | } 63 | case 'ArgumentsNode': { 64 | const argsNode = new ArgumentsNode(); 65 | // Preserve internal state if present 66 | if (typeof n.type === 'string') argsNode.type = n.type; 67 | if (typeof n.index === 'number') argsNode.index = n.index; 68 | if (typeof n.keyIndex === 'number') argsNode.keyIndex = n.keyIndex; 69 | argsNode.nodes = {}; 70 | for (const key of Object.keys(n.nodes || {})) { 71 | argsNode.nodes[key] = buildNode(n.nodes[key]); 72 | } 73 | return argsNode; 74 | } 75 | case 'ArrayNode': { 76 | const arrNode = new ArrayNode(); 77 | if (typeof n.type === 'string') arrNode.type = n.type; 78 | if (typeof n.index === 'number') arrNode.index = n.index; 79 | if (typeof n.keyIndex === 'number') arrNode.keyIndex = n.keyIndex; 80 | arrNode.nodes = {}; 81 | for (const key of Object.keys(n.nodes || {})) { 82 | arrNode.nodes[key] = buildNode(n.nodes[key]); 83 | } 84 | return arrNode; 85 | } 86 | case 'FunctionNode': { 87 | const args = buildNode(n.nodes?.arguments); 88 | return new FunctionNode(n.attributes?.name, args); 89 | } 90 | case 'GetAttrNode': { 91 | const node = new GetAttrNode( 92 | buildNode(n.nodes?.node), 93 | buildNode(n.nodes?.attribute), 94 | buildNode(n.nodes?.fnArguments), 95 | n.attributes?.type 96 | ); 97 | // restore flags if present 98 | if (n.attributes && typeof n.attributes.is_null_coalesce === 'boolean') { 99 | node.attributes.is_null_coalesce = n.attributes.is_null_coalesce; 100 | } 101 | if (n.attributes && typeof n.attributes.is_short_circuited === 'boolean') { 102 | node.attributes.is_short_circuited = n.attributes.is_short_circuited; 103 | } 104 | return node; 105 | } 106 | case 'Node': { 107 | // Generic container Node used by Parser for argument lists 108 | const generic = new Node(); 109 | if (Array.isArray(n.nodes)) { 110 | // Convert array to object with numeric keys to match original 111 | generic.nodes = n.nodes.map(buildNode); 112 | } else { 113 | generic.nodes = {}; 114 | for (const key of Object.keys(n.nodes || {})) { 115 | generic.nodes[key] = buildNode(n.nodes[key]); 116 | } 117 | } 118 | // Restore attributes if any 119 | generic.attributes = n.attributes || {}; 120 | return generic; 121 | } 122 | default: { 123 | // Fallback: try to reconstruct as a generic Node 124 | const generic = new Node(); 125 | generic.name = n.name; 126 | // children 127 | if (Array.isArray(n.nodes)) { 128 | generic.nodes = n.nodes.map(buildNode); 129 | } else { 130 | generic.nodes = {}; 131 | for (const key of Object.keys(n.nodes || {})) { 132 | generic.nodes[key] = buildNode(n.nodes[key]); 133 | } 134 | } 135 | generic.attributes = n.attributes || {}; 136 | return generic; 137 | } 138 | } 139 | }; 140 | 141 | const buildNodesContainer = (nodesData) => { 142 | if (nodesData === null || nodesData === undefined) { 143 | return nodesData; 144 | } 145 | // Single node object 146 | if (nodesData.name) { 147 | return buildNode(nodesData); 148 | } 149 | // Array of nodes 150 | if (Array.isArray(nodesData)) { 151 | return nodesData.map(buildNode); 152 | } 153 | // Object map of nodes 154 | if (typeof nodesData === 'object') { 155 | const out = {}; 156 | for (const key of Object.keys(nodesData)) { 157 | out[key] = buildNode(nodesData[key]); 158 | } 159 | return out; 160 | } 161 | return nodesData; 162 | }; 163 | 164 | const expression = obj.expression; 165 | const nodes = buildNodesContainer(obj.nodes); 166 | return new ParsedExpression(expression, nodes); 167 | } 168 | } -------------------------------------------------------------------------------- /src/ExpressionLanguage.js: -------------------------------------------------------------------------------- 1 | import {tokenize} from "./Lexer"; 2 | import Parser, {IGNORE_UNKNOWN_VARIABLES} from "./Parser"; 3 | import Compiler from "./Compiler"; 4 | import ParsedExpression from "./ParsedExpression"; 5 | import ArrayAdapter from "./Cache/ArrayAdapter"; 6 | import LogicException from "./LogicException"; 7 | import ExpressionFunction from "./ExpressionFunction"; 8 | 9 | export default class ExpressionLanguage { 10 | constructor(cache = null, providers = []) { 11 | this.functions = []; 12 | this.lexer = null; 13 | this.parser = null; 14 | this.compiler = null; 15 | 16 | this.cache = cache || new ArrayAdapter(); 17 | 18 | this._registerBuiltinFunctions(); 19 | 20 | for (let provider of providers) { 21 | this.registerProvider(provider); 22 | } 23 | } 24 | 25 | /** 26 | * Compiles an expression source code. 27 | * 28 | * @param {Expression|string} expression The expression to compile 29 | * @param {Array} names An array of valid names 30 | * 31 | * @returns {string} The compiled javascript source code 32 | */ 33 | compile = (expression, names = []) => { 34 | return this.getCompiler().compile(this.parse(expression, names).getNodes()).getSource(); 35 | }; 36 | 37 | /** 38 | * Evaluate an expression 39 | * 40 | * @param {Expression|string} expression The expression to compile 41 | * @param {Object} values An array of values 42 | * 43 | * @returns {*} The result of the evaluation of the expression 44 | */ 45 | evaluate = (expression, values = {}) => { 46 | return this.parse(expression, Object.keys(values)).getNodes().evaluate(this.functions, values); 47 | }; 48 | 49 | /** 50 | * Parses an expression 51 | * 52 | * @param {Expression|string} expression The expression to parse 53 | * @param {Array} names An array of valid names 54 | * @param {int} flags 55 | * @returns {ParsedExpression} A ParsedExpression instance 56 | */ 57 | parse = (expression, names, flags=0) => { 58 | if (expression instanceof ParsedExpression) { 59 | return expression; 60 | } 61 | 62 | names.sort((a, b) => { 63 | let a_value = a, 64 | b_value = b; 65 | if (typeof a === "object") { 66 | a_value = Object.values(a)[0]; 67 | } 68 | if (typeof b === "object") { 69 | b_value = Object.values(b)[0]; 70 | } 71 | 72 | return a_value.localeCompare(b_value); 73 | }); 74 | 75 | let cacheKeyItems = []; 76 | for (let name of names) { 77 | let value = name; 78 | if (typeof name === "object") { 79 | let tmpName = Object.keys(name)[0], 80 | tmpValue = Object.values(name)[0]; 81 | 82 | value = tmpName + ":" + tmpValue; 83 | } 84 | 85 | cacheKeyItems.push(value); 86 | } 87 | let cacheItem = this.cache.getItem(this.fixedEncodeURIComponent(expression + "//" + cacheKeyItems.join("|"))), 88 | parsedExpression = cacheItem.get(); 89 | if (null === parsedExpression) { 90 | let nodes = this.getParser().parse(this.getLexer().tokenize(expression), names, flags); 91 | parsedExpression = new ParsedExpression(expression, nodes); 92 | 93 | cacheItem.set(parsedExpression); 94 | this.cache.save(cacheItem); 95 | } 96 | 97 | return parsedExpression; 98 | }; 99 | 100 | lint = (expression, names=null, flags=0) => { 101 | if (null === names) { 102 | console.log("Deprecated: passing \"null\" as the second argument of lint is deprecated, pass IGNORE_UNKNOWN_VARIABLES instead as the third argument"); 103 | flags |= IGNORE_UNKNOWN_VARIABLES; 104 | names = []; 105 | } 106 | 107 | if (expression instanceof ParsedExpression) { 108 | return; 109 | } 110 | 111 | // Ensure parser is initialized and pass names/flags to parser.lint 112 | this.getParser().lint(this.getLexer().tokenize(expression), names, flags); 113 | } 114 | 115 | fixedEncodeURIComponent = (str) => { 116 | return encodeURIComponent(str).replace(/[!'()*]/g, function (c) { 117 | return '%' + c.charCodeAt(0).toString(16); 118 | }); 119 | }; 120 | 121 | /** 122 | * Registers a function 123 | * 124 | * @param {string} name The function name 125 | * @param {function} compiler A function able to compile the function 126 | * @param {function} evaluator A function able to evaluate the function 127 | * 128 | * @throws Error 129 | * 130 | * @see ExpressionFunction 131 | */ 132 | register = (name, compiler, evaluator) => { 133 | if (null !== this.parser) { 134 | throw new LogicException("Registering functions after calling evaluate(), compile(), or parse() is not supported.") 135 | } 136 | 137 | this.functions[name] = {compiler: compiler, evaluator: evaluator}; 138 | }; 139 | 140 | addFunction = (expressionFunction) => { 141 | this.register(expressionFunction.getName(), expressionFunction.getCompiler(), expressionFunction.getEvaluator()); 142 | }; 143 | 144 | registerProvider = (provider) => { 145 | for (let fn of provider.getFunctions()) { 146 | this.addFunction(fn); 147 | } 148 | }; 149 | 150 | _registerBuiltinFunctions() { 151 | const minFn = ExpressionFunction.fromJavascript('Math.min', 'min'); 152 | const maxFn = ExpressionFunction.fromJavascript('Math.max', 'max'); 153 | this.addFunction(minFn); 154 | this.addFunction(maxFn); 155 | 156 | // PHP-like constant(name): resolves a global/dotted path from globalThis (or window/global) 157 | this.addFunction(new ExpressionFunction('constant', 158 | function compiler(constantName) { 159 | // Compile to an IIFE that resolves from global object, supporting dotted paths (e.g., "Math.PI") 160 | return `(function(__n){var __g=(typeof globalThis!=='undefined'?globalThis:(typeof window!=='undefined'?window:(typeof global!=='undefined'?global:{})));return __n.split('.')`+ 161 | `.reduce(function(o,k){return o==null?undefined:o[k];}, __g)})(${constantName})`; 162 | }, 163 | function evaluator(values, constantName) { 164 | if (typeof constantName !== 'string' || !constantName) { 165 | return undefined; 166 | } 167 | const getGlobal = () => (typeof globalThis !== 'undefined') ? globalThis : (typeof window !== 'undefined' ? window : (typeof global !== 'undefined' ? global : {})); 168 | const resolvePath = (root, path) => { 169 | return path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), root); 170 | }; 171 | 172 | // First try global resolution (supports dotted path like Math.PI) 173 | let resolved = resolvePath(getGlobal(), constantName); 174 | 175 | // As a convenience, also allow constants supplied in the evaluation values map by exact name 176 | if (resolved === undefined && values && Object.prototype.hasOwnProperty.call(values, constantName)) { 177 | resolved = values[constantName]; 178 | } 179 | 180 | return resolved; 181 | } 182 | )); 183 | 184 | // PHP-like enum(FQN::CASE): resolves a namespaced path from global object 185 | this.addFunction(new ExpressionFunction('enum', 186 | function compiler(enumName) { 187 | // normalize separators ('.', '\\', '::') into path segments without using regex 188 | return `(function(__n){var __g=(typeof globalThis!=='undefined'?globalThis:(typeof window!=='undefined'?window:(typeof global!=='undefined'?global:{})));`+ 189 | `if(typeof __n!=='string'||!__n)return undefined;`+ 190 | `var s=String(__n);var keys=[],buf='';`+ 191 | `for(var i=0;i (typeof globalThis !== 'undefined') ? globalThis : (typeof window !== 'undefined' ? window : (typeof global !== 'undefined' ? global : {})); 203 | const normalize = (s) => { 204 | // Replace single backslashes and double-colon with dots 205 | return String(s).replace(/\\/g, '.').replace(/::/g, '.'); 206 | }; 207 | const resolvePath = (root, path) => path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), root); 208 | const normalized = normalize(enumName); 209 | if (!normalized) return undefined; 210 | return resolvePath(getGlobal(), normalized); 211 | } 212 | )); 213 | } 214 | 215 | getLexer = () => { 216 | if (null === this.lexer) { 217 | this.lexer = { 218 | tokenize: tokenize 219 | }; 220 | } 221 | 222 | return this.lexer; 223 | } 224 | 225 | getParser = () => { 226 | if (null === this.parser) { 227 | this.parser = new Parser(this.functions); 228 | } 229 | 230 | return this.parser; 231 | }; 232 | 233 | getCompiler = () => { 234 | if (null === this.compiler) { 235 | this.compiler = new Compiler(this.functions); 236 | } 237 | return this.compiler.reset(); 238 | } 239 | } -------------------------------------------------------------------------------- /src/Lexer.js: -------------------------------------------------------------------------------- 1 | import SyntaxError from "./SyntaxError"; 2 | import {Token, TokenStream} from "./TokenStream"; 3 | 4 | export function tokenize(expression) { 5 | expression = expression.replace(/\r|\n|\t|\v|\f/g, ' '); 6 | let cursor = 0, 7 | tokens = [], 8 | brackets = [], 9 | end = expression.length; 10 | 11 | while (cursor < end) { 12 | if (' ' === expression[cursor]) { 13 | ++cursor; 14 | continue; 15 | } 16 | // Skip block comments 17 | if (expression.substr(cursor, 2) === '/*') { 18 | const endIdx = expression.indexOf('*/', cursor + 2); 19 | if (endIdx === -1) { 20 | // Unclosed comment: ignore rest of expression 21 | cursor = end; 22 | break; 23 | } else { 24 | cursor = endIdx + 2; 25 | continue; 26 | } 27 | } 28 | 29 | let number = extractNumber(expression.substr(cursor)); 30 | if (number !== null) { 31 | // numbers 32 | const numberLength = number.length; 33 | const raw = number; 34 | const clean = raw.replace(/_/g, ''); 35 | // Decide integer vs float based on presence of decimal point or exponent 36 | if (clean.indexOf(".") === -1 && clean.indexOf("e") === -1 && clean.indexOf("E") === -1) { 37 | number = parseInt(clean, 10); 38 | } 39 | else { 40 | number = parseFloat(clean); 41 | } 42 | tokens.push(new Token(Token.NUMBER_TYPE, number, cursor + 1)); 43 | cursor += numberLength; 44 | } else { 45 | if ('([{'.indexOf(expression[cursor]) >= 0) { 46 | // opening bracket 47 | brackets.push([expression[cursor], cursor]); 48 | tokens.push(new Token(Token.PUNCTUATION_TYPE, expression[cursor], cursor + 1)); 49 | ++cursor; 50 | } 51 | else { 52 | if (')]}'.indexOf(expression[cursor]) >= 0) { 53 | if (brackets.length === 0) { 54 | throw new SyntaxError(`Unexpected "${expression[cursor]}"`, cursor, expression); 55 | } 56 | 57 | let [expect, cur] = brackets.pop(), 58 | matchExpect = expect.replace("(", ")").replace("{", "}").replace("[", "]"); 59 | if (expression[cursor] !== matchExpect) { 60 | throw new SyntaxError(`Unclosed "${expect}"`, cur, expression); 61 | } 62 | 63 | tokens.push(new Token(Token.PUNCTUATION_TYPE, expression[cursor], cursor + 1)); 64 | ++cursor; 65 | } 66 | else { 67 | let str = extractString(expression.substr(cursor)); 68 | if (str !== null) { 69 | //console.log("adding string: " + str); 70 | tokens.push(new Token(Token.STRING_TYPE, str.captured, cursor + 1)); 71 | cursor += (str.length); 72 | //console.log(`Extracted string: ${str.captured}; Remaining: ${expression.substr(cursor)}`, cursor, expression); 73 | } 74 | else if (expression.substr(cursor, 2) === "\\\\") { 75 | // Two backslashes outside of strings represent a single literal backslash token 76 | tokens.push(new Token(Token.PUNCTUATION_TYPE, "\\", cursor + 1)); 77 | cursor += 2; 78 | } 79 | else { 80 | // If the previous token is a dot accessor ('.' or '?.'), prefer extracting a name before operators 81 | const lastToken = tokens.length > 0 ? tokens[tokens.length - 1] : null; 82 | const preferName = lastToken && lastToken.type === Token.PUNCTUATION_TYPE && (lastToken.value === '.' || lastToken.value === '?.'); 83 | 84 | if (preferName) { 85 | let name = extractName(expression.substr(cursor)); 86 | if (name) { 87 | tokens.push(new Token(Token.NAME_TYPE, name, cursor + 1)); 88 | cursor += name.length; 89 | } 90 | else { 91 | let operator = extractOperator(expression.substr(cursor)); 92 | if (operator) { 93 | tokens.push(new Token(Token.OPERATOR_TYPE, operator, cursor + 1)); 94 | cursor += operator.length; 95 | } 96 | else if (expression.substr(cursor, 2) === '?.' || expression.substr(cursor, 2) === '??') { 97 | tokens.push(new Token(Token.PUNCTUATION_TYPE, expression.substr(cursor, 2), cursor + 1)); 98 | cursor += 2; 99 | } 100 | else if (".,?:".indexOf(expression[cursor]) >= 0) { 101 | tokens.push(new Token(Token.PUNCTUATION_TYPE, expression[cursor], cursor + 1)); 102 | ++cursor; 103 | } 104 | else { 105 | throw new SyntaxError(`Unexpected character "${expression[cursor]}"`, cursor, expression); 106 | } 107 | } 108 | } 109 | else { 110 | let operator = extractOperator(expression.substr(cursor)); 111 | if (operator) { 112 | tokens.push(new Token(Token.OPERATOR_TYPE, operator, cursor + 1)); 113 | cursor += operator.length; 114 | } 115 | else { 116 | if (expression.substr(cursor, 2) === '?.' || expression.substr(cursor, 2) === '??') { 117 | tokens.push(new Token(Token.PUNCTUATION_TYPE, expression.substr(cursor, 2), cursor + 1)); 118 | cursor += 2; 119 | } 120 | else if (".,?:".indexOf(expression[cursor]) >= 0) { 121 | tokens.push(new Token(Token.PUNCTUATION_TYPE, expression[cursor], cursor + 1)); 122 | ++cursor; 123 | } 124 | else { 125 | let name = extractName(expression.substr(cursor)); 126 | if (name) { 127 | tokens.push(new Token(Token.NAME_TYPE, name, cursor + 1)); 128 | cursor += name.length; 129 | //console.log(`Extracted name: ${name}; Remaining: ${expression.substr(cursor)}`, cursor, expression) 130 | } 131 | else { 132 | throw new SyntaxError(`Unexpected character "${expression[cursor]}"`, cursor, expression); 133 | } 134 | } 135 | } 136 | } 137 | } 138 | } 139 | } 140 | } 141 | } 142 | 143 | tokens.push(new Token(Token.EOF_TYPE, null, cursor + 1)); 144 | 145 | if (brackets.length > 0) { 146 | let [expect, cur] = brackets.pop(); 147 | throw new SyntaxError(`Unclosed "${expect}"`, cur, expression); 148 | } 149 | 150 | return new TokenStream(expression, tokens); 151 | } 152 | 153 | function extractNumber(str) { 154 | let extracted = null; 155 | 156 | // Supports: 157 | // - integers: 123, 1_000 158 | // - decimals: 123.45, .45, 123., with optional underscores in integer and fraction parts 159 | // - exponent: e or E with optional sign and digits (e.g., 1.23e+10, .7_189e10) 160 | // Note: underscores are allowed between digits but not at boundaries; we simply capture and 161 | // rely on parseFloat/parseInt after removing underscores implicitly by JavaScript (parseFloat ignores underscores only in modern engines, so we will strip them manually before parsing if needed elsewhere). Here we only extract the literal token string; Tokenize later uses parseInt/parseFloat which will work if underscores are removed there. Tests expect numeric values parsed correctly, so we must ensure extraction includes underscores. 162 | const numberRegex = /^(?:((?:\d(?:_?\d)*)\.(?:\d(?:_?\d)*)|\.(?:\d(?:_?\d)*)|(?:\d(?:_?\d)*))(?:[eE][+-]?\d(?:_?\d)*)?)/; 163 | 164 | let matches = str.match(numberRegex); 165 | if (matches && matches.length > 0) { 166 | extracted = matches[0]; 167 | } 168 | return extracted; 169 | } 170 | 171 | 172 | const strRegex = /^"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'/s; 173 | 174 | function unescapeString(s, quote) { 175 | // Only handle escaping of backslash and the matching quote; do NOT translate control sequences (e.g., \n) 176 | // Replace escaped quote first, then collapse escaped backslashes 177 | if (quote === '"') { 178 | s = s.replace(/\\\"/g, '"'); 179 | } else if (quote === "'") { 180 | s = s.replace(/\\'/g, "'"); 181 | } 182 | // Replace double backslashes with single backslash 183 | s = s.replace(/\\\\/g, '\\'); 184 | return s; 185 | } 186 | /** 187 | * 188 | * @param str 189 | * @returns {null|string} 190 | */ 191 | function extractString(str) { 192 | let extracted = null; 193 | 194 | if (["'", '"'].indexOf(str.substr(0, 1)) === -1) { 195 | return extracted; 196 | } 197 | 198 | let m = strRegex.exec(str); 199 | if (m !== null && m.length > 0) { 200 | if (typeof m[1] !== 'undefined') { 201 | extracted = { 202 | captured: unescapeString(m[1], '"') 203 | }; 204 | } 205 | else { 206 | extracted = { 207 | captured: unescapeString(m[2], "'") 208 | }; 209 | } 210 | 211 | extracted.length = m[0].length; 212 | } 213 | 214 | return extracted; 215 | } 216 | 217 | 218 | const operators = [ 219 | "&&","and","||","or", // Binary 220 | "+", "-", "**", "*", "/", "%", // Arithmetic 221 | "&", "|", "^", ">>", "<<", // Bitwise 222 | "===", "!==", "!=", "==", "<=", ">=", "<", ">", // Comparison 223 | "contains", "matches", "starts with", "ends with", 224 | "not in", "in", "not", "!", "xor", 225 | "~", // String concatenation, 226 | '..', // Range function 227 | ]; 228 | const wordBasedOperators = ["and", "or", "matches", "contains", "starts with", "ends with", "not in", "in", "not", "xor"]; 229 | /** 230 | * 231 | * @param str 232 | * @returns {null|string} 233 | */ 234 | function extractOperator(str) { 235 | let extracted = null; 236 | for (let operator of operators) { 237 | if (str.substr(0, operator.length) === operator) { 238 | // If it is one of the word based operators, make sure there is a space after it 239 | if (wordBasedOperators.indexOf(operator) >= 0) { 240 | if (str.substr(0, operator.length + 1) === operator + " ") { 241 | extracted = operator; 242 | } 243 | } 244 | else { 245 | extracted = operator; 246 | } 247 | break; 248 | } 249 | } 250 | return extracted; 251 | } 252 | 253 | function extractName(str) { 254 | let extracted = null; 255 | 256 | let matches = str.match(/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/); 257 | if (matches && matches.length > 0) { 258 | extracted = matches[0]; 259 | } 260 | 261 | return extracted; 262 | } 263 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | export = ExpressionLanguage; 2 | export as namespace ExpressionLanguage; 3 | 4 | declare class ExpressionLanguage { 5 | constructor(cache?: CacheAdapter | null, providers?: AbstractProvider[]); 6 | 7 | functions: Record; 8 | lexer: Lexer | null; 9 | parser: Parser | null; 10 | compiler: Compiler | null; 11 | 12 | /** 13 | * Compiles an expression source code. 14 | * @param expression The expression to compile 15 | * @param names An array of valid names 16 | * @returns The compiled javascript source code 17 | */ 18 | compile(expression: Expression | string, names?: VariableName[]): string; 19 | 20 | /** 21 | * Evaluate an expression 22 | * @param expression The expression to compile 23 | * @param values An object of values 24 | * @returns The result of the evaluation of the expression 25 | */ 26 | evaluate( 27 | expression: Expression | string, 28 | values?: Record 29 | ): unknown; 30 | 31 | /** 32 | * Parses an expression 33 | * @param expression The expression to parse 34 | * @param names An array of valid names 35 | * @param flags Parser flags 36 | * @returns A ParsedExpression instance 37 | */ 38 | parse( 39 | expression: Expression | string, 40 | names: VariableName[], 41 | flags?: number 42 | ): ParsedExpression; 43 | 44 | /** 45 | * Lint an expression for syntax errors 46 | * @param expression The expression to lint 47 | * @param names An array of valid names (pass null for deprecated behavior) 48 | * @param flags Parser flags 49 | */ 50 | lint( 51 | expression: Expression | string, 52 | names?: VariableName[] | null, 53 | flags?: number 54 | ): void; 55 | 56 | /** 57 | * Registers a function 58 | * @param name The function name 59 | * @param compiler A function able to compile the function 60 | * @param evaluator A function able to evaluate the function 61 | */ 62 | register( 63 | name: string, 64 | compiler: CompilerFunction, 65 | evaluator: EvaluatorFunction 66 | ): void; 67 | 68 | /** 69 | * Adds an ExpressionFunction 70 | * @param expressionFunction The function to add 71 | */ 72 | addFunction(expressionFunction: ExpressionFunction): void; 73 | 74 | /** 75 | * Registers a provider 76 | * @param provider The provider to register 77 | */ 78 | registerProvider(provider: AbstractProvider): void; 79 | 80 | getLexer(): Lexer; 81 | getParser(): Parser; 82 | getCompiler(): Compiler; 83 | } 84 | 85 | declare namespace ExpressionLanguage { 86 | export { 87 | ExpressionLanguage, 88 | Parser, 89 | IGNORE_UNKNOWN_VARIABLES, 90 | IGNORE_UNKNOWN_FUNCTIONS, 91 | OPERATOR_LEFT, 92 | OPERATOR_RIGHT, 93 | tokenize, 94 | ExpressionFunction, 95 | Compiler, 96 | ArrayAdapter, 97 | AbstractProvider, 98 | BasicProvider, 99 | StringProvider, 100 | ArrayProvider, 101 | DateProvider, 102 | // Additional exports 103 | Expression, 104 | ParsedExpression, 105 | Token, 106 | TokenStream, 107 | Node, 108 | SyntaxError, 109 | CacheItem, 110 | // Type exports 111 | VariableName, 112 | FunctionDefinition, 113 | CompilerFunction, 114 | EvaluatorFunction, 115 | CacheAdapter, 116 | Lexer, 117 | }; 118 | } 119 | 120 | // Constants 121 | declare const IGNORE_UNKNOWN_VARIABLES: number; 122 | declare const IGNORE_UNKNOWN_FUNCTIONS: number; 123 | declare const OPERATOR_LEFT: number; 124 | declare const OPERATOR_RIGHT: number; 125 | 126 | // Type aliases 127 | type VariableName = string | Record; 128 | type CompilerFunction = (...args: string[]) => string; 129 | type EvaluatorFunction = ( 130 | values: Record, 131 | ...args: unknown[] 132 | ) => unknown; 133 | 134 | interface FunctionDefinition { 135 | compiler: CompilerFunction; 136 | evaluator: EvaluatorFunction; 137 | } 138 | 139 | interface Lexer { 140 | tokenize(expression: string): TokenStream; 141 | } 142 | 143 | interface CacheAdapter { 144 | getItem(key: string): CacheItem; 145 | save(item: CacheItem): boolean; 146 | get( 147 | key: string, 148 | callback: (item: CacheItem, save: boolean) => unknown, 149 | beta?: unknown, 150 | metadata?: unknown 151 | ): unknown; 152 | getItems(keys: string[]): Record; 153 | hasItem(key: string): boolean; 154 | clear(): boolean; 155 | deleteItem(key: string): boolean; 156 | deleteItems(keys: string[]): boolean; 157 | commit(): boolean; 158 | saveDeferred(item: CacheItem): boolean; 159 | } 160 | 161 | // Lexer function 162 | declare function tokenize(expression: string): TokenStream; 163 | 164 | // Classes 165 | declare class ExpressionFunction { 166 | constructor( 167 | name: string, 168 | compiler: CompilerFunction, 169 | evaluator: EvaluatorFunction 170 | ); 171 | 172 | name: string; 173 | compiler: CompilerFunction; 174 | evaluator: EvaluatorFunction; 175 | 176 | getName(): string; 177 | getCompiler(): CompilerFunction; 178 | getEvaluator(): EvaluatorFunction; 179 | 180 | /** 181 | * Creates an ExpressionFunction from a JavaScript function name (string path). 182 | * @param javascriptFunctionName The JS function name or dotted path on globalThis 183 | * @param expressionFunctionName Optional expression function name 184 | */ 185 | static fromJavascript( 186 | javascriptFunctionName: string, 187 | expressionFunctionName?: string | null 188 | ): ExpressionFunction; 189 | } 190 | 191 | declare class Parser { 192 | constructor(functions?: Record); 193 | 194 | functions: Record; 195 | tokenStream: TokenStream | null; 196 | names: VariableName[] | null; 197 | flags: number; 198 | 199 | unaryOperators: Record; 200 | binaryOperators: Record< 201 | string, 202 | { precedence: number; associativity: number } 203 | >; 204 | 205 | /** 206 | * Parse a token stream into a node tree 207 | * @param tokenStream The token stream to parse 208 | * @param names An array of valid variable names 209 | * @param flags Parser flags 210 | */ 211 | parse(tokenStream: TokenStream, names?: VariableName[], flags?: number): Node; 212 | 213 | /** 214 | * Lint a token stream for syntax errors 215 | * @param tokenStream The token stream to lint 216 | * @param names An array of valid variable names 217 | * @param flags Parser flags 218 | */ 219 | lint(tokenStream: TokenStream, names?: VariableName[], flags?: number): void; 220 | } 221 | 222 | declare class Compiler { 223 | constructor(functions: Record); 224 | 225 | source: string; 226 | functions: Record; 227 | 228 | getFunction(name: string): FunctionDefinition; 229 | 230 | /** 231 | * Gets the current javascript code after compilation. 232 | */ 233 | getSource(): string; 234 | 235 | reset(): this; 236 | 237 | /** 238 | * Compiles a node 239 | */ 240 | compile(node: Node): this; 241 | 242 | subcompile(node: Node): string; 243 | 244 | /** 245 | * Adds a raw string to the compiled code. 246 | */ 247 | raw(str: string): this; 248 | 249 | /** 250 | * Adds a quoted string to the compiled code. 251 | */ 252 | string(value: string): this; 253 | 254 | /** 255 | * Returns a javascript representation of a given value. 256 | */ 257 | repr(value: unknown, isIdentifier?: boolean): this; 258 | } 259 | 260 | declare class ArrayAdapter implements CacheAdapter { 261 | constructor(defaultLifetime?: number); 262 | 263 | defaultLifetime: number; 264 | values: Record; 265 | expiries: Record; 266 | 267 | createCacheItem(key: string, value: unknown, isHit: boolean): CacheItem; 268 | get( 269 | key: string, 270 | callback: (item: CacheItem, save: boolean) => unknown, 271 | beta?: unknown, 272 | metadata?: unknown 273 | ): unknown; 274 | getItem(key: string): CacheItem; 275 | getItems(keys: string[]): Record; 276 | deleteItems(keys: string[]): boolean; 277 | save(item: CacheItem): boolean; 278 | saveDeferred(item: CacheItem): boolean; 279 | commit(): boolean; 280 | delete(key: string): boolean; 281 | getValues(): Record; 282 | hasItem(key: string): boolean; 283 | clear(): boolean; 284 | deleteItem(key: string): boolean; 285 | reset(): void; 286 | } 287 | 288 | declare class CacheItem { 289 | static METADATA_EXPIRY_OFFSET: number; 290 | static RESERVED_CHARACTERS: string[]; 291 | static validateKey(key: string): string; 292 | 293 | key: string | null; 294 | value: unknown; 295 | isHit: boolean; 296 | expiry: number | null; 297 | defaultLifetime: number | null; 298 | metadata: Record; 299 | newMetadata: Record; 300 | innerItem: unknown; 301 | poolHash: unknown; 302 | isTaggable: boolean; 303 | 304 | getKey(): string | null; 305 | get(): unknown; 306 | set(value: unknown): this; 307 | expiresAt(expiration: Date | null): this; 308 | expiresAfter(time: number | null): this; 309 | tag(tags: string | string[]): this; 310 | getMetadata(): Record; 311 | } 312 | 313 | declare abstract class AbstractProvider { 314 | abstract getFunctions(): ExpressionFunction[]; 315 | } 316 | 317 | declare class BasicProvider extends AbstractProvider { 318 | getFunctions(): ExpressionFunction[]; 319 | } 320 | 321 | declare class StringProvider extends AbstractProvider { 322 | getFunctions(): ExpressionFunction[]; 323 | } 324 | 325 | declare class ArrayProvider extends AbstractProvider { 326 | getFunctions(): ExpressionFunction[]; 327 | } 328 | 329 | declare class DateProvider extends AbstractProvider { 330 | getFunctions(): ExpressionFunction[]; 331 | } 332 | 333 | declare class Expression { 334 | constructor(expression: string); 335 | expression: string; 336 | toString(): string; 337 | } 338 | 339 | declare class ParsedExpression extends Expression { 340 | constructor(expression: string, nodes: Node); 341 | nodes: Node; 342 | getNodes(): Node; 343 | 344 | /** 345 | * Reconstructs a ParsedExpression from a JSON representation 346 | */ 347 | static fromJSON(json: string | object): ParsedExpression; 348 | } 349 | 350 | declare class Token { 351 | static EOF_TYPE: "end of expression"; 352 | static NAME_TYPE: "name"; 353 | static NUMBER_TYPE: "number"; 354 | static STRING_TYPE: "string"; 355 | static OPERATOR_TYPE: "operator"; 356 | static PUNCTUATION_TYPE: "punctuation"; 357 | 358 | constructor(type: string, value: unknown, cursor: number); 359 | 360 | value: unknown; 361 | type: string; 362 | cursor: number; 363 | 364 | test(type: string, value?: unknown): boolean; 365 | toString(): string; 366 | isEqualTo(t: Token): boolean; 367 | diff(t: Token): string[]; 368 | } 369 | 370 | declare class TokenStream { 371 | constructor(expression: string, tokens: Token[]); 372 | 373 | expression: string; 374 | position: number; 375 | tokens: Token[]; 376 | 377 | readonly current: Token; 378 | readonly last: Token; 379 | 380 | toString(): string; 381 | next(): void; 382 | expect(type: string, value?: unknown, message?: string): void; 383 | isEOF(): boolean; 384 | isEqualTo(ts: TokenStream): boolean; 385 | diff(ts: TokenStream): Array<{ index: number; diff: string[] }>; 386 | } 387 | 388 | declare class Node { 389 | constructor( 390 | nodes?: Record | Node[], 391 | attributes?: Record 392 | ); 393 | 394 | name: string; 395 | nodes: Record | Node[]; 396 | attributes: Record; 397 | 398 | toString(): string; 399 | compile(compiler: Compiler): void; 400 | evaluate( 401 | functions: Record, 402 | values: Record 403 | ): unknown; 404 | toArray(): unknown[]; 405 | dump(): string; 406 | dumpString(value: string): string; 407 | isHash(value: object): boolean; 408 | } 409 | 410 | declare class SyntaxError extends Error { 411 | constructor( 412 | message: string, 413 | cursor: number, 414 | expression?: string, 415 | subject?: string, 416 | proposals?: string[] 417 | ); 418 | 419 | name: "SyntaxError"; 420 | cursor: number; 421 | expression?: string; 422 | subject?: string; 423 | proposals?: string[]; 424 | 425 | toString(): string; 426 | } 427 | -------------------------------------------------------------------------------- /src/Node/__tests__/BinaryNode.test.js: -------------------------------------------------------------------------------- 1 | import ConstantNode from "../ConstantNode"; 2 | import ArrayNode from "../ArrayNode"; 3 | import BinaryNode from "../BinaryNode"; 4 | import Compiler from "../../Compiler"; 5 | 6 | function getEvaluateData() 7 | { 8 | let arr = new ArrayNode(); 9 | arr.addElement(new ConstantNode('a')); 10 | arr.addElement(new ConstantNode('b')); 11 | 12 | return [ 13 | [true, new BinaryNode('or', new ConstantNode(true), new ConstantNode(false))], 14 | [true, new BinaryNode('||', new ConstantNode(true), new ConstantNode(false))], 15 | [false, new BinaryNode('xor', new ConstantNode(true), new ConstantNode(true))], 16 | [false, new BinaryNode('and', new ConstantNode(true), new ConstantNode(false))], 17 | [false, new BinaryNode('&&', new ConstantNode(true), new ConstantNode(false))], 18 | 19 | [0, new BinaryNode('&', new ConstantNode(2), new ConstantNode(4))], 20 | [6, new BinaryNode('|', new ConstantNode(2), new ConstantNode(4))], 21 | [6, new BinaryNode('^', new ConstantNode(2), new ConstantNode(4))], 22 | [32, new BinaryNode('<<', new ConstantNode(2), new ConstantNode(4))], 23 | [2, new BinaryNode('>>', new ConstantNode(32), new ConstantNode(4))], 24 | 25 | [true, new BinaryNode('<', new ConstantNode(1), new ConstantNode(2))], 26 | [true, new BinaryNode('<=', new ConstantNode(1), new ConstantNode(2))], 27 | [true, new BinaryNode('<=', new ConstantNode(1), new ConstantNode(1))], 28 | 29 | [false, new BinaryNode('>', new ConstantNode(1), new ConstantNode(2))], 30 | [false, new BinaryNode('>=', new ConstantNode(1), new ConstantNode(2))], 31 | [true, new BinaryNode('>=', new ConstantNode(1), new ConstantNode(1))], 32 | 33 | [true, new BinaryNode('===', new ConstantNode(true), new ConstantNode(true))], 34 | [false, new BinaryNode('!==', new ConstantNode(true), new ConstantNode(true))], 35 | 36 | [false, new BinaryNode('==', new ConstantNode(2), new ConstantNode(1))], 37 | [true, new BinaryNode('!=', new ConstantNode(2), new ConstantNode(1))], 38 | 39 | [-1, new BinaryNode('-', new ConstantNode(1), new ConstantNode(2))], 40 | [3, new BinaryNode('+', new ConstantNode(1), new ConstantNode(2))], 41 | [4, new BinaryNode('*', new ConstantNode(2), new ConstantNode(2))], 42 | [1, new BinaryNode('/', new ConstantNode(2), new ConstantNode(2))], 43 | [1, new BinaryNode('%', new ConstantNode(5), new ConstantNode(2))], 44 | [25, new BinaryNode('**', new ConstantNode(5), new ConstantNode(2))], 45 | ['ab', new BinaryNode('~', new ConstantNode('a'), new ConstantNode('b'))], 46 | 47 | [true, new BinaryNode('in', new ConstantNode('a'), arr)], 48 | [false, new BinaryNode('in', new ConstantNode('c'), arr)], 49 | [true, new BinaryNode('not in', new ConstantNode('c'), arr)], 50 | [false, new BinaryNode('not in', new ConstantNode('a'), arr)], 51 | 52 | [[1, 2, 3], new BinaryNode('..', new ConstantNode(1), new ConstantNode(3))], 53 | 54 | [true, new BinaryNode('starts with', new ConstantNode('abc'), new ConstantNode('a'))], 55 | [false, new BinaryNode('starts with', new ConstantNode('abc'), new ConstantNode('b'))], 56 | [true, new BinaryNode('ends with', new ConstantNode('abc'), new ConstantNode('c'))], 57 | [false, new BinaryNode('ends with', new ConstantNode('abc'), new ConstantNode('b'))], 58 | 59 | [true, new BinaryNode('matches', new ConstantNode('abc'), new ConstantNode('/^[a-z]+$/'))], 60 | [false, new BinaryNode('matches', new ConstantNode(''), new ConstantNode('/^[a-z]+$/'))], 61 | [false, new BinaryNode('matches', new ConstantNode(null), new ConstantNode('/^[a-z]+$/'))], 62 | 63 | [true, new BinaryNode('contains', new ConstantNode('abcd'), new ConstantNode('BC'))], 64 | 65 | [true, new BinaryNode('starts with', new ConstantNode('abcd'), new ConstantNode('AB'))], 66 | [true, new BinaryNode('ends with', new ConstantNode('abcd'), new ConstantNode('CD'))], 67 | ]; 68 | } 69 | 70 | function getCompileData() 71 | { 72 | let arr = new ArrayNode(); 73 | arr.addElement(new ConstantNode('a')); 74 | arr.addElement(new ConstantNode('b')); 75 | 76 | return [ 77 | ['(true || false)', new BinaryNode('or', new ConstantNode(true), new ConstantNode(false))], 78 | ['(true || false)', new BinaryNode('||', new ConstantNode(true), new ConstantNode(false))], 79 | ['(true xor true)', new BinaryNode('xor', new ConstantNode(true), new ConstantNode(true))], 80 | ['(true && false)', new BinaryNode('and', new ConstantNode(true), new ConstantNode(false))], 81 | ['(true && false)', new BinaryNode('&&', new ConstantNode(true), new ConstantNode(false))], 82 | 83 | ['(2 & 4)', new BinaryNode('&', new ConstantNode(2), new ConstantNode(4))], 84 | ['(2 | 4)', new BinaryNode('|', new ConstantNode(2), new ConstantNode(4))], 85 | ['(2 ^ 4)', new BinaryNode('^', new ConstantNode(2), new ConstantNode(4))], 86 | ['(2 << 4)', new BinaryNode('<<', new ConstantNode(2), new ConstantNode(4))], 87 | ['(32 >> 4)', new BinaryNode('>>', new ConstantNode(32), new ConstantNode(4))], 88 | 89 | ['(1 < 2)', new BinaryNode('<', new ConstantNode(1), new ConstantNode(2))], 90 | ['(1 <= 2)', new BinaryNode('<=', new ConstantNode(1), new ConstantNode(2))], 91 | ['(1 <= 1)', new BinaryNode('<=', new ConstantNode(1), new ConstantNode(1))], 92 | 93 | ['(1 > 2)', new BinaryNode('>', new ConstantNode(1), new ConstantNode(2))], 94 | ['(1 >= 2)', new BinaryNode('>=', new ConstantNode(1), new ConstantNode(2))], 95 | ['(1 >= 1)', new BinaryNode('>=', new ConstantNode(1), new ConstantNode(1))], 96 | 97 | ['(true === true)', new BinaryNode('===', new ConstantNode(true), new ConstantNode(true))], 98 | ['(true !== true)', new BinaryNode('!==', new ConstantNode(true), new ConstantNode(true))], 99 | 100 | ['(2 == 1)', new BinaryNode('==', new ConstantNode(2), new ConstantNode(1))], 101 | ['(2 != 1)', new BinaryNode('!=', new ConstantNode(2), new ConstantNode(1))], 102 | 103 | ['(1 - 2)', new BinaryNode('-', new ConstantNode(1), new ConstantNode(2))], 104 | ['(1 + 2)', new BinaryNode('+', new ConstantNode(1), new ConstantNode(2))], 105 | ['(2 * 2)', new BinaryNode('*', new ConstantNode(2), new ConstantNode(2))], 106 | ['(2 / 2)', new BinaryNode('/', new ConstantNode(2), new ConstantNode(2))], 107 | ['(5 % 2)', new BinaryNode('%', new ConstantNode(5), new ConstantNode(2))], 108 | ['Math.pow(5, 2)', new BinaryNode('**', new ConstantNode(5), new ConstantNode(2))], 109 | ['("a" . "b")', new BinaryNode('~', new ConstantNode('a'), new ConstantNode('b'))], 110 | 111 | ['includes("a", ["a", "b"])', new BinaryNode('in', new ConstantNode('a'), arr)], 112 | ['includes("c", ["a", "b"])', new BinaryNode('in', new ConstantNode('c'), arr)], 113 | ['!includes("c", ["a", "b"])', new BinaryNode('not in', new ConstantNode('c'), arr)], 114 | ['!includes("a", ["a", "b"])', new BinaryNode('not in', new ConstantNode('a'), arr)], 115 | 116 | ['range(1, 3)', new BinaryNode('..', new ConstantNode(1), new ConstantNode(3))], 117 | 118 | ['/^[a-z]+\$/i.test("abc")', new BinaryNode('matches', new ConstantNode('abc'), new ConstantNode('/^[a-z]+$/i', true))], 119 | 120 | ['("abc".toString().toLowerCase().includes("B".toString().toLowerCase())', new BinaryNode('contains', new ConstantNode('abc'), new ConstantNode('B', false))], 121 | ['("abc".toString().toLowerCase().startsWith("AB".toString().toLowerCase())', new BinaryNode('starts with', new ConstantNode('abc'), new ConstantNode('AB', false))], 122 | ['("abc".toString().toLowerCase().endsWith("BC".toString().toLowerCase())', new BinaryNode('ends with', new ConstantNode('abc'), new ConstantNode('BC', false))], 123 | ]; 124 | } 125 | 126 | function getDumpData() 127 | { 128 | let arr = new ArrayNode(); 129 | arr.addElement(new ConstantNode('a')); 130 | arr.addElement(new ConstantNode('b')); 131 | 132 | return [ 133 | ['(true or false)', new BinaryNode('or', new ConstantNode(true), new ConstantNode(false))], 134 | ['(true || false)', new BinaryNode('||', new ConstantNode(true), new ConstantNode(false))], 135 | ['(true xor true)', new BinaryNode('xor', new ConstantNode(true), new ConstantNode(true))], 136 | ['(true and false)', new BinaryNode('and', new ConstantNode(true), new ConstantNode(false))], 137 | ['(true && false)', new BinaryNode('&&', new ConstantNode(true), new ConstantNode(false))], 138 | 139 | ['(2 & 4)', new BinaryNode('&', new ConstantNode(2), new ConstantNode(4))], 140 | ['(2 | 4)', new BinaryNode('|', new ConstantNode(2), new ConstantNode(4))], 141 | ['(2 ^ 4)', new BinaryNode('^', new ConstantNode(2), new ConstantNode(4))], 142 | ['(2 << 4)', new BinaryNode('<<', new ConstantNode(2), new ConstantNode(4))], 143 | ['(32 >> 4)', new BinaryNode('>>', new ConstantNode(32), new ConstantNode(4))], 144 | 145 | ['(1 < 2)', new BinaryNode('<', new ConstantNode(1), new ConstantNode(2))], 146 | ['(1 <= 2)', new BinaryNode('<=', new ConstantNode(1), new ConstantNode(2))], 147 | ['(1 <= 1)', new BinaryNode('<=', new ConstantNode(1), new ConstantNode(1))], 148 | 149 | ['(1 > 2)', new BinaryNode('>', new ConstantNode(1), new ConstantNode(2))], 150 | ['(1 >= 2)', new BinaryNode('>=', new ConstantNode(1), new ConstantNode(2))], 151 | ['(1 >= 1)', new BinaryNode('>=', new ConstantNode(1), new ConstantNode(1))], 152 | 153 | ['(true === true)', new BinaryNode('===', new ConstantNode(true), new ConstantNode(true))], 154 | ['(true !== true)', new BinaryNode('!==', new ConstantNode(true), new ConstantNode(true))], 155 | 156 | ['(2 == 1)', new BinaryNode('==', new ConstantNode(2), new ConstantNode(1))], 157 | ['(2 != 1)', new BinaryNode('!=', new ConstantNode(2), new ConstantNode(1))], 158 | 159 | ['(1 - 2)', new BinaryNode('-', new ConstantNode(1), new ConstantNode(2))], 160 | ['(1 + 2)', new BinaryNode('+', new ConstantNode(1), new ConstantNode(2))], 161 | ['(2 * 2)', new BinaryNode('*', new ConstantNode(2), new ConstantNode(2))], 162 | ['(2 / 2)', new BinaryNode('/', new ConstantNode(2), new ConstantNode(2))], 163 | ['(5 % 2)', new BinaryNode('%', new ConstantNode(5), new ConstantNode(2))], 164 | ['(5 ** 2)', new BinaryNode('**', new ConstantNode(5), new ConstantNode(2))], 165 | ['("a" ~ "b")', new BinaryNode('~', new ConstantNode('a'), new ConstantNode('b'))], 166 | 167 | ['("a" in ["a", "b"])', new BinaryNode('in', new ConstantNode('a'), arr)], 168 | ['("c" in ["a", "b"])', new BinaryNode('in', new ConstantNode('c'), arr)], 169 | ['("c" not in ["a", "b"])', new BinaryNode('not in', new ConstantNode('c'), arr)], 170 | ['("a" not in ["a", "b"])', new BinaryNode('not in', new ConstantNode('a'), arr)], 171 | 172 | ['(1 .. 3)', new BinaryNode('..', new ConstantNode(1), new ConstantNode(3))], 173 | 174 | ['("abc" matches "/^[a-z]+/i$/")', new BinaryNode('matches', new ConstantNode('abc'), new ConstantNode('/^[a-z]+/i$/'))], 175 | 176 | ['("abc" contains "B")', new BinaryNode('contains', new ConstantNode('abc'), new ConstantNode('B'))], 177 | ['("abc" starts with "AB")', new BinaryNode('starts with', new ConstantNode('abc'), new ConstantNode('AB'))], 178 | ['("abc" ends with "BC")', new BinaryNode('ends with', new ConstantNode('abc'), new ConstantNode('BC'))], 179 | ]; 180 | } 181 | 182 | test('evaluate BinaryNode', () => { 183 | let textIndex = 0; 184 | for (let evaluateParams of getEvaluateData()) { 185 | //console.log("Evaluating: ", evaluateParams); 186 | let evaluated = evaluateParams[1].evaluate({}, {}); 187 | //console.log("Evaluated: ", evaluated); 188 | if (evaluateParams[0] !== null && typeof evaluateParams[0] === "object") { 189 | expect(evaluated).toMatchObject(evaluateParams[0]); 190 | } 191 | else { 192 | expect(evaluated).toBe(evaluateParams[0]); 193 | } 194 | 195 | textIndex++; 196 | } 197 | }); 198 | 199 | test('compile BinaryNode', () => { 200 | for (let compileParams of getCompileData()) { 201 | let compiler = new Compiler({}); 202 | compileParams[1].compile(compiler); 203 | expect(compiler.getSource()).toBe(compileParams[0]); 204 | } 205 | }); 206 | 207 | test('dump BinaryNode', () => { 208 | for (let dumpParams of getDumpData()) { 209 | expect(dumpParams[1].dump()).toBe(dumpParams[0]); 210 | } 211 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Javascript implementation of the Symfony/ExpressionLanguage 2 | 3 | The idea is to be able to evaluate the same expressions client-side (in Javascript with this library) 4 | and server-side (in PHP with the Symfony/ExpressionLanguage). 5 | 6 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 7 | 8 | ## Feature parity 9 | 10 | Below is the current parity of this library with Symfony's ExpressionLanguage features. All items default to supported 11 | status. 12 | 13 | | Category | Feature | Supported | 14 | |-------------------------|------------------------------------------------------------------------------|-----------| 15 | | Literals | Strings (single and double quotes) | ✅ | 16 | | Literals | Numbers (integers, decimals, decimals without leading zero) with underscores | ✅ | 17 | | Literals | Arrays (JSON-like [ ... ]) | ✅ | 18 | | Literals | Hashes/Objects (JSON-like { key: value }) | ✅ | 19 | | Literals | Booleans (true/false) | ✅ | 20 | | Literals | null | ✅ | 21 | | Literals | Exponential/scientific notation | ✅ | 22 | | Literals | Block comments /* ... */ inside expressions | ✅ | 23 | | Escapes | Backslash escaping in strings and regexes | ✅ | 24 | | Escapes | Control characters need escaping (e.g., \n) | ✅ | 25 | | Objects | Access public properties with dot syntax (obj.prop) | ✅ | 26 | | Objects | Call methods with dot syntax (obj.method(...)) | ✅ | 27 | | Objects | Null-safe operator (obj?.prop / obj?.method()) | ✅ | 28 | | Nullish | Null-coalescing operator (a ?? b) | ✅ | 29 | | Functions | constant() | ✅ | 30 | | Functions | enum() | ✅ | 31 | | Functions | min() | ✅ | 32 | | Functions | max() | ✅ | 33 | | Arrays | Access array items with bracket syntax (arr[...]) | ✅ | 34 | | Operators: Arithmetic | +, -, *, /, %, ** | ✅ | 35 | | Operators: Bitwise | &, \| , ^ | ✅ | 36 | | Operators: Bitwise | ~ (not), <<, >> | ✅ | 37 | | Operators: Comparison | ==, ===, !=, !==, <, >, <=, >= | ✅ | 38 | | Operators: Comparison | matches (regex) | ✅ | 39 | | Operators: String tests | contains, starts with, ends with | ✅ | 40 | | Operators: Logical | not/!, and/&&, or/\|\|, xor | ✅ | 41 | | Operators: String | ~ (concatenation) | ✅ | 42 | | Operators: Array | in, not in (strict comparison) | ✅ | 43 | | Operators: Numeric | .. (range) | ✅ | 44 | | Operators: Ternary | a ? b : c, a ?: b, a ? b | ✅ | 45 | | Other | Null-safe operator (?.) | ✅ | 46 | | Other | Null-coalescing operator (??) | ✅ | 47 | | Precedence | Operator precedence as per Symfony docs | ✅ | 48 | | fromPhp() | Supported as fromJavascript() | ✅ | 49 | | Symfony Built-ins | Security expression variables | ⛔️ | 50 | | Symfony Built-ins | Service container expression variables | ⛔️ | 51 | | Symfony Built-ins | Routing expression variables | ⛔️ | 52 | 53 | > Notes: Symfony Built-ins are not supported in the javascript environment 54 | 55 | ## Installation 56 | 57 | ### NPM/Yarn 58 | 59 | ```bash 60 | npm install expression-language 61 | # or 62 | yarn add expression-language 63 | ``` 64 | 65 | ### Browser 66 | 67 | You can also use this library directly in the browser by including it via a script tag: 68 | 69 | ```html 70 | 71 | 72 | 73 | 74 | ``` 75 | 76 | ## Examples 77 | 78 | ### NPM/Yarn Setup 79 | 80 | ```javascript 81 | import {ExpressionLanguage} from "expression-language"; 82 | 83 | let expressionLanguage = new ExpressionLanguage(); 84 | ``` 85 | 86 | ### Browser Setup 87 | 88 | ```html 89 | 90 | 91 | 95 | ``` 96 | 97 | A complete browser example is available in the [examples/browser-usage.html](examples/browser-usage.html) file. 98 | 99 | #### Basic 100 | 101 | ```javascript 102 | let result = expressionLanguage.evaluate('1 + 1'); 103 | // result is 2. 104 | ``` 105 | 106 | #### Multiple clauses 107 | 108 | ```javascript 109 | let result = expressionLanguage.evaluate( 110 | 'a > 0 && b != a', 111 | { 112 | a: 1, 113 | b: 2 114 | } 115 | ); 116 | // result is true 117 | ``` 118 | 119 | #### Object and Array access 120 | ```javascript 121 | let expression = 'a[2] === "three" and b.myMethod(a[1]) === "bar two"'; 122 | let values = { 123 | a: ["one", "two", "three"], 124 | b: { 125 | myProperty: "foo", 126 | myMethod: function (word) { 127 | return "bar " + word; 128 | } 129 | } 130 | }; 131 | let result = expressionLanguage.evaluate(expression, values); 132 | // result is true 133 | ``` 134 | 135 | #### Registering custom functions 136 | You can register functions in two main ways. Make sure to register functions before calling evaluate(), compile(), or parse(); otherwise a LogicException will be thrown. 137 | 138 | - Using register(name, compiler, evaluator): 139 | ```javascript 140 | import { ExpressionLanguage } from 'expression-language'; 141 | 142 | const el = new ExpressionLanguage(); 143 | 144 | // Define how the function should compile to JavaScript and how it should evaluate at runtime. 145 | el.register( 146 | 'double', 147 | // compiler: receives the compiled argument strings and must return JS source 148 | (x) => `((+${x}) * 2)`, 149 | // evaluator: receives (values, ...args) and returns the result 150 | (values, x) => Number(x) * 2 151 | ); 152 | 153 | console.log(el.evaluate('double(21)')); // 42 154 | console.log(el.compile('double(a)', ['a'])); // '((+a) * 2)' 155 | ``` 156 | 157 | - Using addFunction with an ExpressionFunction instance: 158 | ```javascript 159 | import { ExpressionLanguage, ExpressionFunction } from 'expression-language'; 160 | 161 | const el = new ExpressionLanguage(); 162 | const timesFn = new ExpressionFunction( 163 | 'times', 164 | (a, b) => `(${a} * ${b})`, 165 | (values, a, b) => a * b 166 | ); 167 | 168 | el.addFunction(timesFn); 169 | 170 | console.log(el.evaluate('times(6, 7)')); // 42 171 | ``` 172 | 173 | #### Using providers 174 | Providers are a convenient way to bundle and register multiple functions. A provider exposes a getFunctions() method that returns an array of ExpressionFunction instances. You can register providers via the constructor or with registerProvider(). 175 | 176 | - Built-in providers you can use out of the box: 177 | - BasicProvider: isset() 178 | - StringProvider: strtolower, strtoupper, explode, strlen, strstr, stristr, substr 179 | - ArrayProvider: implode, count, array_intersect 180 | - DateProvider: date, strtotime 181 | 182 | - Registering built-in providers: 183 | ```javascript 184 | import { ExpressionLanguage, StringProvider, ArrayProvider, DateProvider, BasicProvider } from 'expression-language'; 185 | 186 | // Pass providers in the constructor (array or any iterable) 187 | const el = new ExpressionLanguage(null, [ 188 | new StringProvider(), 189 | new ArrayProvider(), 190 | new DateProvider(), 191 | new BasicProvider(), 192 | ]); 193 | 194 | console.log(el.evaluate('strtoupper("hello")')); // 'HELLO' 195 | console.log(el.evaluate('count([1,2,3])')); // 3 196 | console.log(el.evaluate('isset(foo.bar)', { foo: { bar: 1 } })); // true 197 | ``` 198 | 199 | - Creating your own provider: 200 | ```javascript 201 | import { ExpressionLanguage, ExpressionFunction } from 'expression-language'; 202 | 203 | class MathProvider { 204 | getFunctions() { 205 | return [ 206 | new ExpressionFunction( 207 | 'clamp', 208 | (x, min, max) => `Math.min(${max}, Math.max(${min}, ${x}))`, 209 | (values, x, min, max) => Math.min(max, Math.max(min, x)) 210 | ), 211 | new ExpressionFunction( 212 | 'pct', 213 | (value, total) => `(((${value}) / (${total})) * 100)`, 214 | (values, value, total) => (value / total) * 100 215 | ) 216 | ]; 217 | } 218 | } 219 | 220 | const el = new ExpressionLanguage(); 221 | el.registerProvider(new MathProvider()); 222 | 223 | console.log(el.evaluate('clamp(150, 0, 100)')); // 100 224 | console.log(el.evaluate('pct(2, 8)')); // 25 225 | ``` 226 | 227 | #### Using ExpressionFunction.fromJavascript() 228 | Use this helper to wrap an existing JavaScript function (resolved from the global object) as an ExpressionFunction. 229 | 230 | Rules and tips: 231 | - If you pass a namespaced/dotted path like 'Math.max', you must also provide an explicit expression function name (e.g., 'max'). 232 | - For non-namespaced global functions (e.g., 'myFn'), the expression function name defaults to the same name. 233 | - The function must exist on globalThis (window in browsers, global in Node). If it does not exist, an error is thrown. 234 | 235 | Examples: 236 | ```javascript 237 | import { ExpressionLanguage, ExpressionFunction } from 'expression-language'; 238 | 239 | const el = new ExpressionLanguage(); 240 | 241 | // 1) Non-namespaced global function 242 | globalThis.mySum = (a, b) => a + b; // or window.mySum in browser 243 | const sumFn = ExpressionFunction.fromJavascript('mySum'); 244 | el.addFunction(sumFn); 245 | console.log(el.evaluate('mySum(20, 22)')); // 42 246 | 247 | // 2) Namespaced (dotted) function requires an explicit expression name 248 | const maxFn = ExpressionFunction.fromJavascript('Math.max', 'max'); 249 | el.addFunction(maxFn); 250 | console.log(el.evaluate('max(1, 3, 2)')); // 3 251 | 252 | // Note: min/max are already built-in and compile to Math.min/Math.max. 253 | ``` 254 | 255 | > Note: Register functions or providers before calling evaluate(), compile(), or parse(); late registration will throw a LogicException. 256 | 257 | #### Using IGNORE_* flags 258 | These flags let you relax strict validation when parsing expressions via the high-level API. They are useful for linting or building tools where variables/functions may be unknown at parse time. 259 | 260 | - IGNORE_UNKNOWN_VARIABLES: allows names that are not provided in the names list. 261 | - IGNORE_UNKNOWN_FUNCTIONS: allows calling functions that are not registered. 262 | - You can combine flags with bitwise OR (|). 263 | 264 | Examples: 265 | ```javascript 266 | import { ExpressionLanguage, IGNORE_UNKNOWN_VARIABLES, IGNORE_UNKNOWN_FUNCTIONS } from 'expression-language'; 267 | 268 | const el = new ExpressionLanguage(); 269 | 270 | // 1) Allow unknown variables when parsing via ExpressionLanguage 271 | el.parse('foo.bar', [], IGNORE_UNKNOWN_VARIABLES); 272 | 273 | // 2) Allow unknown functions when parsing via ExpressionLanguage 274 | el.parse('myFn()', [], IGNORE_UNKNOWN_FUNCTIONS); 275 | 276 | // 3) Allow both unknown functions and variables 277 | el.parse('myFn(foo)', [], IGNORE_UNKNOWN_FUNCTIONS | IGNORE_UNKNOWN_VARIABLES); 278 | ``` 279 | 280 | Linting: 281 | ```javascript 282 | import { ExpressionLanguage, IGNORE_UNKNOWN_VARIABLES, IGNORE_UNKNOWN_FUNCTIONS } from 'expression-language'; 283 | 284 | const el = new ExpressionLanguage(); 285 | 286 | // Validate expressions without executing them 287 | el.lint('a > 0 && myFn(foo)', ['a'], IGNORE_UNKNOWN_FUNCTIONS | IGNORE_UNKNOWN_VARIABLES); 288 | 289 | // By default (flags = 0), unknowns throw: 290 | try { 291 | el.lint('myFn(foo)'); 292 | } catch (e) { 293 | console.warn('Lint failed as expected:', e.message); 294 | } 295 | ``` 296 | 297 | Notes: 298 | - Passing null for the names parameter is deprecated; use IGNORE_UNKNOWN_VARIABLES instead when you want to allow unknown variables. 299 | 300 | ## Continuous Integration and Deployment 301 | 302 | This package uses GitHub Actions for automated workflows: 303 | 304 | 1. **NPM Publishing**: Automatically publishes to npm when the package version changes 305 | 2. **GitHub Releases**: Automatically creates GitHub releases with changelogs and distribution files 306 | 307 | ### For Maintainers 308 | 309 | If you're maintaining this package, you'll need to set up the following: 310 | 311 | #### NPM Publishing 312 | Set up trusted publisher in npmjs.org by following the instructions [here](https://docs.npmjs.com/trusted-publishers). 313 | 314 | #### GitHub Releases 315 | 316 | The GitHub release workflow automatically: 317 | 318 | - Checks if the package version has changed 319 | - Builds the project to generate distribution files 320 | - Creates a GitHub release with the new version tag 321 | - Generates a changelog based on commit messages 322 | - Attaches the distribution files to the release 323 | 324 | No additional setup is required for GitHub releases as it uses the default `GITHUB_TOKEN`. 325 | 326 | Once set up, any push to the main branch will trigger these workflows when the package version changes. 327 | 328 | -------------------------------------------------------------------------------- /src/__tests__/Parser.test.js: -------------------------------------------------------------------------------- 1 | import {tokenize} from "../Lexer"; 2 | import Parser, {IGNORE_UNKNOWN_FUNCTIONS, IGNORE_UNKNOWN_VARIABLES} from "../Parser"; 3 | import ArgumentsNode from "../Node/ArgumentsNode"; 4 | import ConstantNode from "../Node/ConstantNode"; 5 | import NameNode from "../Node/NameNode"; 6 | import UnaryNode from "../Node/UnaryNode"; 7 | import BinaryNode from "../Node/BinaryNode"; 8 | import GetAttrNode from "../Node/GetAttrNode"; 9 | import ConditionalNode from "../Node/ConditionalNode"; 10 | import NullCoalesceNode from "../Node/NullCoalesceNode"; 11 | import ArrayNode from "../Node/ArrayNode"; 12 | 13 | function getParseData() { 14 | let args = new ArgumentsNode(); 15 | args.addElement(new ConstantNode('arg1')); 16 | args.addElement(new ConstantNode(2)); 17 | args.addElement(new ConstantNode(true)); 18 | 19 | let arrayNode = new ArrayNode(); 20 | arrayNode.addElement(new NameNode('bar')); 21 | 22 | return [ 23 | [new NameNode('a'), 'a', ['a']], 24 | [new ConstantNode('a'), '"a"'], 25 | [new ConstantNode(3), '3'], 26 | [new ConstantNode(false), 'false'], 27 | [new ConstantNode(true), 'true'], 28 | [new ConstantNode(null), 'null'], 29 | [new UnaryNode('-', new ConstantNode(3)), '-3'], 30 | [new BinaryNode('-', new ConstantNode(3), new ConstantNode(3)), '3 - 3'], 31 | [new BinaryNode( 32 | '*', 33 | new BinaryNode('-', new ConstantNode(3), new ConstantNode(3)), 34 | new ConstantNode(2) 35 | ), 36 | '(3 - 3) * 2' 37 | ], 38 | [ 39 | new GetAttrNode(new NameNode('foo'), new ConstantNode('bar', true), new ArgumentsNode(), GetAttrNode.PROPERTY_CALL), 40 | 'foo.bar', 41 | ['foo'] 42 | ], 43 | [ 44 | new GetAttrNode(new NameNode('foo'), new ConstantNode('bar', true), new ArgumentsNode(), GetAttrNode.METHOD_CALL), 45 | 'foo.bar()', 46 | ['foo'] 47 | ], 48 | [ 49 | new GetAttrNode(new NameNode('foo'), new ConstantNode('not', true), new ArgumentsNode(), GetAttrNode.METHOD_CALL), 50 | 'foo.not()', 51 | ['foo'] 52 | ], 53 | [ 54 | new GetAttrNode( 55 | new NameNode('foo'), 56 | new ConstantNode('bar', true), 57 | args, 58 | GetAttrNode.METHOD_CALL 59 | ), 60 | 'foo.bar("arg1", 2, true)', 61 | ['foo'] 62 | ], 63 | [ 64 | new GetAttrNode(new NameNode('foo'), new ConstantNode(3), new ArgumentsNode(), GetAttrNode.ARRAY_CALL), 65 | 'foo[3]', 66 | ['foo'] 67 | ], 68 | [ 69 | new ConditionalNode(new ConstantNode(true), new ConstantNode(true), new ConstantNode(false)), 70 | 'true ? true ? false' 71 | ], 72 | [ 73 | new BinaryNode('matches', new ConstantNode('foo'), new ConstantNode('/foo/')), 74 | '"foo" matches "/foo/"' 75 | ], 76 | [ 77 | new BinaryNode('contains', new ConstantNode('foo'), new ConstantNode('fo')), 78 | '"foo" contains "fo"' 79 | ], 80 | [ 81 | new BinaryNode('starts with', new ConstantNode('foo'), new ConstantNode('fo')), 82 | '"foo" starts with "fo"' 83 | ], 84 | [ 85 | new BinaryNode('ends with', new ConstantNode('foo'), new ConstantNode('oo')), 86 | '"foo" ends with "oo"' 87 | ], 88 | [ 89 | new GetAttrNode(new NameNode('foo'), new ConstantNode('bar', true, true), new ArgumentsNode(), GetAttrNode.PROPERTY_CALL), 90 | "foo?.bar", 91 | ['foo'] 92 | ], 93 | [ 94 | new GetAttrNode(new NameNode('foo'), new ConstantNode('bar', true, true), new ArgumentsNode(), GetAttrNode.METHOD_CALL), 95 | "foo?.bar()", 96 | ['foo'] 97 | ], 98 | [ 99 | new GetAttrNode(new NameNode('foo'), new ConstantNode('not', true, true), new ArgumentsNode(), GetAttrNode.METHOD_CALL), 100 | "foo?.not()", 101 | ['foo'] 102 | ], 103 | [ 104 | new NullCoalesceNode(new GetAttrNode(new NameNode('foo'), new ConstantNode('bar', true), new ArgumentsNode(), GetAttrNode.PROPERTY_CALL), new ConstantNode('default')), 105 | 'foo.bar ?? "default"', 106 | ['foo'] 107 | ], 108 | [ 109 | new NullCoalesceNode(new GetAttrNode(new NameNode('foo'), new ConstantNode('bar', true), new ArgumentsNode(), GetAttrNode.ARRAY_CALL), new ConstantNode('default')), 110 | 'foo["bar"] ?? "default"', 111 | ['foo'] 112 | ], 113 | // chained calls 114 | [ 115 | createGetAttrNode( 116 | createGetAttrNode( 117 | createGetAttrNode( 118 | createGetAttrNode(new NameNode('foo'), 'bar', GetAttrNode.METHOD_CALL), 119 | 'foo', GetAttrNode.METHOD_CALL 120 | ), 121 | 'baz', GetAttrNode.PROPERTY_CALL 122 | ), 123 | '3', GetAttrNode.ARRAY_CALL 124 | ), 125 | 'foo.bar().foo().baz[3]', 126 | ['foo'] 127 | ], 128 | 129 | [ 130 | new NameNode('foo'), 131 | 'bar', 132 | [{foo: 'bar'}] 133 | ], 134 | 135 | // Operators collisions 136 | [ 137 | new BinaryNode( 138 | 'in', 139 | new GetAttrNode( 140 | new NameNode('foo'), 141 | new ConstantNode('not', true), 142 | new ArgumentsNode(), 143 | GetAttrNode.PROPERTY_CALL 144 | ), 145 | arrayNode 146 | ), 147 | 'foo.not in [bar]', 148 | ['foo', 'bar'], 149 | ], 150 | [ 151 | new BinaryNode( 152 | 'or', 153 | new UnaryNode('not', new NameNode('foo')), 154 | new GetAttrNode( 155 | new NameNode('foo'), 156 | new ConstantNode('not', true), 157 | new ArgumentsNode(), 158 | GetAttrNode.PROPERTY_CALL 159 | ) 160 | ), 161 | 'not foo or foo.not', 162 | ['foo'], 163 | ], 164 | [ 165 | new BinaryNode( 166 | 'xor', 167 | new NameNode('foo'), 168 | new NameNode('bar'), 169 | ), 170 | 'foo xor bar', 171 | ['foo', 'bar'], 172 | ], 173 | [ 174 | new BinaryNode('..', new ConstantNode(0), new ConstantNode(3)), 175 | '0..3', 176 | ], 177 | [ 178 | new BinaryNode('+', new ConstantNode(0), new ConstantNode(0.1)), 179 | '0+.1', 180 | ], 181 | ]; 182 | } 183 | 184 | function getLintData() { 185 | // Keys are kept as descriptive strings; values are objects with expression, names, optional checks and exception 186 | return { 187 | 'valid expression': { 188 | expression: 'foo["some_key"].callFunction(a ? b)', 189 | names: ['foo', 'a', 'b'], 190 | }, 191 | 'valid expression with null safety': { 192 | expression: 'foo["some_key"]?.callFunction(a ? b)', 193 | names: ['foo', 'a', 'b'], 194 | }, 195 | 'allow expression with unknown names': { 196 | expression: 'foo.bar', 197 | names: [], 198 | checks: IGNORE_UNKNOWN_VARIABLES, 199 | }, 200 | 'allow expression with unknown functions': { 201 | expression: 'foo()', 202 | names: [], 203 | checks: IGNORE_UNKNOWN_FUNCTIONS, 204 | }, 205 | 'allow expression with unknown functions and names': { 206 | expression: 'foo(bar)', 207 | names: [], 208 | checks: IGNORE_UNKNOWN_FUNCTIONS | IGNORE_UNKNOWN_VARIABLES, 209 | }, 210 | 'array with trailing comma': { 211 | expression: '[value1, value2, value3,]', 212 | names: ['value1', 'value2', 'value3'], 213 | }, 214 | 'hashmap with trailing comma': { 215 | expression: '{val1: value1, val2: value2, val3: value3,}', 216 | names: ['value1', 'value2', 'value3'], 217 | }, 218 | 'disallow expression with unknown names by default': { 219 | expression: 'foo.bar', 220 | names: [], 221 | checks: 0, 222 | exception: 'Variable "foo" is not valid around position 1 for expression `foo.bar', 223 | }, 224 | 'disallow expression with unknown functions by default': { 225 | expression: 'foo()', 226 | names: [], 227 | checks: 0, 228 | exception: 'The function "foo" does not exist around position 1 for expression `foo()', 229 | }, 230 | 'operator collisions': { 231 | expression: 'foo.not in [bar]', 232 | names: ['foo', 'bar'], 233 | }, 234 | 'incorrect expression ending': { 235 | expression: 'foo["a"] foo["b"]', 236 | names: ['foo'], 237 | checks: 0, 238 | exception: 'Unexpected token "name" of value "foo" around position 10 for expression `foo["a"] foo["b"]`.', 239 | }, 240 | 'incorrect operator': { 241 | expression: 'foo["some_key"] // 2', 242 | names: ['foo'], 243 | checks: 0, 244 | exception: 'Unexpected token "operator" of value "/" around position 18 for expression `foo["some_key"] // 2`.', 245 | }, 246 | 'incorrect array': { 247 | expression: '[value1, value2 value3]', 248 | names: ['value1', 'value2', 'value3'], 249 | checks: 0, 250 | exception: 'An array element must be followed by a comma. Unexpected token "name" of value "value3" ("punctuation" expected with value ",") around position 17 for expression `[value1, value2 value3]`.', 251 | }, 252 | 'incorrect array element': { 253 | expression: 'foo["some_key")', 254 | names: ['foo'], 255 | checks: 0, 256 | exception: 'Unclosed "[" around position 3 for expression `foo["some_key")`.', 257 | }, 258 | 'incorrect hash key': { 259 | expression: '{+: value1}', 260 | names: ['value1'], 261 | checks: 0, 262 | exception: 'A hash key must be a quoted string, a number, a name, or an expression enclosed in parentheses (unexpected token "operator" of value "+" around position 2 for expression `{+: value1}`.', 263 | }, 264 | 'missed array key': { 265 | expression: 'foo[]', 266 | names: ['foo'], 267 | checks: 0, 268 | exception: 'Unexpected token "punctuation" of value "]" around position 5 for expression `foo[]`.', 269 | }, 270 | 'missed closing bracket in sub expression': { 271 | expression: 'foo[(bar ? bar : "default"]', 272 | names: ['foo', 'bar'], 273 | checks: 0, 274 | exception: 'Unclosed "(" around position 4 for expression `foo[(bar ? bar : "default"]`.', 275 | }, 276 | 'incorrect hash following': { 277 | expression: '{key: foo key2: bar}', 278 | names: ['foo', 'bar'], 279 | checks: 0, 280 | exception: 'A hash value must be followed by a comma. Unexpected token "name" of value "key2" ("punctuation" expected with value ",") around position 11 for expression `{key: foo key2: bar}`.', 281 | }, 282 | 'incorrect hash assign': { 283 | expression: '{key => foo}', 284 | names: ['foo'], 285 | checks: 0, 286 | exception: 'Unexpected character "=" around position 5 for expression `{key => foo}`.', 287 | }, 288 | 'incorrect array as hash using': { 289 | expression: '[foo: foo]', 290 | names: ['foo'], 291 | checks: 0, 292 | exception: 'An array element must be followed by a comma. Unexpected token "punctuation" of value ":" ("punctuation" expected with value ",") around position 5 for expression `[foo: foo]`.', 293 | }, 294 | }; 295 | } 296 | 297 | function createGetAttrNode(node, item, type) { 298 | return new GetAttrNode(node, new ConstantNode(item, GetAttrNode.ARRAY_CALL !== type), new ArgumentsNode(), type); 299 | } 300 | 301 | function getInvalidPostfixData() { 302 | return [ 303 | ['foo."#"', ['foo']], 304 | ['foo."bar"', ['foo']], 305 | ['foo.**', ['foo']], 306 | ['foo.123', ['foo']] 307 | ] 308 | } 309 | 310 | test("parse with invalid name", () => { 311 | try { 312 | let parser = new Parser(); 313 | parser.parse(tokenize("foo")); 314 | console.log("The parser should throw an error."); 315 | expect(true).toBe(false); // This should fail 316 | } catch (err) { 317 | expect(err.toString()).toContain('Variable "foo" is not valid around position 1'); 318 | } 319 | }); 320 | 321 | test("parse with zero in names", () => { 322 | try { 323 | let parser = new Parser(); 324 | parser.parse(tokenize("foo"), [0]); 325 | console.log("The parser should throw an error."); 326 | expect(true).toBe(false); // This should fail 327 | } catch (err) { 328 | expect(err.toString()).toContain('Variable "foo" is not valid around position 1'); 329 | } 330 | }); 331 | 332 | test("parse primary expression with unknown function throws", () => { 333 | try { 334 | let parser = new Parser(); 335 | parser.parse(tokenize("foo()")); 336 | console.log("The parser should throw an error."); 337 | expect(true).toBe(false); 338 | } catch (err) { 339 | expect(err.toString()).toContain('The function "foo" does not exist around position 1'); 340 | } 341 | }); 342 | 343 | test('parse with invalid postfix data', () => { 344 | let invalidPostfixData = getInvalidPostfixData(); 345 | for (let oneTest of invalidPostfixData) { 346 | try { 347 | let parser = new Parser(); 348 | parser.parse(tokenize(oneTest[0]), oneTest[1]); 349 | console.log("The parser should throw an error."); 350 | expect(true).toBe(false); // This should fail 351 | } catch (err) { 352 | expect(err.name).toBe('SyntaxError'); 353 | } 354 | } 355 | }); 356 | 357 | test('name proposal', () => { 358 | try { 359 | let parser = new Parser(); 360 | parser.parse(tokenize('foo > bar'), ['foo', 'baz']); 361 | console.log("The parser should throw an error."); 362 | expect(true).toBe(false); // This should fail 363 | } catch (err) { 364 | expect(err.toString()).toContain('Did you mean "baz"?'); 365 | } 366 | }); 367 | 368 | test('lint', () => { 369 | let lintData = getLintData(); 370 | for (let testKey in lintData) { 371 | let testData = lintData[testKey]; 372 | if (testData.exception) { 373 | try { 374 | let parser = new Parser(); 375 | parser.parse(tokenize(testData.expression), testData.names, testData.checks ?? 0); 376 | console.log("The parser should throw an error."); 377 | expect(true).toBe(false); 378 | } 379 | catch(err) { 380 | expect(err.toString()).toContain(testData.exception); 381 | } 382 | } 383 | else { 384 | let parser = new Parser(); 385 | parser.parse(tokenize(testData.expression), testData.names, testData.checks ?? 0); 386 | } 387 | } 388 | }) 389 | 390 | test('parse', () => { 391 | let parseData = getParseData(); 392 | for (let parseDatum of parseData) { 393 | //console.log("Testing ", parseDatum[1], parseDatum[2]); 394 | 395 | let parser = new Parser(); 396 | let generated = parser.parse(tokenize(parseDatum[1]), parseDatum[2]); 397 | expect(generated.toString()).toBe(parseDatum[0].toString()); 398 | } 399 | }); --------------------------------------------------------------------------------