From 597950e5eadb47aef7dc85113b24ec3825a4ad47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 27 Jul 2025 15:57:34 +0000 Subject: [PATCH 1/2] Initial plan From 3e43946f184da02221dc95f59cc60aba85e50132 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 27 Jul 2025 16:09:27 +0000 Subject: [PATCH 2/2] Implement operator registry system with runtime and codegen classes Co-authored-by: streamich <9773803+streamich@users.noreply.github.com> --- src/ExpressionCodegen.ts | 134 ++++++++++++++++++++ src/ExpressionRuntime.ts | 51 ++++++++ src/OperatorRegistry.ts | 80 ++++++++++++ src/__tests__/operator-registry.spec.ts | 157 ++++++++++++++++++++++++ src/index.ts | 4 + src/registries.ts | 51 ++++++++ 6 files changed, 477 insertions(+) create mode 100644 src/ExpressionCodegen.ts create mode 100644 src/ExpressionRuntime.ts create mode 100644 src/OperatorRegistry.ts create mode 100644 src/__tests__/operator-registry.spec.ts create mode 100644 src/registries.ts diff --git a/src/ExpressionCodegen.ts b/src/ExpressionCodegen.ts new file mode 100644 index 0000000..bc109b0 --- /dev/null +++ b/src/ExpressionCodegen.ts @@ -0,0 +1,134 @@ +import * as util from './util'; +import {Codegen} from '@jsonjoy.com/util/lib/codegen/Codegen'; +import {type ExpressionResult, Literal} from './codegen-steps'; +import {createEvaluate} from './createEvaluate'; +import type {JavaScript} from '@jsonjoy.com/util/lib/codegen'; +import {Vars} from './Vars'; +import type * as types from './types'; +import type {OperatorRegistry} from './OperatorRegistry'; + +export type ExpressionFn = (vars: types.JsonExpressionExecutionContext['vars']) => unknown; + +export interface ExpressionCodegenOptions extends types.JsonExpressionCodegenContext { + expression: types.Expr; +} + +/** + * Code generator for JSON expressions using a specified operator registry. + */ +export class ExpressionCodegen { + protected codegen: Codegen; + protected evaluate: ReturnType; + protected operatorMap: types.OperatorMap; + + public constructor( + private registry: OperatorRegistry, + protected options: ExpressionCodegenOptions + ) { + this.operatorMap = registry.asMap(); + this.codegen = new Codegen({ + args: ['vars'], + epilogue: '', + }); + this.evaluate = createEvaluate({ + operators: this.operatorMap, + ...options + }); + } + + private linkedOperandDeps: Set = new Set(); + private linkOperandDeps = (dependency: unknown, name?: string): string => { + if (name) { + if (this.linkedOperandDeps.has(name)) return name; + this.linkedOperandDeps.add(name); + } else { + name = this.codegen.getRegister(); + } + this.codegen.linkDependency(dependency, name); + return name; + }; + + private operatorConst = (js: JavaScript): string => { + return this.codegen.addConstant(js); + }; + + private subExpression = (expr: types.Expr): ExpressionFn => { + const codegen = new ExpressionCodegen(this.registry, {...this.options, expression: expr}); + const fn = codegen.run().compile(); + return fn; + }; + + protected onExpression(expr: types.Expr | unknown): ExpressionResult { + if (expr instanceof Array) { + if (expr.length === 1) return new Literal(expr[0]); + } else return new Literal(expr); + + const def = this.operatorMap.get(expr[0]); + if (def) { + const [name, , arity, , codegen, impure] = def; + util.assertArity(name, arity, expr); + const operands = expr.slice(1).map((operand) => this.onExpression(operand)); + if (!impure) { + const allLiterals = operands.every((expr) => expr instanceof Literal); + if (allLiterals) { + const result = this.evaluate(expr, {vars: new Vars(undefined)}); + return new Literal(result); + } + } + const ctx: types.OperatorCodegenCtx = { + expr, + operands, + createPattern: this.options.createPattern, + operand: (operand: types.Expression) => this.onExpression(operand), + link: this.linkOperandDeps, + const: this.operatorConst, + subExpression: this.subExpression, + var: (value: string) => this.codegen.var(value), + }; + return codegen(ctx); + } + return new Literal(false); + } + + public run(): this { + const expr = this.onExpression(this.options.expression); + this.codegen.js(`return ${expr};`); + return this; + } + + public generate() { + return this.codegen.generate(); + } + + public compileRaw(): ExpressionFn { + return this.codegen.compile(); + } + + public compile(): ExpressionFn { + const fn = this.compileRaw(); + return (vars: any) => { + try { + return fn(vars); + } catch (err) { + if (err instanceof Error) throw err; + const error = new Error('Expression evaluation error.'); + (error).value = err; + throw error; + } + }; + } + + /** + * Get the operator registry used by this codegen. + */ + getRegistry(): OperatorRegistry { + return this.registry; + } + + /** + * Create a new codegen with a different registry. + */ + withRegistry(registry: OperatorRegistry): ExpressionCodegen { + return new ExpressionCodegen(registry, this.options); + } +} \ No newline at end of file diff --git a/src/ExpressionRuntime.ts b/src/ExpressionRuntime.ts new file mode 100644 index 0000000..0fc00dd --- /dev/null +++ b/src/ExpressionRuntime.ts @@ -0,0 +1,51 @@ +import type {Expr, Literal, JsonExpressionCodegenContext, JsonExpressionExecutionContext} from './types'; +import type {OperatorRegistry} from './OperatorRegistry'; +import {createEvaluate} from './createEvaluate'; + +/** + * Runtime for evaluating JSON expressions using a specified operator registry. + */ +export class ExpressionRuntime { + private evaluateFn: ReturnType; + + constructor( + private registry: OperatorRegistry, + private options: JsonExpressionCodegenContext = {} + ) { + this.evaluateFn = createEvaluate({ + operators: registry.asMap(), + ...options + }); + } + + /** + * Evaluate a JSON expression. + */ + evaluate( + expr: Expr | Literal, + ctx: JsonExpressionExecutionContext & JsonExpressionCodegenContext = {vars: undefined as any} + ): unknown { + return this.evaluateFn(expr, {...this.options, ...ctx}); + } + + /** + * Get the operator registry used by this runtime. + */ + getRegistry(): OperatorRegistry { + return this.registry; + } + + /** + * Create a new runtime with a different registry. + */ + withRegistry(registry: OperatorRegistry): ExpressionRuntime { + return new ExpressionRuntime(registry, this.options); + } + + /** + * Create a new runtime with different options. + */ + withOptions(options: JsonExpressionCodegenContext): ExpressionRuntime { + return new ExpressionRuntime(this.registry, {...this.options, ...options}); + } +} \ No newline at end of file diff --git a/src/OperatorRegistry.ts b/src/OperatorRegistry.ts new file mode 100644 index 0000000..4c8c3bc --- /dev/null +++ b/src/OperatorRegistry.ts @@ -0,0 +1,80 @@ +import type {OperatorDefinition, OperatorMap, Expression} from './types'; +import {operatorsToMap} from './util'; + +/** + * Registry for operators that can be used in JSON expressions. + * Allows building custom operator sets for different use cases. + */ +export class OperatorRegistry { + private operators: OperatorDefinition[] = []; + private operatorMap: OperatorMap | null = null; + + constructor(operators: OperatorDefinition[] = []) { + this.operators = [...operators]; + } + + /** + * Add one or more operators to the registry. + */ + add(...operators: OperatorDefinition[]): this { + this.operators.push(...operators); + this.operatorMap = null; // Invalidate cache + return this; + } + + /** + * Remove an operator by name. + */ + remove(name: string): this { + this.operators = this.operators.filter(([operatorName]) => operatorName !== name); + this.operatorMap = null; // Invalidate cache + return this; + } + + /** + * Check if an operator exists in the registry. + */ + has(name: string): boolean { + return this.operators.some(([operatorName, aliases]) => + operatorName === name || aliases.includes(name) + ); + } + + /** + * Get all operators as an array. + */ + all(): OperatorDefinition[] { + return [...this.operators]; + } + + /** + * Get operators as a Map for efficient lookup. + */ + asMap(): OperatorMap { + if (!this.operatorMap) { + this.operatorMap = operatorsToMap(this.operators); + } + return this.operatorMap; + } + + /** + * Create a new registry with the same operators. + */ + clone(): OperatorRegistry { + return new OperatorRegistry(this.operators); + } + + /** + * Merge this registry with another registry. + */ + merge(other: OperatorRegistry): OperatorRegistry { + return new OperatorRegistry([...this.operators, ...other.operators]); + } + + /** + * Get the number of operators in the registry. + */ + size(): number { + return this.operators.length; + } +} \ No newline at end of file diff --git a/src/__tests__/operator-registry.spec.ts b/src/__tests__/operator-registry.spec.ts new file mode 100644 index 0000000..760523e --- /dev/null +++ b/src/__tests__/operator-registry.spec.ts @@ -0,0 +1,157 @@ +import {OperatorRegistry} from '../OperatorRegistry'; +import {ExpressionRuntime} from '../ExpressionRuntime'; +import {ExpressionCodegen} from '../ExpressionCodegen'; +import {defaultOperatorRegistry, extendedOperatorRegistry} from '../registries'; +import {Vars} from '../Vars'; +import {arithmeticOperators} from '../operators/arithmetic'; +import {comparisonOperators} from '../operators/comparison'; +import {binaryOperators} from '../operators/binary'; + +describe('OperatorRegistry', () => { + test('can create empty registry', () => { + const registry = new OperatorRegistry(); + expect(registry.size()).toBe(0); + }); + + test('can add operators', () => { + const registry = new OperatorRegistry(); + registry.add(...arithmeticOperators); + expect(registry.size()).toBe(arithmeticOperators.length); + expect(registry.has('+')).toBe(true); + expect(registry.has('add')).toBe(true); // alias + }); + + test('can remove operators', () => { + const registry = new OperatorRegistry(arithmeticOperators); + expect(registry.has('+')).toBe(true); + registry.remove('+'); + expect(registry.has('+')).toBe(false); + }); + + test('can clone registry', () => { + const registry = new OperatorRegistry(arithmeticOperators); + const cloned = registry.clone(); + expect(cloned.size()).toBe(registry.size()); + expect(cloned.has('+')).toBe(true); + }); + + test('can merge registries', () => { + const registry1 = new OperatorRegistry(arithmeticOperators); + const registry2 = new OperatorRegistry(comparisonOperators); + const merged = registry1.merge(registry2); + expect(merged.size()).toBe(arithmeticOperators.length + comparisonOperators.length); + expect(merged.has('+')).toBe(true); + expect(merged.has('==')).toBe(true); + }); + + test('asMap returns correct operator map', () => { + const registry = new OperatorRegistry(arithmeticOperators); + const map = registry.asMap(); + expect(map.get('+')).toBeDefined(); + expect(map.get('add')).toBeDefined(); // alias + expect(map.get('+')).toBe(map.get('add')); // should be same operator + }); +}); + +describe('ExpressionRuntime', () => { + test('can evaluate expressions with custom registry', () => { + const registry = new OperatorRegistry(arithmeticOperators); + const runtime = new ExpressionRuntime(registry); + const result = runtime.evaluate(['+', 1, 2], {vars: new Vars(null)}); + expect(result).toBe(3); + }); + + test('throws error for unknown operators', () => { + const registry = new OperatorRegistry(); // empty registry + const runtime = new ExpressionRuntime(registry); + expect(() => { + runtime.evaluate(['+', 1, 2], {vars: new Vars(null)}); + }).toThrow(); + }); + + test('can switch registry', () => { + const arithmeticRegistry = new OperatorRegistry(arithmeticOperators); + const comparisonRegistry = new OperatorRegistry(comparisonOperators); + + const runtime = new ExpressionRuntime(arithmeticRegistry); + expect(runtime.evaluate(['+', 1, 2], {vars: new Vars(null)})).toBe(3); + + const newRuntime = runtime.withRegistry(comparisonRegistry); + expect(newRuntime.evaluate(['==', 1, 1], {vars: new Vars(null)})).toBe(true); + }); +}); + +describe('ExpressionCodegen', () => { + test('can compile expressions with custom registry', () => { + const registry = new OperatorRegistry(arithmeticOperators); + const codegen = new ExpressionCodegen(registry, {expression: ['+', 1, 2]}); + const fn = codegen.run().compile(); + const result = fn(new Vars(null)); + expect(result).toBe(3); + }); + + test('throws error for unknown operators in codegen', () => { + const registry = new OperatorRegistry(); // empty registry + const codegen = new ExpressionCodegen(registry, {expression: ['+', 1, 2]}); + const fn = codegen.run().compile(); + const result = fn(new Vars(null)); + expect(result).toBe(false); // returns false for unknown operators + }); + + test('can switch registry', () => { + const arithmeticRegistry = new OperatorRegistry(arithmeticOperators); + const comparisonRegistry = new OperatorRegistry(comparisonOperators); + + const codegen = new ExpressionCodegen(arithmeticRegistry, {expression: ['+', 1, 2]}); + expect(codegen.run().compile()(new Vars(null))).toBe(3); + + const newCodegen = codegen.withRegistry(comparisonRegistry); + const newCodegenWithExpr = new ExpressionCodegen(comparisonRegistry, {expression: ['==', 1, 1]}); + expect(newCodegenWithExpr.run().compile()(new Vars(null))).toBe(true); + }); +}); + +describe('Built-in Registries', () => { + test('default registry has core operators', () => { + expect(defaultOperatorRegistry.has('+')).toBe(true); + expect(defaultOperatorRegistry.has('==')).toBe(true); + expect(defaultOperatorRegistry.has('and')).toBe(true); + expect(defaultOperatorRegistry.has('type')).toBe(true); + expect(defaultOperatorRegistry.has('len')).toBe(true); + expect(defaultOperatorRegistry.has('cat')).toBe(true); + expect(defaultOperatorRegistry.has('concat')).toBe(true); + expect(defaultOperatorRegistry.has('keys')).toBe(true); + expect(defaultOperatorRegistry.has('if')).toBe(true); + expect(defaultOperatorRegistry.has('get')).toBe(true); + }); + + test('default registry does not have advanced operators', () => { + expect(defaultOperatorRegistry.has('u8')).toBe(false); // binary + expect(defaultOperatorRegistry.has('&')).toBe(false); // bitwise + expect(defaultOperatorRegistry.has('jp.add')).toBe(false); // patch + }); + + test('extended registry has all operators', () => { + expect(extendedOperatorRegistry.has('+')).toBe(true); // arithmetic + expect(extendedOperatorRegistry.has('==')).toBe(true); // comparison + expect(extendedOperatorRegistry.has('u8')).toBe(true); // binary + expect(extendedOperatorRegistry.has('&')).toBe(true); // bitwise + expect(extendedOperatorRegistry.has('jp.add')).toBe(true); // patch + }); + + test('extended registry has more operators than default', () => { + expect(extendedOperatorRegistry.size()).toBeGreaterThan(defaultOperatorRegistry.size()); + }); + + test('can evaluate with default registry', () => { + const runtime = new ExpressionRuntime(defaultOperatorRegistry); + const result = runtime.evaluate(['+', ['*', 2, 3], 4], {vars: new Vars(null)}); + expect(result).toBe(10); + }); + + test('can evaluate with extended registry', () => { + const runtime = new ExpressionRuntime(extendedOperatorRegistry); + const result = runtime.evaluate(['+', ['*', 2, 3], 4], {vars: new Vars(null)}); + expect(result).toBe(10); + }); +}); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 088ff68..2cee7dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,7 @@ export * from './types'; export * from './evaluate'; export * from './codegen'; export * from './Vars'; +export * from './OperatorRegistry'; +export * from './ExpressionRuntime'; +export {ExpressionCodegen, ExpressionCodegenOptions, ExpressionFn} from './ExpressionCodegen'; +export * from './registries'; diff --git a/src/registries.ts b/src/registries.ts new file mode 100644 index 0000000..4cd16c6 --- /dev/null +++ b/src/registries.ts @@ -0,0 +1,51 @@ +import {OperatorRegistry} from './OperatorRegistry'; +import {arithmeticOperators} from './operators/arithmetic'; +import {comparisonOperators} from './operators/comparison'; +import {logicalOperators} from './operators/logical'; +import {typeOperators} from './operators/type'; +import {containerOperators} from './operators/container'; +import {stringOperators} from './operators/string'; +import {arrayOperators} from './operators/array'; +import {objectOperators} from './operators/object'; +import {branchingOperators} from './operators/branching'; +import {inputOperators} from './operators/input'; +import {binaryOperators} from './operators/binary'; +import {bitwiseOperators} from './operators/bitwise'; +import {patchOperators} from './operators/patch'; + +/** + * Default operator registry containing the most commonly used operators. + * Includes: arithmetic, comparison, logical, type, container, string, array, object, branching, and input operators. + */ +export const defaultOperatorRegistry = new OperatorRegistry([ + ...arithmeticOperators, + ...comparisonOperators, + ...logicalOperators, + ...typeOperators, + ...containerOperators, + ...stringOperators, + ...arrayOperators, + ...objectOperators, + ...branchingOperators, + ...inputOperators, +]); + +/** + * Extended operator registry containing all available operators. + * Includes everything from the default registry plus: binary, bitwise, and patch operators. + */ +export const extendedOperatorRegistry = new OperatorRegistry([ + ...arithmeticOperators, + ...comparisonOperators, + ...logicalOperators, + ...typeOperators, + ...containerOperators, + ...stringOperators, + ...arrayOperators, + ...objectOperators, + ...branchingOperators, + ...inputOperators, + ...binaryOperators, + ...bitwiseOperators, + ...patchOperators, +]); \ No newline at end of file