From 422da7e7d6595bd56af6027753f6e747a410fd3a Mon Sep 17 00:00:00 2001 From: benceruleanlu Date: Mon, 28 Apr 2025 15:04:43 -0400 Subject: [PATCH 01/13] Add contextKeyStore and condition --- src/schemas/keyBindingSchema.ts | 3 +- src/services/keybindingService.ts | 10 + src/stores/contextKeyStore.ts | 246 +++++++++++++++++++ src/stores/keybindingStore.ts | 5 +- tests-ui/tests/store/contextKeyStore.spec.ts | 1 + 5 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 src/stores/contextKeyStore.ts create mode 100644 tests-ui/tests/store/contextKeyStore.spec.ts diff --git a/src/schemas/keyBindingSchema.ts b/src/schemas/keyBindingSchema.ts index 31896842fe..a2aa07f667 100644 --- a/src/schemas/keyBindingSchema.ts +++ b/src/schemas/keyBindingSchema.ts @@ -17,7 +17,8 @@ export const zKeybinding = z.object({ // Note: Currently only used to distinguish between global keybindings // and litegraph canvas keybindings. // Do NOT use this field in extensions as it has no effect. - targetElementId: z.string().optional() + targetElementId: z.string().optional(), + condition: z.string().optional() }) // Infer types from schemas diff --git a/src/services/keybindingService.ts b/src/services/keybindingService.ts index b1c23a1cee..527f2e790d 100644 --- a/src/services/keybindingService.ts +++ b/src/services/keybindingService.ts @@ -1,5 +1,6 @@ import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings' import { useCommandStore } from '@/stores/commandStore' +import { useContextKeyStore } from '@/stores/contextKeyStore' import { KeyComboImpl, KeybindingImpl, @@ -11,6 +12,7 @@ export const useKeybindingService = () => { const keybindingStore = useKeybindingStore() const commandStore = useCommandStore() const settingStore = useSettingStore() + const contextKeyStore = useContextKeyStore() const keybindHandler = async function (event: KeyboardEvent) { const keyCombo = KeyComboImpl.fromEvent(event) @@ -32,6 +34,14 @@ export const useKeybindingService = () => { const keybinding = keybindingStore.getKeybinding(keyCombo) if (keybinding && keybinding.targetElementId !== 'graph-canvas') { + // If condition exists and evaluates to false + // TODO: Complex context key evaluation + if ( + keybinding.condition && + contextKeyStore.evaluateCondition(keybinding.condition) !== true + ) { + return + } // Prevent default browser behavior first, then execute the command event.preventDefault() await commandStore.execute(keybinding.commandId) diff --git a/src/stores/contextKeyStore.ts b/src/stores/contextKeyStore.ts new file mode 100644 index 0000000000..499f2ec5db --- /dev/null +++ b/src/stores/contextKeyStore.ts @@ -0,0 +1,246 @@ +import { get, set, unset } from 'lodash' +import { defineStore } from 'pinia' +import { reactive } from 'vue' + +/** + * Tokenizes a context key expression string. + * @param expr The expression string (e.g., "key1 && !key2 || (key3 && key4)"). + * @returns An array of tokens. + * @throws Error if invalid characters are found. + */ +function tokenize(expr: string): { t: string }[] { + const tokens: { t: string }[] = [] + const re = /\s*([A-Za-z0-9_.]+|==|!=|&&|\|\||!|\(|\))\s*/g + let m: RegExpExecArray | null + while ((m = re.exec(expr))) { + tokens.push({ t: m[1] }) + } + if (re.lastIndex !== expr.length) { + throw new Error(`Invalid character in expression at pos ${re.lastIndex}`) + } + return tokens +} + +const OP_PRECEDENCE: Record = { + '||': 1, + '&&': 2, + '==': 3, + '!=': 3 +} + +type Token = { t: string } +interface IdentifierNode { + type: 'Identifier' + name: string +} +interface UnaryNode { + type: 'Unary' + op: '!' + left?: never + right?: never + arg: ASTNode +} +interface BinaryNode { + type: 'Binary' + op: '&&' | '||' | '==' | '!=' + left: ASTNode + right: ASTNode +} +type ASTNode = IdentifierNode | UnaryNode | BinaryNode + +/** + * Parses a sequence of tokens into an Abstract Syntax Tree (AST). + * Implements a simple recursive descent parser for boolean expressions + * with precedence (NOT > AND > OR) and parentheses. + * @param tokens The array of tokens from `tokenize`. + * @returns The root node of the AST. + * @throws Error on syntax errors (e.g., mismatched parentheses, unexpected tokens). + */ +function parseAST(tokens: Token[]): ASTNode { + let i = 0 + + function peek(): string | undefined { + return tokens[i]?.t + } + + function consume(expected?: string): string { + const tok = tokens[i++]?.t + if (expected && tok !== expected) { + throw new Error(`Expected ${expected}, got ${tok ?? 'end of input'}`) + } + if (!tok) { + throw new Error(`Expected ${expected}, got end of input`) + } + return tok + } + + function parsePrimary(): any { + if (peek() === '!') { + consume('!') + return { type: 'Unary', op: '!', arg: parsePrimary() } + } + if (peek() === '(') { + consume('(') + const expr = parseExpression(0) + consume(')') + return expr + } + const id = consume() + if (!/^[A-Za-z0-9_.]+$/.test(id)) { + throw new Error(`Invalid identifier: ${id}`) + } + return { type: 'Identifier', name: id } + } + + function parseExpression(minPrec: number): any { + let left = parsePrimary() + while (true) { + const op = peek() + const prec = op ? OP_PRECEDENCE[op] : undefined + if (prec === undefined || prec < minPrec) break + consume(op) + const right = parseExpression(prec + 1) + left = { type: 'Binary', op, left, right } + } + return left + } + + const ast = parseExpression(0) + if (i < tokens.length) { + throw new Error(`Unexpected token ${peek()}`) + } + return ast +} + +type ContextValue = string | number | boolean + +function getNodeRawValue( + node: ASTNode, + getContextKey: (key: string) => ContextValue | undefined +): ContextValue | boolean { + if (node.type === 'Identifier') { + const raw = getContextKey(node.name) + return raw === undefined ? false : raw + } + return evalAst(node, getContextKey) +} + +/** + * Evaluates an AST node recursively. + * @param node The AST node to evaluate. + * @param getContextKey A function to retrieve the boolean value of a context key identifier. + * @returns The boolean result of the evaluation. + * @throws Error for unknown AST node types. + */ +function evalAst( + node: ASTNode, + getContextKey: (key: string) => ContextValue | undefined +): boolean { + switch (node.type) { + case 'Identifier': { + const raw = getContextKey(node.name) + if (raw === undefined) return false + if (typeof raw === 'boolean') return raw + if (typeof raw === 'string') return raw.length > 0 + if (typeof raw === 'number') return raw !== 0 + return false + } + case 'Unary': + return !evalAst(node.arg, getContextKey) + case 'Binary': { + const { op, left, right } = node + if (op === '&&' || op === '||') { + const l = evalAst(left, getContextKey) + const r = evalAst(right, getContextKey) + return op === '&&' ? l && r : l || r + } + const lRaw = getNodeRawValue(left, getContextKey) + const rRaw = getNodeRawValue(right, getContextKey) + return op === '==' ? lRaw === rRaw : lRaw !== rRaw + } + default: + throw new Error(`Unknown AST node type: ${(node as ASTNode).type}`) + } +} + +/** + * Parses and evaluates a context key expression string. + * + * @param expr The expression string (e.g., "key1 && !key2"). + * @param getContextKey A function to resolve context key identifiers to boolean values. + * @returns The boolean result of the expression. + * @throws Error on parsing or evaluation errors. + */ +function evaluateExpression( + expr: string, + getContextKey: (key: string) => ContextValue | undefined +): boolean { + if (!expr) return true + + try { + const tokens = tokenize(expr) + const ast = parseAST(tokens) + return evalAst(ast, getContextKey) + } catch (error) { + console.error(`Error evaluating expression "${expr}":`, error) + return false + } +} + +export const useContextKeyStore = defineStore('contextKeys', () => { + const contextKeys = reactive>({}) + + /** + * Get a stored context key by path + * @param {string} path - The path to the context key (e.g., 'a.b.c'). + * @returns {boolean|undefined} The value of the context key, or undefined if not found. + */ + function getContextKey(path: string): ContextValue | undefined { + return get(contextKeys, path) + } + + /** + * Set or update a context key value at a given path + * @param {string} path - The path to the context key (e.g., 'a.b.c'). + * @param {boolean} value - The value to set for the context key. + */ + function setContextKey(path: string, value: ContextValue) { + set(contextKeys, path, value) + } + + /** + * Remove a context key by path + * @param {string} path - The path to the context key to remove (e.g., 'a.b.c'). + */ + function removeContextKey(path: string) { + unset(contextKeys, path) + } + + /** + * Clear all context keys + */ + function clearAllContextKeys() { + for (const key in contextKeys) { + delete contextKeys[key] + } + } + + /** + * Evaluates a context key expression string using the current context keys. + * Returns false if the expression is invalid or if any referenced key is undefined. + * @param {string} expr - The expression string (e.g., "key1 && !key2 || (key3 && key4)"). + * @returns {boolean} The result of the expression evaluation. + */ + function evaluateCondition(expr: string): boolean { + return evaluateExpression(expr, getContextKey) + } + + return { + contextKeys, + getContextKey, + setContextKey, + removeContextKey, + clearAllContextKeys, + evaluateCondition + } +}) diff --git a/src/stores/keybindingStore.ts b/src/stores/keybindingStore.ts index f045689a11..d4c2efa439 100644 --- a/src/stores/keybindingStore.ts +++ b/src/stores/keybindingStore.ts @@ -9,11 +9,13 @@ export class KeybindingImpl implements Keybinding { commandId: string combo: KeyComboImpl targetElementId?: string + condition?: string constructor(obj: Keybinding) { this.commandId = obj.commandId this.combo = new KeyComboImpl(obj.combo) this.targetElementId = obj.targetElementId + this.condition = obj.condition } equals(other: unknown): boolean { @@ -22,7 +24,8 @@ export class KeybindingImpl implements Keybinding { return raw instanceof KeybindingImpl ? this.commandId === raw.commandId && this.combo.equals(raw.combo) && - this.targetElementId === raw.targetElementId + this.targetElementId === raw.targetElementId && + this.condition === raw.condition : false } } diff --git a/tests-ui/tests/store/contextKeyStore.spec.ts b/tests-ui/tests/store/contextKeyStore.spec.ts new file mode 100644 index 0000000000..b260568e41 --- /dev/null +++ b/tests-ui/tests/store/contextKeyStore.spec.ts @@ -0,0 +1 @@ +// TODO@benceruleanlu: Write unit tests after contextKeyStore is finished From 758721753f57537a08ca4d62b469b78373b62dd4 Mon Sep 17 00:00:00 2001 From: benceruleanlu Date: Wed, 30 Apr 2025 15:18:06 -0400 Subject: [PATCH 02/13] support literals --- src/stores/contextKeyStore.ts | 53 +++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/src/stores/contextKeyStore.ts b/src/stores/contextKeyStore.ts index 499f2ec5db..9da1de8000 100644 --- a/src/stores/contextKeyStore.ts +++ b/src/stores/contextKeyStore.ts @@ -10,7 +10,8 @@ import { reactive } from 'vue' */ function tokenize(expr: string): { t: string }[] { const tokens: { t: string }[] = [] - const re = /\s*([A-Za-z0-9_.]+|==|!=|&&|\|\||!|\(|\))\s*/g + const re = + /\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|==|!=|&&|\|\||[A-Za-z0-9_.]+|!|\(|\))\s*/g let m: RegExpExecArray | null while ((m = re.exec(expr))) { tokens.push({ t: m[1] }) @@ -46,7 +47,11 @@ interface BinaryNode { left: ASTNode right: ASTNode } -type ASTNode = IdentifierNode | UnaryNode | BinaryNode +interface LiteralNode { + type: 'Literal' + value: ContextValue +} +type ASTNode = IdentifierNode | UnaryNode | BinaryNode | LiteralNode /** * Parses a sequence of tokens into an Abstract Syntax Tree (AST). @@ -74,7 +79,7 @@ function parseAST(tokens: Token[]): ASTNode { return tok } - function parsePrimary(): any { + function parsePrimary(): ASTNode { if (peek() === '!') { consume('!') return { type: 'Unary', op: '!', arg: parsePrimary() } @@ -85,22 +90,36 @@ function parseAST(tokens: Token[]): ASTNode { consume(')') return expr } - const id = consume() - if (!/^[A-Za-z0-9_.]+$/.test(id)) { - throw new Error(`Invalid identifier: ${id}`) + const tok = consume() + // string literal? + if ( + (tok[0] === '"' && tok[tok.length - 1] === '"') || + (tok[0] === "'" && tok[tok.length - 1] === "'") + ) { + const raw = tok.slice(1, -1).replace(/\\(.)/g, '$1') + return { type: 'Literal', value: raw } + } + // numeric literal? + if (/^\d+(\.\d+)?$/.test(tok)) { + return { type: 'Literal', value: Number(tok) } + } + // identifier + if (!/^[A-Za-z0-9_.]+$/.test(tok)) { + throw new Error(`Invalid identifier: ${tok}`) } - return { type: 'Identifier', name: id } + return { type: 'Identifier', name: tok } } - function parseExpression(minPrec: number): any { + function parseExpression(minPrec: number): ASTNode { let left = parsePrimary() while (true) { - const op = peek() - const prec = op ? OP_PRECEDENCE[op] : undefined + const tok = peek() + const prec = tok ? OP_PRECEDENCE[tok] : undefined if (prec === undefined || prec < minPrec) break - consume(op) + consume(tok) const right = parseExpression(prec + 1) - left = { type: 'Binary', op, left, right } + // cast tok to the exact operator union + left = { type: 'Binary', op: tok as BinaryNode['op'], left, right } } return left } @@ -118,6 +137,9 @@ function getNodeRawValue( node: ASTNode, getContextKey: (key: string) => ContextValue | undefined ): ContextValue | boolean { + if (node.type === 'Literal') { + return node.value + } if (node.type === 'Identifier') { const raw = getContextKey(node.name) return raw === undefined ? false : raw @@ -137,6 +159,13 @@ function evalAst( getContextKey: (key: string) => ContextValue | undefined ): boolean { switch (node.type) { + case 'Literal': { + const v = node.value + if (typeof v === 'boolean') return v + if (typeof v === 'string') return v.length > 0 + if (typeof v === 'number') return v !== 0 + return false + } case 'Identifier': { const raw = getContextKey(node.name) if (raw === undefined) return false From d366a1e8ef32769e28814615fb880fb4e9d40d37 Mon Sep 17 00:00:00 2001 From: benceruleanlu Date: Wed, 30 Apr 2025 15:18:46 -0400 Subject: [PATCH 03/13] "e2e" parseAST tests --- tests-ui/tests/store/contextKeyStore.spec.ts | 51 +++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/tests-ui/tests/store/contextKeyStore.spec.ts b/tests-ui/tests/store/contextKeyStore.spec.ts index b260568e41..1a01e73f50 100644 --- a/tests-ui/tests/store/contextKeyStore.spec.ts +++ b/tests-ui/tests/store/contextKeyStore.spec.ts @@ -1 +1,50 @@ -// TODO@benceruleanlu: Write unit tests after contextKeyStore is finished +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useContextKeyStore } from '@/stores/contextKeyStore' + +describe('evalAst via evaluateCondition', () => { + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + store = useContextKeyStore() + }) + + it('evaluates logical AND and NOT correctly', () => { + store.setContextKey('a', true) + store.setContextKey('b', false) + const result = store.evaluateCondition('a && !b') + expect(result).toBe(true) + }) + + it('evaluates OR and AND precedence correctly', () => { + store.setContextKey('a', false) + store.setContextKey('b', true) + store.setContextKey('c', false) + const result = store.evaluateCondition('a || b && c') + expect(result).toBe(false) + }) + + it('evaluates equality and inequality with numeric values', () => { + store.setContextKey('d', 1) + store.setContextKey('e', 1) + const eq = store.evaluateCondition('d == e') + const neq = store.evaluateCondition('d != e') + expect(eq).toBe(true) + expect(neq).toBe(false) + }) + + it('checks identifier truthiness for string and zero numeric', () => { + store.setContextKey('s', 'hello') + store.setContextKey('z', 0) + expect(store.evaluateCondition('s')).toBe(true) + expect(store.evaluateCondition('!s')).toBe(false) + expect(store.evaluateCondition('z')).toBe(false) + }) + + it('evaluates literals correctly', () => { + store.setContextKey('s', 'hello') + expect(store.evaluateCondition('s != "hello"')).toBe(false) + }) +}) From 9272179bcef3c7c9770c57979a8e55affe3aa06d Mon Sep 17 00:00:00 2001 From: benceruleanlu Date: Wed, 30 Apr 2025 15:45:23 -0400 Subject: [PATCH 04/13] fix bad re.lastIndex usage --- src/stores/contextKeyStore.ts | 9 +++++++-- tests-ui/tests/store/contextKeyStore.spec.ts | 7 +++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/stores/contextKeyStore.ts b/src/stores/contextKeyStore.ts index 9da1de8000..6ea13b2e38 100644 --- a/src/stores/contextKeyStore.ts +++ b/src/stores/contextKeyStore.ts @@ -10,14 +10,19 @@ import { reactive } from 'vue' */ function tokenize(expr: string): { t: string }[] { const tokens: { t: string }[] = [] + let pos = 0 const re = /\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|==|!=|&&|\|\||[A-Za-z0-9_.]+|!|\(|\))\s*/g let m: RegExpExecArray | null while ((m = re.exec(expr))) { + if (m.index !== pos) { + throw new Error(`Invalid character in expression at pos ${pos}`) + } tokens.push({ t: m[1] }) + pos = re.lastIndex } - if (re.lastIndex !== expr.length) { - throw new Error(`Invalid character in expression at pos ${re.lastIndex}`) + if (pos !== expr.length) { + throw new Error(`Invalid character in expression at pos ${pos}`) } return tokens } diff --git a/tests-ui/tests/store/contextKeyStore.spec.ts b/tests-ui/tests/store/contextKeyStore.spec.ts index 1a01e73f50..5f0da3918f 100644 --- a/tests-ui/tests/store/contextKeyStore.spec.ts +++ b/tests-ui/tests/store/contextKeyStore.spec.ts @@ -11,6 +11,13 @@ describe('evalAst via evaluateCondition', () => { store = useContextKeyStore() }) + it('evaluates logical OR correctly', () => { + store.setContextKey('a', true) + store.setContextKey('b', false) + const result = store.evaluateCondition('a || b') + expect(result).toBe(true) + }) + it('evaluates logical AND and NOT correctly', () => { store.setContextKey('a', true) store.setContextKey('b', false) From ff83bbd4daa3f864ef4f4a27bc2c2c23a6eec4dd Mon Sep 17 00:00:00 2001 From: benceruleanlu Date: Wed, 30 Apr 2025 15:50:54 -0400 Subject: [PATCH 05/13] Extract expression parsing to Util file --- src/stores/contextKeyStore.ts | 219 +----------------------------- src/utils/expressionParserUtil.ts | 217 +++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 218 deletions(-) create mode 100644 src/utils/expressionParserUtil.ts diff --git a/src/stores/contextKeyStore.ts b/src/stores/contextKeyStore.ts index 6ea13b2e38..1f93f3166d 100644 --- a/src/stores/contextKeyStore.ts +++ b/src/stores/contextKeyStore.ts @@ -2,224 +2,7 @@ import { get, set, unset } from 'lodash' import { defineStore } from 'pinia' import { reactive } from 'vue' -/** - * Tokenizes a context key expression string. - * @param expr The expression string (e.g., "key1 && !key2 || (key3 && key4)"). - * @returns An array of tokens. - * @throws Error if invalid characters are found. - */ -function tokenize(expr: string): { t: string }[] { - const tokens: { t: string }[] = [] - let pos = 0 - const re = - /\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|==|!=|&&|\|\||[A-Za-z0-9_.]+|!|\(|\))\s*/g - let m: RegExpExecArray | null - while ((m = re.exec(expr))) { - if (m.index !== pos) { - throw new Error(`Invalid character in expression at pos ${pos}`) - } - tokens.push({ t: m[1] }) - pos = re.lastIndex - } - if (pos !== expr.length) { - throw new Error(`Invalid character in expression at pos ${pos}`) - } - return tokens -} - -const OP_PRECEDENCE: Record = { - '||': 1, - '&&': 2, - '==': 3, - '!=': 3 -} - -type Token = { t: string } -interface IdentifierNode { - type: 'Identifier' - name: string -} -interface UnaryNode { - type: 'Unary' - op: '!' - left?: never - right?: never - arg: ASTNode -} -interface BinaryNode { - type: 'Binary' - op: '&&' | '||' | '==' | '!=' - left: ASTNode - right: ASTNode -} -interface LiteralNode { - type: 'Literal' - value: ContextValue -} -type ASTNode = IdentifierNode | UnaryNode | BinaryNode | LiteralNode - -/** - * Parses a sequence of tokens into an Abstract Syntax Tree (AST). - * Implements a simple recursive descent parser for boolean expressions - * with precedence (NOT > AND > OR) and parentheses. - * @param tokens The array of tokens from `tokenize`. - * @returns The root node of the AST. - * @throws Error on syntax errors (e.g., mismatched parentheses, unexpected tokens). - */ -function parseAST(tokens: Token[]): ASTNode { - let i = 0 - - function peek(): string | undefined { - return tokens[i]?.t - } - - function consume(expected?: string): string { - const tok = tokens[i++]?.t - if (expected && tok !== expected) { - throw new Error(`Expected ${expected}, got ${tok ?? 'end of input'}`) - } - if (!tok) { - throw new Error(`Expected ${expected}, got end of input`) - } - return tok - } - - function parsePrimary(): ASTNode { - if (peek() === '!') { - consume('!') - return { type: 'Unary', op: '!', arg: parsePrimary() } - } - if (peek() === '(') { - consume('(') - const expr = parseExpression(0) - consume(')') - return expr - } - const tok = consume() - // string literal? - if ( - (tok[0] === '"' && tok[tok.length - 1] === '"') || - (tok[0] === "'" && tok[tok.length - 1] === "'") - ) { - const raw = tok.slice(1, -1).replace(/\\(.)/g, '$1') - return { type: 'Literal', value: raw } - } - // numeric literal? - if (/^\d+(\.\d+)?$/.test(tok)) { - return { type: 'Literal', value: Number(tok) } - } - // identifier - if (!/^[A-Za-z0-9_.]+$/.test(tok)) { - throw new Error(`Invalid identifier: ${tok}`) - } - return { type: 'Identifier', name: tok } - } - - function parseExpression(minPrec: number): ASTNode { - let left = parsePrimary() - while (true) { - const tok = peek() - const prec = tok ? OP_PRECEDENCE[tok] : undefined - if (prec === undefined || prec < minPrec) break - consume(tok) - const right = parseExpression(prec + 1) - // cast tok to the exact operator union - left = { type: 'Binary', op: tok as BinaryNode['op'], left, right } - } - return left - } - - const ast = parseExpression(0) - if (i < tokens.length) { - throw new Error(`Unexpected token ${peek()}`) - } - return ast -} - -type ContextValue = string | number | boolean - -function getNodeRawValue( - node: ASTNode, - getContextKey: (key: string) => ContextValue | undefined -): ContextValue | boolean { - if (node.type === 'Literal') { - return node.value - } - if (node.type === 'Identifier') { - const raw = getContextKey(node.name) - return raw === undefined ? false : raw - } - return evalAst(node, getContextKey) -} - -/** - * Evaluates an AST node recursively. - * @param node The AST node to evaluate. - * @param getContextKey A function to retrieve the boolean value of a context key identifier. - * @returns The boolean result of the evaluation. - * @throws Error for unknown AST node types. - */ -function evalAst( - node: ASTNode, - getContextKey: (key: string) => ContextValue | undefined -): boolean { - switch (node.type) { - case 'Literal': { - const v = node.value - if (typeof v === 'boolean') return v - if (typeof v === 'string') return v.length > 0 - if (typeof v === 'number') return v !== 0 - return false - } - case 'Identifier': { - const raw = getContextKey(node.name) - if (raw === undefined) return false - if (typeof raw === 'boolean') return raw - if (typeof raw === 'string') return raw.length > 0 - if (typeof raw === 'number') return raw !== 0 - return false - } - case 'Unary': - return !evalAst(node.arg, getContextKey) - case 'Binary': { - const { op, left, right } = node - if (op === '&&' || op === '||') { - const l = evalAst(left, getContextKey) - const r = evalAst(right, getContextKey) - return op === '&&' ? l && r : l || r - } - const lRaw = getNodeRawValue(left, getContextKey) - const rRaw = getNodeRawValue(right, getContextKey) - return op === '==' ? lRaw === rRaw : lRaw !== rRaw - } - default: - throw new Error(`Unknown AST node type: ${(node as ASTNode).type}`) - } -} - -/** - * Parses and evaluates a context key expression string. - * - * @param expr The expression string (e.g., "key1 && !key2"). - * @param getContextKey A function to resolve context key identifiers to boolean values. - * @returns The boolean result of the expression. - * @throws Error on parsing or evaluation errors. - */ -function evaluateExpression( - expr: string, - getContextKey: (key: string) => ContextValue | undefined -): boolean { - if (!expr) return true - - try { - const tokens = tokenize(expr) - const ast = parseAST(tokens) - return evalAst(ast, getContextKey) - } catch (error) { - console.error(`Error evaluating expression "${expr}":`, error) - return false - } -} +import { ContextValue, evaluateExpression } from '@/utils/expressionParserUtil' export const useContextKeyStore = defineStore('contextKeys', () => { const contextKeys = reactive>({}) diff --git a/src/utils/expressionParserUtil.ts b/src/utils/expressionParserUtil.ts new file mode 100644 index 0000000000..80e8cdc0ad --- /dev/null +++ b/src/utils/expressionParserUtil.ts @@ -0,0 +1,217 @@ +type Token = { t: string } +interface IdentifierNode { + type: 'Identifier' + name: string +} +interface UnaryNode { + type: 'Unary' + op: '!' + left?: never + right?: never + arg: ASTNode +} +interface BinaryNode { + type: 'Binary' + op: '&&' | '||' | '==' | '!=' + left: ASTNode + right: ASTNode +} +interface LiteralNode { + type: 'Literal' + value: ContextValue +} +type ASTNode = IdentifierNode | UnaryNode | BinaryNode | LiteralNode +export type ContextValue = string | number | boolean + +const OP_PRECEDENCE: Record = { + '||': 1, + '&&': 2, + '==': 3, + '!=': 3 +} + +/** + * Tokenizes a context key expression string. + * @param expr The expression string (e.g., "key1 && !key2 || (key3 && key4)"). + * @returns An array of tokens. + * @throws Error if invalid characters are found. + */ +export function tokenize(expr: string): Token[] { + const tokens: Token[] = [] + let pos = 0 + const re = + /\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|==|!=|&&|\|\||[A-Za-z0-9_.]+|!|\(|\))\s*/g + let m: RegExpExecArray | null + while ((m = re.exec(expr))) { + if (m.index !== pos) { + throw new Error(`Invalid character in expression at pos ${pos}`) + } + tokens.push({ t: m[1] }) + pos = re.lastIndex + } + if (pos !== expr.length) { + throw new Error(`Invalid character in expression at pos ${pos}`) + } + return tokens +} + +/** + * Parses a sequence of tokens into an Abstract Syntax Tree (AST). + * Implements a simple recursive descent parser for boolean expressions + * with precedence (NOT > AND > OR) and parentheses. + * @param tokens The array of tokens from `tokenize`. + * @returns The root node of the AST. + * @throws Error on syntax errors (e.g., mismatched parentheses, unexpected tokens). + */ +export function parseAST(tokens: Token[]): ASTNode { + let i = 0 + + function peek(): string | undefined { + return tokens[i]?.t + } + + function consume(expected?: string): string { + const tok = tokens[i++]?.t + if (expected && tok !== expected) { + throw new Error(`Expected ${expected}, got ${tok ?? 'end of input'}`) + } + if (!tok) { + throw new Error(`Expected ${expected}, got end of input`) + } + return tok + } + + function parsePrimary(): ASTNode { + if (peek() === '!') { + consume('!') + return { type: 'Unary', op: '!', arg: parsePrimary() } + } + if (peek() === '(') { + consume('(') + const expr = parseExpression(0) + consume(')') + return expr + } + const tok = consume() + // string literal? + if ( + (tok[0] === '"' && tok[tok.length - 1] === '"') || + (tok[0] === "'" && tok[tok.length - 1] === "'") + ) { + const raw = tok.slice(1, -1).replace(/\\(.)/g, '$1') + return { type: 'Literal', value: raw } + } + // numeric literal? + if (/^\d+(\.\d+)?$/.test(tok)) { + return { type: 'Literal', value: Number(tok) } + } + // identifier + if (!/^[A-Za-z0-9_.]+$/.test(tok)) { + throw new Error(`Invalid identifier: ${tok}`) + } + return { type: 'Identifier', name: tok } + } + + function parseExpression(minPrec: number): ASTNode { + let left = parsePrimary() + while (true) { + const tok = peek() + const prec = tok ? OP_PRECEDENCE[tok] : undefined + if (prec === undefined || prec < minPrec) break + consume(tok) + const right = parseExpression(prec + 1) + // cast tok to the exact operator union + left = { type: 'Binary', op: tok as BinaryNode['op'], left, right } + } + return left + } + + const ast = parseExpression(0) + if (i < tokens.length) { + throw new Error(`Unexpected token ${peek()}`) + } + return ast +} + +function getNodeRawValue( + node: ASTNode, + getContextKey: (key: string) => ContextValue | undefined +): ContextValue | boolean { + if (node.type === 'Literal') { + return node.value + } + if (node.type === 'Identifier') { + const raw = getContextKey(node.name) + return raw === undefined ? false : raw + } + return evalAst(node, getContextKey) +} + +/** + * Evaluates an AST node recursively. + * @param node The AST node to evaluate. + * @param getContextKey A function to retrieve the boolean value of a context key identifier. + * @returns The boolean result of the evaluation. + * @throws Error for unknown AST node types. + */ +export function evalAst( + node: ASTNode, + getContextKey: (key: string) => ContextValue | undefined +): boolean { + switch (node.type) { + case 'Literal': { + const v = node.value + if (typeof v === 'boolean') return v + if (typeof v === 'string') return v.length > 0 + if (typeof v === 'number') return v !== 0 + return false + } + case 'Identifier': { + const raw = getContextKey(node.name) + if (raw === undefined) return false + if (typeof raw === 'boolean') return raw + if (typeof raw === 'string') return raw.length > 0 + if (typeof raw === 'number') return raw !== 0 + return false + } + case 'Unary': + return !evalAst(node.arg, getContextKey) + case 'Binary': { + const { op, left, right } = node + if (op === '&&' || op === '||') { + const l = evalAst(left, getContextKey) + const r = evalAst(right, getContextKey) + return op === '&&' ? l && r : l || r + } + const lRaw = getNodeRawValue(left, getContextKey) + const rRaw = getNodeRawValue(right, getContextKey) + return op === '==' ? lRaw === rRaw : lRaw !== rRaw + } + default: + throw new Error(`Unknown AST node type: ${(node as ASTNode).type}`) + } +} + +/** + * Parses and evaluates a context key expression string. + * + * @param expr The expression string (e.g., "key1 && !key2"). + * @param getContextKey A function to resolve context key identifiers to boolean values. + * @returns The boolean result of the expression. + * @throws Error on parsing or evaluation errors. + */ +export function evaluateExpression( + expr: string, + getContextKey: (key: string) => ContextValue | undefined +): boolean { + if (!expr) return true + + try { + const tokens = tokenize(expr) + const ast = parseAST(tokens) + return evalAst(ast, getContextKey) + } catch (error) { + console.error(`Error evaluating expression "${expr}":`, error) + return false + } +} From 9e43303846354709fb1f5727b2f027b4cdde7652 Mon Sep 17 00:00:00 2001 From: benceruleanlu Date: Wed, 30 Apr 2025 16:04:06 -0400 Subject: [PATCH 06/13] Extract toBoolean helper --- src/utils/expressionParserUtil.ts | 47 ++++++++++++++++--------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/src/utils/expressionParserUtil.ts b/src/utils/expressionParserUtil.ts index 80e8cdc0ad..43d0013592 100644 --- a/src/utils/expressionParserUtil.ts +++ b/src/utils/expressionParserUtil.ts @@ -133,16 +133,28 @@ export function parseAST(tokens: Token[]): ASTNode { return ast } -function getNodeRawValue( +/** + * Converts a ContextValue or undefined to boolean. + */ +function toBoolean(val: ContextValue | undefined): boolean { + if (val === undefined) return false + if (typeof val === 'boolean') return val + if (typeof val === 'number') return val !== 0 + if (typeof val === 'string') return val.length > 0 + return false +} + +/** + * Retrieves raw value of an AST node for equality checks. + */ +function getRawValue( node: ASTNode, getContextKey: (key: string) => ContextValue | undefined ): ContextValue | boolean { - if (node.type === 'Literal') { - return node.value - } + if (node.type === 'Literal') return node.value if (node.type === 'Identifier') { - const raw = getContextKey(node.name) - return raw === undefined ? false : raw + const val = getContextKey(node.name) + return val === undefined ? false : val } return evalAst(node, getContextKey) } @@ -159,21 +171,10 @@ export function evalAst( getContextKey: (key: string) => ContextValue | undefined ): boolean { switch (node.type) { - case 'Literal': { - const v = node.value - if (typeof v === 'boolean') return v - if (typeof v === 'string') return v.length > 0 - if (typeof v === 'number') return v !== 0 - return false - } - case 'Identifier': { - const raw = getContextKey(node.name) - if (raw === undefined) return false - if (typeof raw === 'boolean') return raw - if (typeof raw === 'string') return raw.length > 0 - if (typeof raw === 'number') return raw !== 0 - return false - } + case 'Literal': + return toBoolean(node.value) + case 'Identifier': + return toBoolean(getContextKey(node.name)) case 'Unary': return !evalAst(node.arg, getContextKey) case 'Binary': { @@ -183,8 +184,8 @@ export function evalAst( const r = evalAst(right, getContextKey) return op === '&&' ? l && r : l || r } - const lRaw = getNodeRawValue(left, getContextKey) - const rRaw = getNodeRawValue(right, getContextKey) + const lRaw = getRawValue(left, getContextKey) + const rRaw = getRawValue(right, getContextKey) return op === '==' ? lRaw === rRaw : lRaw !== rRaw } default: From 9673560ced7816dea1568e54d72cfc5685813b89 Mon Sep 17 00:00:00 2001 From: benceruleanlu Date: Wed, 30 Apr 2025 16:06:28 -0400 Subject: [PATCH 07/13] cache + TOKEN_REGEX --- src/utils/expressionParserUtil.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/utils/expressionParserUtil.ts b/src/utils/expressionParserUtil.ts index 43d0013592..d497f49212 100644 --- a/src/utils/expressionParserUtil.ts +++ b/src/utils/expressionParserUtil.ts @@ -30,6 +30,12 @@ const OP_PRECEDENCE: Record = { '!=': 3 } +// hoist and reuse the regex, avoid re‑allocating literal each call +const TOKEN_REGEX = + /\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|==|!=|&&|\|\||[A-Za-z0-9_.]+|!|\(|\))\s*/g +// cache parsed ASTs per expression +const astCache = new Map() + /** * Tokenizes a context key expression string. * @param expr The expression string (e.g., "key1 && !key2 || (key3 && key4)"). @@ -39,8 +45,8 @@ const OP_PRECEDENCE: Record = { export function tokenize(expr: string): Token[] { const tokens: Token[] = [] let pos = 0 - const re = - /\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|==|!=|&&|\|\||[A-Za-z0-9_.]+|!|\(|\))\s*/g + // clone/reset regex state + const re = new RegExp(TOKEN_REGEX) let m: RegExpExecArray | null while ((m = re.exec(expr))) { if (m.index !== pos) { @@ -208,8 +214,14 @@ export function evaluateExpression( if (!expr) return true try { - const tokens = tokenize(expr) - const ast = parseAST(tokens) + let ast: ASTNode + if (astCache.has(expr)) { + ast = astCache.get(expr)! + } else { + const tokens = tokenize(expr) + ast = parseAST(tokens) + astCache.set(expr, ast) + } return evalAst(ast, getContextKey) } catch (error) { console.error(`Error evaluating expression "${expr}":`, error) From a74dc0cde2e2d6e2c72b12d041701665a474d225 Mon Sep 17 00:00:00 2001 From: benceruleanlu Date: Wed, 30 Apr 2025 16:10:20 -0400 Subject: [PATCH 08/13] Nerf contextKeyStore tests --- tests-ui/tests/store/contextKeyStore.spec.ts | 54 ++++++-------------- 1 file changed, 17 insertions(+), 37 deletions(-) diff --git a/tests-ui/tests/store/contextKeyStore.spec.ts b/tests-ui/tests/store/contextKeyStore.spec.ts index 5f0da3918f..220b4f973f 100644 --- a/tests-ui/tests/store/contextKeyStore.spec.ts +++ b/tests-ui/tests/store/contextKeyStore.spec.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { useContextKeyStore } from '@/stores/contextKeyStore' -describe('evalAst via evaluateCondition', () => { +describe('contextKeyStore', () => { let store: ReturnType beforeEach(() => { @@ -11,47 +11,27 @@ describe('evalAst via evaluateCondition', () => { store = useContextKeyStore() }) - it('evaluates logical OR correctly', () => { - store.setContextKey('a', true) - store.setContextKey('b', false) - const result = store.evaluateCondition('a || b') - expect(result).toBe(true) + it('should set and get a context key', () => { + store.setContextKey('key1', true) + expect(store.getContextKey('key1')).toBe(true) }) - it('evaluates logical AND and NOT correctly', () => { - store.setContextKey('a', true) - store.setContextKey('b', false) - const result = store.evaluateCondition('a && !b') - expect(result).toBe(true) + it('should remove a context key', () => { + store.setContextKey('key1', true) + store.removeContextKey('key1') + expect(store.getContextKey('key1')).toBeUndefined() }) - it('evaluates OR and AND precedence correctly', () => { - store.setContextKey('a', false) - store.setContextKey('b', true) - store.setContextKey('c', false) - const result = store.evaluateCondition('a || b && c') - expect(result).toBe(false) + it('should clear all context keys', () => { + store.setContextKey('key1', true) + store.setContextKey('key2', false) + store.clearAllContextKeys() + expect(Object.keys(store.contextKeys)).toHaveLength(0) }) - it('evaluates equality and inequality with numeric values', () => { - store.setContextKey('d', 1) - store.setContextKey('e', 1) - const eq = store.evaluateCondition('d == e') - const neq = store.evaluateCondition('d != e') - expect(eq).toBe(true) - expect(neq).toBe(false) - }) - - it('checks identifier truthiness for string and zero numeric', () => { - store.setContextKey('s', 'hello') - store.setContextKey('z', 0) - expect(store.evaluateCondition('s')).toBe(true) - expect(store.evaluateCondition('!s')).toBe(false) - expect(store.evaluateCondition('z')).toBe(false) - }) - - it('evaluates literals correctly', () => { - store.setContextKey('s', 'hello') - expect(store.evaluateCondition('s != "hello"')).toBe(false) + it('should evaluate a simple condition', () => { + store.setContextKey('key1', true) + store.setContextKey('key2', false) + expect(store.evaluateCondition('key1 && !key2')).toBe(true) }) }) From 1003bd61a0ffdeebf9923d388fa9715cd151bd42 Mon Sep 17 00:00:00 2001 From: benceruleanlu Date: Wed, 30 Apr 2025 16:15:35 -0400 Subject: [PATCH 09/13] Boilerplate unit tests for expressionParserUtil --- .../tests/utils/expressionParserUtil.test.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 tests-ui/tests/utils/expressionParserUtil.test.ts diff --git a/tests-ui/tests/utils/expressionParserUtil.test.ts b/tests-ui/tests/utils/expressionParserUtil.test.ts new file mode 100644 index 0000000000..8969ef5d0a --- /dev/null +++ b/tests-ui/tests/utils/expressionParserUtil.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' + +import { + ContextValue, + evaluateExpression, + parseAST, + tokenize +} from '@/utils/expressionParserUtil' + +describe('tokenize()', () => { + it('splits identifiers, literals, operators and parentheses', () => { + const tokens = tokenize('a && !b || (c == "d")') + expect(tokens.map((t) => t.t)).toEqual([ + 'a', + '&&', + '!', + 'b', + '||', + '(', + 'c', + '==', + '"d"', + ')' + ]) + }) + + it('throws on encountering invalid characters', () => { + expect(() => tokenize('a & b')).toThrowError(/Invalid character/) + }) +}) + +describe('parseAST()', () => { + it('parses a single identifier', () => { + const ast = parseAST(tokenize('x')) + expect(ast).toEqual({ type: 'Identifier', name: 'x' }) + }) + + it('respects default precedence (&& over ||)', () => { + const ast = parseAST(tokenize('a || b && c')) + expect(ast).toEqual({ + type: 'Binary', + op: '||', + left: { type: 'Identifier', name: 'a' }, + right: { + type: 'Binary', + op: '&&', + left: { type: 'Identifier', name: 'b' }, + right: { type: 'Identifier', name: 'c' } + } + }) + }) + + it('honors parentheses to override precedence', () => { + const ast = parseAST(tokenize('(a || b) && c')) + expect(ast).toEqual({ + type: 'Binary', + op: '&&', + left: { + type: 'Binary', + op: '||', + left: { type: 'Identifier', name: 'a' }, + right: { type: 'Identifier', name: 'b' } + }, + right: { type: 'Identifier', name: 'c' } + }) + }) + + it('parses unary NOT correctly', () => { + const ast = parseAST(tokenize('!a && b')) + expect(ast).toEqual({ + type: 'Binary', + op: '&&', + left: { type: 'Unary', op: '!', arg: { type: 'Identifier', name: 'a' } }, + right: { type: 'Identifier', name: 'b' } + }) + }) +}) + +describe('evaluateExpression()', () => { + const context: Record = { + a: true, + b: false, + c: true, + d: '', + num1: 1, + num0: 0 + } + const getContextKey = (key: string) => context[key] + + it('returns true for empty expression', () => { + expect(evaluateExpression('', getContextKey)).toBe(true) + }) + + it('evaluates literals and basic comparisons', () => { + expect(evaluateExpression('"hi"', getContextKey)).toBe(true) + expect(evaluateExpression("''", getContextKey)).toBe(false) + expect(evaluateExpression('1', getContextKey)).toBe(true) + expect(evaluateExpression('0', getContextKey)).toBe(false) + expect(evaluateExpression('1 == 1', getContextKey)).toBe(true) + expect(evaluateExpression('1 != 2', getContextKey)).toBe(true) + expect(evaluateExpression("'x' == 'y'", getContextKey)).toBe(false) + }) + + it('evaluates logical AND, OR and NOT', () => { + expect(evaluateExpression('a && b', getContextKey)).toBe(false) + expect(evaluateExpression('a || b', getContextKey)).toBe(true) + expect(evaluateExpression('!b', getContextKey)).toBe(true) + }) + + it('respects operator precedence and parentheses', () => { + expect(evaluateExpression('a || b && c', getContextKey)).toBe(true) + expect(evaluateExpression('(a || b) && c', getContextKey)).toBe(true) + expect(evaluateExpression('!(a && b) || c', getContextKey)).toBe(true) + }) + + it('safely handles syntax errors by returning false', () => { + expect(evaluateExpression('a &&', getContextKey)).toBe(false) + expect(evaluateExpression('foo $ bar', getContextKey)).toBe(false) + }) +}) From 2cdddf221b3da00ac2a83937dc4924113ff1fb20 Mon Sep 17 00:00:00 2001 From: benceruleanlu Date: Wed, 30 Apr 2025 16:29:32 -0400 Subject: [PATCH 10/13] Support comparison operators --- src/utils/expressionParserUtil.ts | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/utils/expressionParserUtil.ts b/src/utils/expressionParserUtil.ts index d497f49212..0de3649aa9 100644 --- a/src/utils/expressionParserUtil.ts +++ b/src/utils/expressionParserUtil.ts @@ -12,7 +12,7 @@ interface UnaryNode { } interface BinaryNode { type: 'Binary' - op: '&&' | '||' | '==' | '!=' + op: '&&' | '||' | '==' | '!=' | '<' | '>' | '<=' | '>=' left: ASTNode right: ASTNode } @@ -27,12 +27,16 @@ const OP_PRECEDENCE: Record = { '||': 1, '&&': 2, '==': 3, - '!=': 3 + '!=': 3, + '<': 3, + '>': 3, + '<=': 3, + '>=': 3 } // hoist and reuse the regex, avoid re‑allocating literal each call const TOKEN_REGEX = - /\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|==|!=|&&|\|\||[A-Za-z0-9_.]+|!|\(|\))\s*/g + /\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|==|!=|<=|>=|&&|\|\||<|>|[A-Za-z0-9_.]+|!|\(|\))\s*/g // cache parsed ASTs per expression const astCache = new Map() @@ -192,7 +196,22 @@ export function evalAst( } const lRaw = getRawValue(left, getContextKey) const rRaw = getRawValue(right, getContextKey) - return op === '==' ? lRaw === rRaw : lRaw !== rRaw + switch (op) { + case '==': + return lRaw === rRaw + case '!=': + return lRaw !== rRaw + case '<': + return (lRaw as any) < (rRaw as any) + case '>': + return (lRaw as any) > (rRaw as any) + case '<=': + return (lRaw as any) <= (rRaw as any) + case '>=': + return (lRaw as any) >= (rRaw as any) + default: + throw new Error(`Unsupported operator: ${op}`) + } } default: throw new Error(`Unknown AST node type: ${(node as ASTNode).type}`) From da6c62aa806875e9042bdaa76f7ec2564823d118 Mon Sep 17 00:00:00 2001 From: benceruleanlu Date: Wed, 30 Apr 2025 16:36:39 -0400 Subject: [PATCH 11/13] Update JSDocs to reflect expression changes --- src/stores/contextKeyStore.ts | 22 ++++----- src/utils/expressionParserUtil.ts | 78 ++++++++++++++++++++----------- 2 files changed, 62 insertions(+), 38 deletions(-) diff --git a/src/stores/contextKeyStore.ts b/src/stores/contextKeyStore.ts index 1f93f3166d..8a8509c146 100644 --- a/src/stores/contextKeyStore.ts +++ b/src/stores/contextKeyStore.ts @@ -8,33 +8,33 @@ export const useContextKeyStore = defineStore('contextKeys', () => { const contextKeys = reactive>({}) /** - * Get a stored context key by path - * @param {string} path - The path to the context key (e.g., 'a.b.c'). - * @returns {boolean|undefined} The value of the context key, or undefined if not found. + * Get a stored context key by path. + * @param {string} path - The dot-separated path to the context key (e.g., 'a.b.c'). + * @returns {ContextValue | undefined} The value of the context key, or undefined if not found. */ function getContextKey(path: string): ContextValue | undefined { return get(contextKeys, path) } /** - * Set or update a context key value at a given path - * @param {string} path - The path to the context key (e.g., 'a.b.c'). - * @param {boolean} value - The value to set for the context key. + * Set or update a context key value at a given path. + * @param {string} path - The dot-separated path to the context key (e.g., 'a.b.c'). + * @param {ContextValue} value - The value to set for the context key. */ function setContextKey(path: string, value: ContextValue) { set(contextKeys, path, value) } /** - * Remove a context key by path - * @param {string} path - The path to the context key to remove (e.g., 'a.b.c'). + * Remove a context key by path. + * @param {string} path - The dot-separated path to the context key to remove (e.g., 'a.b.c'). */ function removeContextKey(path: string) { unset(contextKeys, path) } /** - * Clear all context keys + * Clear all context keys from the store. */ function clearAllContextKeys() { for (const key in contextKeys) { @@ -45,8 +45,8 @@ export const useContextKeyStore = defineStore('contextKeys', () => { /** * Evaluates a context key expression string using the current context keys. * Returns false if the expression is invalid or if any referenced key is undefined. - * @param {string} expr - The expression string (e.g., "key1 && !key2 || (key3 && key4)"). - * @returns {boolean} The result of the expression evaluation. + * @param {string} expr - The expression string to evaluate (e.g., "key1 && !key2 || (key3 == 'type2')"). + * @returns {boolean} The result of the expression evaluation. Returns false if the expression is invalid. */ function evaluateCondition(expr: string): boolean { return evaluateExpression(expr, getContextKey) diff --git a/src/utils/expressionParserUtil.ts b/src/utils/expressionParserUtil.ts index 0de3649aa9..5b921b5b6a 100644 --- a/src/utils/expressionParserUtil.ts +++ b/src/utils/expressionParserUtil.ts @@ -34,44 +34,50 @@ const OP_PRECEDENCE: Record = { '>=': 3 } -// hoist and reuse the regex, avoid re‑allocating literal each call +// Regular expression for tokenizing expressions const TOKEN_REGEX = /\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|==|!=|<=|>=|&&|\|\||<|>|[A-Za-z0-9_.]+|!|\(|\))\s*/g -// cache parsed ASTs per expression + +// Cache for storing parsed ASTs to improve performance const astCache = new Map() /** - * Tokenizes a context key expression string. - * @param expr The expression string (e.g., "key1 && !key2 || (key3 && key4)"). - * @returns An array of tokens. - * @throws Error if invalid characters are found. + * Tokenizes a context key expression string into an array of tokens. + * + * This function breaks down an expression string into smaller components (tokens) + * that can be parsed into an Abstract Syntax Tree (AST). + * + * @param {string} expr - The expression string to tokenize (e.g., "key1 && !key2 || (key3 && key4)"). + * @returns {Token[]} An array of tokens representing the components of the expression. + * @throws {Error} If invalid characters are found in the expression. */ export function tokenize(expr: string): Token[] { const tokens: Token[] = [] let pos = 0 - // clone/reset regex state - const re = new RegExp(TOKEN_REGEX) + const re = new RegExp(TOKEN_REGEX) // Clone/reset regex state let m: RegExpExecArray | null while ((m = re.exec(expr))) { if (m.index !== pos) { - throw new Error(`Invalid character in expression at pos ${pos}`) + throw new Error(`Invalid character in expression at position ${pos}`) } tokens.push({ t: m[1] }) pos = re.lastIndex } if (pos !== expr.length) { - throw new Error(`Invalid character in expression at pos ${pos}`) + throw new Error(`Invalid character in expression at position ${pos}`) } return tokens } /** * Parses a sequence of tokens into an Abstract Syntax Tree (AST). - * Implements a simple recursive descent parser for boolean expressions - * with precedence (NOT > AND > OR) and parentheses. - * @param tokens The array of tokens from `tokenize`. - * @returns The root node of the AST. - * @throws Error on syntax errors (e.g., mismatched parentheses, unexpected tokens). + * + * This function implements a recursive descent parser for boolean expressions + * with support for operator precedence and parentheses. + * + * @param {Token[]} tokens - The array of tokens generated by `tokenize`. + * @returns {ASTNode} The root node of the parsed AST. + * @throws {Error} If there are syntax errors, such as mismatched parentheses or unexpected tokens. */ export function parseAST(tokens: Token[]): ASTNode { let i = 0 @@ -130,7 +136,6 @@ export function parseAST(tokens: Token[]): ASTNode { if (prec === undefined || prec < minPrec) break consume(tok) const right = parseExpression(prec + 1) - // cast tok to the exact operator union left = { type: 'Binary', op: tok as BinaryNode['op'], left, right } } return left @@ -144,7 +149,12 @@ export function parseAST(tokens: Token[]): ASTNode { } /** - * Converts a ContextValue or undefined to boolean. + * Converts a ContextValue or undefined to a boolean value. + * + * This utility ensures consistent truthy/falsy evaluation for different types of values. + * + * @param {ContextValue | undefined} val - The value to convert. + * @returns {boolean} The boolean representation of the value. */ function toBoolean(val: ContextValue | undefined): boolean { if (val === undefined) return false @@ -155,7 +165,14 @@ function toBoolean(val: ContextValue | undefined): boolean { } /** - * Retrieves raw value of an AST node for equality checks. + * Retrieves the raw value of an AST node for equality checks. + * + * This function resolves the value of a node, whether it's a literal, identifier, + * or a nested expression, for comparison purposes. + * + * @param {ASTNode} node - The AST node to evaluate. + * @param {(key: string) => ContextValue | undefined} getContextKey - A function to retrieve context key values. + * @returns {ContextValue | boolean} The raw value of the node. */ function getRawValue( node: ASTNode, @@ -170,11 +187,15 @@ function getRawValue( } /** - * Evaluates an AST node recursively. - * @param node The AST node to evaluate. - * @param getContextKey A function to retrieve the boolean value of a context key identifier. - * @returns The boolean result of the evaluation. - * @throws Error for unknown AST node types. + * Evaluates an AST node recursively to compute its boolean value. + * + * This function traverses the AST and evaluates each node based on its type + * (e.g., literal, identifier, unary, or binary). + * + * @param {ASTNode} node - The AST node to evaluate. + * @param {(key: string) => ContextValue | undefined} getContextKey - A function to retrieve context key values. + * @returns {boolean} The boolean result of the evaluation. + * @throws {Error} If the AST node type is unknown or unsupported. */ export function evalAst( node: ASTNode, @@ -221,10 +242,13 @@ export function evalAst( /** * Parses and evaluates a context key expression string. * - * @param expr The expression string (e.g., "key1 && !key2"). - * @param getContextKey A function to resolve context key identifiers to boolean values. - * @returns The boolean result of the expression. - * @throws Error on parsing or evaluation errors. + * This function combines tokenization, parsing, and evaluation to compute + * the boolean result of an expression. It also caches parsed ASTs for performance. + * + * @param {string} expr - The expression string to evaluate (e.g., "key1 && !key2"). + * @param {(key: string) => ContextValue | undefined} getContextKey - A function to resolve context key identifiers. + * @returns {boolean} The boolean result of the expression. + * @throws {Error} If there are parsing or evaluation errors. */ export function evaluateExpression( expr: string, From f8c556feb34577e90505ad5cd7d6fe16bfeebb0e Mon Sep 17 00:00:00 2001 From: benceruleanlu Date: Wed, 30 Apr 2025 16:45:53 -0400 Subject: [PATCH 12/13] Update expressionParserUtil with comparison unit tests --- tests-ui/tests/utils/expressionParserUtil.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests-ui/tests/utils/expressionParserUtil.test.ts b/tests-ui/tests/utils/expressionParserUtil.test.ts index 8969ef5d0a..7ec93cc9e4 100644 --- a/tests-ui/tests/utils/expressionParserUtil.test.ts +++ b/tests-ui/tests/utils/expressionParserUtil.test.ts @@ -83,7 +83,7 @@ describe('evaluateExpression()', () => { c: true, d: '', num1: 1, - num0: 0 + num2: 2 } const getContextKey = (key: string) => context[key] @@ -107,6 +107,13 @@ describe('evaluateExpression()', () => { expect(evaluateExpression('!b', getContextKey)).toBe(true) }) + it('evaluates comparison operators correctly', () => { + expect(evaluateExpression('num1 < num2', getContextKey)).toBe(true) + expect(evaluateExpression('num3 > num2', getContextKey)).toBe(true) + expect(evaluateExpression('num1 <= num2', getContextKey)).toBe(true) + expect(evaluateExpression('num3 >= num2', getContextKey)).toBe(true) + }) + it('respects operator precedence and parentheses', () => { expect(evaluateExpression('a || b && c', getContextKey)).toBe(true) expect(evaluateExpression('(a || b) && c', getContextKey)).toBe(true) From a77c954353a9ba3e1d1c275feb66389c19ef6b42 Mon Sep 17 00:00:00 2001 From: benceruleanlu Date: Wed, 30 Apr 2025 17:02:42 -0400 Subject: [PATCH 13/13] Fix broken test expectations --- tests-ui/tests/utils/expressionParserUtil.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests-ui/tests/utils/expressionParserUtil.test.ts b/tests-ui/tests/utils/expressionParserUtil.test.ts index 7ec93cc9e4..f6733d6a73 100644 --- a/tests-ui/tests/utils/expressionParserUtil.test.ts +++ b/tests-ui/tests/utils/expressionParserUtil.test.ts @@ -83,7 +83,8 @@ describe('evaluateExpression()', () => { c: true, d: '', num1: 1, - num2: 2 + num2: 2, + num3: 3 } const getContextKey = (key: string) => context[key] @@ -109,8 +110,8 @@ describe('evaluateExpression()', () => { it('evaluates comparison operators correctly', () => { expect(evaluateExpression('num1 < num2', getContextKey)).toBe(true) - expect(evaluateExpression('num3 > num2', getContextKey)).toBe(true) - expect(evaluateExpression('num1 <= num2', getContextKey)).toBe(true) + expect(evaluateExpression('num1 > num2', getContextKey)).toBe(false) + expect(evaluateExpression('num1 <= num1', getContextKey)).toBe(true) expect(evaluateExpression('num3 >= num2', getContextKey)).toBe(true) })