From 43842adc870df4cea8bf5578a49f7eeebef009c5 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath <2+defunkt@users.noreply.github.com> Date: Fri, 10 Oct 2025 14:21:43 -0700 Subject: [PATCH] TRY_CALL --- GUIDE.md | 27 +++++++++ SPEC.md | 127 ++++++++++++++++++++++++++-------------- src/bytecode.ts | 4 ++ src/opcode.ts | 1 + src/validator.ts | 7 ++- src/vm.ts | 21 +++++++ tests/functions.test.ts | 121 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 261 insertions(+), 47 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index 62453ca..c2587eb 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -219,6 +219,7 @@ CALL - `CALL` - Call function (see calling convention above) - `TAIL_CALL` - Tail-recursive call (no stack growth) - `RETURN` - Return from function (pops return value) +- `TRY_CALL ` - Call function (if found), push value (if exists), or push name as string (if not found) - `BREAK` - Exit iterator/loop (unwinds to break target) ### Arrays @@ -404,6 +405,32 @@ STORE factorial TAIL_CALL ; Reuses stack frame ``` +### Optional Function Calls (TRY_CALL) +Call function if defined, otherwise use value or name as string: +``` +; Define optional hook +MAKE_FUNCTION () .onInit +STORE onInit + +; Later: call if defined, skip if not +TRY_CALL onInit ; Calls onInit() if it's a function + ; Pushes value if it exists but isn't a function + ; Pushes "onInit" as string if undefined + +; Use with values +PUSH 42 +STORE answer +TRY_CALL answer ; Pushes 42 (not a function) + +; Use with undefined +TRY_CALL unknown ; Pushes "unknown" as string +``` + +**Use Cases**: +- Optional hooks/callbacks in DSLs +- Shell-like languages where unknown identifiers become strings +- Templating systems with optional transformers + ## Key Concepts ### Truthiness diff --git a/SPEC.md b/SPEC.md index d884a0f..9d8add2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -46,7 +46,7 @@ type Value = **toNumber**: number → identity, string → parseFloat (or 0), boolean → 1/0, others → 0 -**toString**: string → identity, number → string, boolean → string, null → "null", +**toString**: string → identity, number → string, boolean → string, null → "null", function → "", array → "[item, item]", dict → "{key: value, ...}" **isTrue**: Only `null` and `false` are falsy. Everything else (including `0`, `""`, empty arrays, empty dicts) is truthy. @@ -124,26 +124,26 @@ type ExceptionHandler = { ### Stack Operations #### PUSH -**Operand**: Index into constants pool (number) -**Effect**: Push constant onto stack +**Operand**: Index into constants pool (number) +**Effect**: Push constant onto stack **Stack**: [] → [value] #### POP -**Operand**: None -**Effect**: Discard top of stack +**Operand**: None +**Effect**: Discard top of stack **Stack**: [value] → [] #### DUP -**Operand**: None -**Effect**: Duplicate top of stack +**Operand**: None +**Effect**: Duplicate top of stack **Stack**: [value] → [value, value] ### Variable Operations #### LOAD -**Operand**: Variable name (string) -**Effect**: Push variable value onto stack -**Stack**: [] → [value] +**Operand**: Variable name (string) +**Effect**: Push variable value onto stack +**Stack**: [] → [value] **Errors**: Throws if variable not found in scope chain #### STORE @@ -178,7 +178,7 @@ TRY_LOAD y ; Pushes "y" (variable doesn't exist) All arithmetic operations pop two values, perform operation, push result as number. #### ADD -**Stack**: [a, b] → [a + b] +**Stack**: [a, b] → [a + b] **Note**: Only for numbers (use separate string concat if needed) #### SUB @@ -265,9 +265,9 @@ end: **Stack**: [condition] → [] #### BREAK -**Operand**: None -**Effect**: Unwind call stack until frame with `isBreakTarget = true`, resume there -**Stack**: No change +**Operand**: None +**Effect**: Unwind call stack until frame with `isBreakTarget = true`, resume there +**Stack**: No change **Errors**: Throws if no break target found **Behavior**: @@ -281,16 +281,16 @@ end: ### Exception Handling #### PUSH_TRY -**Operand**: Catch block offset (number) -**Effect**: Push exception handler +**Operand**: Catch block offset (number) +**Effect**: Push exception handler **Stack**: No change Registers a try block. If THROW occurs before POP_TRY, execution jumps to catch address. #### PUSH_FINALLY -**Operand**: Finally block offset (number) -**Effect**: Add finally address to most recent exception handler -**Stack**: No change +**Operand**: Finally block offset (number) +**Effect**: Add finally address to most recent exception handler +**Stack**: No change **Errors**: Throws if no exception handler to modify Adds a finally block to the current try/catch. The finally block will execute whether an exception is thrown or not. @@ -312,8 +312,8 @@ Adds a finally block to the current try/catch. The finally block will execute wh - Finally blocks should end with normal control flow (no special terminator needed) #### THROW -**Operand**: None -**Effect**: Throw exception with error value from stack +**Operand**: None +**Effect**: Throw exception with error value from stack **Stack**: [errorValue] → (unwound) **Behavior**: @@ -333,8 +333,8 @@ Adds a finally block to the current try/catch. The finally block will execute wh ### Function Operations #### MAKE_FUNCTION -**Operand**: Index into constants pool (number) -**Effect**: Create function value, capturing current scope +**Operand**: Index into constants pool (number) +**Effect**: Create function value, capturing current scope **Stack**: [] → [function] The constant must be a `function_def` with: @@ -391,8 +391,8 @@ The created function captures `currentScope` as its `parentScope`. - Enables unbounded tail recursion without stack overflow #### RETURN -**Operand**: None -**Effect**: Return from function +**Operand**: None +**Effect**: Return from function **Stack**: [returnValue] → (restored stack with returnValue on top) **Behavior**: @@ -404,19 +404,56 @@ The created function captures `currentScope` as its `parentScope`. **Errors**: Throws if no call frame to return from +#### TRY_CALL +**Operand**: Variable name (string) +**Effect**: Conditionally call function or push value/string onto stack +**Stack**: [] → [returnValue | value | name] +**Errors**: Never throws (unlike CALL) + +**Behavior**: +1. Look up variable by name in scope chain +2. **If variable is a function**: Call it with 0 arguments (no positional, no named) and push the returned value onto the stack. +3. **If variable exists but is not a function**: Push the variable's value onto stack +4. **If variable doesn't exist**: Push the variable name as a string onto stack + +**Use Cases**: +- DSL/templating languages with "call if callable, otherwise use as literal" semantics +- Shell-like behavior where unknown identifiers become strings +- Optional function hooks (call if defined, silently skip if not) + +**Implementation Note**: +- Uses intentional fall-through in VM switch statement from TRY_CALL to CALL case +- When function is found, stacks are set up to match CALL's expectations exactly +- No break target marking or frame pushing occurs when non-function value is found + +**Example**: +``` +MAKE_FUNCTION () .body +STORE greet +PUSH 42 +STORE answer +TRY_CALL greet ; Calls function greet(), returns its value +TRY_CALL answer ; Pushes 42 (number value) +TRY_CALL unknown ; Pushes "unknown" (string) + +.body: + PUSH "Hello!" + RETURN +``` + ### Array Operations #### MAKE_ARRAY -**Operand**: Number of items (number) -**Effect**: Create array from N stack items +**Operand**: Number of items (number) +**Effect**: Create array from N stack items **Stack**: [item1, item2, ..., itemN] → [array] Items are popped in reverse order (item1 is array[0]). #### ARRAY_GET -**Operand**: None -**Effect**: Get array element at index -**Stack**: [array, index] → [value] +**Operand**: None +**Effect**: Get array element at index +**Stack**: [array, index] → [value] **Errors**: Throws if not array or index out of bounds Index is coerced to number and floored. @@ -442,41 +479,41 @@ Index is coerced to number and floored. ### Dictionary Operations #### MAKE_DICT -**Operand**: Number of key-value pairs (number) -**Effect**: Create dict from N key-value pairs +**Operand**: Number of key-value pairs (number) +**Effect**: Create dict from N key-value pairs **Stack**: [key1, val1, key2, val2, ...] → [dict] Keys are coerced to strings. #### DICT_GET -**Operand**: None -**Effect**: Get dict value for key +**Operand**: None +**Effect**: Get dict value for key **Stack**: [dict, key] → [value] -Returns null if key not found. Key is coerced to string. +Returns null if key not found. Key is coerced to string. **Errors**: Throws if not dict #### DICT_SET -**Operand**: None -**Effect**: Set dict value for key (mutates dict) +**Operand**: None +**Effect**: Set dict value for key (mutates dict) **Stack**: [dict, key, value] → [] -Key is coerced to string. +Key is coerced to string. **Errors**: Throws if not dict #### DICT_HAS -**Operand**: None -**Effect**: Check if key exists in dict +**Operand**: None +**Effect**: Check if key exists in dict **Stack**: [dict, key] → [boolean] -Key is coerced to string. +Key is coerced to string. **Errors**: Throws if not dict ### TypeScript Interop #### CALL_NATIVE -**Operand**: Function name (string) -**Effect**: Call registered TypeScript function +**Operand**: Function name (string) +**Effect**: Call registered TypeScript function **Stack**: [...args] → [returnValue] **Behavior**: @@ -501,8 +538,8 @@ type TypeScriptFunction = (...args: Value[]) => Promise | Value; ### Special #### HALT -**Operand**: None -**Effect**: Stop execution +**Operand**: None +**Effect**: Stop execution **Stack**: No change ## Label Syntax diff --git a/src/bytecode.ts b/src/bytecode.ts index dcaac4c..c836eb2 100644 --- a/src/bytecode.ts +++ b/src/bytecode.ts @@ -27,6 +27,7 @@ type InstructionTuple = // Variables | ["LOAD", string] | ["STORE", string] + | ["TRY_LOAD", string] // Arithmetic | ["ADD"] | ["SUB"] | ["MUL"] | ["DIV"] | ["MOD"] @@ -54,6 +55,7 @@ type InstructionTuple = | ["CALL"] | ["TAIL_CALL"] | ["RETURN"] + | ["TRY_CALL", string] // Arrays | ["MAKE_ARRAY", number] @@ -329,6 +331,8 @@ function toBytecodeFromArray(program: ProgramItem[]): Bytecode /* throws */ { case "LOAD": case "STORE": + case "TRY_LOAD": + case "TRY_CALL": case "CALL_NATIVE": operandValue = operand as string break diff --git a/src/opcode.ts b/src/opcode.ts index a200bbe..d5d7dcb 100644 --- a/src/opcode.ts +++ b/src/opcode.ts @@ -44,6 +44,7 @@ export enum OpCode { CALL, // operand: none | stack: [fn, ...args, posCount, namedCount] → [result] | marks break target TAIL_CALL, // operand: none | stack: [fn, ...args, posCount, namedCount] → [result] | reuses frame RETURN, // operand: none | stack: [value] → (restored with value) | return from function + TRY_CALL, /// operand: variable name (identifier) | stack: [] → [value] | call a function, load a variable, or load a string // arrays MAKE_ARRAY, // operand: item count (number) | stack: [item1, ..., itemN] → [array] diff --git a/src/validator.ts b/src/validator.ts index 9321cec..6e63071 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -34,6 +34,8 @@ const OPCODES_WITH_OPERANDS = new Set([ OpCode.PUSH, OpCode.LOAD, OpCode.STORE, + OpCode.TRY_LOAD, + OpCode.TRY_CALL, OpCode.JUMP, OpCode.JUMP_IF_FALSE, OpCode.JUMP_IF_TRUE, @@ -327,8 +329,9 @@ export function validateBytecode(source: string): ValidationResult { } } - // Validate variable names for LOAD/STORE - if ((opCode === OpCode.LOAD || opCode === OpCode.STORE) && + // Validate variable names for LOAD/STORE/TRY_LOAD/TRY_CALL + if ((opCode === OpCode.LOAD || opCode === OpCode.STORE || + opCode === OpCode.TRY_LOAD || opCode === OpCode.TRY_CALL) && !isValidIdentifier(operand)) { errors.push({ line: lineNum, diff --git a/src/vm.ts b/src/vm.ts index 9f66dad..b4e804b 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -353,6 +353,27 @@ export class VM { }) break + // @ts-ignore + case OpCode.TRY_CALL: { + const varName = instruction.operand as string + const value = this.scope.get(varName) + + if (value?.type === 'function') { + this.stack.push(value) + this.stack.push(toValue(0)) + this.stack.push(toValue(0)) + // No `break` here -- we want to fall through to OpCode.CALL! + } else if (value) { + this.stack.push(value) + break + } else { + this.stack.push(toValue(varName)) + break + } + } + + // don't put any `case` statement here - `TRY_CALL` MUST go before `CALL!` + case OpCode.CALL: { // Pop named count from stack (top) const namedCount = toNumber(this.stack.pop()!) diff --git a/tests/functions.test.ts b/tests/functions.test.ts index 61a8d8f..1ba69de 100644 --- a/tests/functions.test.ts +++ b/tests/functions.test.ts @@ -375,3 +375,124 @@ test("CALL - named args with defaults on fixed params", async () => { // x should use default value 5 expect(result).toEqual({ type: 'number', value: 5 }) }) + +test("TRY_CALL - calls function if found", async () => { + const bytecode = toBytecode([ + ["MAKE_FUNCTION", [], ".body"], + ["STORE", "myFunc"], + ["TRY_CALL", "myFunc"], + ["HALT"], + [".body:"], + ["PUSH", 42], + ["RETURN"] + ]) + + const result = await new VM(bytecode).run() + expect(result).toEqual({ type: 'number', value: 42 }) +}) + +test("TRY_CALL - pushes value if variable exists but is not a function", async () => { + const bytecode = toBytecode([ + ["PUSH", 99], + ["STORE", "myVar"], + ["TRY_CALL", "myVar"], + ["HALT"] + ]) + + const result = await new VM(bytecode).run() + expect(result).toEqual({ type: 'number', value: 99 }) +}) + +test("TRY_CALL - pushes string if variable not found", async () => { + const bytecode = toBytecode([ + ["TRY_CALL", "unknownVar"], + ["HALT"] + ]) + + const result = await new VM(bytecode).run() + expect(result).toEqual({ type: 'string', value: 'unknownVar' }) +}) + +test("TRY_CALL - handles arrays", async () => { + const bytecode = toBytecode([ + ["PUSH", 1], + ["PUSH", 2], + ["MAKE_ARRAY", 2], + ["STORE", "myArray"], + ["TRY_CALL", "myArray"], + ["HALT"] + ]) + + const result = await new VM(bytecode).run() + expect(result.type).toBe('array') + if (result.type === 'array') { + expect(result.value).toEqual([ + { type: 'number', value: 1 }, + { type: 'number', value: 2 } + ]) + } +}) + +test("TRY_CALL - handles dicts", async () => { + const bytecode = toBytecode([ + ["PUSH", "key"], + ["PUSH", "value"], + ["MAKE_DICT", 1], + ["STORE", "myDict"], + ["TRY_CALL", "myDict"], + ["HALT"] + ]) + + const result = await new VM(bytecode).run() + expect(result.type).toBe('dict') + if (result.type === 'dict') { + expect(result.value.get('key')).toEqual({ type: 'string', value: 'value' }) + } +}) + +test("TRY_CALL - handles null values", async () => { + const bytecode = toBytecode([ + ["PUSH", null], + ["STORE", "myNull"], + ["TRY_CALL", "myNull"], + ["HALT"] + ]) + + const result = await new VM(bytecode).run() + expect(result).toEqual({ type: 'null', value: null }) +}) + +test("TRY_CALL - function can access its parameters", async () => { + const bytecode = toBytecode([ + ["MAKE_FUNCTION", ["x"], ".body"], + ["STORE", "addFive"], + ["PUSH", 10], + ["STORE", "x"], + ["TRY_CALL", "addFive"], + ["HALT"], + [".body:"], + ["LOAD", "x"], + ["PUSH", 5], + ["ADD"], + ["RETURN"] + ]) + + const result = await new VM(bytecode).run() + // Function is called with 0 args, so x inside function should be null + // Then we add 5 to null (which coerces to 0) + expect(result).toEqual({ type: 'number', value: 5 }) +}) + +test("TRY_CALL - with string format", async () => { + const bytecode = toBytecode(` + MAKE_FUNCTION () #4 + STORE myFunc + TRY_CALL myFunc + HALT + PUSH 100 + RETURN + `) + + const result = await new VM(bytecode).run() + expect(result).toEqual({ type: 'number', value: 100 }) +})