diff --git a/README.md b/README.md index 0cfecf5..fb07101 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ It's where Shrimp live. ### Exception Handling - [x] PUSH_TRY +- [x] PUSH_FINALLY - [x] POP_TRY - [x] THROW @@ -76,7 +77,7 @@ It's where Shrimp live. ## Test Status -✅ **53 tests passing** covering: +✅ **58 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,7 +87,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 +- Exception handling (PUSH_TRY, PUSH_FINALLY, POP_TRY, THROW) with nested try/finally blocks and call stack unwinding - HALT instruction ## Design Decisions diff --git a/SPEC.md b/SPEC.md index 48ece35..717cdbc 100644 --- a/SPEC.md +++ b/SPEC.md @@ -109,6 +109,7 @@ type CallFrame = { ```typescript type ExceptionHandler = { catchAddress: number // Where to jump on exception + finallyAddress?: number // Where to jump for finally block (always runs) callStackDepth: number // Call stack depth when handler pushed scope: Scope // Scope to restore in catch block } @@ -263,18 +264,36 @@ end: ### Exception Handling #### PUSH_TRY -**Operand**: Catch block address (number) +**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 +**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. + #### POP_TRY **Operand**: None **Effect**: Pop exception handler (try block completed without exception) **Stack**: No change **Errors**: Throws if no handler to pop +**Behavior**: +1. Pop exception handler +2. If handler has `finallyAddress`, jump there +3. Otherwise continue to next instruction + +**Notes**: +- The VM ensures finally runs when try completes normally +- The compiler must ensure catch blocks jump to finally when present +- Finally blocks should end with normal control flow (no special terminator needed) + #### THROW **Operand**: None **Effect**: Throw exception with error value from stack @@ -288,6 +307,7 @@ Registers a try block. If THROW occurs before POP_TRY, execution jumps to catch 5. Restore handler's scope 6. Push error value back onto stack 7. Jump to handler's catch address +8. **Note**: After catch block executes, compiler must jump to finally if present ### Function Operations @@ -495,10 +515,10 @@ skip_body: ### Try-Catch ``` -PUSH_TRY N # catch is N instructions ahead +PUSH_TRY catch_label # try block POP_TRY -JUMP M # skip catch block +JUMP end_label catch_label: STORE 'errorVar' # Error is on stack # catch block @@ -597,6 +617,8 @@ All of these should throw errors: - THROW unwinds call stack to handler's depth, not just to handler - Exception handlers form a stack (nested try blocks) - Error value on stack is available in catch block via STORE +- Finally blocks always execute, even if there's a return/break in try or catch +- Finally executes after try (if no exception) or after catch (if exception) ## VM Initialization @@ -616,7 +638,7 @@ const result = await vm.execute() 2. **Type coercion** for arithmetic, comparison, and logical ops 3. **Scope chain** resolution (local, parent, global) 4. **Call frames** (nested calls, return values) -5. **Exception handling** (nested try blocks, unwinding) +5. **Exception handling** (nested try blocks, unwinding, finally blocks) 6. **Break/continue** (nested functions, iterator pattern) 7. **Closures** (capturing variables, multiple nesting levels) 8. **Tail calls** (self-recursive, mutual recursion) diff --git a/src/exception.ts b/src/exception.ts index 21a9fba..f6dc4d3 100644 --- a/src/exception.ts +++ b/src/exception.ts @@ -1,7 +1,8 @@ import { Scope } from "./scope" export type ExceptionHandler = { - catchAddress: number // Where to jump when exception is caught - callStackDepth: number // Call stack depth when handler was pushed - scope: Scope // Scope to restore when catching + catchAddress: number // Where to jump when exception is caught + finallyAddress?: number // Where to jump for `finally` block + callStackDepth: number // Call stack depth when handler was pushed + scope: Scope // Scope to restore when catching } \ No newline at end of file diff --git a/src/opcode.ts b/src/opcode.ts index a48f436..47bf678 100644 --- a/src/opcode.ts +++ b/src/opcode.ts @@ -35,6 +35,7 @@ export enum OpCode { // exception handling PUSH_TRY, + PUSH_FINALLY, POP_TRY, THROW, diff --git a/src/vm.ts b/src/vm.ts index 09c1b99..552a997 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -186,6 +186,14 @@ export class VM { }) break + case OpCode.PUSH_FINALLY: + const finallyAddress = instruction.operand as number + if (this.exceptionHandlers.length === 0) + throw new Error('PUSH_FINALLY: no exception handler to modify') + + this.exceptionHandlers[this.exceptionHandlers.length - 1]!.finallyAddress = finallyAddress + break + case OpCode.POP_TRY: if (this.exceptionHandlers.length === 0) throw new Error('POP_TRY: no exception handler to pop') @@ -201,17 +209,22 @@ export class VM { throw new Error(`Uncaught exception: ${errorMsg}`) } - const handler = this.exceptionHandlers.pop()! + const throwHandler = this.exceptionHandlers.pop()! - while (this.callStack.length > handler.callStackDepth) + while (this.callStack.length > throwHandler.callStackDepth) this.callStack.pop() - this.scope = handler.scope + this.scope = throwHandler.scope this.stack.push(errorValue) - // subtract 1 because pc was incremented - this.pc = handler.catchAddress - 1 + // Jump to finally if present, otherwise jump to catch + const targetAddress = throwHandler.finallyAddress !== undefined + ? throwHandler.finallyAddress + : throwHandler.catchAddress + + // subtract 1 because pc will be incremented + this.pc = targetAddress - 1 break case OpCode.MAKE_ARRAY: diff --git a/tests/exceptions.test.ts b/tests/exceptions.test.ts index b2e0c80..5345da3 100644 --- a/tests/exceptions.test.ts +++ b/tests/exceptions.test.ts @@ -172,3 +172,144 @@ test("POP_TRY - error when no handler to pop", async () => { await expect(vm.run()).rejects.toThrow('POP_TRY: no exception handler to pop') }) + +test("PUSH_FINALLY - finally executes after successful try", async () => { + // Try block completes normally, compiler jumps to finally + const vm = new VM({ + instructions: [ + { op: OpCode.PUSH_TRY, operand: 8 }, // catch at 8 + { op: OpCode.PUSH_FINALLY, operand: 9 }, // finally at 9 + { op: OpCode.PUSH, operand: 0 }, // push 10 + { op: OpCode.STORE, operand: 'x' }, + { op: OpCode.POP_TRY }, + { op: OpCode.JUMP, operand: 3 }, // compiler jumps to finally (5->6, +3 = 9) + + // Not executed + { op: OpCode.HALT }, + { op: OpCode.HALT }, + + // Catch block (instruction 8) - not executed + { op: OpCode.HALT }, + + // Finally block (instruction 9) + { op: OpCode.PUSH, operand: 1 }, // push 100 + { op: OpCode.LOAD, operand: 'x' }, // load 10 + { op: OpCode.ADD }, // 110 + { op: OpCode.HALT } + ], + constants: [ + toValue(10), + toValue(100) + ] + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'number', value: 110 }) +}) + +test("PUSH_FINALLY - finally executes after exception", async () => { + // Try block throws, THROW jumps to finally (skipping catch) + const vm = new VM({ + instructions: [ + { op: OpCode.PUSH_TRY, operand: 5 }, // catch at 5 + { op: OpCode.PUSH_FINALLY, operand: 7 }, // finally at 7 + { op: OpCode.PUSH, operand: 0 }, // push error + { op: OpCode.THROW }, // throw (jumps to finally, not catch!) + { op: OpCode.HALT }, // not executed + + // Catch block (instruction 5) - skipped because finally is present + { op: OpCode.STORE, operand: 'err' }, + { op: OpCode.HALT }, + + // Finally block (instruction 7) - error is still on stack + { op: OpCode.POP }, // discard error + { op: OpCode.PUSH, operand: 1 }, // push "finally ran" + { op: OpCode.HALT } + ], + constants: [ + toValue('error'), + toValue('finally ran') + ] + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'finally ran' }) +}) + +test("PUSH_FINALLY - finally without catch", async () => { + // Try-finally without catch (compiler generates jump to finally) + const vm = new VM({ + instructions: [ + { op: OpCode.PUSH_TRY, operand: 7 }, // catch at 7 (dummy) + { op: OpCode.PUSH_FINALLY, operand: 7 }, // finally at 7 + { op: OpCode.PUSH, operand: 0 }, // push 42 + { op: OpCode.STORE, operand: 'x' }, + { op: OpCode.POP_TRY }, + { op: OpCode.JUMP, operand: 1 }, // compiler jumps to finally + { op: OpCode.HALT }, // skipped + + // Finally block (instruction 7) + { op: OpCode.LOAD, operand: 'x' }, // load 42 + { op: OpCode.PUSH, operand: 1 }, // push 10 + { op: OpCode.ADD }, // 52 + { op: OpCode.HALT } + ], + constants: [ + toValue(42), + toValue(10) + ] + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'number', value: 52 }) +}) + +test("PUSH_FINALLY - nested try-finally blocks", async () => { + // Nested try-finally blocks with compiler-generated jumps + const vm = new VM({ + instructions: [ + { op: OpCode.PUSH_TRY, operand: 11 }, // outer catch at 11 + { op: OpCode.PUSH_FINALLY, operand: 14 }, // outer finally at 14 + { op: OpCode.PUSH_TRY, operand: 9 }, // inner catch at 9 + { op: OpCode.PUSH_FINALLY, operand: 10 }, // inner finally at 10 + { op: OpCode.PUSH, operand: 0 }, // push 1 + { op: OpCode.POP_TRY }, // inner pop (instruction 5) + { op: OpCode.JUMP, operand: 3 }, // jump to inner finally (6->7, +3 = 10) + { op: OpCode.HALT }, // skipped + { op: OpCode.HALT }, // skipped + + // Inner catch (instruction 9) - not executed + { op: OpCode.HALT }, + + // Inner finally (instruction 10) + { op: OpCode.PUSH, operand: 1 }, // push 10 + { op: OpCode.POP_TRY }, // outer pop (instruction 11) + { op: OpCode.JUMP, operand: 1 }, // jump to outer finally (12->13, +1 = 14) + + // Outer catch (instruction 13) - not executed + { op: OpCode.HALT }, + + // Outer finally (instruction 14) + { op: OpCode.ADD }, // 1 + 10 = 11 + { op: OpCode.HALT } + ], + constants: [ + toValue(1), + toValue(10) + ] + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'number', value: 11 }) +}) + +test("PUSH_FINALLY - error when no handler", async () => { + const vm = new VM({ + instructions: [ + { op: OpCode.PUSH_FINALLY, operand: 5 } + ], + constants: [] + }) + + await expect(vm.run()).rejects.toThrow('PUSH_FINALLY: no exception handler to modify') +})