add REPL support
This commit is contained in:
parent
937861e27b
commit
1fb5effb0a
27
CLAUDE.md
27
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`
|
||||
|
|
|
|||
60
GUIDE.md
60
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
|
||||
|
|
|
|||
44
src/vm.ts
44
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<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
112
tests/repl.test.ts
Normal 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
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user