diff --git a/README.md b/README.md index 3d18aef..1a04385 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,52 @@ const allowed = await parseAsync('ADMIN | (SPECIAL & !BANNED)', async token => { Like the synchronous API, evaluation short-circuits so subsequent async checks are skipped once the outcome is determined. +## Custom Operators + +You can register custom binary and prefix unary operators with their own precedence, associativity, and evaluation logic using `createParser`: + +```javascript +import { createParser } from 'logical-expression-parser'; + +const parser = createParser({ + customOperators: [ + { + kind: 'binary', + symbol: '^', + precedence: 15, // between OR (10) and AND (20) + associativity: 'left', + evaluate: (left, right) => left !== right(), + evaluateAsync: async (left, right) => left !== (await right()), + }, + { + kind: 'binary', + symbol: '->', + precedence: 5, // looser than OR + associativity: 'right', + // Short-circuiting: if left is false, right() is skipped + evaluate: (left, right) => (!left ? true : right()), + evaluateAsync: async (left, right) => (!left ? true : await right()), + }, + { + kind: 'prefix', + symbol: '~', + precedence: 30, // same as '!' + evaluate: operand => !operand, + evaluateAsync: async operand => !(await operand), + }, + ], +}); + +const allowed = parser.parse('ADMIN ^ (USER -> BETA)', checker); +``` + +### Built-in Precedence Reference +- `|` (OR): `10` +- `&` (AND): `20` +- `!` (NOT): `30` + +Word operators (e.g. `XOR`, `NAND`) are also supported with word-boundary safety (they won't split literals like `XOR_GATE`). + ## Types ```typescript @@ -89,7 +135,23 @@ export type BinaryNode = { readonly left: AstNode; readonly right: AstNode; }; -export type AstNode = LiteralNode | NotNode | BinaryNode; +export type CustomBinaryNode = { + readonly type: 'custom_binary'; + readonly operator: string; + readonly left: AstNode; + readonly right: AstNode; +}; +export type CustomUnaryNode = { + readonly type: 'custom_unary'; + readonly operator: string; + readonly operand: AstNode; +}; +export type AstNode = + | LiteralNode + | NotNode + | BinaryNode + | CustomBinaryNode + | CustomUnaryNode; ``` ## Errors diff --git a/package.json b/package.json index d2db2f9..8bb56ed 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "build:esm": "tsc -p tsconfig.esm.json", "build:markers": "node scripts/write-package-markers.mjs", "build:test": "tsc -p tsconfig.test.json", - "test": "npm run build && npm run build:test && c8 node --test test-build/test/ast.test.js test-build/test/errors.test.js test-build/test/parse.test.js && npm run smoke", + "test": "npm run build && npm run build:test && c8 node --test test-build/test/ast.test.js test-build/test/errors.test.js test-build/test/parse.test.js test-build/test/custom-operators.test.js && npm run smoke", "smoke": "node scripts/smoke.mjs", "prepublishOnly": "npm test" }, diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 9bf17f9..fb9d300 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -27,6 +27,18 @@ for (const [label, mod] of [ true, `${label}: async evaluation`, ); + assert.equal(typeof mod.createParser, 'function', `${label}: createParser export`); + const customParser = mod.createParser({ + customOperators: [ + { + kind: 'binary', + symbol: '^', + precedence: 15, + evaluate: (l, r) => l !== r(), + }, + ], + }); + assert.equal(customParser.parse('A ^ B', t => t === 'A'), true, `${label}: custom operator`); assert.equal(mod.parse('!(A&B)', t => t === 'A'), true, `${label}: NOT semantics`); assert.equal(mod.parse('A&B|C', t => t === 'C'), true, `${label}: precedence`); assert.deepEqual( diff --git a/src/ast.ts b/src/ast.ts index 377fdae..3d08e3b 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -5,50 +5,257 @@ export type BinaryNode = { readonly left: AstNode; readonly right: AstNode; }; -export type AstNode = LiteralNode | NotNode | BinaryNode; +export type CustomBinaryNode = { + readonly type: 'custom_binary'; + readonly operator: string; + readonly left: AstNode; + readonly right: AstNode; +}; +export type CustomUnaryNode = { + readonly type: 'custom_unary'; + readonly operator: string; + readonly operand: AstNode; +}; +export type AstNode = + | LiteralNode + | NotNode + | BinaryNode + | CustomBinaryNode + | CustomUnaryNode; export type TokenChecker = (token: string) => boolean; export type AsyncTokenChecker = (token: string) => boolean | Promise; -const evaluateNode = (node: AstNode, checker: TokenChecker): boolean => { +export type Associativity = 'left' | 'right'; + +export interface CustomBinaryOperator { + readonly kind: 'binary'; + readonly symbol: string; + /** + * Precedence level. Higher numbers bind tighter. + * Built-in references: + * '|' (OR) = 10 + * '&' (AND) = 20 + * '!' (NOT) = 30 + */ + readonly precedence: number; + readonly associativity?: Associativity; + readonly evaluate: (left: boolean, right: () => boolean) => boolean; + readonly evaluateAsync?: ( + left: boolean, + right: () => Promise, + ) => Promise; +} + +export interface CustomUnaryOperator { + readonly kind: 'prefix'; + readonly symbol: string; + /** Precedence level. Defaults to 30 (same as '!'). */ + readonly precedence?: number; + readonly evaluate: (operand: boolean) => boolean; + readonly evaluateAsync?: (operand: Promise) => Promise; +} + +export type CustomOperator = CustomBinaryOperator | CustomUnaryOperator; + +export interface CustomOperatorRegistry { + readonly binary: ReadonlyMap; + readonly unary: ReadonlyMap; +} + +const RESERVED_SYMBOLS = new Set(['(', ')', '!', '&', '|']); + +export const buildOperatorRegistry = ( + operators?: readonly CustomOperator[], +): CustomOperatorRegistry => { + const binary = new Map(); + const unary = new Map(); + + if (!operators) { + return { binary, unary }; + } + + for (const op of operators) { + if (!op || typeof op.symbol !== 'string' || op.symbol.trim().length === 0) { + throw new Error('Custom operator must have a non-empty symbol string'); + } + if (/\s/.test(op.symbol)) { + throw new Error( + `Invalid operator symbol '${op.symbol}': symbol cannot contain whitespace`, + ); + } + if (op.symbol.includes('(') || op.symbol.includes(')')) { + throw new Error( + `Invalid operator symbol '${op.symbol}': symbol cannot contain parentheses`, + ); + } + if (RESERVED_SYMBOLS.has(op.symbol)) { + throw new Error( + `Invalid operator symbol '${op.symbol}': cannot override reserved built-in operator`, + ); + } + if (typeof op.evaluate !== 'function') { + throw new TypeError( + `Custom operator '${op.symbol}' must provide an evaluate function`, + ); + } + if ( + op.evaluateAsync !== undefined && + typeof op.evaluateAsync !== 'function' + ) { + throw new TypeError( + `Custom operator '${op.symbol}' evaluateAsync must be a function if provided`, + ); + } + + if (op.kind === 'binary') { + if (!Number.isFinite(op.precedence)) { + throw new TypeError( + `Custom binary operator '${op.symbol}' must have a finite numeric precedence`, + ); + } + if ( + op.associativity !== undefined && + op.associativity !== 'left' && + op.associativity !== 'right' + ) { + throw new TypeError( + `Custom binary operator '${op.symbol}' associativity must be 'left' or 'right'`, + ); + } + if (binary.has(op.symbol) || unary.has(op.symbol)) { + throw new Error(`Duplicate custom operator '${op.symbol}'`); + } + binary.set(op.symbol, op); + } else if (op.kind === 'prefix') { + if (op.precedence !== undefined && !Number.isFinite(op.precedence)) { + throw new TypeError( + `Custom unary operator '${op.symbol}' must have a finite numeric precedence if provided`, + ); + } + if (unary.has(op.symbol) || binary.has(op.symbol)) { + throw new Error(`Duplicate custom operator '${op.symbol}'`); + } + unary.set(op.symbol, op); + } else { + throw new Error(`Invalid operator kind for '${(op as any)?.symbol}'`); + } + } + + return { binary, unary }; +}; + +export interface EvaluateOptions { + readonly customOperators?: readonly CustomOperator[]; +} + +export const evaluateNode = ( + node: AstNode, + checker: TokenChecker, + registry?: CustomOperatorRegistry, +): boolean => { switch (node.type) { case 'literal': return checker(node.value); case 'not': - return !evaluateNode(node.operand, checker); + return !evaluateNode(node.operand, checker, registry); case 'and': - return evaluateNode(node.left, checker) && evaluateNode(node.right, checker); + return ( + evaluateNode(node.left, checker, registry) && + evaluateNode(node.right, checker, registry) + ); case 'or': - return evaluateNode(node.left, checker) || evaluateNode(node.right, checker); + return ( + evaluateNode(node.left, checker, registry) || + evaluateNode(node.right, checker, registry) + ); + case 'custom_binary': { + const op = registry?.binary.get(node.operator); + if (!op) { + throw new Error(`Unknown custom binary operator '${node.operator}'`); + } + const left = evaluateNode(node.left, checker, registry); + return op.evaluate(left, () => + evaluateNode(node.right, checker, registry), + ); + } + case 'custom_unary': { + const op = registry?.unary.get(node.operator); + if (!op) { + throw new Error(`Unknown custom unary operator '${node.operator}'`); + } + const operand = evaluateNode(node.operand, checker, registry); + return op.evaluate(operand); + } } }; /** Evaluates the AST using `checker` for each literal. Short-circuits like `&&`/`||`. */ -export const evaluate = (node: AstNode, checker: TokenChecker): boolean => { +export const evaluate = ( + node: AstNode, + checker: TokenChecker, + options?: EvaluateOptions, +): boolean => { if (typeof checker !== 'function') { throw new TypeError( `Expected checker to be a function, got ${checker === null ? 'null' : typeof checker}`, ); } - return evaluateNode(node, checker); + const registry = options?.customOperators + ? buildOperatorRegistry(options.customOperators) + : undefined; + return evaluateNode(node, checker, registry); }; -const evaluateNodeAsync = async ( +export const evaluateNodeAsync = async ( node: AstNode, checker: AsyncTokenChecker, + registry?: CustomOperatorRegistry, ): Promise => { switch (node.type) { case 'literal': return Boolean(await checker(node.value)); case 'not': - return !(await evaluateNodeAsync(node.operand, checker)); + return !(await evaluateNodeAsync(node.operand, checker, registry)); case 'and': { - const left = await evaluateNodeAsync(node.left, checker); - return left ? await evaluateNodeAsync(node.right, checker) : false; + const left = await evaluateNodeAsync(node.left, checker, registry); + return left + ? await evaluateNodeAsync(node.right, checker, registry) + : false; } case 'or': { - const left = await evaluateNodeAsync(node.left, checker); - return left ? true : await evaluateNodeAsync(node.right, checker); + const left = await evaluateNodeAsync(node.left, checker, registry); + return left + ? true + : await evaluateNodeAsync(node.right, checker, registry); + } + case 'custom_binary': { + const op = registry?.binary.get(node.operator); + if (!op) { + throw new Error(`Unknown custom binary operator '${node.operator}'`); + } + if (!op.evaluateAsync) { + throw new TypeError( + `Custom binary operator '${node.operator}' does not implement evaluateAsync`, + ); + } + const left = await evaluateNodeAsync(node.left, checker, registry); + return await op.evaluateAsync(left, () => + evaluateNodeAsync(node.right, checker, registry), + ); + } + case 'custom_unary': { + const op = registry?.unary.get(node.operator); + if (!op) { + throw new Error(`Unknown custom unary operator '${node.operator}'`); + } + if (!op.evaluateAsync) { + throw new TypeError( + `Custom unary operator '${node.operator}' does not implement evaluateAsync`, + ); + } + const operand = evaluateNodeAsync(node.operand, checker, registry); + return await op.evaluateAsync(operand); } } }; @@ -57,11 +264,15 @@ const evaluateNodeAsync = async ( export const evaluateAsync = async ( node: AstNode, checker: AsyncTokenChecker, + options?: EvaluateOptions, ): Promise => { if (typeof checker !== 'function') { throw new TypeError( `Expected checker to be a function, got ${checker === null ? 'null' : typeof checker}`, ); } - return evaluateNodeAsync(node, checker); + const registry = options?.customOperators + ? buildOperatorRegistry(options.customOperators) + : undefined; + return evaluateNodeAsync(node, checker, registry); }; diff --git a/src/index.ts b/src/index.ts index b87ee09..665ec08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,40 +1,121 @@ import { parseTokens } from './parser.js'; -import { tokenize } from './tokenizer.js'; +import { compileTokenizer } from './tokenizer.js'; import { - evaluate, - evaluateAsync, + buildOperatorRegistry, + evaluateNode, + evaluateNodeAsync, type AstNode, type AsyncTokenChecker, + type CustomOperator, type TokenChecker, } from './ast.js'; -export { evaluate, evaluateAsync } from './ast.js'; export { LEPSyntaxError } from './parser.js'; export type { AstNode, AsyncTokenChecker, + Associativity, BinaryNode, + CustomBinaryNode, + CustomBinaryOperator, + CustomOperator, + CustomUnaryNode, + CustomUnaryOperator, + EvaluateOptions, LiteralNode, NotNode, TokenChecker, } from './ast.js'; -/** Parses the expression into an AST. Throws `TypeError` or `LEPSyntaxError` on malformed input. */ -export const parseAst = (expression: string): AstNode => { - if (typeof expression !== 'string') { - throw new TypeError( - `Expected expression to be a string, got ${expression === null ? 'null' : typeof expression}`, - ); - } - return parseTokens(tokenize(expression), expression.length); +export interface ParserOptions { + /** Additional custom operators to register alongside built-in !, &, |. */ + readonly customOperators?: readonly CustomOperator[]; +} + +export interface ParserInstance { + /** Parses the expression into an AST. Throws `TypeError` or `LEPSyntaxError` on malformed input. */ + readonly parseAst: (expression: string) => AstNode; + /** Parses and evaluates the expression. Throws `TypeError` or `LEPSyntaxError` on malformed input. */ + readonly parse: (expression: string, checker: TokenChecker) => boolean; + /** Parses and evaluates the expression asynchronously. Throws `TypeError` or `LEPSyntaxError` on malformed input. */ + readonly parseAsync: ( + expression: string, + checker: AsyncTokenChecker, + ) => Promise; + /** Evaluates the AST using `checker` for each literal. Short-circuits like `&&`/`||`. */ + readonly evaluate: (node: AstNode, checker: TokenChecker) => boolean; + /** Evaluates the AST asynchronously using `checker` for each literal. Short-circuits like `&&`/`||`. */ + readonly evaluateAsync: ( + node: AstNode, + checker: AsyncTokenChecker, + ) => Promise; +} + +/** + * Creates an isolated parser instance configured with custom operators. + */ +export const createParser = (options?: ParserOptions): ParserInstance => { + const customOperators = options?.customOperators; + const registry = buildOperatorRegistry(customOperators); + const tokenizer = compileTokenizer(customOperators); + + const parseAst = (expression: string): AstNode => { + if (typeof expression !== 'string') { + throw new TypeError( + `Expected expression to be a string, got ${expression === null ? 'null' : typeof expression}`, + ); + } + return parseTokens(tokenizer(expression), expression.length, registry); + }; + + const evaluateInstance = (node: AstNode, checker: TokenChecker): boolean => { + if (typeof checker !== 'function') { + throw new TypeError( + `Expected checker to be a function, got ${checker === null ? 'null' : typeof checker}`, + ); + } + return evaluateNode(node, checker, registry); + }; + + const evaluateAsyncInstance = async ( + node: AstNode, + checker: AsyncTokenChecker, + ): Promise => { + if (typeof checker !== 'function') { + throw new TypeError( + `Expected checker to be a function, got ${checker === null ? 'null' : typeof checker}`, + ); + } + return evaluateNodeAsync(node, checker, registry); + }; + + const parse = (expression: string, checker: TokenChecker): boolean => + evaluateInstance(parseAst(expression), checker); + + const parseAsync = async ( + expression: string, + checker: AsyncTokenChecker, + ): Promise => evaluateAsyncInstance(parseAst(expression), checker); + + return { + parseAst, + parse, + parseAsync, + evaluate: evaluateInstance, + evaluateAsync: evaluateAsyncInstance, + }; }; -/** Parses and evaluates the expression. Throws `TypeError` or `LEPSyntaxError` on malformed input. */ -export const parse = (expression: string, checker: TokenChecker): boolean => - evaluate(parseAst(expression), checker); +const defaultParser = createParser(); + +/** Parses the expression into an AST using default operators (!, &, |). */ +export const parseAst = defaultParser.parseAst; -/** Parses and evaluates the expression asynchronously. Throws `TypeError` or `LEPSyntaxError` on malformed input. */ -export const parseAsync = async ( - expression: string, - checker: AsyncTokenChecker, -): Promise => evaluateAsync(parseAst(expression), checker); +/** Parses and evaluates the expression using default operators (!, &, |). */ +export const parse = defaultParser.parse; + +/** Parses and evaluates the expression asynchronously using default operators (!, &, |). */ +export const parseAsync = defaultParser.parseAsync; + +/** Evaluates the AST using `checker` for each literal. Supports optional customOperators. */ +export { evaluate, evaluateAsync } from './ast.js'; diff --git a/src/parser.ts b/src/parser.ts index 62af335..9be7ba6 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,5 +1,5 @@ import { type Token } from './tokenizer.js'; -import { type AstNode } from './ast.js'; +import { type AstNode, type CustomOperatorRegistry } from './ast.js'; /** Thrown for malformed expressions. `instanceof SyntaxError`; carries the char `offset`. */ export class LEPSyntaxError extends SyntaxError { @@ -11,14 +11,23 @@ export class LEPSyntaxError extends SyntaxError { } } +interface ResolvedBinaryOperator { + readonly isBuiltin: boolean; + readonly type: 'and' | 'or' | 'custom_binary'; + readonly symbol: string; + readonly precedence: number; + readonly associativity: 'left' | 'right'; +} + /** - * Recursive-descent parser over the token stream. + * Precedence-climbing parser over the token stream. * - * Grammar (all left-associative, `!` binds tightest): - * or := and ('|' and)* - * and := not ('&' not)* - * not := '!' not | atom - * atom := literal | '(' or ')' + * Supports built-in operators: + * - '!' (prefix, precedence 30) + * - '&' (binary, precedence 20, left-associative) + * - '|' (binary, precedence 10, left-associative) + * - '(...)' (grouping) + * Along with user-registered custom prefix and binary operators. */ class Parser { private position = 0; @@ -26,13 +35,14 @@ class Parser { constructor( private readonly tokens: readonly Token[], private readonly inputLength: number, + private readonly registry?: CustomOperatorRegistry, ) {} parse(): AstNode { if (this.tokens.length === 0) { throw new LEPSyntaxError('Empty expression', 0); } - const node = this.parseOr(); + const node = this.parseExpression(0); const trailing = this.peek(); if (trailing) { throw new LEPSyntaxError(`Unexpected '${trailing.value}'`, trailing.offset); @@ -44,53 +54,131 @@ class Parser { return this.tokens[this.position]; } - private parseOr(): AstNode { - let left = this.parseAnd(); - while (this.peek()?.type === 'or') { - this.position += 1; - const right = this.parseAnd(); - left = { type: 'or', left, right }; + private getBinaryOperator(token: Token): ResolvedBinaryOperator | undefined { + if (token.type === 'and') { + return { + isBuiltin: true, + type: 'and', + symbol: '&', + precedence: 20, + associativity: 'left', + }; } - return left; + if (token.type === 'or') { + return { + isBuiltin: true, + type: 'or', + symbol: '|', + precedence: 10, + associativity: 'left', + }; + } + if (token.type === 'custom' && this.registry) { + const customOp = this.registry.binary.get(token.value); + if (customOp) { + return { + isBuiltin: false, + type: 'custom_binary', + symbol: customOp.symbol, + precedence: customOp.precedence, + associativity: customOp.associativity ?? 'left', + }; + } + } + return undefined; } - private parseAnd(): AstNode { - let left = this.parseNot(); - while (this.peek()?.type === 'and') { + private parseExpression(minPrecedence: number): AstNode { + let left = this.parsePrefix(); + + while (true) { + const token = this.peek(); + if (!token) { + break; + } + + const binaryOp = this.getBinaryOperator(token); + if (!binaryOp || binaryOp.precedence < minPrecedence) { + break; + } + this.position += 1; - const right = this.parseNot(); - left = { type: 'and', left, right }; + + const nextMinPrecedence = + binaryOp.associativity === 'right' + ? binaryOp.precedence + : binaryOp.precedence + 1; + + const right = this.parseExpression(nextMinPrecedence); + + if (binaryOp.isBuiltin) { + left = { + type: binaryOp.type as 'and' | 'or', + left, + right, + }; + } else { + left = { + type: 'custom_binary', + operator: binaryOp.symbol, + left, + right, + }; + } } + return left; } - private parseNot(): AstNode { - if (this.peek()?.type === 'not') { + private parsePrefix(): AstNode { + const token = this.peek(); + if (!token) { + throw new LEPSyntaxError( + "Unexpected end of input, expected a literal or '('", + this.inputLength, + ); + } + + if (token.type === 'not') { this.position += 1; - return { type: 'not', operand: this.parseNot() }; + return { + type: 'not', + operand: this.parseExpression(30), + }; } + + if (token.type === 'custom' && this.registry) { + const unaryOp = this.registry.unary.get(token.value); + if (unaryOp) { + this.position += 1; + const prec = unaryOp.precedence ?? 30; + return { + type: 'custom_unary', + operator: unaryOp.symbol, + operand: this.parseExpression(prec), + }; + } + } + return this.parseAtom(); } private parseAtom(): AstNode { const token = this.tokens[this.position++]; - if (!token) { - throw new LEPSyntaxError( - "Unexpected end of input, expected a literal or '('", - this.inputLength, - ); - } if (token.type === 'literal') { return { type: 'literal', value: token.value }; } if (token.type === 'lparen') { - const inner = this.parseOr(); + const inner = this.parseExpression(0); const closing = this.peek(); if (!closing) { throw new LEPSyntaxError("Missing ')'", this.inputLength); } if (closing.type !== 'rparen') { - throw new LEPSyntaxError(`Expected ')' but found '${closing.value}'`, closing.offset); + throw new LEPSyntaxError( + `Expected ')' but found '${closing.value}'`, + closing.offset, + ); } this.position += 1; return inner; @@ -99,5 +187,8 @@ class Parser { } } -export const parseTokens = (tokens: readonly Token[], inputLength: number): AstNode => - new Parser(tokens, inputLength).parse(); +export const parseTokens = ( + tokens: readonly Token[], + inputLength: number, + registry?: CustomOperatorRegistry, +): AstNode => new Parser(tokens, inputLength, registry).parse(); diff --git a/src/tokenizer.ts b/src/tokenizer.ts index f452ffa..304fce5 100644 --- a/src/tokenizer.ts +++ b/src/tokenizer.ts @@ -1,14 +1,25 @@ -export type TokenType = 'literal' | 'lparen' | 'rparen' | 'not' | 'and' | 'or'; +import { type CustomOperator } from './ast.js'; + +export type TokenType = + | 'literal' + | 'lparen' + | 'rparen' + | 'not' + | 'and' + | 'or' + | 'custom'; export interface Token { readonly type: TokenType; - /** Raw text: the operator character, or the literal run. */ + /** Raw text: the operator character/word, or the literal run. */ readonly value: string; /** Character offset of the token in the input expression. */ readonly offset: number; } -const getTokenType = (text: string): TokenType => { +const getBuiltinTokenType = ( + text: string, +): Exclude | undefined => { switch (text) { case '(': return 'lparen'; @@ -21,27 +32,105 @@ const getTokenType = (text: string): TokenType => { case '|': return 'or'; default: - return 'literal'; + return undefined; } }; -// whitespace | single-char operator | literal run (the /u flag keeps -// surrogate pairs such as emoji intact inside literal runs) -const TOKEN_PATTERN = /\s+|[!&|()]|[^!&|()\s]+/gu; const WHITESPACE_PATTERN = /^\s+$/; -export const tokenize = (expression: string): Token[] => { - const tokens: Token[] = []; - for (const match of expression.matchAll(TOKEN_PATTERN)) { - const text = match[0]; - if (WHITESPACE_PATTERN.test(text)) { - continue; +const escapeRegex = (s: string): string => + s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const escapeCharClass = (s: string): string => + s.replace(/[-\\^\]]/g, '\\$&'); + +export const compileTokenizer = ( + customOperators?: readonly CustomOperator[], +): ((expression: string) => Token[]) => { + if (!customOperators || customOperators.length === 0) { + const builtinPattern = /\s+|[!&|()]|[^!&|()\s]+/gu; + return (expression: string): Token[] => { + const tokens: Token[] = []; + for (const match of expression.matchAll(builtinPattern)) { + const text = match[0]; + if (WHITESPACE_PATTERN.test(text)) { + continue; + } + const builtinType = getBuiltinTokenType(text); + tokens.push({ + type: builtinType ?? 'literal', + value: text, + offset: match.index!, + }); + } + return tokens; + }; + } + + const customSymbolSet = new Set(); + const wordOps: string[] = []; + const symOps: string[] = []; + const nonWordChars = new Set(['(', ')', '!', '&', '|']); + + // Sort by length descending so longer operators match before prefixes (e.g. '->' before '-') + const sortedOperators = [...customOperators].sort( + (a, b) => b.symbol.length - a.symbol.length, + ); + + for (const op of sortedOperators) { + customSymbolSet.add(op.symbol); + if (/^\w+$/.test(op.symbol)) { + wordOps.push(escapeRegex(op.symbol)); + } else { + symOps.push(escapeRegex(op.symbol)); + for (const char of op.symbol) { + nonWordChars.add(char); + } } - tokens.push({ - type: getTokenType(text), - value: text, - offset: match.index!, - }); } - return tokens; + + // Built-in operators + for (const builtin of ['!', '&', '|']) { + symOps.push(escapeRegex(builtin)); + } + symOps.sort((a, b) => b.length - a.length); + + const opPatterns: string[] = []; + if (wordOps.length > 0) { + opPatterns.push(...wordOps.map(w => `\\b${w}\\b`)); + } + opPatterns.push(...symOps); + + const excludedClass = escapeCharClass([...nonWordChars].join('')); + const dynamicPattern = new RegExp( + `\\s+|[()]|${opPatterns.join('|')}|[^\\s${excludedClass}]+`, + 'gu', + ); + + return (expression: string): Token[] => { + const tokens: Token[] = []; + for (const match of expression.matchAll(dynamicPattern)) { + const text = match[0]; + if (WHITESPACE_PATTERN.test(text)) { + continue; + } + let type: TokenType; + const builtinType = getBuiltinTokenType(text); + if (builtinType) { + type = builtinType; + } else if (customSymbolSet.has(text)) { + type = 'custom'; + } else { + type = 'literal'; + } + tokens.push({ + type, + value: text, + offset: match.index!, + }); + } + return tokens; + }; }; + +export const tokenize = compileTokenizer(); diff --git a/test/custom-operators.test.ts b/test/custom-operators.test.ts new file mode 100644 index 0000000..de9b17a --- /dev/null +++ b/test/custom-operators.test.ts @@ -0,0 +1,295 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + createParser, + evaluate, + evaluateAsync, + type CustomBinaryOperator, + type CustomUnaryOperator, +} from '../src/index.js'; + +test('custom binary operator (XOR ^) with intermediate precedence', () => { + const xorOp: CustomBinaryOperator = { + kind: 'binary', + symbol: '^', + precedence: 15, // between OR (10) and AND (20) + associativity: 'left', + evaluate: (left, right) => left !== right(), + evaluateAsync: async (left, right) => left !== (await right()), + }; + + const parser = createParser({ customOperators: [xorOp] }); + + // AND binds tighter than XOR: A ^ B & C => A ^ (B & C) + assert.deepEqual(parser.parseAst('A ^ B & C'), { + type: 'custom_binary', + operator: '^', + left: { type: 'literal', value: 'A' }, + right: { + type: 'and', + left: { type: 'literal', value: 'B' }, + right: { type: 'literal', value: 'C' }, + }, + }); + + // XOR binds tighter than OR: A | B ^ C => A | (B ^ C) + assert.deepEqual(parser.parseAst('A | B ^ C'), { + type: 'or', + left: { type: 'literal', value: 'A' }, + right: { + type: 'custom_binary', + operator: '^', + left: { type: 'literal', value: 'B' }, + right: { type: 'literal', value: 'C' }, + }, + }); + + // Evaluation + const env: Record = { A: true, B: true, C: false }; + const checker = (t: string) => Boolean(env[t]); + + assert.equal(parser.parse('A ^ B', checker), false); + assert.equal(parser.parse('A ^ C', checker), true); + assert.equal(parser.parse('C ^ A', checker), true); + assert.equal(parser.parse('C ^ C', checker), false); +}); + +test('right-associative binary operator (Implication ->)', () => { + const impliesOp: CustomBinaryOperator = { + kind: 'binary', + symbol: '->', + precedence: 5, // looser than OR (10) + associativity: 'right', + evaluate: (left, right) => (!left ? true : right()), + evaluateAsync: async (left, right) => (!left ? true : await right()), + }; + + const parser = createParser({ customOperators: [impliesOp] }); + + // Right-associative: A -> B -> C => A -> (B -> C) + assert.deepEqual(parser.parseAst('A -> B -> C'), { + type: 'custom_binary', + operator: '->', + left: { type: 'literal', value: 'A' }, + right: { + type: 'custom_binary', + operator: '->', + left: { type: 'literal', value: 'B' }, + right: { type: 'literal', value: 'C' }, + }, + }); + + // Short-circuiting verification: if left is false, right should NOT be evaluated + const seen: string[] = []; + const checker = (token: string): boolean => { + seen.push(token); + return token === 'A_FALSE' ? false : true; + }; + + seen.length = 0; + assert.equal(parser.parse('A_FALSE -> (B & C)', checker), true); + assert.deepEqual(seen, ['A_FALSE']); +}); + +test('word operators (e.g. XOR, NAND) with word boundary safety', () => { + const xorWordOp: CustomBinaryOperator = { + kind: 'binary', + symbol: 'XOR', + precedence: 15, + evaluate: (left, right) => left !== right(), + evaluateAsync: async (left, right) => left !== (await right()), + }; + + const parser = createParser({ customOperators: [xorWordOp] }); + + assert.deepEqual(parser.parseAst('A XOR B'), { + type: 'custom_binary', + operator: 'XOR', + left: { type: 'literal', value: 'A' }, + right: { type: 'literal', value: 'B' }, + }); + + // Literals containing the word as a substring must NOT be split + assert.deepEqual(parser.parseAst('XOR_VAR & B'), { + type: 'and', + left: { type: 'literal', value: 'XOR_VAR' }, + right: { type: 'literal', value: 'B' }, + }); +}); + +test('custom prefix unary operator (~)', () => { + const tildeOp: CustomUnaryOperator = { + kind: 'prefix', + symbol: '~', + precedence: 30, + evaluate: operand => !operand, + evaluateAsync: async operand => !(await operand), + }; + + const parser = createParser({ customOperators: [tildeOp] }); + + assert.deepEqual(parser.parseAst('~A & B'), { + type: 'and', + left: { + type: 'custom_unary', + operator: '~', + operand: { type: 'literal', value: 'A' }, + }, + right: { type: 'literal', value: 'B' }, + }); + + assert.equal(parser.parse('~A', t => t === 'A'), false); + assert.equal(parser.parse('~A', () => false), true); +}); + +test('async evaluation with custom operators (sync and async checker)', async () => { + const xorOp: CustomBinaryOperator = { + kind: 'binary', + symbol: '^', + precedence: 15, + evaluate: (left, right) => left !== right(), + evaluateAsync: async (left, right) => left !== (await right()), + }; + + const tildeOp: CustomUnaryOperator = { + kind: 'prefix', + symbol: '~', + precedence: 30, + evaluate: operand => !operand, + evaluateAsync: async operand => !(await operand), + }; + + const parser = createParser({ customOperators: [xorOp, tildeOp] }); + + const asyncChecker = async (token: string): Promise => { + await new Promise(resolve => setImmediate(resolve)); + return token === 'ROLE_A'; + }; + + assert.equal(await parser.parseAsync('ROLE_A ^ ROLE_B', asyncChecker), true); + assert.equal(await parser.parseAsync('~ROLE_A', asyncChecker), false); + assert.equal(await parser.parseAsync('~ROLE_B', asyncChecker), true); +}); + +test('standalone evaluate and evaluateAsync with options.customOperators', async () => { + const xorOp: CustomBinaryOperator = { + kind: 'binary', + symbol: '^', + precedence: 15, + evaluate: (left, right) => left !== right(), + evaluateAsync: async (left, right) => left !== (await right()), + }; + + const parser = createParser({ customOperators: [xorOp] }); + const ast = parser.parseAst('A ^ B'); + + assert.equal(evaluate(ast, t => t === 'A', { customOperators: [xorOp] }), true); + assert.equal( + await evaluateAsync(ast, async t => t === 'A', { customOperators: [xorOp] }), + true, + ); +}); + +test('error handling when custom operator lacks evaluateAsync in async mode', async () => { + const syncOnlyBinary: CustomBinaryOperator = { + kind: 'binary', + symbol: '<=>', + precedence: 15, + evaluate: (l, r) => l === r(), + }; + + const syncOnlyUnary: CustomUnaryOperator = { + kind: 'prefix', + symbol: '?', + evaluate: op => op, + }; + + const parser = createParser({ customOperators: [syncOnlyBinary, syncOnlyUnary] }); + await assert.rejects( + async () => parser.parseAsync('A <=> B', async () => true), + TypeError, + ); + await assert.rejects( + async () => parser.parseAsync('?A', async () => true), + TypeError, + ); +}); + +test('validation of invalid operator definitions', () => { + // Empty symbol + // @ts-expect-error test invalid symbol + assert.throws(() => createParser({ customOperators: [{ kind: 'binary', symbol: '', evaluate: () => true }] }), Error); + // @ts-expect-error test whitespace symbol + assert.throws(() => createParser({ customOperators: [{ kind: 'binary', symbol: ' ', evaluate: () => true }] }), Error); + // Symbol with whitespace + // @ts-expect-error test whitespace in symbol + assert.throws(() => createParser({ customOperators: [{ kind: 'binary', symbol: 'A B', evaluate: () => true }] }), Error); + // Symbol with parenthesis + // @ts-expect-error test parenthesis in symbol + assert.throws(() => createParser({ customOperators: [{ kind: 'binary', symbol: '(^)', evaluate: () => true }] }), Error); + // Reserved built-in symbol + assert.throws(() => createParser({ customOperators: [{ kind: 'binary', symbol: '&', precedence: 10, evaluate: () => true }] }), Error); + assert.throws(() => createParser({ customOperators: [{ kind: 'prefix', symbol: '!', evaluate: () => true }] }), Error); + // Non-function evaluate + // @ts-expect-error test invalid evaluate + assert.throws(() => createParser({ customOperators: [{ kind: 'binary', symbol: '^', precedence: 10, evaluate: null }] }), TypeError); + // Invalid evaluateAsync + // @ts-expect-error test invalid evaluateAsync + assert.throws(() => createParser({ customOperators: [{ kind: 'binary', symbol: '^', precedence: 10, evaluate: () => true, evaluateAsync: 123 }] }), TypeError); + // Non-finite precedence + assert.throws(() => createParser({ customOperators: [{ kind: 'binary', symbol: '^', precedence: NaN, evaluate: () => true }] }), TypeError); + // Non-finite precedence for unary + assert.throws(() => createParser({ customOperators: [{ kind: 'prefix', symbol: '~', precedence: Infinity, evaluate: () => true }] }), TypeError); + // Invalid associativity + // @ts-expect-error test invalid associativity + assert.throws(() => createParser({ customOperators: [{ kind: 'binary', symbol: '^', precedence: 10, associativity: 'middle', evaluate: () => true }] }), TypeError); + // Duplicate operator symbol + assert.throws(() => createParser({ + customOperators: [ + { kind: 'binary', symbol: '^', precedence: 10, evaluate: () => true }, + { kind: 'binary', symbol: '^', precedence: 15, evaluate: () => true }, + ], + }), Error); + assert.throws(() => createParser({ + customOperators: [ + { kind: 'binary', symbol: 'op', precedence: 10, evaluate: () => true }, + { kind: 'prefix', symbol: 'op', evaluate: () => true }, + ], + }), Error); + // Invalid kind + // @ts-expect-error test invalid kind + assert.throws(() => createParser({ customOperators: [{ kind: 'postfix', symbol: '?', evaluate: () => true }] }), Error); +}); + +test('evaluating AST with unregistered custom operator throws Error', async () => { + const unregBinary = { + type: 'custom_binary' as const, + operator: '???', + left: { type: 'literal' as const, value: 'A' }, + right: { type: 'literal' as const, value: 'B' }, + }; + assert.throws(() => evaluate(unregBinary, () => true), Error); + await assert.rejects(async () => evaluateAsync(unregBinary, async () => true), Error); + + const unregUnary = { + type: 'custom_unary' as const, + operator: '???', + operand: { type: 'literal' as const, value: 'A' }, + }; + assert.throws(() => evaluate(unregUnary, () => true), Error); + await assert.rejects(async () => evaluateAsync(unregUnary, async () => true), Error); +}); + +test('parser instance validates arguments', async () => { + const parser = createParser(); + // @ts-expect-error test invalid expression + assert.throws(() => parser.parseAst(null), TypeError); + // @ts-expect-error test invalid checker + assert.throws(() => parser.evaluate({ type: 'literal', value: 'A' }, null), TypeError); + // @ts-expect-error test invalid checker + assert.throws(() => parser.evaluate({ type: 'literal', value: 'A' }, 'not-a-fn'), TypeError); + // @ts-expect-error test invalid checker + await assert.rejects(async () => parser.evaluateAsync({ type: 'literal', value: 'A' }, null), TypeError); + // @ts-expect-error test invalid checker + await assert.rejects(async () => parser.evaluateAsync({ type: 'literal', value: 'A' }, 'not-a-fn'), TypeError); +});