From 1fb5effb0a55d5cb3c4e703163d78d2df4a563a0 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 25 Oct 2025 09:10:43 -0700 Subject: [PATCH] add REPL support --- CLAUDE.md | 27 +++++++++++ GUIDE.md | 60 ++++++++++++++++++++++++ src/vm.ts | 44 ++++++++++++++++++ tests/repl.test.ts | 112 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 243 insertions(+) create mode 100644 tests/repl.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 44bf4d0..4b3ac5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,6 +222,33 @@ HALT HALT ``` +### REPL Mode (Incremental Execution) + +For building REPLs (like the Shrimp REPL), use `vm.continue()` and `vm.appendBytecode()`: + +```typescript +const vm = new VM(toBytecode([]), natives) +await vm.run() // Initialize (empty bytecode) + +// User enters: x = 42 +const line1 = compileLine("x = 42") // No HALT! +vm.appendBytecode(line1) +await vm.continue() // Execute only line 1 + +// User enters: x + 10 +const line2 = compileLine("x + 10") // No HALT! +vm.appendBytecode(line2) +await vm.continue() // Execute only line 2, result is 52 +``` + +**Key points**: +- `vm.run()` resets PC to 0 (re-executes everything) - use for initial setup only +- `vm.continue()` resumes from current PC (executes only new bytecode) +- `vm.appendBytecode(bytecode)` properly handles constant index remapping +- Don't use HALT in REPL lines - let VM stop naturally +- Scope and variables persist across all lines +- Side effects only run once + ## TypeScript Configuration - Import alias: `#reef` maps to `./src/index.ts` diff --git a/GUIDE.md b/GUIDE.md index 7c02d2f..f51bf0a 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -731,6 +731,66 @@ await vm.call('log', 'Hello from TypeScript!') - Objects: converted to ReefVM dicts - Functions: Reef functions are converted to callable JavaScript functions +### REPL Mode (Incremental Compilation) + +ReefVM supports incremental bytecode execution for building REPLs. This allows you to execute code line-by-line while preserving scope and avoiding re-execution of side effects. + +**The Problem**: By default, `vm.run()` resets the program counter (PC) to 0, re-executing all previous bytecode. This makes it impossible to implement a REPL where each line executes only once. + +**The Solution**: Use `vm.continue()` to resume execution from where you left off: + +```typescript +// Line 1: Define variable +const line1 = toBytecode([ + ["PUSH", 42], + ["STORE", "x"] +]) + +const vm = new VM(line1) +await vm.run() // Execute first line + +// Line 2: Use the variable +const line2 = toBytecode([ + ["LOAD", "x"], + ["PUSH", 10], + ["ADD"] +]) + +vm.appendBytecode(line2) // Append new bytecode with proper constant remapping +await vm.continue() // Execute ONLY the new bytecode + +// Result: 52 (42 + 10) +// The first line never re-executed! +``` + +**Key methods**: +- `vm.run()`: Resets PC to 0 and runs from the beginning (normal execution) +- `vm.continue()`: Continues from current PC (REPL mode) +- `vm.appendBytecode(bytecode)`: Helper that properly appends bytecode with constant index remapping + +**Important**: Don't use `HALT` in REPL mode! The VM naturally stops when it runs out of instructions. Using `HALT` sets `vm.stopped = true`, which prevents `continue()` from resuming. + +**Example REPL pattern**: +```typescript +const vm = new VM(toBytecode([]), { /* native functions */ }) + +while (true) { + const input = await getUserInput() // Get next line from user + const bytecode = compileLine(input) // Compile to bytecode (no HALT!) + + vm.appendBytecode(bytecode) // Append to VM + const result = await vm.continue() // Execute only the new code + + console.log(fromValue(result)) // Show result to user +} +``` + +This pattern ensures: +- Variables persist between lines +- Side effects (like `echo` or function calls) only run once +- Previous bytecode never re-executes +- Scope accumulates across all lines + ### Empty Stack - RETURN with empty stack returns null - HALT with empty stack returns null diff --git a/src/vm.ts b/src/vm.ts index 818944a..604cb38 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -66,6 +66,50 @@ export class VM { return this.stack[this.stack.length - 1] || toValue(null) } + // Resume execution from current PC without resetting + // Useful for REPL mode where you append bytecode incrementally + async continue(): Promise { + this.stopped = false + + while (!this.stopped && this.pc < this.instructions.length) { + const instruction = this.instructions[this.pc]! + await this.execute(instruction) + this.pc++ + } + + return this.stack[this.stack.length - 1] || toValue(null) + } + + // Helper for REPL mode: append new bytecode with proper constant index remapping + appendBytecode(bytecode: Bytecode): void { + const constantOffset = this.constants.length + + this.constants.push(...bytecode.constants) + + for (const instruction of bytecode.instructions) { + if (instruction.operand !== undefined && typeof instruction.operand === 'number') { + // Opcodes that reference constants need their operand adjusted + if (instruction.op === OpCode.PUSH || instruction.op === OpCode.MAKE_FUNCTION) { + this.instructions.push({ + op: instruction.op, + operand: instruction.operand + constantOffset + }) + } else { + this.instructions.push(instruction) + } + } else { + this.instructions.push(instruction) + } + } + + if (bytecode.labels) { + const instructionOffset = this.instructions.length - bytecode.instructions.length + for (const [addr, label] of bytecode.labels.entries()) { + this.labels.set(addr + instructionOffset, label) + } + } + } + async execute(instruction: Instruction) /* throws */ { switch (instruction.op) { case OpCode.PUSH: diff --git a/tests/repl.test.ts b/tests/repl.test.ts new file mode 100644 index 0000000..8c65b28 --- /dev/null +++ b/tests/repl.test.ts @@ -0,0 +1,112 @@ +import { test, expect } from "bun:test" +import { VM, toBytecode } from "#reef" + +test("REPL mode - demonstrates PC reset problem", async () => { + // Track how many times each line executes + let line1Count = 0 + let line2Count = 0 + + // Line 1: Set x = 5, track execution + const line1 = toBytecode([ + ["LOAD", "trackLine1"], + ["PUSH", 0], + ["PUSH", 0], + ["CALL"], + ["POP"], + ["PUSH", 5], + ["STORE", "x"] + ]) + + const vm = new VM(line1, { + trackLine1: () => { line1Count++; return null }, + trackLine2: () => { line2Count++; return null } + }) + + await vm.run() + expect(vm.scope.get("x")).toEqual({ type: "number", value: 5 }) + expect(line1Count).toBe(1) + expect(line2Count).toBe(0) + + // Line 2: Track execution, load x + const line2 = toBytecode([ + ["LOAD", "trackLine2"], + ["PUSH", 0], + ["PUSH", 0], + ["CALL"], + ["POP"], + ["LOAD", "x"] + ]) + + // Append line2 bytecode to VM (what a REPL would do) + vm.instructions.push(...line2.instructions) + for (const constant of line2.constants) { + if (!vm.constants.includes(constant)) { + vm.constants.push(constant) + } + } + + // Current behavior: run() resets PC to 0, re-executing everything + await vm.run() + + // PROBLEM: Line 1 ran AGAIN (count is now 2, not 1) + // This is the issue for REPL - side effects run multiple times + expect(line1Count).toBe(2) // Ran twice! Should still be 1 + expect(line2Count).toBe(1) // This is correct + + // What we WANT for REPL: + // - Only execute the NEW bytecode (line 2) + // - line1Count should stay at 1 + // - line2Count should be 1 +}) + +test("REPL mode - continue() executes only new bytecode", async () => { + // Track how many times each line executes + let line1Count = 0 + let line2Count = 0 + + // Line 1: Set x = 5, track execution + const line1 = toBytecode([ + ["LOAD", "trackLine1"], + ["PUSH", 0], + ["PUSH", 0], + ["CALL"], + ["POP"], + ["PUSH", 5], + ["STORE", "x"] + ]) + + const vm = new VM(line1, { + trackLine1: () => { line1Count++; return null }, + trackLine2: () => { line2Count++; return null } + }) + + await vm.run() + expect(vm.scope.get("x")).toEqual({ type: "number", value: 5 }) + expect(line1Count).toBe(1) + expect(line2Count).toBe(0) + + // Line 2: Track execution, load x and add 10 + const line2 = toBytecode([ + ["LOAD", "trackLine2"], + ["PUSH", 0], + ["PUSH", 0], + ["CALL"], + ["POP"], + ["LOAD", "x"], + ["PUSH", 10], + ["ADD"] + ]) + + // Append line2 bytecode to VM using the helper method + vm.appendBytecode(line2) + + // SOLUTION: Use continue() instead of run() to resume from current PC + await vm.continue() + + const result = vm.stack[vm.stack.length - 1] + + // SUCCESS: Line 1 only ran once, line 2 ran once + expect(line1Count).toBe(1) // Still 1! Side effect didn't re-run + expect(line2Count).toBe(1) // Ran once as expected + expect(result).toEqual({ type: "number", value: 15 }) // 5 + 10 +})