From 45e4c29df44ad61c75cd4cdb6731910b2a05149f Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sun, 5 Oct 2025 19:07:28 -0700 Subject: [PATCH] tail calls --- README.md | 11 +- src/vm.ts | 36 +++++++ tests/tail-call.test.ts | 229 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+), 5 deletions(-) create mode 100644 tests/tail-call.test.ts diff --git a/README.md b/README.md index 0b9ca60..ccade6b 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ It's where Shrimp live. ### Functions - [x] MAKE_FUNCTION - [x] CALL -- [ ] TAIL_CALL +- [x] TAIL_CALL - [x] RETURN ### Arrays @@ -77,7 +77,7 @@ It's where Shrimp live. ## Test Status -✅ **66 tests passing** covering: +✅ **70 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,9 +86,10 @@ It's where Shrimp live. - Control flow with **relative jumps** (JUMP, JUMP_IF_FALSE, JUMP_IF_TRUE, BREAK, CONTINUE) - 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 +- Function operations (MAKE_FUNCTION, CALL, TAIL_CALL, RETURN) with parameter binding +- **Tail call optimization** with unbounded recursion (10,000+ iterations without stack overflow) - Exception handling (PUSH_TRY, PUSH_FINALLY, POP_TRY, THROW) with nested try/finally blocks and call stack unwinding -- TypeScript interop (CALL_NATIVE) with sync and async functions +- Native function interop (CALL_NATIVE) with sync and async functions - HALT instruction ## Design Decisions @@ -98,4 +99,4 @@ It's where Shrimp live. - **Short-circuiting via compiler**: No AND/OR opcodes—compilers use JUMP patterns for proper short-circuit evaluation 🚧 **Still TODO**: -- Advanced function features (TAIL_CALL, variadic params, kwargs, default parameters) \ No newline at end of file +- Advanced function features (variadic params, kwargs, named arguments in CALL) \ No newline at end of file diff --git a/src/vm.ts b/src/vm.ts index b2d96aa..1c3bc34 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -399,6 +399,42 @@ export class VM { this.pc = fn.body - 1 break + case OpCode.TAIL_CALL: + const tailArgCount = instruction.operand as number + + const tailArgs: Value[] = [] + for (let i = 0; i < tailArgCount; i++) + tailArgs.unshift(this.stack.pop()!) + + const tailFn = this.stack.pop()! + + if (tailFn.type !== 'function') + throw new Error('TAIL_CALL: not a function') + + this.scope = new Scope(tailFn.parentScope) + + // same logic as CALL + for (let i = 0; i < tailFn.params.length; i++) { + const paramName = tailFn.params[i]! + const argValue = tailArgs[i] + + if (argValue !== undefined) { + this.scope.set(paramName, argValue) + } else if (tailFn.defaults[paramName] !== undefined) { + const defaultIdx = tailFn.defaults[paramName]! + const defaultValue = this.constants[defaultIdx]! + if (defaultValue.type === 'function_def') + throw new Error('Default value cannot be a function definition') + this.scope.set(paramName, defaultValue) + } else { + this.scope.set(paramName, toValue(null)) + } + } + + // subtract 1 because PC was incremented + this.pc = tailFn.body - 1 + break + case OpCode.RETURN: const returnValue = this.stack.length ? this.stack.pop()! : toValue(null) diff --git a/tests/tail-call.test.ts b/tests/tail-call.test.ts new file mode 100644 index 0000000..e1d463e --- /dev/null +++ b/tests/tail-call.test.ts @@ -0,0 +1,229 @@ +import { test, expect } from "bun:test" +import { VM } from "#vm" +import { OpCode } from "#opcode" +import { toValue } from "#value" + +test("TAIL_CALL - basic tail recursive countdown", async () => { + // Tail recursive function that counts down to 0 + // function countdown(n) { + // if (n === 0) return "done" + // return countdown(n - 1) // tail call + // } + const vm = new VM({ + instructions: [ + // 0: Setup - create function and call it + { op: OpCode.MAKE_FUNCTION, operand: 0 }, + { op: OpCode.STORE, operand: 'countdown' }, + { op: OpCode.LOAD, operand: 'countdown' }, + { op: OpCode.PUSH, operand: 1 }, // start with 5 + { op: OpCode.CALL, operand: 1 }, + { op: OpCode.HALT }, + + // 6: Function body + { op: OpCode.LOAD, operand: 'n' }, + { op: OpCode.PUSH, operand: 2 }, // 0 + { op: OpCode.EQ }, + { op: OpCode.JUMP_IF_FALSE, operand: 2 }, // if n !== 0, jump to recursive case + { op: OpCode.PUSH, operand: 3 }, // return "done" + { op: OpCode.RETURN }, + + // 12: Recursive case (tail call) + { op: OpCode.LOAD, operand: 'countdown' }, + { op: OpCode.LOAD, operand: 'n' }, + { op: OpCode.PUSH, operand: 4 }, // 1 + { op: OpCode.SUB }, + { op: OpCode.TAIL_CALL, operand: 1 } // tail call with n-1 + ], + constants: [ + { + type: 'function_def', + params: ['n'], + defaults: {}, + body: 6, + variadic: false, + kwargs: false + }, + toValue(5), + toValue(0), + toValue('done'), + toValue(1) + ] + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'done' }) +}) + +test("TAIL_CALL - tail recursive sum with accumulator", async () => { + // function sum(n, acc = 0) { + // if (n === 0) return acc + // return sum(n - 1, acc + n) // tail call + // } + const vm = new VM({ + instructions: [ + // 0: Setup + { op: OpCode.MAKE_FUNCTION, operand: 0 }, + { op: OpCode.STORE, operand: 'sum' }, + { op: OpCode.LOAD, operand: 'sum' }, + { op: OpCode.PUSH, operand: 1 }, // n = 10 + { op: OpCode.PUSH, operand: 2 }, // acc = 0 + { op: OpCode.CALL, operand: 2 }, + { op: OpCode.HALT }, + + // 7: Function body + { op: OpCode.LOAD, operand: 'n' }, + { op: OpCode.PUSH, operand: 2 }, // 0 + { op: OpCode.EQ }, + { op: OpCode.JUMP_IF_FALSE, operand: 2 }, + { op: OpCode.LOAD, operand: 'acc' }, + { op: OpCode.RETURN }, + + // 13: Recursive case + { op: OpCode.LOAD, operand: 'sum' }, + { op: OpCode.LOAD, operand: 'n' }, + { op: OpCode.PUSH, operand: 3 }, // 1 + { op: OpCode.SUB }, + { op: OpCode.LOAD, operand: 'acc' }, + { op: OpCode.LOAD, operand: 'n' }, + { op: OpCode.ADD }, + { op: OpCode.TAIL_CALL, operand: 2 } + ], + constants: [ + { + type: 'function_def', + params: ['n', 'acc'], + defaults: {}, + body: 7, + variadic: false, + kwargs: false + }, + toValue(10), + toValue(0), + toValue(1) + ] + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'number', value: 55 }) // sum of 1..10 +}) + +test("TAIL_CALL - doesn't overflow stack with deep recursion", async () => { + // This would overflow the stack with regular CALL + // but should work fine with TAIL_CALL + const vm = new VM({ + instructions: [ + // 0: Setup + { op: OpCode.MAKE_FUNCTION, operand: 0 }, + { op: OpCode.STORE, operand: 'deep' }, + { op: OpCode.LOAD, operand: 'deep' }, + { op: OpCode.PUSH, operand: 1 }, // 10000 iterations + { op: OpCode.CALL, operand: 1 }, + { op: OpCode.HALT }, + + // 6: Function body + { op: OpCode.LOAD, operand: 'n' }, + { op: OpCode.PUSH, operand: 2 }, // 0 + { op: OpCode.LTE }, + { op: OpCode.JUMP_IF_FALSE, operand: 2 }, + { op: OpCode.PUSH, operand: 3 }, // "success" + { op: OpCode.RETURN }, + + // 12: Tail recursive case + { op: OpCode.LOAD, operand: 'deep' }, + { op: OpCode.LOAD, operand: 'n' }, + { op: OpCode.PUSH, operand: 4 }, // 1 + { op: OpCode.SUB }, + { op: OpCode.TAIL_CALL, operand: 1 } + ], + constants: [ + { + type: 'function_def', + params: ['n'], + defaults: {}, + body: 6, + variadic: false, + kwargs: false + }, + toValue(10000), // This would overflow with regular recursion + toValue(0), + toValue('success'), + toValue(1) + ] + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'string', value: 'success' }) +}) + +test("TAIL_CALL - tail call to different function", async () => { + // TAIL_CALL can call a different function (mutual recursion) + // function even(n) { return n === 0 ? true : odd(n - 1) } + // function odd(n) { return n === 0 ? false : even(n - 1) } + const vm = new VM({ + instructions: [ + // 0: Setup both functions + { op: OpCode.MAKE_FUNCTION, operand: 0 }, // even + { op: OpCode.STORE, operand: 'even' }, + { op: OpCode.MAKE_FUNCTION, operand: 1 }, // odd + { op: OpCode.STORE, operand: 'odd' }, + { op: OpCode.LOAD, operand: 'even' }, + { op: OpCode.PUSH, operand: 2 }, // test with 7 + { op: OpCode.CALL, operand: 1 }, + { op: OpCode.HALT }, + + // 8: even function body + { op: OpCode.LOAD, operand: 'n' }, + { op: OpCode.PUSH, operand: 3 }, // 0 + { op: OpCode.EQ }, + { op: OpCode.JUMP_IF_FALSE, operand: 2 }, + { op: OpCode.PUSH, operand: 4 }, // true + { op: OpCode.RETURN }, + // Tail call to odd + { op: OpCode.LOAD, operand: 'odd' }, + { op: OpCode.LOAD, operand: 'n' }, + { op: OpCode.PUSH, operand: 5 }, // 1 + { op: OpCode.SUB }, + { op: OpCode.TAIL_CALL, operand: 1 }, + + // 19: odd function body + { op: OpCode.LOAD, operand: 'n' }, + { op: OpCode.PUSH, operand: 3 }, // 0 + { op: OpCode.EQ }, + { op: OpCode.JUMP_IF_FALSE, operand: 2 }, + { op: OpCode.PUSH, operand: 6 }, // false + { op: OpCode.RETURN }, + // Tail call to even + { op: OpCode.LOAD, operand: 'even' }, + { op: OpCode.LOAD, operand: 'n' }, + { op: OpCode.PUSH, operand: 5 }, // 1 + { op: OpCode.SUB }, + { op: OpCode.TAIL_CALL, operand: 1 } + ], + constants: [ + { + type: 'function_def', + params: ['n'], + defaults: {}, + body: 8, // even body + variadic: false, + kwargs: false + }, + { + type: 'function_def', + params: ['n'], + defaults: {}, + body: 19, // odd body + variadic: false, + kwargs: false + }, + toValue(7), + toValue(0), + toValue(true), + toValue(1), + toValue(false) + ] + }) + + const result = await vm.run() + expect(result).toEqual({ type: 'boolean', value: false }) // 7 is odd +})