Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
12 changes: 12 additions & 0 deletions scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
239 changes: 225 additions & 14 deletions src/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;

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<boolean>,
) => Promise<boolean>;
}

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<boolean>) => Promise<boolean>;
}

export type CustomOperator = CustomBinaryOperator | CustomUnaryOperator;

export interface CustomOperatorRegistry {
readonly binary: ReadonlyMap<string, CustomBinaryOperator>;
readonly unary: ReadonlyMap<string, CustomUnaryOperator>;
}

const RESERVED_SYMBOLS = new Set(['(', ')', '!', '&', '|']);

export const buildOperatorRegistry = (
operators?: readonly CustomOperator[],
): CustomOperatorRegistry => {
const binary = new Map<string, CustomBinaryOperator>();
const unary = new Map<string, CustomUnaryOperator>();

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<boolean> => {
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);
}
}
};
Expand All @@ -57,11 +264,15 @@ const evaluateNodeAsync = async (
export const evaluateAsync = async (
node: AstNode,
checker: AsyncTokenChecker,
options?: EvaluateOptions,
): Promise<boolean> => {
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);
};
Loading
Loading