Compare commits

...

55 Commits

Author SHA1 Message Date
b2a6021fb8 require labels for JUMP opcodes to avoid compiler bugs 2025-11-09 22:18:10 -08:00
5350bb8c2b you're lost 2025-11-09 21:30:48 -08:00
47c6d3b32f allow . in label name 2025-11-09 21:30:44 -08:00
5ade608278 fix linter errors 2025-11-08 15:58:21 -08:00
614f5c4f91 use fancy matchers, like Shrimp 2025-11-08 11:38:41 -08:00
3e2e68b31f Passing NULL to a function triggers its default value 2025-11-08 10:53:38 -08:00
d7a971db24 scope.vars() 2025-11-08 08:40:15 -08:00
f439c25742 add TYPE opcode 2025-11-08 00:01:21 -08:00
47e227f50c don't need vm for simple toValue calls 2025-11-07 23:36:53 -08:00
15884ac239 add bitwise operators 2025-11-07 22:51:28 -08:00
bffb83a528 more robust isValue() check 2025-11-05 15:43:30 -08:00
bd1736b474 toValue is hungry 2025-11-05 15:20:55 -08:00
f4e24f427f gimme all of it 2025-11-05 15:17:54 -08:00
e7201e691c even more debug info 2025-11-05 15:16:41 -08:00
11b119a322 give more info on toValue() error 2025-11-05 15:11:24 -08:00
33ea94a247 shortcut 2025-11-05 14:17:48 -08:00
f1cc717711 pushScope accepts locals 2025-11-05 13:55:59 -08:00
54cd9ce8e8 add pushScope/popScope for Power Users 2025-11-05 13:49:19 -08:00
0f39e9401e fromValue can convert native functions now 2025-11-01 23:09:11 -07:00
676f53c66b aideas 2025-11-01 23:03:33 -07:00
fa021e3f18 better error message 2025-10-29 21:43:44 -07:00
4b2fd61554 SWAP opcode 2025-10-29 20:37:09 -07:00
c69b172c78 ADD: error checking 2025-10-29 16:03:26 -07:00
0b5d3e634c too chatty 2025-10-29 15:35:10 -07:00
ba8376e2c3 dicts, too 2025-10-29 15:28:34 -07:00
956fd576f8 ADD concats arrays too 2025-10-29 15:25:37 -07:00
9618dd6414 ADD now concats strings, too 2025-10-29 15:20:28 -07:00
b58f848a65 convert native exceptions to shrimp exceptions 2025-10-29 15:07:59 -07:00
3647159286 remove old imports 2025-10-29 14:57:28 -07:00
030eb74871 mark functions as raw=true to deal with Value directly 2025-10-29 13:10:05 -07:00
052f989e82 fix nested globals 2025-10-28 22:59:51 -07:00
Chris Wanstrath
e542070677 vm.set(), new VM(bytecode, globalVars) 2025-10-28 13:05:24 -07:00
97b6722a11 support roundtrip value conversions 2025-10-26 12:52:57 -07:00
d50b143c9d cough cough 2025-10-26 12:27:02 -07:00
e300946c48 throw real errors 2025-10-26 12:25:34 -07:00
bf6607d368 put builtin global functions into a higher scope 2025-10-26 08:53:51 -07:00
da61c1de50 isValue() 2025-10-26 08:24:58 -07:00
d359d6e27d even more 2025-10-25 20:34:30 -07:00
eb128ec831 share more 2025-10-25 20:27:38 -07:00
286d5ff943 slightly better error message 2025-10-25 20:07:10 -07:00
aa8ecb7cf6 VM constructor also accepts valueFunctions 2025-10-25 19:00:12 -07:00
bbdfcdb54a regexp is valid now 2025-10-25 15:53:07 -07:00
8d9510e9ae update docs 2025-10-25 10:12:36 -07:00
17d846b999 more repl support 2025-10-25 09:48:17 -07:00
1fb5effb0a add REPL support 2025-10-25 09:10:43 -07:00
937861e27b export OpCode 2025-10-25 08:22:50 -07:00
42c0e62597 show natives in debugger/repl 2025-10-25 07:56:06 -07:00
46829df28b TypeScriptFunction, more exports 2025-10-25 07:55:58 -07:00
e1e7cdf1ef vm.call() native functions too 2025-10-24 16:25:55 -07:00
f79fea33c5 more native named arg tests 2025-10-24 11:00:02 -07:00
797eb281cb vm.call(name, ...args) 2025-10-24 10:53:00 -07:00
91d3eb43e4 Call Reef closures inside native functions 2025-10-24 10:26:18 -07:00
eb4f103ba3 Merge pull request 'Varadic args are always last in javascript' (#3) from varadic-and-named into main
Reviewed-on: #3
2025-10-23 20:30:23 +00:00
995487f2d5 varadic args are always last in javascript 2025-10-23 11:15:02 -07:00
a885a59140 Merge pull request 'Make TRY_CALL work with native functions AND a debug helper' (#2) from TRY_CALL into main
Reviewed-on: #2
2025-10-21 01:53:39 +00:00
33 changed files with 5902 additions and 320 deletions

145
CLAUDE.md
View File

@ -55,7 +55,7 @@ No build step required - Bun runs TypeScript directly.
### Critical Design Decisions
**Relative jumps**: All JUMP instructions use PC-relative offsets (not absolute addresses), making bytecode position-independent. PUSH_TRY/PUSH_FINALLY use absolute addresses.
**Label-based jumps**: All JUMP instructions (`JUMP`, `JUMP_IF_FALSE`, `JUMP_IF_TRUE`) require label operands (`.label`), not numeric offsets. Labels are resolved to PC-relative offsets during compilation, making bytecode position-independent. PUSH_TRY/PUSH_FINALLY use absolute addresses and can accept either labels or numeric offsets.
**Truthiness semantics**: Only `null` and `false` are falsy. Unlike JavaScript, `0`, `""`, empty arrays, and empty dicts are truthy.
@ -137,40 +137,100 @@ Array format features:
- Function params as string arrays: `["MAKE_FUNCTION", ["x", "y=10"], ".body"]`
- See `tests/programmatic.test.ts` and `examples/programmatic.ts` for examples
### Native Function Registration
### Native Function Registration and Global Values
**Option 1**: Pass to `run()` or `VM` constructor (convenience)
```typescript
const result = await run(bytecode, {
add: (a: number, b: number) => a + b,
greet: (name: string) => `Hello, ${name}!`
greet: (name: string) => `Hello, ${name}!`,
pi: 3.14159,
config: { debug: true, port: 8080 }
})
// Or with VM constructor
const vm = new VM(bytecode, { add, greet })
const vm = new VM(bytecode, { add, greet, pi, config })
```
**Option 2**: Register with `vm.registerFunction()` (manual)
**Option 2**: Set values with `vm.set()` (manual)
```typescript
const vm = new VM(bytecode)
vm.registerFunction('add', (a: number, b: number) => a + b)
// Set functions (auto-wrapped to native functions)
vm.set('add', (a: number, b: number) => a + b)
// Set any other values (auto-converted to ReefVM Values)
vm.set('pi', 3.14159)
vm.set('config', { debug: true, port: 8080 })
await vm.run()
```
**Option 3**: Register Value-based functions (for direct Value access)
**Option 3**: Set Value-based functions with `vm.setValueFunction()` (advanced)
For functions that work directly with ReefVM Value types:
```typescript
vm.registerValueFunction('customOp', (a: Value, b: Value): Value => {
const vm = new VM(bytecode)
// Set Value-based function (no wrapping, works directly with Values)
vm.setValueFunction('customOp', (a: Value, b: Value): Value => {
return toValue(toNumber(a) + toNumber(b))
})
await vm.run()
```
Auto-wrapping handles:
- Value ↔ native type conversion (`fromValue`/`toValue`)
- Functions: wrapped as native functions with Value ↔ native type conversion
- Sync and async functions
- Arrays, objects, primitives, null, RegExp
- All values converted via `toValue()`
### Label Usage (Preferred)
Use labels instead of numeric offsets for readability:
### Calling Functions from TypeScript
Use `vm.call()` to invoke Reef or native functions from TypeScript:
```typescript
const bytecode = toBytecode(`
MAKE_FUNCTION (x y=10) .add
STORE add
HALT
.add:
LOAD x
LOAD y
ADD
RETURN
`)
const vm = new VM(bytecode, {
log: (msg: string) => console.log(msg) // Native function
})
await vm.run()
// Call Reef function with positional arguments
const result1 = await vm.call('add', 5, 3) // → 8
// Call Reef function with named arguments (pass final object)
const result2 = await vm.call('add', 5, { y: 20 }) // → 25
// Call Reef function with all named arguments
const result3 = await vm.call('add', { x: 10, y: 15 }) // → 25
// Call native function
await vm.call('log', 'Hello!')
```
**How it works**:
- Looks up function (Reef or native) in VM scope
- For Reef functions: converts to callable JavaScript function using `fnFromValue`
- For native functions: calls directly
- Automatically converts arguments to ReefVM Values
- Converts result back to JavaScript types
### Label Usage (Required for JUMP instructions)
All JUMP instructions must use labels:
```
JUMP .skip
PUSH 42
@ -180,6 +240,67 @@ HALT
HALT
```
### Function Definition Patterns
When defining functions, you MUST prevent the PC from falling through into function bodies. Two patterns:
**Pattern 1: JUMP over function bodies (Recommended)**
```
MAKE_FUNCTION (params) .body
STORE function_name
JUMP .end ; Skip over function body
.body:
<function code>
RETURN
.end:
<continue with program>
```
**Pattern 2: Function bodies after HALT**
```
MAKE_FUNCTION (params) .body
STORE function_name
<use the function>
HALT ; Stop before function bodies
.body:
<function code>
RETURN
```
Pattern 1 is required for:
- Defining multiple functions before using them
- REPL mode
- Any case where execution continues after defining a function
Pattern 2 only works if you HALT before reaching function bodies.
### 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`
@ -365,7 +486,7 @@ Run `bun test` to verify all tests pass before committing.
## Common Gotchas
**Jump offsets**: JUMP/JUMP_IF_FALSE/JUMP_IF_TRUE use relative offsets from the next instruction (PC + 1). PUSH_TRY/PUSH_FINALLY use absolute instruction indices.
**Label requirements**: JUMP/JUMP_IF_FALSE/JUMP_IF_TRUE require label operands (`.label`), not numeric offsets. The bytecode compiler resolves labels to PC-relative offsets internally. PUSH_TRY/PUSH_FINALLY can use either labels or absolute instruction indices (`#N`).
**Stack operations**: Most binary operations pop in reverse order (second operand is popped first, then first operand).

340
GUIDE.md
View File

@ -179,12 +179,25 @@ PUSH 1 ; Named count
CALL
```
**Null triggers defaults**: Pass `null` to use default values:
```
; Function: greet(name='Guest', msg='Hello')
LOAD greet
PUSH null ; Use default for 'name'
PUSH "Hi" ; Provide 'msg'
PUSH 2
PUSH 0
CALL ; → "Hi, Guest"
```
## Opcodes
### Stack
- `PUSH <const>` - Push constant
- `POP` - Remove top
- `DUP` - Duplicate top
- `SWAP` - Swap top two values
- `TYPE` - Pop value, push its type as string
### Variables
- `LOAD <name>` - Push variable value (throws if not found)
@ -194,6 +207,10 @@ CALL
### Arithmetic
- `ADD`, `SUB`, `MUL`, `DIV`, `MOD` - Binary ops (pop 2, push result)
### Bitwise
- `BIT_AND`, `BIT_OR`, `BIT_XOR` - Bitwise logical ops (pop 2, push result)
- `BIT_SHL`, `BIT_SHR`, `BIT_USHR` - Bitwise shift ops (pop 2, push result)
### Comparison
- `EQ`, `NEQ`, `LT`, `GT`, `LTE`, `GTE` - Pop 2, push boolean
@ -241,6 +258,40 @@ CALL
## Compiler Patterns
### Function Definitions
When defining functions, you must prevent the PC from "falling through" into the function body during sequential execution. There are two standard patterns:
**Pattern 1: JUMP over function bodies (Recommended)**
```
MAKE_FUNCTION (params) .body
STORE function_name
JUMP .end ; Skip over function body
.body:
<function code>
RETURN
.end:
<continue with program>
```
**Pattern 2: Function bodies after HALT**
```
MAKE_FUNCTION (params) .body
STORE function_name
<use the function>
HALT ; Stop execution before function bodies
.body:
<function code>
RETURN
```
**Important**: Pattern 2 only works if you HALT before reaching function bodies. Pattern 1 is more flexible and required for:
- Defining multiple functions before using them
- REPL mode (incremental execution)
- Any case where execution continues after defining a function
**Why?** `MAKE_FUNCTION` creates a function value but doesn't jump to the body—it just stores the body's address. Without JUMP or HALT, the PC increments into the function body and executes it as top-level code.
### If-Else
```
<condition>
@ -326,6 +377,125 @@ POP
.end: ; Result on stack
```
### Reversing Operand Order
Use SWAP to reverse operand order for non-commutative operations:
```
; Compute 10 / 2 when values are in reverse order
PUSH 2
PUSH 10
SWAP ; Now: [10, 2]
DIV ; 10 / 2 = 5
```
```
; Compute "hello" - "world" (subtraction with strings coerced to numbers)
PUSH "world"
PUSH "hello"
SWAP ; Now: ["hello", "world"]
SUB ; Result based on operand order
```
**Common Use Cases**:
- Division and subtraction when operands are in wrong order
- String concatenation with specific order
- Preparing arguments for functions that care about position
### Bitwise Operations
All bitwise operations work with 32-bit signed integers:
```
; Bitwise AND (masking)
PUSH 5
PUSH 3
BIT_AND ; → 1 (0101 & 0011 = 0001)
; Bitwise OR (combining flags)
PUSH 5
PUSH 3
BIT_OR ; → 7 (0101 | 0011 = 0111)
; Bitwise XOR (toggling bits)
PUSH 5
PUSH 3
BIT_XOR ; → 6 (0101 ^ 0011 = 0110)
; Left shift (multiply by power of 2)
PUSH 5
PUSH 2
BIT_SHL ; → 20 (5 << 2 = 5 * 4)
; Arithmetic right shift (divide by power of 2, preserves sign)
PUSH 20
PUSH 2
BIT_SHR ; → 5 (20 >> 2 = 20 / 4)
PUSH -20
PUSH 2
BIT_SHR ; → -5 (sign preserved)
; Logical right shift (zero-fill)
PUSH -1
PUSH 1
BIT_USHR ; → 2147483647 (unsigned shift)
```
**Common Use Cases**:
- Flags and bit masks: `flags band MASK` to test, `flags bor FLAG` to set
- Fast multiplication/division by powers of 2
- Color manipulation: extract RGB components
- Low-level bit manipulation for protocols or file formats
### Runtime Type Checking (TYPE)
Get the type of a value as a string for runtime introspection:
```
; Basic type check
PUSH 42
TYPE ; → "number"
PUSH "hello"
TYPE ; → "string"
MAKE_ARRAY #3
TYPE ; → "array"
```
**Type Guard Pattern** (check type before operation):
```
; Safe addition - only add if both are numbers
LOAD x
DUP
TYPE
PUSH "number"
EQ
JUMP_IF_FALSE .not_number
LOAD y
DUP
TYPE
PUSH "number"
EQ
JUMP_IF_FALSE .cleanup_not_number
ADD ; Safe to add
JUMP .end
.cleanup_not_number:
POP ; Remove y
.not_number:
POP ; Remove x
PUSH null
.end:
```
**Common Use Cases**:
- Type validation before operations
- Polymorphic functions that handle multiple types
- Debugging and introspection
- Dynamic dispatch in DSLs
- Safe coercion with fallbacks
### Try-Catch
```
PUSH_TRY .catch
@ -362,7 +532,8 @@ Functions automatically capture current scope:
PUSH 0
STORE counter
MAKE_FUNCTION () .increment
RETURN
STORE increment_fn
JUMP .main
.increment:
LOAD counter ; Captured variable
@ -371,6 +542,18 @@ RETURN
STORE counter
LOAD counter
RETURN
.main:
LOAD increment_fn
PUSH 0
PUSH 0
CALL ; Returns 1
POP
LOAD increment_fn
PUSH 0
PUSH 0
CALL ; Returns 2 (counter persists!)
HALT
```
### Tail Recursion
@ -378,7 +561,7 @@ Use TAIL_CALL instead of CALL for last call:
```
MAKE_FUNCTION (n acc) .factorial
STORE factorial
<...>
JUMP .main
.factorial:
LOAD n
@ -398,6 +581,15 @@ STORE factorial
PUSH 2
PUSH 0
TAIL_CALL ; Reuses stack frame
.main:
LOAD factorial
PUSH 5
PUSH 1
PUSH 2
PUSH 0
CALL ; factorial(5, 1) = 120
HALT
```
### Optional Function Calls (TRY_CALL)
@ -541,6 +733,8 @@ Only `null` and `false` are falsy. Everything else (including `0`, `""`, empty a
**Arithmetic ops** (ADD, SUB, MUL, DIV, MOD) coerce both operands to numbers.
**Bitwise ops** (BIT_AND, BIT_OR, BIT_XOR, BIT_SHL, BIT_SHR, BIT_USHR) coerce both operands to 32-bit signed integers.
**Comparison ops** (LT, GT, LTE, GTE) coerce both operands to numbers.
**Equality ops** (EQ, NEQ) use type-aware comparison with deep equality for arrays/dicts.
@ -565,11 +759,24 @@ Variable and function parameter names support Unicode and emoji:
### Parameter Binding Priority
For function calls, parameters bound in order:
1. Positional argument (if provided)
2. Named argument (if provided and matches param name)
1. Named argument (if provided and matches param name)
2. Positional argument (if provided)
3. Default value (if defined)
4. Null
**Null Triggering Defaults**: Passing `null` as an argument (positional or named) triggers the default value if one exists. This allows callers to explicitly "opt-in" to defaults:
```
# Function with defaults: greet(name='Guest', greeting='Hello')
LOAD greet
PUSH null # Triggers default: name='Guest'
PUSH 'Hi' # Provided: greeting='Hi'
PUSH 2
PUSH 0
CALL # Returns "Hi, Guest"
```
This works for both ReefVM functions and native TypeScript functions. If no default exists, `null` is bound as-is.
### Exception Handlers
- PUSH_TRY uses absolute addresses for catch blocks
- Nested try blocks form a stack
@ -606,18 +813,18 @@ const vm = new VM(bytecode, { add, greet })
**Method 2**: Register after construction
```typescript
const vm = new VM(bytecode)
vm.registerFunction('add', (a: number, b: number) => a + b)
vm.set('add', (a: number, b: number) => a + b)
await vm.run()
```
**Method 3**: Value-based functions (for full control)
```typescript
vm.registerValueFunction('customOp', (a: Value, b: Value): Value => {
vm.setValueFunction('customOp', (a: Value, b: Value): Value => {
return { type: 'number', value: toNumber(a) + toNumber(b) }
})
```
**Auto-wrapping**: `registerFunction` automatically converts between native TypeScript types and ReefVM Value types. Both sync and async functions work.
**Auto-wrapping**: `vm.set()` automatically converts between native TypeScript types and ReefVM Value types. Both sync and async functions work.
**Usage in bytecode**:
```
@ -646,12 +853,12 @@ CALL ; → "Hi, Alice!"
```typescript
// Basic @named - collects all named args
vm.registerFunction('greet', (atNamed: any = {}) => {
vm.set('greet', (atNamed: any = {}) => {
return `Hello, ${atNamed.name || 'World'}!`
})
// Mixed positional and @named
vm.registerFunction('configure', (name: string, atOptions: any = {}) => {
vm.set('configure', (name: string, atOptions: any = {}) => {
return {
name,
debug: atOptions.debug || false,
@ -676,6 +883,121 @@ CALL ; atOptions receives {debug: true, port: 8080}
Named arguments that match fixed parameter names are bound to those parameters. Remaining unmatched named arguments are collected into the `atXxx` parameter as a plain JavaScript object.
### Calling Functions from TypeScript
You can call both Reef and native functions from TypeScript using `vm.call()`:
```typescript
const bytecode = toBytecode(`
MAKE_FUNCTION (name greeting="Hello") .greet
STORE greet
HALT
.greet:
LOAD greeting
PUSH " "
LOAD name
PUSH "!"
STR_CONCAT #4
RETURN
`)
const vm = new VM(bytecode, {
log: (msg: string) => console.log(msg) // Native function
})
await vm.run()
// Call Reef function with positional arguments
const result1 = await vm.call('greet', 'Alice')
// Returns: "Hello Alice!"
// Call Reef function with named arguments (pass as final object)
const result2 = await vm.call('greet', 'Bob', { greeting: 'Hi' })
// Returns: "Hi Bob!"
// Call Reef function with only named arguments
const result3 = await vm.call('greet', { name: 'Carol', greeting: 'Hey' })
// Returns: "Hey Carol!"
// Call native function
await vm.call('log', 'Hello from TypeScript!')
```
**How it works**:
- `vm.call(functionName, ...args)` looks up the function (Reef or native) in the VM's scope
- For Reef functions: converts to callable JavaScript function
- For native functions: calls directly
- Arguments are automatically converted to ReefVM Values
- Returns the result (automatically converted back to JavaScript types)
**Named arguments**: Pass a plain object as the final argument to provide named arguments. If the last argument is a non-array object, it's treated as named arguments. All preceding arguments are treated as positional.
**Type conversion**: Arguments and return values are automatically converted between JavaScript types and ReefVM Values:
- Primitives: `number`, `string`, `boolean`, `null`
- Arrays: converted recursively
- 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

500
IDEAS.md Normal file
View File

@ -0,0 +1,500 @@
# ReefVM Architectural Improvement Ideas
This document contains architectural ideas for improving ReefVM. These focus on enhancing the VM's capabilities through structural improvements rather than just adding new opcodes.
## 1. Scope Resolution Optimization
**Current Issue**: Variable lookups are O(n) through the scope chain on every `LOAD`. This becomes expensive in deeply nested closures.
**Architectural Solution**: Implement **static scope analysis** with **lexical addressing**:
```typescript
// Instead of: LOAD x (runtime scope chain walk)
// Compile to: LOAD_FAST 2 1 (scope depth 2, slot 1 - O(1) lookup)
class Scope {
locals: Map<string, Value>
parent?: Scope
// NEW: Add indexed slots for fast access
slots: Value[] // Direct array access
nameToSlot: Map<string, number> // Compile-time mapping
}
```
**Benefits**:
- O(1) variable access instead of O(n)
- Critical for hot loops and deeply nested functions
- Compiler can still fall back to named lookup for dynamic cases
---
## 2. Module System Architecture
**Current Gap**: No way to organize code across multiple files or create reusable libraries.
**Architectural Solution**: Add first-class module support:
```typescript
// New opcodes: IMPORT, EXPORT, MAKE_MODULE
// New bytecode structure:
type Bytecode = {
instructions: Instruction[]
constants: Constant[]
exports?: Map<string, number> // Exported symbols
imports?: Import[] // Import declarations
}
type Import = {
modulePath: string
symbols: string[] // [] means import all
alias?: string
}
```
**Pattern**:
```
MAKE_MODULE .module_body
EXPORT add
EXPORT subtract
HALT
.module_body:
MAKE_FUNCTION (x y) .add_impl
RETURN
```
**Benefits**:
- Code organization and reusability
- Circular dependency detection at load time
- Natural namespace isolation
- Enables standard library architecture
---
## 3. Source Map Integration
**Current Issue**: Runtime errors show bytecode addresses, not source locations.
**Architectural Solution**: Add source mapping layer:
```typescript
type Bytecode = {
instructions: Instruction[]
constants: Constant[]
sourceMap?: SourceMap // NEW
}
type SourceMap = {
file?: string
mappings: SourceMapping[] // Instruction index → source location
}
type SourceMapping = {
instruction: number
line: number
column: number
source?: string // Original source text
}
```
**Benefits**:
- Meaningful error messages with line/column
- Debugger can show original source
- Stack traces map to source code
- Critical for production debugging
---
## 4. Debugger Hook Architecture
**Current Gap**: No way to pause execution, inspect state, or step through code.
**Architectural Solution**: Add debug event system:
```typescript
class VM {
debugger?: Debugger
async execute(instruction: Instruction) {
// Before execution
await this.debugger?.onInstruction(this.pc, instruction, this)
// Execute
switch (instruction.op) { ... }
// After execution
await this.debugger?.afterInstruction(this.pc, this)
}
}
interface Debugger {
breakpoints: Set<number>
onInstruction(pc: number, instruction: Instruction, vm: VM): Promise<void>
afterInstruction(pc: number, vm: VM): Promise<void>
onCall(fn: Value, args: Value[]): Promise<void>
onReturn(value: Value): Promise<void>
onException(error: Value): Promise<void>
}
```
**Benefits**:
- Step-through debugging
- Breakpoints at any instruction
- State inspection at any point
- Non-invasive (no bytecode modification)
- Can build IDE integrations
---
## 5. Bytecode Optimization Pass Framework
**Current Gap**: Bytecode is emitted directly, no optimization.
**Architectural Solution**: Add optimization pipeline:
```typescript
type Optimizer = (bytecode: Bytecode) => Bytecode
// Framework for composable optimization passes
class BytecodeOptimizer {
passes: Optimizer[] = []
add(pass: Optimizer): this {
this.passes.push(pass)
return this
}
optimize(bytecode: Bytecode): Bytecode {
return this.passes.reduce((bc, pass) => pass(bc), bytecode)
}
}
// Example passes:
const optimizer = new BytecodeOptimizer()
.add(constantFolding) // PUSH 2; PUSH 3; ADD → PUSH 5
.add(deadCodeElimination) // Remove unreachable code after HALT/RETURN
.add(jumpChaining) // JUMP .a → .a: JUMP .b → JUMP .b directly
.add(peepholeOptimization) // DUP; POP → (nothing)
```
**Benefits**:
- Faster execution without changing compiler
- Can add passes without modifying VM
- Composable and testable
- Enables aggressive optimizations (inlining, constant folding, etc.)
---
## 6. Value Memory Management Architecture
**Current Issue**: No tracking of memory usage, no GC hooks, unbounded growth.
**Architectural Solution**: Add memory management layer:
```typescript
class MemoryManager {
allocatedBytes: number = 0
maxBytes?: number
allocateValue(value: Value): Value {
const size = this.sizeOf(value)
if (this.maxBytes && this.allocatedBytes + size > this.maxBytes) {
throw new Error('Out of memory')
}
this.allocatedBytes += size
return value
}
sizeOf(value: Value): number {
// Estimate memory footprint
}
// Hook for custom GC
gc?: () => void
}
class VM {
memory: MemoryManager
// All value-creating operations check memory
push(value: Value) {
this.memory.allocateValue(value)
this.stack.push(value)
}
}
```
**Benefits**:
- Memory limits for sandboxing
- Memory profiling
- Custom GC strategies
- Prevents runaway memory usage
---
## 7. Instruction Profiler Architecture
**Current Gap**: No way to identify performance bottlenecks in bytecode.
**Architectural Solution**: Add instrumentation layer:
```typescript
class Profiler {
instructionCounts: Map<number, number> = new Map()
instructionTime: Map<number, number> = new Map()
hotFunctions: Map<number, FunctionProfile> = new Map()
recordInstruction(pc: number, duration: number) {
this.instructionCounts.set(pc, (this.instructionCounts.get(pc) || 0) + 1)
this.instructionTime.set(pc, (this.instructionTime.get(pc) || 0) + duration)
}
getHotSpots(): HotSpot[] {
// Identify most-executed instructions
}
generateReport(): ProfileReport {
// Human-readable performance report
}
}
class VM {
profiler?: Profiler
async execute(instruction: Instruction) {
const start = performance.now()
// ... execute ...
const duration = performance.now() - start
this.profiler?.recordInstruction(this.pc, duration)
}
}
```
**Benefits**:
- Identify hot loops and functions
- Guide optimization efforts
- Measure impact of changes
- Can feed into JIT compiler (future)
---
## 8. Standard Library Plugin Architecture
**Current Issue**: Native functions registered manually, no standard library structure.
**Architectural Solution**: Module-based native libraries:
```typescript
interface NativeModule {
name: string
exports: Record<string, any>
init?(vm: VM): void
}
class VM {
modules: Map<string, NativeModule> = new Map()
registerModule(module: NativeModule) {
this.modules.set(module.name, module)
module.init?.(this)
// Auto-register exports to global scope
for (const [name, value] of Object.entries(module.exports)) {
this.set(name, value)
}
}
loadModule(name: string): NativeModule {
return this.modules.get(name) || throw new Error(`Module ${name} not found`)
}
}
// Example usage:
const mathModule: NativeModule = {
name: 'math',
exports: {
sin: Math.sin,
cos: Math.cos,
sqrt: Math.sqrt,
PI: Math.PI
}
}
vm.registerModule(mathModule)
```
**Benefits**:
- Organized standard library
- Lazy loading of modules
- Third-party plugin system
- Clear namespace boundaries
---
## 9. Streaming Bytecode Execution
**Current Limitation**: Must load entire bytecode before execution.
**Architectural Solution**: Incremental bytecode loading:
```typescript
class StreamingBytecode {
chunks: BytecodeChunk[] = []
append(chunk: BytecodeChunk) {
// Remap addresses, merge constants
this.chunks.push(chunk)
}
getInstruction(pc: number): Instruction | undefined {
// Resolve across chunks
}
}
class VM {
async runStreaming(stream: ReadableStream<BytecodeChunk>) {
for await (const chunk of stream) {
this.bytecode.append(chunk)
await this.continue() // Execute new chunk
}
}
}
```
**Benefits**:
- Execute before full load (faster startup)
- Network streaming of bytecode
- Incremental compilation
- Better REPL experience
---
## 10. Type Annotation System (Optional Runtime Types)
**Current Gap**: All values dynamically typed, no way to enforce types.
**Architectural Solution**: Optional type metadata:
```typescript
type TypedValue = Value & {
typeAnnotation?: TypeAnnotation
}
type TypeAnnotation =
| { kind: 'number' }
| { kind: 'string' }
| { kind: 'array', elementType?: TypeAnnotation }
| { kind: 'dict', valueType?: TypeAnnotation }
| { kind: 'function', params: TypeAnnotation[], return: TypeAnnotation }
// New opcodes: TYPE_CHECK, TYPE_ASSERT
// Functions can declare parameter types:
MAKE_FUNCTION (x:number y:string) .body
```
**Benefits**:
- Catch type errors earlier
- Self-documenting code
- Enables static analysis tools
- Optional (doesn't break existing code)
- Can enable optimizations (known number type → skip toNumber())
---
## 11. VM State Serialization
**Current Gap**: Can't save/restore VM execution state.
**Architectural Solution**: Serializable VM state:
```typescript
class VM {
serialize(): SerializedState {
return {
instructions: this.instructions,
constants: this.constants,
pc: this.pc,
stack: this.stack.map(serializeValue),
callStack: this.callStack.map(serializeFrame),
scope: serializeScope(this.scope),
handlers: this.handlers
}
}
static deserialize(state: SerializedState): VM {
const vm = new VM(/* ... */)
vm.restore(state)
return vm
}
}
```
**Benefits**:
- Save/restore execution state
- Distributed computing (send state to workers)
- Crash recovery
- Time-travel debugging
- Checkpoint/restart
---
## 12. Async Iterator Support
**Current Gap**: Iterators work via break, but no async iteration.
**Architectural Solution**: First-class async iteration:
```typescript
// New value type:
type Value = ... | { type: 'async_iterator', value: AsyncIterableIterator<Value> }
// New opcodes: MAKE_ASYNC_ITERATOR, AWAIT_NEXT, YIELD_ASYNC
// Pattern:
for_await (item in asyncIterable) {
// Compiles to AWAIT_NEXT loop
}
```
**Benefits**:
- Stream processing
- Async I/O without blocking
- Natural async patterns
- Matches JavaScript async iterators
---
## Priority Recommendations
### Tier 1 (Highest Impact):
1. **Source Map Integration** - Critical for usability
2. **Module System** - Essential for scaling beyond toy programs
3. **Scope Resolution Optimization** - Performance multiplier
### Tier 2 (High Value):
4. **Debugger Hook Architecture** - Developer experience game-changer
5. **Standard Library Plugin Architecture** - Enables ecosystem
6. **Bytecode Optimization Framework** - Performance without complexity
### Tier 3 (Nice to Have):
7. **Instruction Profiler** - Guides future optimization
8. **Memory Management** - Important for production use
9. **VM State Serialization** - Enables advanced use cases
### Tier 4 (Future/Experimental):
10. **Type Annotations** - Optional, doesn't break existing code
11. **Streaming Bytecode** - Mostly useful for large programs
12. **Async Iterators** - Specialized use case
---
## Design Principles
These improvements focus on:
- **Performance** (scope optimization, bytecode optimization)
- **Developer Experience** (source maps, debugger, profiler)
- **Scalability** (modules, standard library architecture)
- **Production Readiness** (memory management, serialization)
All ideas maintain ReefVM's core design philosophy of simplicity, orthogonality, and explicit behavior.

View File

@ -44,11 +44,14 @@ Commands: `clear`, `reset`, `exit`.
- Variadic functions with positional rest parameters (`...rest`)
- Named arguments (named) that collect unmatched named args into a dict (`@named`)
- Mixed positional and named arguments with proper priority binding
- Default parameter values with null-triggering: passing `null` explicitly uses the default value
- 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
- Native function interop with auto-wrapping for native TypeScript types
- Native functions stored in scope, called via LOAD + CALL
- Native functions support `atXxx` parameters (e.g., `atOptions`) to collect unmatched named args
- Pass functions directly to `run(bytecode, { fnName: fn })` or `new VM(bytecode, { fnName: fn })`
- Call Reef functions from TypeScript with `vm.call(name, ...args)` with automatic type conversion
## Design Decisions
@ -58,3 +61,4 @@ Commands: `clear`, `reset`, `exit`.
- Variadic parameters: Functions can collect remaining positional arguments into an array using `...rest` syntax
- Named parameters: Functions can collect unmatched named arguments into a dict using `@named` syntax
- Argument binding priority: Named args bind to regular params first, with unmatched ones going to `@named`
- Null triggers defaults: Passing `null` to a parameter with a default value explicitly uses that default (applies to both ReefVM and native functions)

196
SPEC.md
View File

@ -138,6 +138,24 @@ type ExceptionHandler = {
**Effect**: Duplicate top of stack
**Stack**: [value] → [value, value]
#### SWAP
**Operand**: None
**Effect**: Swap the top two values on the stack
**Stack**: [value1, value2] → [value2, value1]
#### TYPE
**Operand**: None
**Effect**: Pop value from stack, push its type as a string
**Stack**: [value] → [typeString]
Returns the type of a value as a string.
**Example**:
```
PUSH 42
TYPE ; Pushes "number"
```
### Variable Operations
#### LOAD
@ -179,7 +197,29 @@ All arithmetic operations pop two values, perform operation, push result as numb
#### ADD
**Stack**: [a, b] → [a + b]
**Note**: Only for numbers (use separate string concat if needed)
Performs different operations depending on operand types:
- If either operand is a string, converts both to strings and concatenates
- Else if both operands are arrays, concatenates the arrays
- Else if both operands are dicts, merges them (b's keys overwrite a's keys on conflict)
- Else if both operands are numbers, performs numeric addition
- Otherwise, throws an error
**Examples**:
- `5 + 3``8` (numeric addition)
- `"hello" + " world"``"hello world"` (string concatenation)
- `"count: " + 42``"count: 42"` (string concatenation)
- `100 + " items"``"100 items"` (string concatenation)
- `[1, 2, 3] + [4]``[1, 2, 3, 4]` (array concatenation)
- `[1, 2] + [3, 4]``[1, 2, 3, 4]` (array concatenation)
- `{a: 1} + {b: 2}``{a: 1, b: 2}` (dict merge)
- `{a: 1, b: 2} + {b: 99}``{a: 1, b: 99}` (dict merge, b overwrites)
**Invalid operations** (throw errors):
- `true + false` → Error
- `null + 5` → Error
- `[1] + 5` → Error
- `{a: 1} + 5` → Error
#### SUB
**Stack**: [a, b] → [a - b]
@ -193,6 +233,62 @@ All arithmetic operations pop two values, perform operation, push result as numb
#### MOD
**Stack**: [a, b] → [a % b]
### Bitwise Operations
All bitwise operations coerce operands to 32-bit signed integers, perform the operation, and push the result as a number.
#### BIT_AND
**Operand**: None
**Stack**: [a, b] → [a & b]
Performs bitwise AND operation. Both operands are coerced to 32-bit signed integers.
**Example**: `5 & 3``1` (binary: `0101 & 0011``0001`)
#### BIT_OR
**Operand**: None
**Stack**: [a, b] → [a | b]
Performs bitwise OR operation. Both operands are coerced to 32-bit signed integers.
**Example**: `5 | 3``7` (binary: `0101 | 0011``0111`)
#### BIT_XOR
**Operand**: None
**Stack**: [a, b] → [a ^ b]
Performs bitwise XOR (exclusive OR) operation. Both operands are coerced to 32-bit signed integers.
**Example**: `5 ^ 3``6` (binary: `0101 ^ 0011``0110`)
#### BIT_SHL
**Operand**: None
**Stack**: [a, b] → [a << b]
Performs left shift operation. Left operand is coerced to 32-bit signed integer, right operand determines shift amount (masked to 0-31).
**Example**: `5 << 2``20` (binary: `0101` shifted left 2 positions → `10100`)
#### BIT_SHR
**Operand**: None
**Stack**: [a, b] → [a >> b]
Performs sign-preserving right shift operation. Left operand is coerced to 32-bit signed integer, right operand determines shift amount (masked to 0-31). The sign bit is preserved (arithmetic shift).
**Example**:
- `20 >> 2``5` (binary: `10100` shifted right 2 positions → `0101`)
- `-20 >> 2``-5` (sign bit preserved)
#### BIT_USHR
**Operand**: None
**Stack**: [a, b] → [a >>> b]
Performs zero-fill right shift operation. Left operand is coerced to 32-bit signed integer, right operand determines shift amount (masked to 0-31). Zeros are shifted in from the left (logical shift).
**Example**:
- `-1 >>> 1``2147483647` (all bits shift right, zero fills from left)
- `-8 >>> 1``2147483644`
### Comparison Operations
All comparison operations pop two values, compare, push boolean result.
@ -231,39 +327,45 @@ All comparison operations pop two values, compare, push boolean result.
```
<evaluate left>
DUP
JUMP_IF_FALSE #2 # skip POP and <evaluate right>
JUMP_IF_FALSE .end
POP
<evaluate right>
end:
.end:
```
**OR pattern** (short-circuits if left side is true):
```
<evaluate left>
DUP
JUMP_IF_TRUE #2 # skip POP and <evaluate right>
JUMP_IF_TRUE .end
POP
<evaluate right>
end:
.end:
```
### Control Flow
#### JUMP
**Operand**: Offset (number)
**Effect**: Add offset to PC (relative jump)
**Operand**: Label (string)
**Effect**: Jump to the specified label
**Stack**: No change
**Note**: JUMP only accepts label operands (`.label`), not numeric offsets. The VM resolves labels to relative offsets internally.
#### JUMP_IF_FALSE
**Operand**: Offset (number)
**Effect**: If top of stack is falsy, add offset to PC (relative jump)
**Operand**: Label (string)
**Effect**: If top of stack is falsy, jump to the specified label
**Stack**: [condition] → []
**Note**: JUMP_IF_FALSE only accepts label operands (`.label`), not numeric offsets.
#### JUMP_IF_TRUE
**Operand**: Offset (number)
**Effect**: If top of stack is truthy, add offset to PC (relative jump)
**Operand**: Label (string)
**Effect**: If top of stack is truthy, jump to the specified label
**Stack**: [condition] → []
**Note**: JUMP_IF_TRUE only accepts label operands (`.label`), not numeric offsets.
#### BREAK
**Operand**: None
**Effect**: Unwind call stack until frame with `isBreakTarget = true`, resume there
@ -378,11 +480,19 @@ The created function captures `currentScope` as its `parentScope`.
3. Default value (if defined)
4. Null
**Null Value Semantics**:
- Passing `null` as an argument explicitly triggers the default value (if one exists)
- This allows callers to "opt-in" to defaults even when providing arguments positionally
- If no default exists, `null` is bound as-is
- This applies to both ReefVM functions and native TypeScript functions
- Example: `fn(null, 20)` where `fn(x=10, y)` binds `x=10` (default triggered), `y=20`
**Named Args Handling**:
- Named args that match fixed parameter names are bound to those params
- If the function has `named: true`, remaining named args (that don't match any fixed param) are collected into the last parameter as a dict
- This allows flexible calling: `fn(x=10, y=20, extra=30)` where `extra` goes to the named args dict
- **Native functions support named arguments** - parameter names are extracted from the function signature at call time
- Passing `null` via named args also triggers defaults: `fn(x=null)` triggers `x`'s default
**Errors**: Throws if top of stack is not a function (or native function)
@ -622,7 +732,7 @@ const vm = new VM(bytecode, {
})
// Or after construction:
vm.registerFunction('multiply', (a: number, b: number) => a * b)
vm.set('multiply', (a: number, b: number) => a * b)
```
**Usage in Bytecode**:
@ -637,9 +747,9 @@ CALL ; Call it like any other function
**Native Function Types**:
1. **Auto-wrapped functions** (via `registerFunction`): Accept and return native TypeScript types (number, string, boolean, array, object, etc.). The VM automatically converts between Value types and native types.
1. **Auto-wrapped functions** (via `vm.set()`): Accept and return native TypeScript types (number, string, boolean, array, object, etc.). The VM automatically converts between Value types and native types.
2. **Value-based functions** (via `registerValueFunction`): Accept and return `Value` types directly for full control over type handling.
2. **Value-based functions** (via `vm.setValueFunction()`): Accept and return `Value` types directly for full control over type handling.
**Auto-Wrapping Behavior**:
- Parameters: `Value` → native type (number, string, boolean, array, object, null, RegExp)
@ -655,27 +765,27 @@ CALL ; Call it like any other function
**Examples**:
```typescript
// Auto-wrapped native types
vm.registerFunction('add', (a: number, b: number) => a + b)
vm.registerFunction('greet', (name: string) => `Hello, ${name}!`)
vm.registerFunction('range', (n: number) => Array.from({ length: n }, (_, i) => i))
vm.set('add', (a: number, b: number) => a + b)
vm.set('greet', (name: string) => `Hello, ${name}!`)
vm.set('range', (n: number) => Array.from({ length: n }, (_, i) => i))
// With defaults
vm.registerFunction('greet', (name: string, greeting = 'Hello') => {
vm.set('greet', (name: string, greeting = 'Hello') => {
return `${greeting}, ${name}!`
})
// Variadic functions
vm.registerFunction('sum', (...nums: number[]) => {
vm.set('sum', (...nums: number[]) => {
return nums.reduce((acc, n) => acc + n, 0)
})
// Value-based for custom logic
vm.registerValueFunction('customOp', (a: Value, b: Value): Value => {
vm.setValueFunction('customOp', (a: Value, b: Value): Value => {
return { type: 'number', value: toNumber(a) + toNumber(b) }
})
// Async functions
vm.registerFunction('fetchData', async (url: string) => {
vm.set('fetchData', async (url: string) => {
const response = await fetch(url)
return response.json()
})
@ -710,14 +820,16 @@ CALL ; → "Hi, Bob!"
## Label Syntax
The bytecode format supports labels for improved readability:
The bytecode format requires labels for control flow jumps:
**Label Definition**: `.label_name:` marks an instruction position
**Label Reference**: `.label_name` in operands (e.g., `JUMP .loop_start`)
Labels are resolved to numeric offsets during parsing. The original numeric offset syntax (`#N`) is still supported for backwards compatibility.
Labels are resolved to relative PC offsets during bytecode compilation. All JUMP instructions (`JUMP`, `JUMP_IF_FALSE`, `JUMP_IF_TRUE`) require label operands.
Example with labels:
**Note**: Exception handling instructions (`PUSH_TRY`, `PUSH_FINALLY`) and function definitions (`MAKE_FUNCTION`) can use either labels or absolute instruction indices (`#N`).
Example:
```
JUMP .skip
.middle:
@ -728,15 +840,6 @@ JUMP .skip
HALT
```
Equivalent with numeric offsets:
```
JUMP #2
PUSH 999
HALT
PUSH 42
HALT
```
## Common Bytecode Patterns
### If-Else Statement
@ -812,6 +915,29 @@ PUSH 1 # namedCount
CALL
```
### Null Triggering Default Values
```
# Function: greet(name='Guest', greeting='Hello')
MAKE_FUNCTION (name='Guest' greeting='Hello') .greet_body
STORE 'greet'
JUMP .main
.greet_body:
LOAD 'greeting'
PUSH ', '
ADD
LOAD 'name'
ADD
RETURN
.main:
# Call with null for first param - triggers default
LOAD 'greet'
PUSH null # name will use default 'Guest'
PUSH 'Hi' # greeting='Hi' (provided)
PUSH 2 # positionalCount
PUSH 0 # namedCount
CALL # Returns "Hi, Guest"
```
### Tail Recursive Function
```
MAKE_FUNCTION (n acc) .factorial_body
@ -912,10 +1038,10 @@ const vm = new VM(bytecode, {
})
// Or register after construction
vm.registerFunction('multiply', (a: number, b: number) => a * b)
vm.set('multiply', (a: number, b: number) => a * b)
// Or use Value-based functions
vm.registerValueFunction('customOp', (a: Value, b: Value): Value => {
vm.setValueFunction('customOp', (a: Value, b: Value): Value => {
return { type: 'number', value: toNumber(a) + toNumber(b) }
})

View File

@ -104,6 +104,8 @@ function formatValue(value: Value): string {
} else if (value.type === 'function') {
const params = value.params.join(', ')
return `${colors.dim}<fn(${params})>${colors.reset}`
} else if (value.type === 'native') {
return `${colors.dim}<native>${colors.reset}`
}
return String(value)
}

View File

@ -38,6 +38,8 @@ function formatValue(value: Value): string {
} else if (value.type === 'function') {
const params = value.params.join(', ')
return `${colors.dim}<fn(${params})>${colors.reset}`
} else if (value.type === 'native') {
return `${colors.dim}<native>${colors.reset}`
}
return String(value)
}

2
bunfig.toml Normal file
View File

@ -0,0 +1,2 @@
[test]
preload = ["./tests/setup.ts"]

116
examples/add-with-arrays.ts Normal file
View File

@ -0,0 +1,116 @@
/**
* Demonstrates the ADD opcode working with arrays
*
* ADD now handles array concatenation:
* - [1, 2, 3] + [4] === [1, 2, 3, 4]
* - If both operands are arrays, they are concatenated
*/
import { toBytecode, run } from "#reef"
// Basic array concatenation
const basicConcat = toBytecode([
["PUSH", 1],
["PUSH", 2],
["PUSH", 3],
["MAKE_ARRAY", 3],
["PUSH", 4],
["MAKE_ARRAY", 1],
["ADD"],
["HALT"]
])
console.log('Basic array concatenation ([1, 2, 3] + [4]):')
const result1 = await run(basicConcat)
console.log(result1)
// Output: { type: 'array', value: [1, 2, 3, 4] }
// Concatenate two multi-element arrays
const multiConcat = toBytecode([
["PUSH", 1],
["PUSH", 2],
["MAKE_ARRAY", 2],
["PUSH", 3],
["PUSH", 4],
["MAKE_ARRAY", 2],
["ADD"],
["HALT"]
])
console.log('\nConcatenate two arrays ([1, 2] + [3, 4]):')
const result2 = await run(multiConcat)
console.log(result2)
// Output: { type: 'array', value: [1, 2, 3, 4] }
// Concatenate multiple arrays in sequence
const multipleConcat = toBytecode([
["PUSH", 1],
["MAKE_ARRAY", 1],
["PUSH", 2],
["MAKE_ARRAY", 1],
["ADD"],
["PUSH", 3],
["MAKE_ARRAY", 1],
["ADD"],
["PUSH", 4],
["MAKE_ARRAY", 1],
["ADD"],
["HALT"]
])
console.log('\nMultiple concatenations ([1] + [2] + [3] + [4]):')
const result3 = await run(multipleConcat)
console.log(result3)
// Output: { type: 'array', value: [1, 2, 3, 4] }
// Concatenate arrays with mixed types
const mixedTypes = toBytecode([
["PUSH", 1],
["PUSH", "hello"],
["MAKE_ARRAY", 2],
["PUSH", true],
["PUSH", null],
["MAKE_ARRAY", 2],
["ADD"],
["HALT"]
])
console.log('\nConcatenate arrays with mixed types ([1, "hello"] + [true, null]):')
const result4 = await run(mixedTypes)
console.log(result4)
// Output: { type: 'array', value: [1, "hello", true, null] }
// Concatenate empty array with non-empty
const emptyConcat = toBytecode([
["MAKE_ARRAY", 0],
["PUSH", 1],
["PUSH", 2],
["PUSH", 3],
["MAKE_ARRAY", 3],
["ADD"],
["HALT"]
])
console.log('\nConcatenate empty array with [1, 2, 3] ([] + [1, 2, 3]):')
const result5 = await run(emptyConcat)
console.log(result5)
// Output: { type: 'array', value: [1, 2, 3] }
// Nested arrays
const nestedConcat = toBytecode([
["PUSH", 1],
["PUSH", 2],
["MAKE_ARRAY", 2],
["MAKE_ARRAY", 1],
["PUSH", 3],
["PUSH", 4],
["MAKE_ARRAY", 2],
["MAKE_ARRAY", 1],
["ADD"],
["HALT"]
])
console.log('\nConcatenate nested arrays ([[1, 2]] + [[3, 4]]):')
const result6 = await run(nestedConcat)
console.log(result6)
// Output: { type: 'array', value: [[1, 2], [3, 4]] }

158
examples/add-with-dicts.ts Normal file
View File

@ -0,0 +1,158 @@
/**
* Demonstrates the ADD opcode working with dicts
*
* ADD now handles dict merging:
* - {a: 1} + {b: 2} === {a: 1, b: 2}
* - If both operands are dicts, they are merged
* - Keys from the second dict overwrite keys from the first on conflict
*/
import { toBytecode, run } from "#reef"
// Basic dict merge
const basicMerge = toBytecode([
["PUSH", "a"],
["PUSH", 1],
["MAKE_DICT", 1],
["PUSH", "b"],
["PUSH", 2],
["MAKE_DICT", 1],
["ADD"],
["HALT"]
])
console.log('Basic dict merge ({a: 1} + {b: 2}):')
const result1 = await run(basicMerge)
console.log(result1)
// Output: { type: 'dict', value: Map { a: 1, b: 2 } }
// Merge with overlapping keys
const overlapMerge = toBytecode([
["PUSH", "a"],
["PUSH", 1],
["PUSH", "b"],
["PUSH", 2],
["MAKE_DICT", 2],
["PUSH", "b"],
["PUSH", 99],
["PUSH", "c"],
["PUSH", 3],
["MAKE_DICT", 2],
["ADD"],
["HALT"]
])
console.log('\nMerge with overlapping keys ({a: 1, b: 2} + {b: 99, c: 3}):')
const result2 = await run(overlapMerge)
console.log(result2)
console.log('Note: b is overwritten from 2 to 99')
// Output: { type: 'dict', value: Map { a: 1, b: 99, c: 3 } }
// Merge multiple dicts in sequence
const multipleMerge = toBytecode([
["PUSH", "a"],
["PUSH", 1],
["MAKE_DICT", 1],
["PUSH", "b"],
["PUSH", 2],
["MAKE_DICT", 1],
["ADD"],
["PUSH", "c"],
["PUSH", 3],
["MAKE_DICT", 1],
["ADD"],
["PUSH", "d"],
["PUSH", 4],
["MAKE_DICT", 1],
["ADD"],
["HALT"]
])
console.log('\nMultiple merges ({a: 1} + {b: 2} + {c: 3} + {d: 4}):')
const result3 = await run(multipleMerge)
console.log(result3)
// Output: { type: 'dict', value: Map { a: 1, b: 2, c: 3, d: 4 } }
// Merge dicts with different value types
const mixedTypes = toBytecode([
["PUSH", "num"],
["PUSH", 42],
["PUSH", "str"],
["PUSH", "hello"],
["MAKE_DICT", 2],
["PUSH", "bool"],
["PUSH", true],
["PUSH", "null"],
["PUSH", null],
["MAKE_DICT", 2],
["ADD"],
["HALT"]
])
console.log('\nMerge dicts with different types ({num: 42, str: "hello"} + {bool: true, null: null}):')
const result4 = await run(mixedTypes)
console.log(result4)
// Output: { type: 'dict', value: Map { num: 42, str: 'hello', bool: true, null: null } }
// Merge empty dict with non-empty
const emptyMerge = toBytecode([
["MAKE_DICT", 0],
["PUSH", "x"],
["PUSH", 100],
["PUSH", "y"],
["PUSH", 200],
["MAKE_DICT", 2],
["ADD"],
["HALT"]
])
console.log('\nMerge empty dict with {x: 100, y: 200} ({} + {x: 100, y: 200}):')
const result5 = await run(emptyMerge)
console.log(result5)
// Output: { type: 'dict', value: Map { x: 100, y: 200 } }
// Merge dicts with nested structures
const nestedMerge = toBytecode([
["PUSH", "data"],
["PUSH", 1],
["PUSH", 2],
["MAKE_ARRAY", 2],
["MAKE_DICT", 1],
["PUSH", "config"],
["PUSH", "debug"],
["PUSH", true],
["MAKE_DICT", 1],
["MAKE_DICT", 1],
["ADD"],
["HALT"]
])
console.log('\nMerge dicts with nested structures:')
const result6 = await run(nestedMerge)
console.log(result6)
// Output: { type: 'dict', value: Map { data: [1, 2], config: { debug: true } } }
// Building configuration objects
const configBuild = toBytecode([
// Default config
["PUSH", "debug"],
["PUSH", false],
["PUSH", "port"],
["PUSH", 3000],
["PUSH", "host"],
["PUSH", "localhost"],
["MAKE_DICT", 3],
// Override with user config
["PUSH", "debug"],
["PUSH", true],
["PUSH", "port"],
["PUSH", 8080],
["MAKE_DICT", 2],
["ADD"],
["HALT"]
])
console.log('\nBuilding config (defaults + overrides):')
const result7 = await run(configBuild)
console.log(result7)
// Output: { type: 'dict', value: Map { debug: true, port: 8080, host: 'localhost' } }

View File

@ -0,0 +1,73 @@
/**
* Demonstrates the ADD opcode working with both numbers and strings
*
* ADD now behaves like JavaScript's + operator:
* - If either operand is a string, it does string concatenation
* - Otherwise, it does numeric addition
*/
import { toBytecode, run } from "#reef"
// Numeric addition
const numericAdd = toBytecode(`
PUSH 10
PUSH 5
ADD
HALT
`)
console.log('Numeric addition (10 + 5):')
console.log(await run(numericAdd))
// Output: { type: 'number', value: 15 }
// String concatenation
const stringConcat = toBytecode(`
PUSH "hello"
PUSH " world"
ADD
HALT
`)
console.log('\nString concatenation ("hello" + " world"):')
console.log(await run(stringConcat))
// Output: { type: 'string', value: 'hello world' }
// Mixed: string + number
const mixedConcat = toBytecode(`
PUSH "count: "
PUSH 42
ADD
HALT
`)
console.log('\nMixed concatenation ("count: " + 42):')
console.log(await run(mixedConcat))
// Output: { type: 'string', value: 'count: 42' }
// Building a message
const buildMessage = toBytecode(`
PUSH "You have "
PUSH 3
ADD
PUSH " new messages"
ADD
HALT
`)
console.log('\nBuilding a message:')
console.log(await run(buildMessage))
// Output: { type: 'string', value: 'You have 3 new messages' }
// Computing then concatenating
const computeAndConcat = toBytecode(`
PUSH "Result: "
PUSH 10
PUSH 5
ADD
ADD
HALT
`)
console.log('\nComputing then concatenating ("Result: " + (10 + 5)):')
console.log(await run(computeAndConcat))
// Output: { type: 'string', value: 'Result: 15' }

View File

@ -9,7 +9,7 @@ const bytecode = toBytecode(`
const vm = new VM(bytecode)
vm.registerFunction('print', (...args: Value[]): Value => {
vm.set('print', (...args: Value[]): Value => {
console.log(...args.map(toString))
return toNull()
})

View File

@ -16,13 +16,15 @@ export type Constant =
| Value
| FunctionDef
type Atom = number | string | boolean | null
type Atom = RegExp | number | string | boolean | null
type InstructionTuple =
// Stack
| ["PUSH", Atom]
| ["POP"]
| ["DUP"]
| ["SWAP"]
| ["TYPE"]
// Variables
| ["LOAD", string]
@ -32,6 +34,9 @@ type InstructionTuple =
// Arithmetic
| ["ADD"] | ["SUB"] | ["MUL"] | ["DIV"] | ["MOD"]
// Bitwise
| ["BIT_AND"] | ["BIT_OR"] | ["BIT_XOR"] | ["BIT_SHL"] | ["BIT_SHR"] | ["BIT_USHR"]
// Comparison
| ["EQ"] | ["NEQ"] | ["LT"] | ["GT"] | ["LTE"] | ["GTE"]
@ -39,9 +44,9 @@ type InstructionTuple =
| ["NOT"]
// Control flow
| ["JUMP", string | number]
| ["JUMP_IF_FALSE", string | number]
| ["JUMP_IF_TRUE", string | number]
| ["JUMP", string]
| ["JUMP_IF_FALSE", string]
| ["JUMP_IF_TRUE", string]
| ["BREAK"]
// Exception handling
@ -51,7 +56,7 @@ type InstructionTuple =
| ["THROW"]
// Functions
| ["MAKE_FUNCTION", string[], string | number]
| ["MAKE_FUNCTION", string[], string]
| ["CALL"]
| ["TAIL_CALL"]
| ["RETURN"]
@ -83,30 +88,6 @@ type LabelDefinition = [string] // Just ".label_name:"
export type ProgramItem = InstructionTuple | LabelDefinition
//
// Parse bytecode from human-readable string format.
// Operand types are determined by prefix/literal:
// #42 -> immediate number (e.g., JUMP #5, MAKE_ARRAY #3)
// .label -> label reference (e.g., JUMP .loop_start, MAKE_FUNCTION (x y) .body)
// name -> variable/function name (e.g., LOAD x, LOAD_NATIVE add)
// 42 -> number constant (e.g., PUSH 42)
// "str" -> string constant (e.g., PUSH "hello")
// 'str' -> string constant (e.g., PUSH 'hello')
// true -> boolean constant (e.g., PUSH true)
// false -> boolean constant (e.g., PUSH false)
// null -> null constant (e.g., PUSH null)
//
// Labels:
// .label_name: -> label definition (marks current instruction position)
//
// Function definitions:
// MAKE_FUNCTION (x y) #7 -> basic function (numeric offset)
// MAKE_FUNCTION (x y) .body -> basic function (label reference)
// MAKE_FUNCTION (x y=42) #7 -> with defaults
// MAKE_FUNCTION (x ...rest) #7 -> variadic
// MAKE_FUNCTION (x @named) #7 -> named
//
function parseFunctionParams(paramStr: string, constants: Constant[]): {
params: string[]
defaults: Record<string, number>
@ -368,6 +349,29 @@ function toBytecodeFromArray(program: ProgramItem[]): Bytecode /* throws */ {
}
}
////
// Parse bytecode from human-readable string format.
// Operand types are determined by prefix/literal:
// #42 -> immediate number (e.g., JUMP #5, MAKE_ARRAY #3)
// .label -> label reference (e.g., JUMP .loop_start, MAKE_FUNCTION (x y) .body)
// name -> variable/function name (e.g., LOAD x, LOAD_NATIVE add)
// 42 -> number constant (e.g., PUSH 42)
// "str" -> string constant (e.g., PUSH "hello")
// 'str' -> string constant (e.g., PUSH 'hello')
// true -> boolean constant (e.g., PUSH true)
// false -> boolean constant (e.g., PUSH false)
// null -> null constant (e.g., PUSH null)
//
// Labels:
// .label_name: -> label definition (marks current instruction position)
//
// Function definitions:
// MAKE_FUNCTION (x y) #7 -> basic function (numeric offset)
// MAKE_FUNCTION (x y) .body -> basic function (label reference)
// MAKE_FUNCTION (x y=42) #7 -> with defaults
// MAKE_FUNCTION (x ...rest) #7 -> variadic
// MAKE_FUNCTION (x @named) #7 -> named
function toBytecodeFromString(str: string): Bytecode /* throws */ {
const lines = str.trim().split("\n")
@ -386,7 +390,7 @@ function toBytecodeFromString(str: string): Bytecode /* throws */ {
if (!trimmed) continue
// Check for label definition (.label_name:)
if (/^\.[a-zA-Z_][a-zA-Z0-9_]*:$/.test(trimmed)) {
if (/^\.[a-zA-Z_][a-zA-Z0-9_.]*:$/.test(trimmed)) {
const labelName = trimmed.slice(1, -1)
labels.set(labelName, cleanLines.length)
continue

View File

@ -1,4 +1,5 @@
import { type Value, type NativeFunction, fromValue, toValue } from "./value"
import { VM } from "./vm"
export type ParamInfo = {
params: string[]
@ -9,11 +10,13 @@ export type ParamInfo = {
const WRAPPED_MARKER = Symbol('reef-wrapped')
export function wrapNative(fn: Function): (...args: Value[]) => Promise<Value> {
const wrapped = async (...values: Value[]) => {
const nativeArgs = values.map(fromValue)
const result = await fn(...nativeArgs)
return toValue(result)
export function wrapNative(vm: VM, fn: Function): (this: VM, ...args: Value[]) => Promise<Value> {
if ((fn as any).raw) return fn as (this: VM, ...args: Value[]) => Promise<Value>
const wrapped = async function (this: VM, ...values: Value[]) {
const nativeArgs = values.map(arg => fromValue(arg, vm))
const result = await fn.call(this, ...nativeArgs)
return toValue(result, this)
}
const wrappedObj = wrapped as any

View File

@ -1,14 +1,17 @@
import type { Bytecode } from "./bytecode"
import { type Value } from "./value"
import type { Value } from "./value"
import { VM } from "./vm"
export async function run(bytecode: Bytecode, functions?: Record<string, Function>): Promise<Value> {
const vm = new VM(bytecode, functions)
export async function run(bytecode: Bytecode, globals?: Record<string, any>): Promise<Value> {
const vm = new VM(bytecode, globals)
return await vm.run()
}
export { type Bytecode, toBytecode, type ProgramItem } from "./bytecode"
export { wrapNative } from "./function"
export { type Value, toValue, toString, toNumber, fromValue, toNull } from "./value"
export { VM } from "./vm"
export { bytecodeToString } from "./format"
export { wrapNative, isWrapped, type ParamInfo, extractParamInfo, getOriginalFunction } from "./function"
export { OpCode } from "./opcode"
export { Scope } from "./scope"
export type { Value, TypeScriptFunction, NativeFunction } from "./value"
export { isValue, toValue, toString, toNumber, fromValue, toNull, fnFromValue } from "./value"
export { VM } from "./vm"

View File

@ -3,12 +3,16 @@ export enum OpCode {
PUSH, // operand: constant index (number) | stack: [] → [value]
POP, // operand: none | stack: [value] → []
DUP, // operand: none | stack: [value] → [value, value]
SWAP, // operand: none | stack: [value1, value2] → [value2, value1]
// variables
LOAD, // operand: variable name (identifier) | stack: [] → [value]
STORE, // operand: variable name (identifier) | stack: [value] → []
TRY_LOAD, // operand: variable name (identifier) | stack: [] → [value] | load a variable (if found) or a string
// information
TYPE, // operand: none | stack: [a] → []
// math (coerce to number, pop 2, push result)
ADD, // operand: none | stack: [a, b] → [a + b]
SUB, // operand: none | stack: [a, b] → [a - b]
@ -16,6 +20,14 @@ export enum OpCode {
DIV, // operand: none | stack: [a, b] → [a / b]
MOD, // operand: none | stack: [a, b] → [a % b]
// bitwise operations (coerce to 32-bit integers, pop 2, push result)
BIT_AND, // operand: none | stack: [a, b] → [a & b]
BIT_OR, // operand: none | stack: [a, b] → [a | b]
BIT_XOR, // operand: none | stack: [a, b] → [a ^ b]
BIT_SHL, // operand: none | stack: [a, b] → [a << b]
BIT_SHR, // operand: none | stack: [a, b] → [a >> b] (sign-preserving)
BIT_USHR, // operand: none | stack: [a, b] → [a >>> b] (zero-fill)
// comparison (pop 2, push boolean)
EQ, // operand: none | stack: [a, b] → [a == b] (deep equality)
NEQ, // operand: none | stack: [a, b] → [a != b]

View File

@ -30,6 +30,15 @@ export class Scope {
has(name: string): boolean {
return this.locals.has(name) || this.parent?.has(name) || false
}
vars(): string[] {
const vars = new Set(this.parent?.vars())
for (const name of this.locals.keys())
vars.add(name)
return [...vars].sort()
}
}

View File

@ -50,11 +50,19 @@ const OPCODES_WITH_OPERANDS = new Set([
const OPCODES_WITHOUT_OPERANDS = new Set([
OpCode.POP,
OpCode.DUP,
OpCode.SWAP,
OpCode.TYPE,
OpCode.ADD,
OpCode.SUB,
OpCode.MUL,
OpCode.DIV,
OpCode.MOD,
OpCode.BIT_AND,
OpCode.BIT_OR,
OpCode.BIT_XOR,
OpCode.BIT_SHL,
OpCode.BIT_SHR,
OpCode.BIT_USHR,
OpCode.EQ,
OpCode.NEQ,
OpCode.LT,
@ -79,11 +87,15 @@ const OPCODES_WITHOUT_OPERANDS = new Set([
OpCode.DOT_GET,
])
// immediate = immediate number, eg #5
const OPCODES_REQUIRING_IMMEDIATE_OR_LABEL = new Set([
// JUMP* instructions require labels only (no numeric immediates)
const OPCODES_REQUIRING_LABEL = new Set([
OpCode.JUMP,
OpCode.JUMP_IF_FALSE,
OpCode.JUMP_IF_TRUE,
])
// PUSH_TRY/PUSH_FINALLY still allow immediate or label
const OPCODES_REQUIRING_IMMEDIATE_OR_LABEL = new Set([
OpCode.PUSH_TRY,
OpCode.PUSH_FINALLY,
])
@ -189,6 +201,16 @@ export function validateBytecode(source: string): ValidationResult {
// Validate specific operand formats
if (operand) {
if (OPCODES_REQUIRING_LABEL.has(opCode)) {
if (!operand.startsWith('.')) {
errors.push({
line: lineNum,
message: `${opName} requires label (.label), got: ${operand}`,
})
continue
}
}
if (OPCODES_REQUIRING_IMMEDIATE_OR_LABEL.has(opCode)) {
if (!operand.startsWith('#') && !operand.startsWith('.')) {
errors.push({
@ -302,11 +324,11 @@ export function validateBytecode(source: string): ValidationResult {
}
}
// Validate body address
if (!bodyAddr!.startsWith('.') && !bodyAddr!.startsWith('#')) {
// Validate body address (must be a label)
if (!bodyAddr!.startsWith('.')) {
errors.push({
line: lineNum,
message: `Invalid body address: expected .label or #offset`,
message: `Invalid body address: expected .label, got: ${bodyAddr}`,
})
}

View File

@ -1,6 +1,12 @@
import { wrapNative, getOriginalFunction } from "./function"
import { OpCode } from "./opcode"
import { Scope } from "./scope"
import { VM } from "./vm"
export type NativeFunction = (...args: Value[]) => Promise<Value> | Value
export type TypeScriptFunction = (this: VM, ...args: any[]) => any
const REEF_FUNCTION = Symbol('__reefFunction')
const VALUE_TYPES = new Set(['null', 'boolean', 'number', 'string', 'array', 'dict', 'regex', 'native', 'function'])
export type Value =
| { type: 'null', value: null }
@ -33,15 +39,19 @@ export type FunctionDef = {
named: boolean
}
export function toValue(v: any): Value /* throws */ {
export function isValue(v: any): boolean {
return !!(v && typeof v === 'object' && VALUE_TYPES.has(v.type) && 'value' in v)
}
export function toValue(v: any, vm?: VM): Value /* throws */ {
if (v === null || v === undefined)
return { type: 'null', value: null }
if (v && typeof v === 'object' && 'type' in v && 'value' in v)
if (isValue(v))
return v as Value
if (Array.isArray(v))
return { type: 'array', value: v.map(toValue) }
return { type: 'array', value: v.map(x => toValue(x, vm)) }
if (v instanceof RegExp)
return { type: 'regex', value: v }
@ -54,15 +64,38 @@ export function toValue(v: any): Value /* throws */ {
case 'string':
return { type: 'string', value: v }
case 'function':
throw "can't toValue() a js function yet"
if ((v as any)[REEF_FUNCTION])
return (v as any)[REEF_FUNCTION]
let fn = vm ? wrapNative(vm, v) : cantCallFunctionWithoutVM(v)
return { type: 'native', fn, value: '<function>' }
case 'object':
const dict: Dict = new Map()
for (const key of Object.keys(v)) dict.set(key, toValue(v[key]))
for (const key of Object.keys(v)) dict.set(key, toValue(v[key], vm))
return { type: 'dict', value: dict }
default:
throw `can't toValue this: ${v}`
throw new Error(`can't toValue this: ${v}`)
}
}
function cantCallFunctionWithoutVM(fn: Function) {
const name = fn.name || '<anonymous>'
const str = fn.toString().slice(0, 100)
return (...args: Value[]) => {
const stack = new Error().stack || ''
const stackLines = stack.split('\n')
.slice(1)
.filter(line => !line.includes('toValue'))
.map(line => ' ' + line.trim())
.join('\n')
throw new Error(
`can't call function that was converted without a vm\n` +
` Function: ${name}\n` +
` Source: ${str}${str.length > 100 ? '...' : ''}\n` +
` Called from:\n${stackLines}`
)
}
}
@ -154,28 +187,87 @@ export function isEqual(a: Value, b: Value): boolean {
}
}
export function fromValue(v: Value): any {
export function fromValue(v: Value, vm?: VM): any {
switch (v.type) {
case 'null':
return null
case 'boolean':
return v.value
case 'number':
return v.value
case 'string':
return v.value
case 'array':
return v.value.map(fromValue)
return v.value.map(x => fromValue(x, vm))
case 'dict':
return Object.fromEntries(v.value.entries().map(([k, v]) => [k, fromValue(v)]))
return Object.fromEntries(v.value.entries().map(([k, v]) => [k, fromValue(v, vm)]))
case 'regex':
return v.value
case 'function':
if (!vm || !(vm instanceof VM)) {
const stack = new Error().stack || ''
const stackLines = stack.split('\n')
.slice(1)
.filter(line => !line.includes('fromValue'))
.map(line => ' ' + line.trim())
.join('\n')
throw new Error(
`VM is required for function conversion\n` +
` Function params: [${v.params.join(', ')}]\n` +
` Function body at instruction: ${v.body}\n` +
` Called from:\n${stackLines}`
)
}
return fnFromValue(v, vm)
case 'native':
return '<function>'
return getOriginalFunction(v.fn)
}
}
export function toNull(): Value {
return toValue(null)
}
export function fnFromValue(fn: Value, vm: VM): Function {
if (fn.type !== 'function')
throw new Error('Value is not a function')
const wrapper = async function (...args: any[]) {
let positional: any[] = args
let named: Record<string, any> = {}
if (args.length > 0 && !Array.isArray(args[args.length - 1]) && args[args.length - 1].constructor === Object) {
named = args[args.length - 1]
positional = args.slice(0, -1)
}
const newVM = new VM({
instructions: vm.instructions,
constants: vm.constants,
labels: vm.labels
})
newVM.scope = fn.parentScope
newVM.stack.push(fn)
newVM.stack.push(...positional.map(x => toValue(x, vm)))
for (const [key, val] of Object.entries(named)) {
newVM.stack.push(toValue(key))
newVM.stack.push(toValue(val, vm))
}
newVM.stack.push(toValue(positional.length))
newVM.stack.push(toValue(Object.keys(named).length))
const targetDepth = newVM.callStack.length
await newVM.execute({ op: OpCode.CALL })
newVM.pc++
while (newVM.callStack.length > targetDepth && newVM.pc < newVM.instructions.length) {
await newVM.execute(newVM.instructions[newVM.pc]!)
newVM.pc++
}
return fromValue(newVM.stack.pop() || toNull(), vm)
}
// support roundtrips, eg fromValue(toValue(fn))
; (wrapper as any)[REEF_FUNCTION] = fn
return wrapper
}

291
src/vm.ts
View File

@ -3,8 +3,9 @@ import type { ExceptionHandler } from "./exception"
import { type Frame } from "./frame"
import { OpCode } from "./opcode"
import { Scope } from "./scope"
import { type Value, type NativeFunction, toValue, toNumber, isTrue, isEqual, toString, fromValue } from "./value"
import { extractParamInfo, wrapNative, isWrapped, getOriginalFunction } from "./function"
import type { Value, NativeFunction, TypeScriptFunction } from "./value"
import { toValue, toNumber, isTrue, isEqual, toString, fromValue, fnFromValue } from "./value"
import { extractParamInfo, getOriginalFunction } from "./function"
export class VM {
pc = 0
@ -18,26 +19,71 @@ export class VM {
labels: Map<number, string> = new Map()
nativeFunctions: Map<string, NativeFunction> = new Map()
constructor(bytecode: Bytecode, functions?: Record<string, Function>) {
constructor(bytecode: Bytecode, globals?: Record<string, any>) {
this.instructions = bytecode.instructions
this.constants = bytecode.constants
this.labels = bytecode.labels || new Map()
this.scope = new Scope()
if (functions)
for (const name of Object.keys(functions))
this.registerFunction(name, functions[name]!)
if (globals) {
for (const name of Object.keys(globals ?? {}))
this.set(name, globals[name])
this.scope = new Scope(this.scope)
}
}
registerFunction(name: string, fn: Function) {
const wrapped = isWrapped(fn) ? fn as NativeFunction : wrapNative(fn)
this.scope.set(name, { type: 'native', fn: wrapped, value: '<function>' })
async call(name: string, ...args: any) {
const value = this.scope.get(name)
if (!value) throw new Error(`Can't find ${name}`)
if (value.type !== 'function' && value.type !== 'native')
throw new Error(`Can't call ${name}`)
if (value.type === 'native') {
return await this.callNative(value.fn, args)
} else {
const fn = fnFromValue(value, this)
return await fn(...args)
}
}
registerValueFunction(name: string, fn: NativeFunction) {
has(name: string): boolean {
return this.scope.has(name)
}
vars(): string[] {
return this.scope.vars()
}
get(name: string) {
return this.scope.get(name)
}
set(name: string, value: any) {
this.scope.set(name, toValue(value, this))
}
setFunction(name: string, fn: TypeScriptFunction) {
this.scope.set(name, toValue(fn, this))
}
setValueFunction(name: string, fn: NativeFunction) {
this.scope.set(name, { type: 'native', fn, value: '<function>' })
}
pushScope(locals?: Record<string, any>) {
this.scope = new Scope(this.scope)
if (locals)
for (const [name, value] of Object.entries(locals))
this.set(name, value)
}
popScope() {
this.scope = this.scope.parent!
}
async run(): Promise<Value> {
this.pc = 0
this.stopped = false
@ -51,6 +97,57 @@ 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
const instructionOffset = this.instructions.length
// Remap function body addresses in constants before adding them
for (const constant of bytecode.constants) {
if (constant.type === 'function_def') {
this.constants.push({ ...constant, body: constant.body + instructionOffset })
} else {
this.constants.push(constant)
}
}
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) {
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:
@ -71,8 +168,32 @@ export class VM {
this.stack.push(this.stack[this.stack.length - 1]!)
break
case OpCode.SWAP:
const first = this.stack.pop()!
const second = this.stack.pop()!
this.stack.push(first)
this.stack.push(second)
break
case OpCode.ADD:
this.binaryOp((a, b) => toNumber(a) + toNumber(b))
const b = this.stack.pop()!
const a = this.stack.pop()!
if (a.type === 'string' || b.type === 'string') {
this.stack.push(toValue(toString(a) + toString(b)))
} else if (a.type === 'array' && b.type === 'array') {
this.stack.push({ type: 'array', value: [...a.value, ...b.value] })
} else if (a.type === 'dict' && b.type === 'dict') {
const merged = new Map(a.value)
for (const [key, value] of b.value) {
merged.set(key, value)
}
this.stack.push({ type: 'dict', value: merged })
} else if (a.type === 'number' && b.type === 'number') {
this.stack.push(toValue(a.value + b.value))
} else {
throw new Error(`ADD: Cannot add ${a.type} and ${b.type}`)
}
break
case OpCode.SUB:
@ -120,6 +241,31 @@ export class VM {
this.stack.push({ type: 'boolean', value: !isTrue(val) })
break
// Bitwise operations
case OpCode.BIT_AND:
this.binaryOp((a, b) => (toNumber(a) | 0) & (toNumber(b) | 0))
break
case OpCode.BIT_OR:
this.binaryOp((a, b) => (toNumber(a) | 0) | (toNumber(b) | 0))
break
case OpCode.BIT_XOR:
this.binaryOp((a, b) => (toNumber(a) | 0) ^ (toNumber(b) | 0))
break
case OpCode.BIT_SHL:
this.binaryOp((a, b) => (toNumber(a) | 0) << (toNumber(b) | 0))
break
case OpCode.BIT_SHR:
this.binaryOp((a, b) => (toNumber(a) | 0) >> (toNumber(b) | 0))
break
case OpCode.BIT_USHR:
this.binaryOp((a, b) => (toNumber(a) | 0) >>> (toNumber(b) | 0))
break
case OpCode.HALT:
this.stopped = true
break
@ -140,13 +286,18 @@ export class VM {
const value = this.scope.get(varName)
if (value === undefined)
this.stack.push(toValue(varName))
this.stack.push(toValue(varName, this))
else
this.stack.push(value)
break
}
case OpCode.TYPE:
const value = this.stack.pop()!
this.stack.push(toValue(value.type))
break
case OpCode.STORE:
const name = instruction.operand as string
const toStore = this.stack.pop()!
@ -342,10 +493,10 @@ export class VM {
const target = this.stack.pop()!
if (target.type === 'array')
this.stack.push(toValue(target.value?.[Number(index.value)]))
this.stack.push(toValue(target.value?.[Number(index.value)], this))
else if (target.type === 'dict')
this.stack.push(toValue(target.value?.get(String(index.value))))
this.stack.push(toValue(target.value?.get(String(index.value)), this))
else
throw new Error(`DOT_GET: ${target.type} not supported`)
@ -451,16 +602,25 @@ export class VM {
let nativePositionalArgIndex = 0
// Bind fixed parameters using priority: named arg > positional arg > default > null
// Note: null values trigger defaults (null acts as "use default")
for (let i = 0; i < nativeFixedParamCount; i++) {
const paramName = paramInfo.params[i]!
let paramValue: Value | undefined
// Check if named argument was provided for this param
if (namedArgs.has(paramName)) {
nativeArgs.push(namedArgs.get(paramName)!)
paramValue = namedArgs.get(paramName)!
namedArgs.delete(paramName) // Remove from named args so it won't go to @named
} else if (nativePositionalArgIndex < positionalArgs.length) {
nativeArgs.push(positionalArgs[nativePositionalArgIndex]!)
paramValue = positionalArgs[nativePositionalArgIndex]!
nativePositionalArgIndex++
}
// If the parameter value is null and a default exists, use the default
if (paramValue?.type === 'null' && paramInfo.defaults[paramName] !== undefined) {
nativeArgs.push(paramInfo.defaults[paramName]!)
} else if (paramValue) {
nativeArgs.push(paramValue)
} else if (paramInfo.defaults[paramName] !== undefined) {
nativeArgs.push(paramInfo.defaults[paramName]!)
} else {
@ -468,14 +628,6 @@ export class VM {
}
}
// Handle variadic parameter (TypeScript rest parameters)
// For TypeScript functions with ...rest, we spread the remaining args
// rather than wrapping them in an array
if (paramInfo.variadic) {
const remainingArgs = positionalArgs.slice(nativePositionalArgIndex)
nativeArgs.push(...remainingArgs)
}
// Handle named parameter (collect remaining unmatched named args)
// Parameter names matching atXxx pattern (e.g., atOptions, atNamed) collect extra named args
if (paramInfo.named) {
@ -484,18 +636,54 @@ export class VM {
namedDict.set(key, value)
}
// Convert dict to plain JavaScript object for the native function
const namedObj = fromValue({ type: 'dict', value: namedDict })
nativeArgs.push(toValue(namedObj))
const namedObj = fromValue({ type: 'dict', value: namedDict }, this)
nativeArgs.push(toValue(namedObj, this))
}
// Handle variadic parameter (TypeScript rest parameters)
// For TypeScript functions with ...rest, we spread the remaining args
// rather than wrapping them in an array
if (paramInfo.variadic) {
const remainingArgs = positionalArgs.slice(nativePositionalArgIndex)
nativeArgs.push(...remainingArgs)
}
// Call the native function with bound args
const result = await fn.fn(...nativeArgs)
this.stack.push(result)
break
try {
const result = await fn.fn.call(this, ...nativeArgs)
this.stack.push(result)
break
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
const errorValue = toValue(errorMessage)
// no exception handlers, let it crash
if (this.exceptionHandlers.length === 0) {
throw new Error(errorMessage)
}
// use existing THROW logic
const throwHandler = this.exceptionHandlers.pop()!
while (this.callStack.length > throwHandler.callStackDepth)
this.callStack.pop()
this.scope = throwHandler.scope
this.stack.push(errorValue)
// 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
}
}
if (fn.type !== 'function')
throw new Error('CALL: not a function')
throw new Error(`CALL: ${fn.value} is not a function`)
if (this.callStack.length > 0)
this.callStack[this.callStack.length - 1]!.isBreakTarget = true
@ -517,16 +705,29 @@ export class VM {
let positionalArgIndex = 0
// Bind fixed parameters using priority: named arg > positional arg > default > null
// Note: null values trigger defaults (null acts as "use default")
for (let i = 0; i < fixedParamCount; i++) {
const paramName = fn.params[i]!
let paramValue: Value | undefined
// Check if named argument was provided for this param
if (namedArgs.has(paramName)) {
this.scope.set(paramName, namedArgs.get(paramName)!)
paramValue = namedArgs.get(paramName)!
namedArgs.delete(paramName) // Remove from named args so it won't go to named
} else if (positionalArgIndex < positionalArgs.length) {
this.scope.set(paramName, positionalArgs[positionalArgIndex]!)
paramValue = positionalArgs[positionalArgIndex]!
positionalArgIndex++
}
// If the parameter value is null and a default exists, use the default
if (paramValue && paramValue.type === 'null' && fn.defaults[paramName] !== undefined) {
const defaultIdx = fn.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 if (paramValue) {
this.scope.set(paramName, paramValue)
} else if (fn.defaults[paramName] !== undefined) {
const defaultIdx = fn.defaults[paramName]!
const defaultValue = this.constants[defaultIdx]!
@ -587,7 +788,7 @@ export class VM {
const tailFn = this.stack.pop()!
if (tailFn.type !== 'function')
throw new Error('TAIL_CALL: not a function')
throw new Error(`TAIL_CALL: ${tailFn.value} is not a function`)
this.scope = new Scope(tailFn.parentScope)
@ -656,7 +857,7 @@ export class VM {
break
default:
throw `Unknown op: ${instruction.op}`
throw new Error(`Unknown op: ${instruction.op}`)
}
}
@ -673,4 +874,26 @@ export class VM {
const result = fn(a, b)
this.stack.push({ type: 'boolean', value: result })
}
async callNative(nativeFn: NativeFunction, args: any[]): Promise<Value> {
const originalFn = getOriginalFunction(nativeFn)
const lastArg = args[args.length - 1]
if (lastArg && !Array.isArray(lastArg) && typeof lastArg === 'object') {
const paramInfo = extractParamInfo(originalFn)
const positional = args.slice(0, -1)
const named = lastArg
args = [...positional]
for (let i = positional.length; i < paramInfo.params.length; i++) {
const paramName = paramInfo.params[i]!
if (named[paramName] !== undefined) {
args[i] = named[paramName]
}
}
}
const result = await originalFn.call(this, ...args)
return toValue(result, this)
}
}

107
tests/bitwise.test.ts Normal file
View File

@ -0,0 +1,107 @@
import { expect, describe, test } from 'bun:test'
import { toBytecode, run } from '#reef'
describe('bitwise operations', () => {
test('BIT_AND', async () => {
const bytecode = toBytecode([
["PUSH", 5], ["PUSH", 3], ["BIT_AND"], ["HALT"]
])
await expect(bytecode).toBeNumber(1)
})
test('BIT_AND with zero', async () => {
const bytecode = toBytecode([
["PUSH", 5], ["PUSH", 0], ["BIT_AND"], ["HALT"]
])
await expect(bytecode).toBeNumber(0)
})
test('BIT_OR', async () => {
const bytecode = toBytecode([
["PUSH", 5], ["PUSH", 3], ["BIT_OR"], ["HALT"]
])
await expect(bytecode).toBeNumber(7)
})
test('BIT_OR with zero', async () => {
const bytecode = toBytecode([
["PUSH", 5], ["PUSH", 0], ["BIT_OR"], ["HALT"]
])
await expect(bytecode).toBeNumber(5)
})
test('BIT_XOR', async () => {
const bytecode = toBytecode([
["PUSH", 5], ["PUSH", 3], ["BIT_XOR"], ["HALT"]
])
await expect(bytecode).toBeNumber(6)
})
test('BIT_XOR with itself returns zero', async () => {
const bytecode = toBytecode([
["PUSH", 5], ["PUSH", 5], ["BIT_XOR"], ["HALT"]
])
await expect(bytecode).toBeNumber(0)
})
test('BIT_SHL', async () => {
const bytecode = toBytecode([
["PUSH", 5], ["PUSH", 2], ["BIT_SHL"], ["HALT"]
])
await expect(bytecode).toBeNumber(20)
})
test('BIT_SHL by zero', async () => {
const bytecode = toBytecode([
["PUSH", 5], ["PUSH", 0], ["BIT_SHL"], ["HALT"]
])
await expect(bytecode).toBeNumber(5)
})
test('BIT_SHR', async () => {
const bytecode = toBytecode([
["PUSH", 20], ["PUSH", 2], ["BIT_SHR"], ["HALT"]
])
await expect(bytecode).toBeNumber(5)
})
test('BIT_SHR preserves sign for negative numbers', async () => {
const bytecode = toBytecode([
["PUSH", -20], ["PUSH", 2], ["BIT_SHR"], ["HALT"]
])
await expect(bytecode).toBeNumber(-5)
})
test('BIT_USHR', async () => {
const bytecode = toBytecode([
["PUSH", -1], ["PUSH", 1], ["BIT_USHR"], ["HALT"]
])
await expect(bytecode).toBeNumber(2147483647)
})
test('BIT_USHR does not preserve sign', async () => {
const bytecode = toBytecode([
["PUSH", -8], ["PUSH", 1], ["BIT_USHR"], ["HALT"]
])
await expect(bytecode).toBeNumber(2147483644)
})
test('compound bitwise operations', async () => {
const bytecode = toBytecode([
// (5 & 3) | (8 ^ 12)
["PUSH", 5], ["PUSH", 3], ["BIT_AND"], // stack: [1]
["PUSH", 8], ["PUSH", 12], ["BIT_XOR"], // stack: [1, 4]
["BIT_OR"], // stack: [5]
["HALT"]
])
await expect(bytecode).toBeNumber(5)
})
test('shift with large shift amounts', async () => {
const bytecode = toBytecode([
["PUSH", 1], ["PUSH", 31], ["BIT_SHL"], ["HALT"]
])
// 1 << 31 = -2147483648 (most significant bit set)
await expect(bytecode).toBeNumber(-2147483648)
})
})

View File

@ -15,7 +15,7 @@ test("PUSH_TRY and POP_TRY - no exception thrown", async () => {
PUSH 999
HALT
`
expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 52 })
await expect(str).toBeNumber(52)
})
test("THROW - catch exception with error value", async () => {
@ -29,7 +29,7 @@ test("THROW - catch exception with error value", async () => {
.catch:
HALT
`
expect(await run(toBytecode(str))).toEqual({ type: 'string', value: 'error occurred' })
await expect(str).toBeString('error occurred')
})
test("THROW - uncaught exception throws JS error", async () => {
@ -58,7 +58,7 @@ test("THROW - exception with nested try blocks", async () => {
PUSH "outer error"
HALT
`
expect(await run(toBytecode(str))).toEqual({ type: 'string', value: 'inner error' })
await expect(str).toBeString('inner error')
})
test("THROW - exception skips outer handler", async () => {
@ -75,7 +75,7 @@ test("THROW - exception skips outer handler", async () => {
.outer_catch:
HALT
`
expect(await run(toBytecode(str))).toEqual({ type: 'string', value: 'error message' })
await expect(str).toBeString('error message')
})
test("THROW - exception unwinds call stack", async () => {
@ -150,7 +150,7 @@ test("PUSH_FINALLY - finally executes after successful try", async () => {
ADD
HALT
`
expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 110 })
await expect(str).toBeNumber(110)
})
test("PUSH_FINALLY - finally executes after exception", async () => {
@ -169,7 +169,7 @@ test("PUSH_FINALLY - finally executes after exception", async () => {
PUSH "finally ran"
HALT
`
expect(await run(toBytecode(str))).toEqual({ type: 'string', value: 'finally ran' })
await expect(str).toBeString('finally ran')
})
test("PUSH_FINALLY - finally without catch", async () => {
@ -189,7 +189,7 @@ test("PUSH_FINALLY - finally without catch", async () => {
ADD
HALT
`
expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 52 })
await expect(str).toBeNumber(52)
})
test("PUSH_FINALLY - nested try-finally blocks", async () => {
@ -214,7 +214,7 @@ test("PUSH_FINALLY - nested try-finally blocks", async () => {
ADD
HALT
`
expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 11 })
await expect(str).toBeNumber(11)
})
test("PUSH_FINALLY - error when no handler", async () => {

View File

@ -104,7 +104,7 @@ describe("functions parameter", () => {
expect(result).toEqual({ type: 'number', value: 200 })
})
test("can combine with manual registerFunction", async () => {
test("can combine with manual vm.set", async () => {
const bytecode = toBytecode(`
LOAD add
PUSH 5
@ -127,7 +127,7 @@ describe("functions parameter", () => {
})
// Register another function manually
vm.registerFunction('subtract', (a: number, b: number) => a - b)
vm.set('subtract', (a: number, b: number) => a - b)
const result = await vm.run()
expect(result).toEqual({ type: 'number', value: 6 })
@ -224,7 +224,7 @@ describe("functions parameter", () => {
})
// Override with manual registration
vm.registerFunction('getValue', () => 200)
vm.set('getValue', () => 200)
const result = await vm.run()
expect(result).toEqual({ type: 'number', value: 200 })

View File

@ -487,7 +487,7 @@ test("TRY_CALL - handles null values", async () => {
test("TRY_CALL - function can access its parameters", async () => {
const bytecode = toBytecode([
["MAKE_FUNCTION", ["x"], ".body"],
["MAKE_FUNCTION", ["x=0"], ".body"],
["STORE", "addFive"],
["PUSH", 10],
["STORE", "x"],
@ -501,8 +501,8 @@ test("TRY_CALL - function can access its parameters", async () => {
])
const result = await run(bytecode)
// Function is called with 0 args, so x inside function should be null
// Then we add 5 to null (which coerces to 0)
// Function is called with 0 args, so x defaults to 0
// Then we add 5 to 0
expect(result).toEqual({ type: 'number', value: 5 })
})
@ -519,3 +519,206 @@ test("TRY_CALL - with string format", async () => {
const result = await run(bytecode)
expect(result).toEqual({ type: 'number', value: 100 })
})
test("CALL - passing null triggers default value for single parameter", async () => {
const bytecode = toBytecode([
["MAKE_FUNCTION", ["x=42"], ".body"],
["STORE", "func"],
["JUMP", ".end"],
[".body:"],
["LOAD", "x"],
["RETURN"],
[".end:"],
["LOAD", "func"],
["PUSH", null],
["PUSH", 1],
["PUSH", 0],
["CALL"],
["HALT"]
])
const result = await run(bytecode)
// Passing null should trigger the default value of 42
expect(result).toEqual({ type: 'number', value: 42 })
})
test("CALL - passing null triggers default value for multiple parameters", async () => {
const bytecode = toBytecode([
["MAKE_FUNCTION", ["a=10", "b=20", "c=30"], ".body"],
["STORE", "func"],
["JUMP", ".end"],
[".body:"],
["LOAD", "a"],
["LOAD", "b"],
["ADD"],
["LOAD", "c"],
["ADD"],
["RETURN"],
[".end:"],
["LOAD", "func"],
["PUSH", 5],
["PUSH", null],
["PUSH", null],
["PUSH", 3],
["PUSH", 0],
["CALL"],
["HALT"]
])
const result = await run(bytecode)
// a=5 (provided), b=20 (null triggers default), c=30 (null triggers default)
// Result: 5 + 20 + 30 = 55
expect(result).toEqual({ type: 'number', value: 55 })
})
test("CALL - null in middle parameter triggers default", async () => {
const bytecode = toBytecode([
["MAKE_FUNCTION", ["x=100", "y=200", "z=300"], ".body"],
["STORE", "func"],
["JUMP", ".end"],
[".body:"],
["LOAD", "x"],
["LOAD", "y"],
["ADD"],
["LOAD", "z"],
["ADD"],
["RETURN"],
[".end:"],
["LOAD", "func"],
["PUSH", 1],
["PUSH", null],
["PUSH", 3],
["PUSH", 3],
["PUSH", 0],
["CALL"],
["HALT"]
])
const result = await run(bytecode)
// x=1, y=200 (null triggers default), z=3
// Result: 1 + 200 + 3 = 204
expect(result).toEqual({ type: 'number', value: 204 })
})
test("CALL - null with named arguments triggers default", async () => {
const bytecode = toBytecode([
["MAKE_FUNCTION", ["x=50", "y=75"], ".body"],
["STORE", "func"],
["JUMP", ".end"],
[".body:"],
["LOAD", "x"],
["LOAD", "y"],
["ADD"],
["RETURN"],
[".end:"],
["LOAD", "func"],
["PUSH", "x"],
["PUSH", null],
["PUSH", "y"],
["PUSH", 25],
["PUSH", 0],
["PUSH", 2],
["CALL"],
["HALT"]
])
const result = await run(bytecode)
// x=50 (null triggers default), y=25 (provided via named arg)
// Result: 50 + 25 = 75
expect(result).toEqual({ type: 'number', value: 75 })
})
test("CALL - null with string default value", async () => {
const bytecode = toBytecode([
["MAKE_FUNCTION", ["name='Guest'"], ".body"],
["STORE", "greet"],
["JUMP", ".end"],
[".body:"],
["PUSH", "Hello, "],
["LOAD", "name"],
["ADD"],
["RETURN"],
[".end:"],
["LOAD", "greet"],
["PUSH", null],
["PUSH", 1],
["PUSH", 0],
["CALL"],
["HALT"]
])
const result = await run(bytecode)
// Passing null should trigger the default value 'Guest'
expect(result).toEqual({ type: 'string', value: 'Hello, Guest' })
})
test("CALL - null with no default still results in null", async () => {
const bytecode = toBytecode([
["MAKE_FUNCTION", ["x"], ".body"],
["STORE", "func"],
["JUMP", ".end"],
[".body:"],
["LOAD", "x"],
["RETURN"],
[".end:"],
["LOAD", "func"],
["PUSH", null],
["PUSH", 1],
["PUSH", 0],
["CALL"],
["HALT"]
])
const result = await run(bytecode)
// No default value, so null should be returned
expect(result).toEqual({ type: 'null', value: null })
})
test("CALL - null triggers default with variadic parameters", async () => {
const bytecode = toBytecode([
["MAKE_FUNCTION", ["x=99", "...rest"], ".body"],
["STORE", "func"],
["JUMP", ".end"],
[".body:"],
["LOAD", "x"],
["RETURN"],
[".end:"],
["LOAD", "func"],
["PUSH", null],
["PUSH", 1],
["PUSH", 2],
["PUSH", 3],
["PUSH", 4],
["PUSH", 0],
["CALL"],
["HALT"]
])
const result = await run(bytecode)
// x should be 99 (null triggers default), rest gets [1, 2, 3]
expect(result).toEqual({ type: 'number', value: 99 })
})
test("CALL - null triggers default with @named parameter", async () => {
const bytecode = toBytecode([
["MAKE_FUNCTION", ["x=777", "@named"], ".body"],
["STORE", "func"],
["JUMP", ".end"],
[".body:"],
["LOAD", "x"],
["RETURN"],
[".end:"],
["LOAD", "func"],
["PUSH", null],
["PUSH", "foo"],
["PUSH", "bar"],
["PUSH", 1],
["PUSH", 1],
["CALL"],
["HALT"]
])
const result = await run(bytecode)
// x should be 777 (null triggers default)
expect(result).toEqual({ type: 'number', value: 777 })
})

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -159,14 +159,14 @@ describe("RegExp", () => {
PUSH /bar/
NEQ
`
expect(await run(toBytecode(str))).toEqual({ type: 'boolean', value: true })
await expect(str).toBeBoolean(true)
const str2 = `
PUSH /test/i
PUSH /test/i
NEQ
`
expect(await run(toBytecode(str2))).toEqual({ type: 'boolean', value: false })
await expect(str2).toBeBoolean(false)
})
test("is truthy", async () => {
@ -177,7 +177,7 @@ describe("RegExp", () => {
PUSH 42
.end:
`
expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 42 })
await expect(str).toBeNumber(42)
})
test("NOT returns false (regex is truthy)", async () => {
@ -185,7 +185,7 @@ describe("RegExp", () => {
PUSH /pattern/
NOT
`
expect(await run(toBytecode(str))).toEqual({ type: 'boolean', value: false })
await expect(str).toBeBoolean(false)
})
test("in arrays", async () => {
@ -301,7 +301,7 @@ describe("RegExp", () => {
PUSH /bar/i
STR_CONCAT #3
`
expect(await run(toBytecode(str))).toEqual({ type: 'string', value: '/foo/ and /bar/i' })
await expect(str).toBeString('/foo/ and /bar/i')
})
test("DUP with regex", async () => {
@ -311,7 +311,7 @@ describe("RegExp", () => {
EQ
`
// Same regex duplicated should be equal
expect(await run(toBytecode(str))).toEqual({ type: 'boolean', value: true })
await expect(str).toBeBoolean(true)
})
test("empty pattern", async () => {
@ -365,7 +365,7 @@ describe("RegExp", () => {
PUSH /xyz/
EQ
`
expect(await run(toBytecode(str1))).toEqual({ type: 'boolean', value: false })
await expect(str1).toBeBoolean(false)
// Same pattern, different flags
const str2 = `
@ -373,7 +373,7 @@ describe("RegExp", () => {
PUSH /test/i
EQ
`
expect(await run(toBytecode(str2))).toEqual({ type: 'boolean', value: false })
await expect(str2).toBeBoolean(false)
// Different order of flags (should be equal)
const str3 = `
@ -381,7 +381,7 @@ describe("RegExp", () => {
PUSH /test/gi
EQ
`
expect(await run(toBytecode(str3))).toEqual({ type: 'boolean', value: true })
await expect(str3).toBeBoolean(true)
})
test("with native functions", async () => {
@ -399,7 +399,7 @@ describe("RegExp", () => {
const vm = new VM(bytecode)
// Register a native function that takes a string and regex
vm.registerFunction('match', (str: string, pattern: RegExp) => {
vm.set('match', (str: string, pattern: RegExp) => {
return pattern.test(str)
})
@ -422,7 +422,7 @@ describe("RegExp", () => {
const vm = new VM(bytecode)
vm.registerFunction('replace', (str: string, pattern: RegExp, replacement: string) => {
vm.set('replace', (str: string, pattern: RegExp, replacement: string) => {
return str.replace(pattern, replacement)
})
@ -444,7 +444,7 @@ describe("RegExp", () => {
const vm = new VM(bytecode)
vm.registerFunction('extractNumbers', (str: string, pattern: RegExp) => {
vm.set('extractNumbers', (str: string, pattern: RegExp) => {
return str.match(pattern) || []
})

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

@ -0,0 +1,149 @@
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
})
test("REPL mode - function calls work across chunks", async () => {
const vm = new VM(toBytecode([]))
await vm.run()
// Line 1: Define a function
const line1 = toBytecode([
["MAKE_FUNCTION", ["x"], ".body"],
["STORE", "add1"],
["JUMP", ".end"],
[".body:"],
["LOAD", "x"],
["PUSH", 1],
["ADD"],
["RETURN"],
[".end:"]
])
vm.appendBytecode(line1)
await vm.continue()
expect(vm.scope.get("add1")?.type).toBe("function")
// Line 2: Call the function
const line2 = toBytecode([
["LOAD", "add1"],
["PUSH", 10],
["PUSH", 1],
["PUSH", 0],
["CALL"]
])
vm.appendBytecode(line2)
const result = await vm.continue()
expect(result).toEqual({ type: "number", value: 11 })
})

243
tests/scope.test.ts Normal file
View File

@ -0,0 +1,243 @@
import { test, expect } from "bun:test"
import { Scope, toValue } from "#reef"
test("Scope - create empty scope", () => {
const scope = new Scope()
expect(scope.parent).toBeUndefined()
expect(scope.locals.size).toBe(0)
})
test("Scope - create child scope with parent", () => {
const parent = new Scope()
const child = new Scope(parent)
expect(child.parent).toBe(parent)
expect(child.locals.size).toBe(0)
})
test("Scope - set and get variable in same scope", () => {
const scope = new Scope()
const value = toValue(42)
scope.set("x", value)
expect(scope.get("x")).toBe(value)
})
test("Scope - get returns undefined for non-existent variable", () => {
const scope = new Scope()
expect(scope.get("x")).toBeUndefined()
})
test("Scope - set updates existing variable in same scope", () => {
const scope = new Scope()
scope.set("x", toValue(10))
expect(scope.get("x")).toEqual({ type: "number", value: 10 })
scope.set("x", toValue(20))
expect(scope.get("x")).toEqual({ type: "number", value: 20 })
})
test("Scope - get searches parent scope", () => {
const parent = new Scope()
const child = new Scope(parent)
parent.set("x", toValue(42))
expect(child.get("x")).toEqual({ type: "number", value: 42 })
})
test("Scope - get searches multiple levels up", () => {
const grandparent = new Scope()
const parent = new Scope(grandparent)
const child = new Scope(parent)
grandparent.set("x", toValue(100))
expect(child.get("x")).toEqual({ type: "number", value: 100 })
})
test("Scope - child updates parent variable when it exists (no shadowing)", () => {
const parent = new Scope()
const child = new Scope(parent)
parent.set("x", toValue(10))
child.set("x", toValue(20))
// set() updates parent's variable, not shadow it
expect(parent.get("x")).toEqual({ type: "number", value: 20 })
expect(child.get("x")).toEqual({ type: "number", value: 20 })
expect(child.locals.has("x")).toBe(false)
})
test("Scope - set updates parent scope variable when it exists", () => {
const parent = new Scope()
const child = new Scope(parent)
parent.set("x", toValue(10))
child.set("x", toValue(20))
// Should update parent's variable, not create new local one
expect(parent.get("x")).toEqual({ type: "number", value: 20 })
expect(child.get("x")).toEqual({ type: "number", value: 20 })
expect(child.locals.has("x")).toBe(false)
})
test("Scope - set creates new local variable when not found in parent", () => {
const parent = new Scope()
const child = new Scope(parent)
parent.set("x", toValue(10))
child.set("y", toValue(20))
expect(parent.get("x")).toEqual({ type: "number", value: 10 })
expect(parent.get("y")).toBeUndefined()
expect(child.get("x")).toEqual({ type: "number", value: 10 })
expect(child.get("y")).toEqual({ type: "number", value: 20 })
expect(child.locals.has("y")).toBe(true)
})
test("Scope - has returns true for variable in current scope", () => {
const scope = new Scope()
scope.set("x", toValue(42))
expect(scope.has("x")).toBe(true)
expect(scope.has("y")).toBe(false)
})
test("Scope - has returns true for variable in parent scope", () => {
const parent = new Scope()
const child = new Scope(parent)
parent.set("x", toValue(42))
expect(child.has("x")).toBe(true)
expect(child.has("y")).toBe(false)
})
test("Scope - has searches multiple levels up", () => {
const grandparent = new Scope()
const parent = new Scope(grandparent)
const child = new Scope(parent)
grandparent.set("x", toValue(100))
expect(child.has("x")).toBe(true)
expect(parent.has("x")).toBe(true)
expect(grandparent.has("x")).toBe(true)
})
test("Scope.vars() - returns empty array for empty scope", () => {
const scope = new Scope()
expect(scope.vars()).toEqual([])
})
test("Scope.vars() - returns single variable from current scope", () => {
const scope = new Scope()
scope.set("x", toValue(42))
expect(scope.vars()).toEqual(["x"])
})
test("Scope.vars() - returns multiple variables from current scope", () => {
const scope = new Scope()
scope.set("x", toValue(1))
scope.set("y", toValue(2))
scope.set("z", toValue(3))
const vars = scope.vars()
expect(vars.length).toBe(3)
expect(vars).toContain("x")
expect(vars).toContain("y")
expect(vars).toContain("z")
})
test("Scope.vars() - includes variables from parent scope", () => {
const parent = new Scope()
const child = new Scope(parent)
parent.set("x", toValue(1))
child.set("y", toValue(2))
const vars = child.vars()
expect(vars.length).toBe(2)
expect(vars).toContain("x")
expect(vars).toContain("y")
})
test("Scope.vars() - includes variables from multiple parent scopes", () => {
const grandparent = new Scope()
const parent = new Scope(grandparent)
const child = new Scope(parent)
grandparent.set("x", toValue(1))
parent.set("y", toValue(2))
child.set("z", toValue(3))
const vars = child.vars()
expect(vars.length).toBe(3)
expect(vars).toContain("x")
expect(vars).toContain("y")
expect(vars).toContain("z")
})
test("Scope.vars() - no duplicates when child updates parent variable", () => {
const parent = new Scope()
const child = new Scope(parent)
parent.set("x", toValue(10))
child.set("x", toValue(20)) // Updates parent, doesn't create local
// Only one "x" since child doesn't have its own local x
const vars = child.vars()
expect(vars).toEqual(["x"])
})
test("Scope.vars() - can't have duplicates if manually set in locals", () => {
const parent = new Scope()
const child = new Scope(parent)
parent.set("x", toValue(10))
child.locals.set("x", toValue(20))
const vars = child.vars()
expect(vars).toEqual(["x"])
expect(child.get("x")).toEqual({ type: "number", value: 20 })
expect(parent.get("x")).toEqual({ type: "number", value: 10 })
})
test("Scope.vars() - handles deep scope chains", () => {
let scope = new Scope()
// Create a deep chain: level0 -> level1 -> ... -> level5
for (let i = 0; i < 5; i++) {
scope.set(`var${i}`, toValue(i))
scope = new Scope(scope)
}
// Final scope should have all variables from the chain
const vars = scope.vars()
expect(vars.length).toBe(5)
expect(vars).toContain("var0")
expect(vars).toContain("var1")
expect(vars).toContain("var2")
expect(vars).toContain("var3")
expect(vars).toContain("var4")
})
test("Scope - can store and retrieve functions", () => {
const scope = new Scope()
const fnValue = {
type: 'function' as const,
value: '<function>' as const,
params: ['x'],
defaults: {},
body: 0,
variadic: false,
named: false,
parentScope: scope
}
scope.set("myFunc", fnValue)
expect(scope.get("myFunc")).toBe(fnValue)
expect(scope.get("myFunc")?.type).toBe("function")
})

249
tests/setup.ts Normal file
View File

@ -0,0 +1,249 @@
import { expect } from "bun:test"
import { toValue, fromValue, type Value, toBytecode, run, type Bytecode } from "#reef"
import { isEqual } from "../src/value"
declare module "bun:test" {
interface Matchers<T> {
/**
* Run bytecode and assert that the result equals a JavaScript value after conversion via toValue()
* @example expect(bytecode).toEqualValue(42)
* @example expect("PUSH 5\nPUSH 3\nADD").toEqualValue(8)
* @example expect([["PUSH", 42]]).toEqualValue(42)
*/
toEqualValue(expected: any): Promise<void>
/**
* Run bytecode and assert that the result is null
* @example expect(bytecode).toBeNull()
* @example expect("PUSH null").toBeNull()
*/
toBeNull(): Promise<void>
/**
* Run bytecode and assert that the result is a boolean with the expected value
* @example expect(bytecode).toBeBoolean(true)
* @example expect("PUSH true").toBeBoolean(true)
*/
toBeBoolean(expected: boolean): Promise<void>
/**
* Run bytecode and assert that the result is a number with the expected value
* @example expect(bytecode).toBeNumber(42)
* @example expect("PUSH 42").toBeNumber(42)
*/
toBeNumber(expected: number): Promise<void>
/**
* Run bytecode and assert that the result is a string with the expected value
* @example expect(bytecode).toBeString("hello")
* @example expect("PUSH \"hello\"").toBeString("hello")
*/
toBeString(expected: string): Promise<void>
/**
* Run bytecode and assert that the result is an array with the expected values
* @example expect(bytecode).toBeArray([1, 2, 3])
*/
toBeArray(expected: any[]): Promise<void>
/**
* Run bytecode and assert that the result is a dict with the expected key-value pairs
* @example expect(bytecode).toBeDict({ x: 10, y: 20 })
*/
toBeDict(expected: Record<string, any>): Promise<void>
/**
* Run bytecode and assert that the result is a function (Reef or native)
* @example expect(bytecode).toBeFunction()
*/
toBeFunction(): Promise<void>
/**
* Run bytecode and assert that the result is truthy according to ReefVM semantics
* (only null and false are falsy)
* @example expect(bytecode).toBeTruthy()
*/
toBeTruthy(): Promise<void>
/**
* Run bytecode and assert that the result is falsy according to ReefVM semantics
* (only null and false are falsy)
* @example expect(bytecode).toBeFalsy()
*/
toBeFalsy(): Promise<void>
}
}
expect.extend({
async toEqualValue(this: void, received: unknown, expected: any) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received as string | any[]) : received as Bytecode
const result = await run(bytecode)
const expectedValue = toValue(expected)
const pass = isEqual(result, expectedValue)
return {
pass,
message: () =>
pass
? `Expected value NOT to equal ${formatValue(expectedValue)}, but it did`
: `Expected value to equal ${formatValue(expectedValue)}, but received ${formatValue(result)}`,
}
},
async toBeNull(this: void, received: unknown) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received as string | any[]) : received as Bytecode
const result = await run(bytecode)
const pass = result.type === "null"
return {
pass,
message: () =>
pass
? `Expected value NOT to be null, but it was`
: `Expected value to be null, but received ${formatValue(result)}`,
}
},
async toBeBoolean(this: void, received: unknown, expected: boolean) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received as string | any[]) : received as Bytecode
const result = await run(bytecode)
const pass = result.type === "boolean" && result.value === expected
return {
pass,
message: () =>
pass
? `Expected value NOT to be boolean ${expected}, but it was`
: `Expected value to be boolean ${expected}, but received ${formatValue(result)}`,
}
},
async toBeNumber(this: void, received: unknown, expected: number) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received as string | any[]) : received as Bytecode
const result = await run(bytecode)
const pass = result.type === "number" && result.value === expected
return {
pass,
message: () =>
pass
? `Expected value NOT to be number ${expected}, but it was`
: `Expected value to be number ${expected}, but received ${formatValue(result)}`,
}
},
async toBeString(this: void, received: unknown, expected: string) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received as string | any[]) : received as Bytecode
const result = await run(bytecode)
const pass = result.type === "string" && result.value === expected
return {
pass,
message: () =>
pass
? `Expected value NOT to be string "${expected}", but it was`
: `Expected value to be string "${expected}", but received ${formatValue(result)}`,
}
},
async toBeArray(this: void, received: unknown, expected: any[]) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received as string | any[]) : received as Bytecode
const result = await run(bytecode)
const expectedValue = toValue(expected)
const pass = result.type === "array" && isEqual(result, expectedValue)
return {
pass,
message: () =>
pass
? `Expected value NOT to be array ${formatValue(expectedValue)}, but it was`
: `Expected value to be array ${formatValue(expectedValue)}, but received ${formatValue(result)}`,
}
},
async toBeDict(this: void, received: unknown, expected: Record<string, any>) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received as string | any[]) : received as Bytecode
const result = await run(bytecode)
const expectedValue = toValue(expected)
const pass = result.type === "dict" && isEqual(result, expectedValue)
return {
pass,
message: () =>
pass
? `Expected value NOT to be dict ${formatValue(expectedValue)}, but it was`
: `Expected value to be dict ${formatValue(expectedValue)}, but received ${formatValue(result)}`,
}
},
async toBeFunction(this: void, received: unknown) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received as string | any[]) : received as Bytecode
const result = await run(bytecode)
const pass = result.type === "function" || result.type === "native"
return {
pass,
message: () =>
pass
? `Expected value NOT to be a function, but it was`
: `Expected value to be a function, but received ${formatValue(result)}`,
}
},
async toBeTruthy(this: void, received: unknown) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received as string | any[]) : received as Bytecode
const result = await run(bytecode)
// ReefVM semantics: only null and false are falsy
const pass = !(result.type === "null" || (result.type === "boolean" && !result.value))
return {
pass,
message: () =>
pass
? `Expected value NOT to be truthy, but it was: ${formatValue(result)}`
: `Expected value to be truthy, but received ${formatValue(result)}`,
}
},
async toBeFalsy(this: void, received: unknown) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received as string | any[]) : received as Bytecode
const result = await run(bytecode)
// ReefVM semantics: only null and false are falsy
const pass = result.type === "null" || (result.type === "boolean" && !result.value)
return {
pass,
message: () =>
pass
? `Expected value NOT to be falsy, but it was: ${formatValue(result)}`
: `Expected value to be falsy, but received ${formatValue(result)}`,
}
},
})
function formatValue(value: Value): string {
switch (value.type) {
case "null":
return "null"
case "boolean":
case "number":
return String(value.value)
case "string":
return `"${value.value}"`
case "array":
return `[${value.value.map(formatValue).join(", ")}]`
case "dict": {
const entries = Array.from(value.value.entries())
.map(([k, v]) => `${k}: ${formatValue(v)}`)
.join(", ")
return `{${entries}}`
}
case "regex":
return String(value.value)
case "function":
case "native":
return "<function>"
default:
return String(value)
}
}

View File

@ -201,17 +201,17 @@ test("formatValidationErrors produces readable output", () => {
expect(formatted).toContain("UNKNOWN")
})
test("detects JUMP without # or .label", () => {
test("detects JUMP without .label", () => {
const source = `
JUMP 5
HALT
`
const result = validateBytecode(source)
expect(result.valid).toBe(false)
expect(result.errors[0]!.message).toContain("JUMP requires immediate (#number) or label (.label)")
expect(result.errors[0]!.message).toContain("JUMP requires label (.label)")
})
test("detects JUMP_IF_TRUE without # or .label", () => {
test("detects JUMP_IF_TRUE without .label", () => {
const source = `
PUSH true
JUMP_IF_TRUE 2
@ -219,10 +219,10 @@ test("detects JUMP_IF_TRUE without # or .label", () => {
`
const result = validateBytecode(source)
expect(result.valid).toBe(false)
expect(result.errors[0]!.message).toContain("JUMP_IF_TRUE requires immediate (#number) or label (.label)")
expect(result.errors[0]!.message).toContain("JUMP_IF_TRUE requires label (.label)")
})
test("detects JUMP_IF_FALSE without # or .label", () => {
test("detects JUMP_IF_FALSE without .label", () => {
const source = `
PUSH false
JUMP_IF_FALSE 2
@ -230,17 +230,18 @@ test("detects JUMP_IF_FALSE without # or .label", () => {
`
const result = validateBytecode(source)
expect(result.valid).toBe(false)
expect(result.errors[0]!.message).toContain("JUMP_IF_FALSE requires immediate (#number) or label (.label)")
expect(result.errors[0]!.message).toContain("JUMP_IF_FALSE requires label (.label)")
})
test("allows JUMP with immediate number", () => {
test("rejects JUMP with immediate number", () => {
const source = `
JUMP #2
PUSH 999
HALT
`
const result = validateBytecode(source)
expect(result.valid).toBe(true)
expect(result.valid).toBe(false)
expect(result.errors[0]!.message).toContain("JUMP requires label (.label)")
})
test("detects MAKE_ARRAY without #", () => {

302
tests/value.test.ts Normal file
View File

@ -0,0 +1,302 @@
import { test, expect } from "bun:test"
import { isValue, toValue } from "#reef"
test("isValue - recognizes valid Value objects", () => {
expect(isValue({ type: 'number', value: 42 })).toBe(true)
expect(isValue({ type: 'number', value: 0 })).toBe(true)
expect(isValue({ type: 'string', value: 'hello' })).toBe(true)
expect(isValue({ type: 'string', value: '' })).toBe(true)
expect(isValue({ type: 'boolean', value: true })).toBe(true)
expect(isValue({ type: 'boolean', value: false })).toBe(true)
expect(isValue({ type: 'null', value: null })).toBe(true)
expect(isValue({ type: 'array', value: [] })).toBe(true)
expect(isValue({ type: 'array', value: [toValue(1), toValue(2)] })).toBe(true)
expect(isValue({ type: 'dict', value: new Map() })).toBe(true)
expect(isValue({ type: 'regex', value: /test/ })).toBe(true)
expect(isValue({
type: 'function',
value: '<function>',
params: [],
defaults: {},
body: 0,
variadic: false,
named: false,
parentScope: null as any
})).toBe(true)
expect(isValue({
type: 'native',
value: '<function>',
fn: (() => { }) as any
})).toBe(true)
})
test("isValue - rejects primitives", () => {
expect(isValue(42)).toBe(false)
expect(isValue(0)).toBe(false)
expect(isValue('hello')).toBe(false)
expect(isValue('')).toBe(false)
expect(isValue(true)).toBe(false)
expect(isValue(false)).toBe(false)
expect(isValue(null)).toBe(false)
expect(isValue(undefined)).toBe(false)
})
test("isValue - rejects plain objects", () => {
expect(isValue({})).toBe(false)
expect(isValue({ foo: 'bar' })).toBe(false)
expect(isValue({ type: 'number' })).toBe(false)
expect(isValue({ value: 42 })).toBe(false)
})
test("isValue - rejects arrays and functions", () => {
expect(isValue([])).toBe(false)
expect(isValue([1, 2, 3])).toBe(false)
expect(isValue(() => { })).toBe(false)
expect(isValue(function () { })).toBe(false)
})
test("isValue - rejects other object types", () => {
expect(isValue(new Date())).toBe(false)
expect(isValue(/regex/)).toBe(false)
expect(isValue(new Map())).toBe(false)
expect(isValue(new Set())).toBe(false)
})
test("isValue - used by toValue to detect already-converted values", () => {
const value = toValue(42)
expect(isValue(value)).toBe(true)
const result = toValue(value)
expect(result).toBe(value)
})
test("isValue - edge cases with type and value properties", () => {
expect(isValue({ type: 'number', value: 42, extra: 'data' })).toBe(true)
expect(isValue({ type: null, value: 42 })).toBe(false)
expect(isValue({ type: 'number', value: undefined })).toBe(true)
expect(isValue({ type: 'number', val: 42 })).toBe(false)
expect(isValue({ typ: 'number', value: 42 })).toBe(false)
})
test("isValue - rejects objects with invalid type values", () => {
expect(isValue({ type: 'text', value: 'Bob' })).toBe(false)
expect(isValue({ type: 'email', value: 'test@example.com' })).toBe(false)
expect(isValue({ type: 'password', value: 'secret' })).toBe(false)
expect(isValue({ type: 'checkbox', value: true })).toBe(false)
expect(isValue({ type: 'custom', value: 123 })).toBe(false)
expect(isValue({ type: 'unknown', value: null })).toBe(false)
})
test("toValue - correctly handles HTML input props", async () => {
const { VM, toBytecode, toValue } = await import("#reef")
const bytecode = toBytecode([["HALT"]])
const vm = new VM(bytecode)
const inputProps = { type: 'text', value: 'Bob' }
const converted = toValue(inputProps, vm)
expect(converted.type).toBe('dict')
expect(converted.value.get('type')).toEqual({ type: 'string', value: 'text' })
expect(converted.value.get('value')).toEqual({ type: 'string', value: 'Bob' })
})
test("toValue - converts wrapped Reef functions back to original Value", async () => {
const { VM, toBytecode, fnFromValue } = await import("#reef")
// Create a Reef function
const bytecode = toBytecode([
["MAKE_FUNCTION", ["x"], ".body"],
["STORE", "add1"],
["JUMP", ".end"],
[".body:"],
["LOAD", "x"],
["PUSH", 1],
["ADD"],
["RETURN"],
[".end:"],
["HALT"]
])
const vm = new VM(bytecode)
await vm.run()
const reefFunction = vm.scope.get("add1")!
expect(reefFunction.type).toBe("function")
// Convert to JS function
const jsFunction = fnFromValue(reefFunction, vm)
expect(typeof jsFunction).toBe("function")
// Convert back to Value - should return the original Reef function
const backToValue = toValue(jsFunction)
expect(backToValue).toBe(reefFunction) // Same reference
expect(backToValue.type).toBe("function")
})
test("fromValue - converts native function back to original JS function", async () => {
const { VM, toBytecode, toValue, fromValue } = await import("#reef")
const bytecode = toBytecode([["HALT"]])
const vm = new VM(bytecode)
// Create a native JS function
const originalFn = (x: number, y: number) => x * y
// Convert to Value (wraps it as a native function)
const nativeValue = toValue(originalFn, vm)
expect(nativeValue.type).toBe("native")
// Convert back to JS - should get the original function
const convertedFn = fromValue(nativeValue, vm)
expect(typeof convertedFn).toBe("function")
// Verify it's the same function
expect(convertedFn).toBe(originalFn)
// Verify it works correctly
expect(convertedFn(3, 4)).toBe(12)
})
test("fromValue - native function roundtrip preserves functionality", async () => {
const { VM, toBytecode, toValue, fromValue } = await import("#reef")
const bytecode = toBytecode([["HALT"]])
const vm = new VM(bytecode)
// Create a native function with state
let callCount = 0
const countingFn = (n: number) => {
callCount++
return n * callCount
}
// Roundtrip through Value
const nativeValue = toValue(countingFn, vm)
const roundtrippedFn = fromValue(nativeValue, vm)
// Verify it maintains state across calls
expect(roundtrippedFn(10)).toBe(10) // 10 * 1
expect(roundtrippedFn(10)).toBe(20) // 10 * 2
expect(roundtrippedFn(10)).toBe(30) // 10 * 3
expect(callCount).toBe(3)
})
test("fromValue - async native function roundtrip", async () => {
const { VM, toBytecode, toValue, fromValue } = await import("#reef")
const bytecode = toBytecode([["HALT"]])
const vm = new VM(bytecode)
const asyncFn = async (x: number, y: number) => {
await new Promise(resolve => setTimeout(resolve, 1))
return x + y
}
const nativeValue = toValue(asyncFn, vm)
expect(nativeValue.type).toBe("native")
const roundtrippedFn = fromValue(nativeValue, vm)
const result = await roundtrippedFn(5, 7)
expect(result).toBe(12)
})
test("toValue - throws helpful error when calling function converted without VM", async () => {
function myFunction(x: number) {
return x * 2
}
const value = toValue(myFunction)
expect(value.type).toBe('native')
// Error is thrown when calling the function, not when converting
await expect(async () => {
await value.fn({ type: 'number', value: 5 })
}).toThrow(/can't call function that was converted without a vm/)
await expect(async () => {
await value.fn({ type: 'number', value: 5 })
}).toThrow(/Function: myFunction/)
await expect(async () => {
await value.fn({ type: 'number', value: 5 })
}).toThrow(/Source:/)
await expect(async () => {
await value.fn({ type: 'number', value: 5 })
}).toThrow(/Called from:/)
})
test("toValue - error message shows function info for arrow functions", async () => {
const anonymousFn = (x: number) => x * 2
const value = toValue(anonymousFn)
expect(value.type).toBe('native')
// Arrow functions show as <anonymous>
await expect(async () => {
await value.fn({ type: 'number', value: 5 })
}).toThrow(/Function: <anonymous>/)
await expect(async () => {
await value.fn({ type: 'number', value: 5 })
}).toThrow(/Source:/)
})
test("toValue - error when function is nested in object without VM", async () => {
const obj = {
name: "test",
handler: (x: number) => x * 2
}
const value = toValue(obj)
expect(value.type).toBe('dict')
const handlerValue = value.value.get('handler')!
expect(handlerValue.type).toBe('native')
await expect(async () => {
await (handlerValue as any).fn({ type: 'number', value: 5 })
}).toThrow(/can't call function that was converted without a vm/)
await expect(async () => {
await (handlerValue as any).fn({ type: 'number', value: 5 })
}).toThrow(/Function: handler/)
})
test("toValue - error when function is nested in array without VM", async () => {
const arr = [1, 2, (x: number) => x * 2]
const value = toValue(arr)
expect(value.type).toBe('array')
const fnValue = value.value[2]!
expect(fnValue.type).toBe('native')
await expect(async () => {
await (fnValue as any).fn({ type: 'number', value: 5 })
}).toThrow(/can't call function that was converted without a vm/)
})
test("fromValue - throws helpful error when converting function without VM", async () => {
const { Scope, fromValue } = await import("#reef")
const reefFunction = {
type: 'function' as const,
params: ['x', 'y'],
defaults: {},
body: 10,
parentScope: new Scope(),
variadic: false,
named: false,
value: '<function>' as const
}
expect(() => fromValue(reefFunction)).toThrow(/VM is required for function conversion/)
expect(() => fromValue(reefFunction)).toThrow(/Function params: \[x, y\]/)
expect(() => fromValue(reefFunction)).toThrow(/Function body at instruction: 10/)
expect(() => fromValue(reefFunction)).toThrow(/Called from:/)
})

54
tests/vm.test.ts Normal file
View File

@ -0,0 +1,54 @@
import { test, expect, describe } from "bun:test"
import { VM } from "#vm"
import { toBytecode } from "#bytecode"
import { toValue } from "#value"
describe("VM scope methods", () => {
test("pushScope creates isolated child scope", async () => {
const bytecode = toBytecode([["HALT"]])
const vm = new VM(bytecode)
vm.set("x", 42)
vm.pushScope()
const xValue = vm.scope.get("x")
expect(xValue).toEqual({ type: "number", value: 42 })
vm.set("y", 100)
expect(vm.scope.get("x")).toEqual({ type: "number", value: 42 })
expect(vm.scope.get("y")).toEqual({ type: "number", value: 100 })
})
test("popScope returns to parent scope and child variables are not accessible", async () => {
const bytecode = toBytecode([["HALT"]])
const vm = new VM(bytecode)
vm.set("x", 42)
vm.pushScope()
vm.set("y", 100)
expect(vm.scope.get("x")).toEqual({ type: "number", value: 42 })
expect(vm.scope.get("y")).toEqual({ type: "number", value: 100 })
vm.popScope()
expect(vm.scope.get("x")).toEqual({ type: "number", value: 42 })
expect(vm.scope.get("y")).toBeUndefined()
})
test("pushScope with locals initializes child scope with variables", async () => {
const bytecode = toBytecode([["HALT"]])
const vm = new VM(bytecode)
vm.set("x", 42)
vm.pushScope({ y: 100, z: "hello" })
expect(vm.scope.get("x")).toEqual({ type: "number", value: 42 })
expect(vm.scope.get("y")).toEqual({ type: "number", value: 100 })
expect(vm.scope.get("z")).toEqual({ type: "string", value: "hello" })
vm.popScope()
expect(vm.scope.get("x")).toEqual({ type: "number", value: 42 })
expect(vm.scope.get("y")).toBeUndefined()
expect(vm.scope.get("z")).toBeUndefined()
})
})