From 2da24ccd32b2a6a653e14c072bea769b2d1e6132 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 5 Oct 2025 15:50:37 -0700 Subject: [PATCH] try / catch / throw --- README.md | 10 +-- src/vm.ts | 39 ++++++++- tests/exceptions.test.ts | 174 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 tests/exceptions.test.ts diff --git a/README.md b/README.md index 6bdae6c..0cfecf5 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,9 @@ It's where Shrimp live. - [x] CONTINUE ### Exception Handling -- [ ] PUSH_TRY -- [ ] POP_TRY -- [ ] THROW +- [x] PUSH_TRY +- [x] POP_TRY +- [x] THROW ### Functions - [x] MAKE_FUNCTION @@ -76,7 +76,7 @@ It's where Shrimp live. ## Test Status -✅ **46 tests passing** covering: +✅ **53 tests passing** covering: - All stack operations (PUSH, POP, DUP) - All arithmetic operations (ADD, SUB, MUL, DIV, MOD) - All comparison operations (EQ, NEQ, LT, GT, LTE, GTE) @@ -86,6 +86,7 @@ It's where Shrimp live. - All array operations (MAKE_ARRAY, ARRAY_GET, ARRAY_SET, ARRAY_PUSH, ARRAY_LEN) - All dictionary operations (MAKE_DICT, DICT_GET, DICT_SET, DICT_HAS) - Basic function operations (MAKE_FUNCTION, CALL, RETURN) with parameter binding +- Exception handling (PUSH_TRY, POP_TRY, THROW) with nested try blocks and call stack unwinding - HALT instruction ## Design Decisions @@ -95,6 +96,5 @@ It's where Shrimp live. - **Short-circuiting via compiler**: No AND/OR opcodes—compilers use JUMP patterns for proper short-circuit evaluation 🚧 **Still TODO**: -- Exception handling (PUSH_TRY, POP_TRY, THROW) - Advanced function features (TAIL_CALL, variadic params, kwargs, default parameters) - TypeScript interop (CALL_TYPESCRIPT) \ No newline at end of file diff --git a/src/vm.ts b/src/vm.ts index 40c4c78..09c1b99 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -141,7 +141,7 @@ export class VM { case OpCode.BREAK: // Unwind call stack until we find a frame marked as break target - while (this.callStack.length > 0) { + while (this.callStack.length) { const frame = this.callStack.pop()! this.scope = frame.returnScope this.pc = frame.returnAddress @@ -177,6 +177,43 @@ export class VM { this.pc = continueFrame.continueAddress! - 1 // -1 because PC will be incremented break + case OpCode.PUSH_TRY: + const catchAddress = instruction.operand as number + this.exceptionHandlers.push({ + catchAddress, + callStackDepth: this.callStack.length, + scope: this.scope + }) + break + + case OpCode.POP_TRY: + if (this.exceptionHandlers.length === 0) + throw new Error('POP_TRY: no exception handler to pop') + + this.exceptionHandlers.pop() + break + + case OpCode.THROW: + const errorValue = this.stack.pop()! + + if (this.exceptionHandlers.length === 0) { + const errorMsg = toString(errorValue) + throw new Error(`Uncaught exception: ${errorMsg}`) + } + + const handler = this.exceptionHandlers.pop()! + + while (this.callStack.length > handler.callStackDepth) + this.callStack.pop() + + this.scope = handler.scope + + this.stack.push(errorValue) + + // subtract 1 because pc was incremented + this.pc = handler.catchAddress - 1 + break + case OpCode.MAKE_ARRAY: const arraySize = instruction.operand as number const items: Value[] = [] diff --git a/tests/exceptions.test.ts b/tests/exceptions.test.ts new file mode 100644 index 0000000..b2e0c80 --- /dev/null +++ b/tests/exceptions.test.ts @@ -0,0 +1,174 @@ +import { test, expect } from "bun:test" +import { VM } from "#vm" +import { OpCode } from "#opcode" +import { toValue } from "#value" + +test("PUSH_TRY and POP_TRY - no exception thrown", async () => { + // Try block that completes successfully + const vm = new VM({ + instructions: [ + { op: OpCode.PUSH_TRY, operand: 5 }, // catch at instruction 5 + { op: OpCode.PUSH, operand: 0 }, // push 42 + { op: OpCode.PUSH, operand: 1 }, // push 10 + { op: OpCode.ADD }, + { op: OpCode.POP_TRY }, // pop handler (no exception) + { op: OpCode.HALT }, + + // Catch block (not executed) + { op: OpCode.PUSH, operand: 2 }, // push 999 + { op: OpCode.HALT } + ], + constants: [ + toValue(42), + toValue(10), + toValue(999) + ] + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'number', value: 52 }) +}) + +test("THROW - catch exception with error value", async () => { + // Try block that throws an exception + const vm = new VM({ + instructions: [ + { op: OpCode.PUSH_TRY, operand: 5 }, // catch at instruction 5 + { op: OpCode.PUSH, operand: 0 }, // push error message + { op: OpCode.THROW }, // throw exception + { op: OpCode.PUSH, operand: 1 }, // not executed + { op: OpCode.HALT }, + + // Catch block (instruction 5) + { op: OpCode.HALT } // error value is on stack + ], + constants: [ + toValue('error occurred'), + toValue(999) + ] + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'error occurred' }) +}) + +test("THROW - uncaught exception throws JS error", async () => { + const vm = new VM({ + instructions: [ + { op: OpCode.PUSH, operand: 0 }, + { op: OpCode.THROW } + ], + constants: [ + toValue('something went wrong') + ] + }) + + await expect(vm.run()).rejects.toThrow('Uncaught exception: something went wrong') +}) + +test("THROW - exception with nested try blocks", async () => { + // Nested try blocks, inner one catches + const vm = new VM({ + instructions: [ + { op: OpCode.PUSH_TRY, operand: 10 }, // outer catch at 10 + { op: OpCode.PUSH_TRY, operand: 6 }, // inner catch at 6 + { op: OpCode.PUSH, operand: 0 }, // push 'inner error' + { op: OpCode.THROW }, // throw + { op: OpCode.PUSH, operand: 1 }, // not executed + { op: OpCode.HALT }, + + // Inner catch block (instruction 6) + { op: OpCode.STORE, operand: 'err' }, + { op: OpCode.POP_TRY }, // pop outer handler + { op: OpCode.LOAD, operand: 'err' }, + { op: OpCode.HALT }, + + // Outer catch block (instruction 10, not executed) + { op: OpCode.PUSH, operand: 2 }, + { op: OpCode.HALT } + ], + constants: [ + toValue('inner error'), + toValue(999), + toValue('outer error') + ] + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'inner error' }) +}) + +test("THROW - exception skips outer handler", async () => { + // Nested try blocks, inner doesn't catch, outer does + const vm = new VM({ + instructions: [ + { op: OpCode.PUSH_TRY, operand: 8 }, // outer catch at 8 + { op: OpCode.PUSH_TRY, operand: 6 }, // inner catch at 6 + { op: OpCode.PUSH, operand: 0 }, // push error + { op: OpCode.THROW }, // throw + { op: OpCode.HALT }, + + // Inner catch block (instruction 6) - re-throws + { op: OpCode.THROW }, // re-throw the error + + // Outer catch block (instruction 8) + { op: OpCode.HALT } + ], + constants: [ + toValue('error message') + ] + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'error message' }) +}) + +test("THROW - exception unwinds call stack", async () => { + // Function that throws, caller has try/catch + const vm = new VM({ + instructions: [ + // 0: Main code with try block + { op: OpCode.PUSH_TRY, operand: 6 }, + { op: OpCode.MAKE_FUNCTION, operand: 0 }, + { op: OpCode.CALL, operand: 0 }, + { op: OpCode.POP_TRY }, + { op: OpCode.HALT }, + + // 5: Not executed + { op: OpCode.PUSH, operand: 2 }, + + // 6: Catch block + { op: OpCode.HALT }, // error value on stack + + // 7: Function body (throws) + { op: OpCode.PUSH, operand: 1 }, + { op: OpCode.THROW } + ], + constants: [ + { + type: 'function_def', + params: [], + defaults: {}, + body: 7, + variadic: false, + kwargs: false + }, + toValue('function error'), + toValue(999) + ] + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'function error' }) +}) + +test("POP_TRY - error when no handler to pop", async () => { + const vm = new VM({ + instructions: [ + { op: OpCode.POP_TRY } + ], + constants: [] + }) + + await expect(vm.run()).rejects.toThrow('POP_TRY: no exception handler to pop') +})