Compare commits

...

14 Commits

Author SHA1 Message Date
Benjamin Lu
637795a973 Merge branch 'main' into conditional-keybinds 2025-05-28 12:15:44 -04:00
benceruleanlu
a77c954353 Fix broken test expectations 2025-05-02 19:53:05 -04:00
benceruleanlu
f8c556feb3 Update expressionParserUtil with comparison unit tests 2025-05-02 19:53:03 -04:00
benceruleanlu
da6c62aa80 Update JSDocs to reflect expression changes 2025-05-02 19:53:01 -04:00
benceruleanlu
2cdddf221b Support comparison operators 2025-05-02 19:52:59 -04:00
benceruleanlu
1003bd61a0 Boilerplate unit tests for expressionParserUtil 2025-05-02 19:52:57 -04:00
benceruleanlu
a74dc0cde2 Nerf contextKeyStore tests 2025-05-02 19:52:55 -04:00
benceruleanlu
9673560ced cache + TOKEN_REGEX 2025-05-02 19:52:53 -04:00
benceruleanlu
9e43303846 Extract toBoolean helper 2025-05-02 19:52:51 -04:00
benceruleanlu
ff83bbd4da Extract expression parsing to Util file 2025-05-02 19:52:49 -04:00
benceruleanlu
9272179bce fix bad re.lastIndex usage 2025-05-02 19:52:46 -04:00
benceruleanlu
d366a1e8ef "e2e" parseAST tests 2025-05-02 19:52:37 -04:00
benceruleanlu
758721753f support literals 2025-05-02 19:52:35 -04:00
benceruleanlu
422da7e7d6 Add contextKeyStore and condition 2025-05-02 19:52:30 -04:00
7 changed files with 517 additions and 2 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,63 @@
import { get, set, unset } from 'lodash'
import { defineStore } from 'pinia'
import { reactive } from 'vue'
import { ContextValue, evaluateExpression } from '@/utils/expressionParserUtil'
export const useContextKeyStore = defineStore('contextKeys', () => {
const contextKeys = reactive<Record<string, ContextValue>>({})
/**
* 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 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 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 from the store.
*/
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 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)
}
return {
contextKeys,
getContextKey,
setContextKey,
removeContextKey,
clearAllContextKeys,
evaluateCondition
}
})

View File

@@ -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
}
}

View File

@@ -0,0 +1,273 @@
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<string, number> = {
'||': 1,
'&&': 2,
'==': 3,
'!=': 3,
'<': 3,
'>': 3,
'<=': 3,
'>=': 3
}
// Regular expression for tokenizing expressions
const TOKEN_REGEX =
/\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|==|!=|<=|>=|&&|\|\||<|>|[A-Za-z0-9_.]+|!|\(|\))\s*/g
// Cache for storing parsed ASTs to improve performance
const astCache = new Map<string, ASTNode>()
/**
* 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
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 position ${pos}`)
}
tokens.push({ t: m[1] })
pos = re.lastIndex
}
if (pos !== expr.length) {
throw new Error(`Invalid character in expression at position ${pos}`)
}
return tokens
}
/**
* Parses a sequence of tokens into an Abstract Syntax Tree (AST).
*
* 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
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)
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
}
/**
* 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
if (typeof val === 'boolean') return val
if (typeof val === 'number') return val !== 0
if (typeof val === 'string') return val.length > 0
return false
}
/**
* 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,
getContextKey: (key: string) => ContextValue | undefined
): ContextValue | boolean {
if (node.type === 'Literal') return node.value
if (node.type === 'Identifier') {
const val = getContextKey(node.name)
return val === undefined ? false : val
}
return evalAst(node, getContextKey)
}
/**
* 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,
getContextKey: (key: string) => ContextValue | undefined
): boolean {
switch (node.type) {
case 'Literal':
return toBoolean(node.value)
case 'Identifier':
return toBoolean(getContextKey(node.name))
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 = getRawValue(left, getContextKey)
const rRaw = getRawValue(right, getContextKey)
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}`)
}
}
/**
* Parses and evaluates a context key expression string.
*
* 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,
getContextKey: (key: string) => ContextValue | undefined
): boolean {
if (!expr) return true
try {
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)
return false
}
}

View File

@@ -0,0 +1,37 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useContextKeyStore } from '@/stores/contextKeyStore'
describe('contextKeyStore', () => {
let store: ReturnType<typeof useContextKeyStore>
beforeEach(() => {
setActivePinia(createPinia())
store = useContextKeyStore()
})
it('should set and get a context key', () => {
store.setContextKey('key1', true)
expect(store.getContextKey('key1')).toBe(true)
})
it('should remove a context key', () => {
store.setContextKey('key1', true)
store.removeContextKey('key1')
expect(store.getContextKey('key1')).toBeUndefined()
})
it('should clear all context keys', () => {
store.setContextKey('key1', true)
store.setContextKey('key2', false)
store.clearAllContextKeys()
expect(Object.keys(store.contextKeys)).toHaveLength(0)
})
it('should evaluate a simple condition', () => {
store.setContextKey('key1', true)
store.setContextKey('key2', false)
expect(store.evaluateCondition('key1 && !key2')).toBe(true)
})
})

View File

@@ -0,0 +1,128 @@
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<string, ContextValue> = {
a: true,
b: false,
c: true,
d: '',
num1: 1,
num2: 2,
num3: 3
}
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('evaluates comparison operators correctly', () => {
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)
})
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)
})
})