add REPL support

This commit is contained in:
Chris Wanstrath 2025-10-25 09:10:43 -07:00
parent 937861e27b
commit 1fb5effb0a
4 changed files with 243 additions and 0 deletions

View File

@ -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`

View File

@ -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

View File

@ -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<Value> {
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:

112
tests/repl.test.ts Normal file
View File

@ -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
})