Compare commits

...

68 Commits
regex ... main

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
47f829fcad A 100% AI written debug function 2025-10-20 09:31:19 -07:00
7f4f73dd41 TRY_CALL recognizes native functions 2025-10-20 09:31:01 -07:00
fa55eb7170 @named in native functions 2025-10-17 14:05:12 -07:00
1cf14636ff add named args to native functions 2025-10-17 13:11:47 -07:00
fe7586a5fa simpler native functions 2025-10-17 12:48:16 -07:00
93eff53a76 just native, <function> 2025-10-17 12:39:14 -07:00
4d2ae1c9fe tmp change - LOAD_NATIVE 2025-10-17 12:35:38 -07:00
62f890e59d don't be cute 2025-10-17 12:13:28 -07:00
1a18a713d7 DOT_GET 2025-10-16 15:51:38 -07:00
b16351ac95 pass native functions to VM 2025-10-16 15:16:57 -07:00
23fcf05439 split up tests some 2025-10-16 14:35:24 -07:00
e5be82e75a regexp tests 2025-10-16 14:27:39 -07:00
dd9ec2a7d1 Merge pull request 'Add regex to reef' (#1) from regex into main
Reviewed-on: #1
2025-10-16 21:15:42 +00:00
36 changed files with 9094 additions and 1361 deletions

178
CLAUDE.md
View File

@ -42,8 +42,7 @@ No build step required - Bun runs TypeScript directly.
- Stack-based execution with program counter (PC)
- Call stack for function frames
- Exception handler stack for try/catch/finally
- Lexical scope chain with parent references
- Native function registry for TypeScript interop
- Lexical scope chain with parent references (includes native functions)
**Key subsystems**:
- **bytecode.ts**: Compiler that converts both string and array formats to executable bytecode. Handles label resolution, constant pool management, and function definition parsing. The `toBytecode()` function accepts either a string (human-readable) or typed array format (programmatic).
@ -56,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.
@ -70,19 +69,22 @@ No build step required - Bun runs TypeScript directly.
**Parameter binding priority**: Named args bind to fixed params first. Unmatched named args go to `@named` dict parameter. Fixed params bind in order: named arg > positional arg > default > null.
**Native function calling**: CALL_NATIVE consumes the entire stack as arguments (different from CALL which pops specific argument counts).
**Native function calling**: Native functions are stored in scope and called via LOAD + CALL, using the same calling convention as Reef functions. Named arguments are supported by extracting parameter names from the function signature at call time.
## Testing Strategy
Tests are organized by feature area:
- **basic.test.ts**: Stack ops, arithmetic, comparisons, variables, control flow
- **opcodes.test.ts**: Stack ops, arithmetic, comparisons, variables, control flow
- **functions.test.ts**: Function creation, calls, closures, defaults, variadic, named args
- **tail-call.test.ts**: Tail call optimization and unbounded recursion
- **exceptions.test.ts**: Try/catch/finally, exception unwinding, nested handlers
- **native.test.ts**: Native function interop (sync and async)
- **functions-parameter.test.ts**: Convenience parameter for passing functions to run() and VM
- **bytecode.test.ts**: Bytecode string parser, label resolution, constants
- **programmatic.test.ts**: Array format API, typed tuples, labels, functions
- **validator.test.ts**: Bytecode validation rules
- **unicode.test.ts**: Unicode and emoji identifiers
- **regex.test.ts**: RegExp support
- **examples.test.ts**: Integration tests for example programs
When adding features:
@ -135,53 +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
ReefVM supports two ways to register native functions:
**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}!`,
pi: 3.14159,
config: { debug: true, port: 8080 }
})
**1. Native TypeScript functions (recommended)** - Auto-converts between native TS and ReefVM types:
// Or with VM constructor
const vm = new VM(bytecode, { add, greet, pi, config })
```
**Option 2**: Set values with `vm.set()` (manual)
```typescript
const vm = new VM(bytecode)
// Works with native TypeScript types!
vm.registerFunction('add', (a: number, b: number) => {
return a + b
})
// Set functions (auto-wrapped to native functions)
vm.set('add', (a: number, b: number) => a + b)
// Supports defaults (like NOSE commands)
vm.registerFunction('ls', (path: string, link = false) => {
return link ? `listing ${path} with links` : `listing ${path}`
})
// Async functions work too
vm.registerFunction('fetch', async (url: string) => {
const response = await fetch(url)
return await response.text()
})
// Set any other values (auto-converted to ReefVM Values)
vm.set('pi', 3.14159)
vm.set('config', { debug: true, port: 8080 })
await vm.run()
```
**2. Value-based functions (manual)** - For functions that need direct Value access:
**Option 3**: Set Value-based functions with `vm.setValueFunction()` (advanced)
For functions that work directly with ReefVM Value types:
```typescript
const vm = new VM(bytecode)
vm.registerValueFunction('customOp', (a: Value, b: Value): Value => {
// Direct access to Value types
// 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()
```
The auto-wrapping handles:
- Converting Value → native types on input (using `fromValue`)
- Converting native types → Value on output (using `toValue`)
- Both sync and async functions
- Arrays, objects, primitives, and null
Auto-wrapping handles:
- 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
@ -191,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`
@ -376,14 +486,12 @@ 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).
**MAKE_ARRAY operand**: Specifies count, not a stack index. `MAKE_ARRAY #3` pops 3 items.
**CALL_NATIVE stack behavior**: Unlike CALL, it consumes all stack values as arguments and clears the stack.
**Finally blocks**: The compiler must generate explicit JUMPs to finally blocks for successful try/catch completion. The VM only auto-jumps to finally on THROW.
**Variable scoping**: STORE updates existing variables in parent scopes or creates in current scope. It does NOT shadow by default.

487
GUIDE.md
View File

@ -42,9 +42,6 @@ OPCODE operand ; comment
- Booleans: `PUSH true`, `PUSH false`
- Null: `PUSH null`
**Native function names**: Registered TypeScript functions
- `CALL_NATIVE print`
## Array Format
The programmatic array format uses TypeScript tuples for type safety:
@ -99,11 +96,6 @@ const result = await run(bytecode)
["MAKE_DICT", 2] // Pop 2 key-value pairs
```
**Native function names**: Strings for registered functions
```typescript
["CALL_NATIVE", "print"]
```
### Functions in Array Format
```typescript
@ -187,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)
@ -202,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
@ -235,6 +244,9 @@ CALL
- `DICT_SET` - Pop value, key, dict; mutate dict
- `DICT_HAS` - Pop key and dict, push boolean
### Unified Access
- `DOT_GET` - Pop index/key and array/dict, push value (null if missing)
### Strings
- `STR_CONCAT #N` - Pop N values, convert to strings, concatenate, push result
@ -244,11 +256,42 @@ CALL
- `POP_TRY` - Remove handler (try succeeded)
- `THROW` - Throw exception (pops error value)
### Native
- `CALL_NATIVE <name>` - Call registered TypeScript function (consumes entire stack as args)
## 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>
@ -334,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
@ -370,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
@ -379,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
@ -386,7 +561,7 @@ Use TAIL_CALL instead of CALL for last call:
```
MAKE_FUNCTION (n acc) .factorial
STORE factorial
<...>
JUMP .main
.factorial:
LOAD n
@ -406,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)
@ -474,6 +658,56 @@ PUSH "!"
STR_CONCAT #2 ; → "Hello World!"
```
### Unified Access (DOT_GET)
DOT_GET provides a single opcode for accessing both arrays and dicts:
```
; Array access
PUSH 10
PUSH 20
PUSH 30
MAKE_ARRAY #3
PUSH 1
DOT_GET ; → 20
; Dict access
PUSH 'name'
PUSH 'Alice'
MAKE_DICT #1
PUSH 'name'
DOT_GET ; → 'Alice'
```
**Chained access**:
```
; Access dict['users'][0]['name']
LOAD dict
PUSH 'users'
DOT_GET ; Get users array
PUSH 0
DOT_GET ; Get first user
PUSH 'name'
DOT_GET ; Get name field
```
**With variables**:
```
LOAD data
LOAD key ; Key can be string or number
DOT_GET ; Works for both array and dict
```
**Null safety**: Returns null for missing keys or out-of-bounds indices
```
MAKE_ARRAY #0
PUSH 0
DOT_GET ; → null (empty array)
MAKE_DICT #0
PUSH 'key'
DOT_GET ; → null (missing key)
```
## Key Concepts
### Truthiness
@ -499,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.
@ -523,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
@ -536,7 +785,7 @@ For function calls, parameters bound in order:
- Finally execution in all cases is compiler's responsibility, not VM's
### Calling Convention
All calls push arguments in order:
All calls (including native functions) push arguments in order:
1. Function
2. Positional args (in order)
3. Named args (key1, val1, key2, val2, ...)
@ -544,8 +793,210 @@ All calls push arguments in order:
5. Named count (as number)
6. CALL or TAIL_CALL
### CALL_NATIVE Behavior
Unlike CALL, CALL_NATIVE consumes the **entire stack** as arguments and clears the stack. The native function receives all values that were on the stack at the time of the call.
Native functions use the same calling convention as Reef functions. They are registered into scope and called via LOAD + CALL.
### Registering Native Functions
Native TypeScript functions are registered into the VM's scope and accessed like regular variables.
**Method 1**: Pass to `run()` or `VM` constructor
```typescript
const result = await run(bytecode, {
add: (a: number, b: number) => a + b,
greet: (name: string) => `Hello, ${name}!`
})
// Or with VM
const vm = new VM(bytecode, { add, greet })
```
**Method 2**: Register after construction
```typescript
const vm = new VM(bytecode)
vm.set('add', (a: number, b: number) => a + b)
await vm.run()
```
**Method 3**: Value-based functions (for full control)
```typescript
vm.setValueFunction('customOp', (a: Value, b: Value): Value => {
return { type: 'number', value: toNumber(a) + toNumber(b) }
})
```
**Auto-wrapping**: `vm.set()` automatically converts between native TypeScript types and ReefVM Value types. Both sync and async functions work.
**Usage in bytecode**:
```
; Positional arguments
LOAD add ; Load native function from scope
PUSH 5
PUSH 10
PUSH 2 ; positionalCount
PUSH 0 ; namedCount
CALL ; Call like any other function
; Named arguments
LOAD greet
PUSH "name"
PUSH "Alice"
PUSH "greeting"
PUSH "Hi"
PUSH 0 ; positionalCount
PUSH 2 ; namedCount
CALL ; → "Hi, Alice!"
```
**Named Arguments**: Native functions support named arguments. Parameter names are extracted from the function signature at call time, and arguments are bound using the same priority as Reef functions (named arg > positional arg > default > null).
**@named Pattern**: Parameters starting with `at` followed by an uppercase letter (e.g., `atOptions`, `atNamed`) collect unmatched named arguments:
```typescript
// Basic @named - collects all named args
vm.set('greet', (atNamed: any = {}) => {
return `Hello, ${atNamed.name || 'World'}!`
})
// Mixed positional and @named
vm.set('configure', (name: string, atOptions: any = {}) => {
return {
name,
debug: atOptions.debug || false,
port: atOptions.port || 3000
}
})
```
Bytecode example:
```
; Call with mixed positional and named args
LOAD configure
PUSH "myApp" ; positional arg → name
PUSH "debug"
PUSH true
PUSH "port"
PUSH 8080
PUSH 1 ; 1 positional arg
PUSH 2 ; 2 named args (debug, port)
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

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,10 +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 (CALL_NATIVE) with sync and async functions
- Write native functions with regular TypeScript types instead of Shrimp's internal Value types
- 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
@ -57,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)

369
SPEC.md
View File

@ -13,10 +13,9 @@ The ReefVM is a stack-based bytecode virtual machine designed for the Shrimp pro
- **Value Stack**: Operand stack for computation
- **Call Stack**: Call frames for function invocations
- **Exception Handlers**: Stack of try/catch handlers
- **Scope Chain**: Linked scopes for lexical variable resolution
- **Scope Chain**: Linked scopes for lexical variable resolution (includes native functions)
- **Program Counter (PC)**: Current instruction index
- **Constants Pool**: Immutable values and function metadata
- **Native Function Registry**: External functions callable from Shrimp
### Execution Model
@ -40,6 +39,7 @@ type Value =
| { type: 'dict', value: Map<string, Value> }
| { type: 'function', params: string[], defaults: Record<string, number>,
body: number, parentScope: Scope, variadic: boolean, named: boolean }
| { type: 'native', fn: NativeFunction, value: '<function>' }
```
### Type Coercion
@ -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
@ -357,15 +459,20 @@ The created function captures `currentScope` as its `parentScope`.
3. Pop named arguments (name/value pairs) from stack
4. Pop positional arguments from stack
5. Pop function from stack
6. Mark current frame (if exists) as break target (`isBreakTarget = true`)
7. Push new call frame with current PC and scope
8. Create new scope with function's parentScope as parent
9. Bind parameters:
6. **If function is native**:
- Mark current frame (if exists) as break target
- Call native function with positional args
- Push return value onto stack
- Done (skip steps 7-11)
7. Mark current frame (if exists) as break target (`isBreakTarget = true`)
8. Push new call frame with current PC and scope
9. Create new scope with function's parentScope as parent
10. Bind parameters:
- For regular functions: bind params by position, then by name, then defaults, then null
- For variadic functions: bind fixed params, collect rest into array
- For functions with `named: true`: bind fixed params by position/name, collect unmatched named args into dict
10. Set currentScope to new scope
11. Jump to function body
11. Set currentScope to new scope
12. Jump to function body
**Parameter Binding Priority** (for fixed params):
1. Named argument (if provided and matches param name)
@ -373,12 +480,21 @@ 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
**Errors**: Throws if top of stack is not a function (or native function)
#### TAIL_CALL
**Operand**: None
@ -509,6 +625,51 @@ Key is coerced to string.
Key is coerced to string.
**Errors**: Throws if not dict
### Unified Access
#### DOT_GET
**Operand**: None
**Effect**: Get value from array or dict
**Stack**: [array|dict, index|key] → [value]
**Behavior**:
- If target is array: coerce index to number and access `array[index]`
- If target is dict: coerce key to string and access `dict.get(key)`
- Returns null if index out of bounds or key not found
**Errors**: Throws if target is not array or dict
**Use Cases**:
- Unified syntax for accessing both arrays and dicts
- Chaining access operations: `obj.users.0.name`
- Generic accessor that works with any indexable type
**Example**:
```
; Array access
PUSH 10
PUSH 20
PUSH 30
MAKE_ARRAY #3
PUSH 1
DOT_GET ; → 20
; Dict access
PUSH 'name'
PUSH 'Alice'
MAKE_DICT #1
PUSH 'name'
DOT_GET ; → 'Alice'
; Chained access
; dict['users'][0]
LOAD dict
PUSH 'users'
DOT_GET
PUSH 0
DOT_GET
```
### String Operations
#### STR_CONCAT
@ -561,28 +722,93 @@ STR_CONCAT #4 ; → "Count: 42, Active: true"
### TypeScript Interop
#### CALL_NATIVE
**Operand**: Function name (string)
**Effect**: Call registered TypeScript function
**Stack**: [...args] → [returnValue]
Native TypeScript functions are registered into the VM's scope and accessed via regular LOAD/CALL operations. They behave identically to Reef functions from the bytecode perspective.
**Behavior**:
1. Look up function by name in registry
2. Mark current frame (if exists) as break target
3. Await function call (native function receives arguments and returns a Value)
4. Push return value onto stack
**Notes**:
- TypeScript functions are passed the raw stack values as arguments
- They must return a valid Value
- They can be async (VM awaits them)
- Like CALL, but function is from TypeScript registry instead of stack
**Errors**: Throws if function not found
**TypeScript Function Signature**:
**Registration**:
```typescript
type TypeScriptFunction = (...args: Value[]) => Promise<Value> | Value;
const vm = new VM(bytecode, {
add: (a: number, b: number) => a + b,
greet: (name: string) => `Hello, ${name}!`
})
// Or after construction:
vm.set('multiply', (a: number, b: number) => a * b)
```
**Usage in Bytecode**:
```
LOAD add ; Load native function from scope
PUSH 5
PUSH 10
PUSH 2 ; positionalCount
PUSH 0 ; namedCount
CALL ; Call it like any other function
```
**Native Function 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 `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)
- Return value: native type → `Value`
- Supports sync and async functions
- Objects convert to dicts, arrays convert to Value arrays
**Named Arguments**:
- Native functions support named arguments by extracting parameter names from the function signature
- Parameter binding follows the same priority as Reef functions: named arg > positional arg > default > null
- TypeScript rest parameters (`...args`) are supported and behave like Reef variadic parameters
**Examples**:
```typescript
// Auto-wrapped native types
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.set('greet', (name: string, greeting = 'Hello') => {
return `${greeting}, ${name}!`
})
// Variadic functions
vm.set('sum', (...nums: number[]) => {
return nums.reduce((acc, n) => acc + n, 0)
})
// Value-based for custom logic
vm.setValueFunction('customOp', (a: Value, b: Value): Value => {
return { type: 'number', value: toNumber(a) + toNumber(b) }
})
// Async functions
vm.set('fetchData', async (url: string) => {
const response = await fetch(url)
return response.json()
})
```
**Calling with Named Arguments**:
```
; Call with positional args
LOAD greet
PUSH "Alice"
PUSH 1
PUSH 0
CALL ; → "Hello, Alice!"
; Call with named args
LOAD greet
PUSH "name"
PUSH "Bob"
PUSH "greeting"
PUSH "Hi"
PUSH 0
PUSH 2
CALL ; → "Hi, Bob!"
```
### Special
@ -594,14 +820,16 @@ type TypeScriptFunction = (...args: Value[]) => Promise<Value> | Value;
## 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:
@ -612,15 +840,6 @@ JUMP .skip
HALT
```
Equivalent with numeric offsets:
```
JUMP #2
PUSH 999
HALT
PUSH 42
HALT
```
## Common Bytecode Patterns
### If-Else Statement
@ -696,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
@ -742,10 +984,9 @@ All of these should throw errors:
6. **Break Outside Loop**: BREAK with no break target
7. **Continue Outside Loop**: CONTINUE with no continue target
8. **Return Outside Function**: RETURN with no call frame
9. **Unknown Function**: CALL_NATIVE with unregistered function
10. **Mismatched Handler**: POP_TRY with no handler
11. **Invalid Constant**: PUSH with invalid constant index
12. **Invalid Function Definition**: MAKE_FUNCTION with non-function_def constant
9. **Mismatched Handler**: POP_TRY with no handler
10. **Invalid Constant**: PUSH with invalid constant index
11. **Invalid Function Definition**: MAKE_FUNCTION with non-function_def constant
## Edge Cases
@ -790,11 +1031,21 @@ All of these should throw errors:
## VM Initialization
```typescript
const vm = new VM(bytecode);
vm.registerFunction('add', (a, b) => {
// Register native functions during construction
const vm = new VM(bytecode, {
add: (a: number, b: number) => a + b,
greet: (name: string) => `Hello, ${name}!`
})
// Or register after construction
vm.set('multiply', (a: number, b: number) => a * b)
// Or use Value-based functions
vm.setValueFunction('customOp', (a: Value, b: Value): Value => {
return { type: 'number', value: toNumber(a) + toNumber(b) }
})
const result = await vm.execute()
const result = await vm.run()
```
## Testing Considerations

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"]
@ -73,8 +78,8 @@ type InstructionTuple =
// Strings
| ["STR_CONCAT", number]
// Native
| ["CALL_NATIVE", string]
// Arrays and dicts
| ["DOT_GET"]
// Special
| ["HALT"]
@ -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, CALL_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>
@ -336,7 +317,6 @@ function toBytecodeFromArray(program: ProgramItem[]): Bytecode /* throws */ {
case "STORE":
case "TRY_LOAD":
case "TRY_CALL":
case "CALL_NATIVE":
operandValue = operand as string
break
@ -369,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")
@ -387,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

202
src/format.ts Normal file
View File

@ -0,0 +1,202 @@
import type { Bytecode, Constant } from "./bytecode"
import { OpCode } from "./opcode"
import type { Value, FunctionDef } from "./value"
/**
* Converts a Bytecode object back to human-readable string format.
* This is the inverse of toBytecode().
*/
export const bytecodeToString = (bytecode: Bytecode): string => {
const lines: string[] = []
const { instructions, constants, labels } = bytecode
for (let i = 0; i < instructions.length; i++) {
// Check if there's a label at this position
if (labels?.has(i)) {
lines.push(`.${labels.get(i)}:`)
}
const instr = instructions[i]!
const opName = OpCode[instr.op] // Get string name from enum
// Format based on whether operand exists
if (instr.operand === undefined) {
lines.push(opName)
} else {
const operandStr = formatOperand(instr.op, instr.operand, constants, labels, i)
lines.push(`${opName} ${operandStr}`)
}
}
return lines.join('\n')
}
/**
* Format an operand based on the opcode type
*/
const formatOperand = (
op: OpCode,
operand: number | string,
constants: Constant[],
labels: Map<number, string> | undefined,
currentIndex: number
): string => {
// Handle string operands (variable names)
if (typeof operand === 'string') {
return operand
}
// Handle numeric operands based on opcode
switch (op) {
case OpCode.PUSH: {
// Look up constant value
const value = constants[operand]
if (!value) {
throw new Error(`Invalid constant index: ${operand}`)
}
return formatConstant(value)
}
case OpCode.MAKE_FUNCTION: {
// Look up function definition and format as (params) .label
const funcDef = constants[operand]
if (!funcDef || !('type' in funcDef) || funcDef.type !== 'function_def') {
throw new Error(`Invalid function definition at constant index: ${operand}`)
}
return formatFunctionDef(funcDef as FunctionDef, labels, constants)
}
case OpCode.JUMP:
case OpCode.JUMP_IF_FALSE:
case OpCode.JUMP_IF_TRUE: {
// Convert relative offset to absolute position
const targetIndex = currentIndex + 1 + operand
const labelName = labels?.get(targetIndex)
return labelName ? `.${labelName}` : `#${operand}`
}
case OpCode.PUSH_TRY:
case OpCode.PUSH_FINALLY: {
// These use absolute positions
const labelName = labels?.get(operand)
return labelName ? `.${labelName}` : `#${operand}`
}
case OpCode.MAKE_ARRAY:
case OpCode.MAKE_DICT:
case OpCode.STR_CONCAT:
// These are just counts
return `#${operand}`
default:
return `#${operand}`
}
}
/**
* Format a constant value (from constants pool)
*/
const formatConstant = (constant: Constant): string => {
// Handle function definitions (shouldn't happen in PUSH, but be safe)
if ('type' in constant && constant.type === 'function_def') {
return '<function_def>'
}
// Handle Value types
const value = constant as Value
switch (value.type) {
case 'null':
return 'null'
case 'boolean':
return value.value.toString()
case 'number':
return value.value.toString()
case 'string':
// Use single quotes and escape special characters
return `'${escapeString(value.value)}'`
case 'regex': {
// Format as /pattern/flags
const pattern = value.value.source
const flags = value.value.flags
return `/${pattern}/${flags}`
}
case 'array': {
// Format as [item1, item2, ...]
const items = value.value.map(formatConstant).join(', ')
return `[${items}]`
}
case 'dict': {
// Format as {key1: value1, key2: value2}
const entries = Array.from(value.value.entries())
.map(([k, v]) => `${k}: ${formatConstant(v)}`)
.join(', ')
return `{${entries}}`
}
case 'function':
case 'native':
return '<function>'
default:
return '<unknown>'
}
}
/**
* Escape special characters in strings for output
*/
const escapeString = (str: string): string => {
return str
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/\n/g, '\\n')
.replace(/\t/g, '\\t')
.replace(/\r/g, '\\r')
.replace(/\$/g, '\\$')
}
/**
* Format a function definition as (params) .label
*/
const formatFunctionDef = (
funcDef: FunctionDef,
labels: Map<number, string> | undefined,
constants: Constant[]
): string => {
const params: string[] = []
for (let i = 0; i < funcDef.params.length; i++) {
const paramName = funcDef.params[i]!
const defaultIndex = funcDef.defaults[paramName]
if (defaultIndex !== undefined) {
// Parameter has a default value
const defaultValue = constants[defaultIndex]
if (!defaultValue) {
throw new Error(`Invalid default value index: ${defaultIndex}`)
}
params.push(`${paramName}=${formatConstant(defaultValue)}`)
} else if (i === funcDef.params.length - 1 && funcDef.variadic) {
// Last parameter and function is variadic
params.push(`...${paramName}`)
} else if (i === funcDef.params.length - 1 && funcDef.named) {
// Last parameter and function accepts named args
params.push(`@${paramName}`)
} else {
// Regular parameter
params.push(paramName)
}
}
// Format body address (prefer label name if available)
const bodyLabel = labels?.get(funcDef.body)
const bodyStr = bodyLabel ? `.${bodyLabel}` : `#${funcDef.body}`
return `(${params.join(' ')}) ${bodyStr}`
}

112
src/function.ts Normal file
View File

@ -0,0 +1,112 @@
import { type Value, type NativeFunction, fromValue, toValue } from "./value"
import { VM } from "./vm"
export type ParamInfo = {
params: string[]
defaults: Record<string, Value>
variadic: boolean
named: boolean
}
const WRAPPED_MARKER = Symbol('reef-wrapped')
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
wrappedObj[WRAPPED_MARKER] = true
// Store the original function for param extraction
wrappedObj.originalFn = fn
return wrapped
}
export function isWrapped(fn: Function): boolean {
return !!(fn as any)[WRAPPED_MARKER]
}
export function getOriginalFunction(fn: NativeFunction): Function {
return (fn as any).originalFn || fn
}
export function extractParamInfo(fn: Function): ParamInfo {
const params: string[] = []
const defaults: Record<string, Value> = {}
let variadic = false
let named = false
const fnStr = fn.toString()
// Match function signature: function(a, b) or (a, b) => or async (a, b) =>
const match = fnStr.match(/(?:function\s*.*?\(|^\s*\(|^\s*async\s*\(|^\s*async\s+function\s*.*?\()([^)]*)\)/)
if (!match || !match[1]) {
return { params, defaults, variadic, named }
}
const paramStr = match[1].trim()
if (!paramStr) {
return { params, defaults, variadic, named }
}
// Split parameters by comma (naive - doesn't handle nested objects/arrays)
const paramParts = paramStr.split(',').map(p => p.trim())
for (const part of paramParts) {
// Check for rest parameters (...rest)
if (part.startsWith('...')) {
variadic = true
const paramName = part.slice(3).trim()
params.push(paramName)
}
// Check for default values (name = value)
else if (part.includes('=')) {
const eqIndex = part.indexOf('=')
const paramName = part.slice(0, eqIndex).trim()
const defaultStr = part.slice(eqIndex + 1).trim()
params.push(paramName)
// Check if this is a named parameter (atXxx pattern)
if (/^at[A-Z]/.test(paramName)) {
named = true
}
// Try to parse the default value (only simple literals)
try {
if (defaultStr === 'null') {
defaults[paramName] = toValue(null)
} else if (defaultStr === 'true') {
defaults[paramName] = toValue(true)
} else if (defaultStr === 'false') {
defaults[paramName] = toValue(false)
} else if (/^-?\d+(\.\d+)?$/.test(defaultStr)) {
defaults[paramName] = toValue(parseFloat(defaultStr))
} else if (/^['"].*['"]$/.test(defaultStr)) {
defaults[paramName] = toValue(defaultStr.slice(1, -1))
}
// For complex defaults (like {}), we skip them and let the function's own default be used
} catch {
// If parsing fails, ignore the default
}
} else {
// Regular parameter
const paramName = part.trim()
params.push(paramName)
// Check if this is a named parameter (atXxx pattern)
if (/^at[A-Z]/.test(paramName)) {
named = true
}
}
}
return { params, defaults, variadic, named }
}

View File

@ -1,12 +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): Promise<Value> {
const vm = new VM(bytecode)
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 { type Value, toValue, toString, toNumber, fromValue, toNull, wrapNative } from "./value"
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]
@ -59,12 +71,12 @@ export enum OpCode {
DICT_SET, // operand: none | stack: [dict, key, value] → [] | mutates dict
DICT_HAS, // operand: none | stack: [dict, key] → [boolean]
// arrays and dicts
DOT_GET, // operand: none | stack: [array|dict, index|key] → [value] | unified accessor, returns null if missing
// strings
STR_CONCAT, // operand: value count (number) | stack: [val1, ..., valN] → [string] | concatenate N values
// typescript interop
CALL_NATIVE, // operand: function name (identifier) | stack: [...args] → [result] | consumes entire stack
// special
HALT // operand: none | stop execution
}

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

@ -45,17 +45,24 @@ const OPCODES_WITH_OPERANDS = new Set([
OpCode.MAKE_DICT,
OpCode.STR_CONCAT,
OpCode.MAKE_FUNCTION,
OpCode.CALL_NATIVE,
])
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,
@ -77,13 +84,18 @@ const OPCODES_WITHOUT_OPERANDS = new Set([
OpCode.DICT_GET,
OpCode.DICT_SET,
OpCode.DICT_HAS,
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,4 +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 }
@ -8,6 +16,7 @@ export type Value =
| { type: 'array', value: Value[] }
| { type: 'dict', value: Dict }
| { type: 'regex', value: RegExp }
| { type: 'native', fn: NativeFunction, value: '<function>' }
| {
type: 'function',
params: string[],
@ -30,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 }
@ -51,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}`
)
}
}
@ -101,6 +137,8 @@ export function toString(v: Value): string {
return 'null'
case 'function':
return '<function>'
case 'native':
return '<function>'
case 'array':
return `[${v.value.map(toString).join(', ')}]`
case 'dict': {
@ -121,9 +159,7 @@ export function isEqual(a: Value, b: Value): boolean {
case 'null':
return true
case 'boolean':
return a.value === b.value
case 'number':
return a.value === b.value
case 'string':
return a.value === b.value
case 'array': {
@ -144,30 +180,44 @@ export function isEqual(a: Value, b: Value): boolean {
return String(a.value) === String(b.value)
}
case 'function':
case 'native':
return false // functions never equal
default:
return false
}
}
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':
return '<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 getOriginalFunction(v.fn)
}
}
@ -175,21 +225,49 @@ export function toNull(): Value {
return toValue(null)
}
const WRAPPED_MARKER = Symbol('reef-wrapped')
export function fnFromValue(fn: Value, vm: VM): Function {
if (fn.type !== 'function')
throw new Error('Value is not a function')
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)
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 wrappedObj = wrapped as any
wrappedObj[WRAPPED_MARKER] = true
const newVM = new VM({
instructions: vm.instructions,
constants: vm.constants,
labels: vm.labels
})
newVM.scope = fn.parentScope
return wrapped
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++
}
export function isWrapped(fn: Function): boolean {
return !!(fn as any)[WRAPPED_MARKER]
return fromValue(newVM.stack.pop() || toNull(), vm)
}
// support roundtrips, eg fromValue(toValue(fn))
; (wrapper as any)[REEF_FUNCTION] = fn
return wrapper
}

370
src/vm.ts
View File

@ -3,9 +3,9 @@ import type { ExceptionHandler } from "./exception"
import { type Frame } from "./frame"
import { OpCode } from "./opcode"
import { Scope } from "./scope"
import { type Value, toValue, toNumber, isTrue, isEqual, toString, wrapNative, isWrapped } from "./value"
type NativeFunction = (...args: Value[]) => Promise<Value> | Value
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
@ -19,20 +19,69 @@ export class VM {
labels: Map<number, string> = new Map()
nativeFunctions: Map<string, NativeFunction> = new Map()
constructor(bytecode: Bytecode) {
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 (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.nativeFunctions.set(name, wrapped)
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) {
this.nativeFunctions.set(name, fn)
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> {
@ -48,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:
@ -68,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:
@ -117,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
@ -137,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()!
@ -334,6 +488,22 @@ export class VM {
this.stack.push({ type: 'boolean', value: hasDict.value.has(toString(hasKey)) })
break
case OpCode.DOT_GET: {
const index = this.stack.pop()!
const target = this.stack.pop()!
if (target.type === 'array')
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))
else
throw new Error(`DOT_GET: ${target.type} not supported`)
break
}
case OpCode.STR_CONCAT:
let count = instruction.operand as number
let parts = []
@ -363,16 +533,17 @@ export class VM {
})
break
// @ts-ignore
case OpCode.TRY_CALL: {
const varName = instruction.operand as string
const value = this.scope.get(varName)
if (value?.type === 'function') {
if (value?.type === 'function' || value?.type === 'native') {
this.stack.push(value)
this.stack.push(toValue(0))
this.stack.push(toValue(0))
// No `break` here -- we want to fall through to OpCode.CALL!
this.instructions[this.pc] = { op: OpCode.CALL }
this.pc--
break
} else if (value) {
this.stack.push(value)
break
@ -382,8 +553,6 @@ export class VM {
}
}
// don't put any `case` statement here - `TRY_CALL` MUST go before `CALL!`
case OpCode.CALL: {
// Pop named count from stack (top)
const namedCount = toNumber(this.stack.pop()!)
@ -411,8 +580,110 @@ export class VM {
const fn = this.stack.pop()!
// Handle native functions
if (fn.type === 'native') {
// Mark current frame as break target (like regular CALL does)
if (this.callStack.length > 0)
this.callStack[this.callStack.length - 1]!.isBreakTarget = true
// Extract parameter info on-demand
const originalFn = getOriginalFunction(fn.fn)
const paramInfo = extractParamInfo(originalFn)
// Bind parameters using the same priority as Reef functions
const nativeArgs: Value[] = []
// Determine how many params are fixed (excluding variadic and named)
let nativeFixedParamCount = paramInfo.params.length
if (paramInfo.variadic) nativeFixedParamCount--
if (paramInfo.named) nativeFixedParamCount--
// Track which positional args have been consumed
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)) {
paramValue = namedArgs.get(paramName)!
namedArgs.delete(paramName) // Remove from named args so it won't go to @named
} else if (nativePositionalArgIndex < positionalArgs.length) {
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 {
nativeArgs.push(toValue(null))
}
}
// Handle named parameter (collect remaining unmatched named args)
// Parameter names matching atXxx pattern (e.g., atOptions, atNamed) collect extra named args
if (paramInfo.named) {
const namedDict = new Map<string, Value>()
for (const [key, value] of namedArgs) {
namedDict.set(key, value)
}
// Convert dict to plain JavaScript object for the native function
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
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
@ -434,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]!
@ -504,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)
@ -572,30 +856,8 @@ export class VM {
this.stack.push(returnValue)
break
case OpCode.CALL_NATIVE:
const functionName = instruction.operand as string
const tsFunction = this.nativeFunctions.get(functionName)
if (!tsFunction)
throw new Error(`CALL_NATIVE: function not found: ${functionName}`)
// Mark current frame as break target (like CALL does)
if (this.callStack.length > 0)
this.callStack[this.callStack.length - 1]!.isBreakTarget = true
// Pop all arguments from stack (TypeScript function consumes entire stack)
const tsArgs = [...this.stack]
this.stack = []
// Call the TypeScript function and await if necessary
const tsResult = await tsFunction(...tsArgs)
// Push result back onto stack
this.stack.push(tsResult)
break
default:
throw `Unknown op: ${instruction.op}`
throw new Error(`Unknown op: ${instruction.op}`)
}
}
@ -612,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)
}
}

File diff suppressed because it is too large Load Diff

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

@ -0,0 +1,232 @@
import { test, expect, describe } from "bun:test"
import { run, VM } from "#index"
import { toBytecode } from "#bytecode"
describe("functions parameter", () => {
test("pass functions to run()", async () => {
const bytecode = toBytecode(`
LOAD add
PUSH 5
PUSH 3
PUSH 2
PUSH 0
CALL
HALT
`)
const result = await run(bytecode, {
add: (a: number, b: number) => a + b
})
expect(result).toEqual({ type: 'number', value: 8 })
})
test("pass functions to VM constructor", async () => {
const bytecode = toBytecode(`
LOAD multiply
PUSH 10
PUSH 2
PUSH 2
PUSH 0
CALL
HALT
`)
const vm = new VM(bytecode, {
multiply: (a: number, b: number) => a * b
})
const result = await vm.run()
expect(result).toEqual({ type: 'number', value: 20 })
})
test("pass multiple functions", async () => {
const bytecode = toBytecode(`
LOAD add
PUSH 10
PUSH 5
PUSH 2
PUSH 0
CALL
STORE sum
LOAD multiply
LOAD sum
PUSH 3
PUSH 2
PUSH 0
CALL
HALT
`)
const result = await run(bytecode, {
add: (a: number, b: number) => a + b,
multiply: (a: number, b: number) => a * b
})
expect(result).toEqual({ type: 'number', value: 45 })
})
test("auto-wraps native functions", async () => {
const bytecode = toBytecode(`
LOAD concat
PUSH "hello"
PUSH "world"
PUSH 2
PUSH 0
CALL
HALT
`)
const result = await run(bytecode, {
concat: (a: string, b: string) => a + " " + b
})
expect(result).toEqual({ type: 'string', value: 'hello world' })
})
test("works with async functions", async () => {
const bytecode = toBytecode(`
LOAD delay
PUSH 100
PUSH 1
PUSH 0
CALL
HALT
`)
const result = await run(bytecode, {
delay: async (n: number) => {
await new Promise(resolve => setTimeout(resolve, 1))
return n * 2
}
})
expect(result).toEqual({ type: 'number', value: 200 })
})
test("can combine with manual vm.set", async () => {
const bytecode = toBytecode(`
LOAD add
PUSH 5
PUSH 3
PUSH 2
PUSH 0
CALL
STORE sum
LOAD subtract
LOAD sum
PUSH 2
PUSH 2
PUSH 0
CALL
HALT
`)
const vm = new VM(bytecode, {
add: (a: number, b: number) => a + b
})
// Register another function manually
vm.set('subtract', (a: number, b: number) => a - b)
const result = await vm.run()
expect(result).toEqual({ type: 'number', value: 6 })
})
test("no functions parameter (undefined)", async () => {
const bytecode = toBytecode(`
PUSH 42
HALT
`)
const result = await run(bytecode)
expect(result).toEqual({ type: 'number', value: 42 })
})
test("empty functions object", async () => {
const bytecode = toBytecode(`
PUSH 99
HALT
`)
const result = await run(bytecode, {})
expect(result).toEqual({ type: 'number', value: 99 })
})
test("function throws error", async () => {
const bytecode = toBytecode(`
LOAD divide
PUSH 0
PUSH 1
PUSH 0
CALL
HALT
`)
try {
await run(bytecode, {
divide: (n: number) => {
if (n === 0) throw new Error("Cannot divide by zero")
return 100 / n
}
})
expect(true).toBe(false) // Should not reach here
} catch (e: any) {
expect(e.message).toContain("Cannot divide by zero")
}
})
test("complex workflow with multiple function calls", async () => {
const bytecode = toBytecode(`
LOAD add
PUSH 5
PUSH 3
PUSH 2
PUSH 0
CALL
STORE result
LOAD multiply
LOAD result
PUSH 2
PUSH 2
PUSH 0
CALL
STORE final
LOAD format
LOAD final
PUSH 1
PUSH 0
CALL
HALT
`)
const result = await run(bytecode, {
add: (a: number, b: number) => a + b,
multiply: (a: number, b: number) => a * b,
format: (n: number) => `Result: ${n}`
})
expect(result).toEqual({ type: 'string', value: 'Result: 16' })
})
test("function overriding - later registration wins", async () => {
const bytecode = toBytecode(`
LOAD getValue
PUSH 5
PUSH 1
PUSH 0
CALL
HALT
`)
const vm = new VM(bytecode, {
getValue: () => 100
})
// Override with manual registration
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

2092
tests/opcodes.test.ts Normal file

File diff suppressed because it is too large Load Diff

459
tests/regex.test.ts Normal file
View File

@ -0,0 +1,459 @@
import { test, expect, describe } from "bun:test"
import { run } from "#index"
import { toBytecode } from "#bytecode"
describe("RegExp", () => {
test("basic pattern parsing", async () => {
const str = `
PUSH /hello/
`
const result = await run(toBytecode(str))
expect(result.type).toBe('regex')
if (result.type === 'regex') {
expect(result.value.source).toBe('hello')
expect(result.value.flags).toBe('')
}
})
test("pattern with flags", async () => {
const str = `
PUSH /test/gi
`
const result = await run(toBytecode(str))
expect(result.type).toBe('regex')
if (result.type === 'regex') {
expect(result.value.source).toBe('test')
expect(result.value.global).toBe(true)
expect(result.value.ignoreCase).toBe(true)
}
})
test("multiple flag combinations", async () => {
// Test i flag
const str1 = `
PUSH /pattern/i
`
const result1 = await run(toBytecode(str1))
expect(result1.type).toBe('regex')
if (result1.type === 'regex') {
expect(result1.value.ignoreCase).toBe(true)
}
// Test g flag
const str2 = `
PUSH /pattern/g
`
const result2 = await run(toBytecode(str2))
expect(result2.type).toBe('regex')
if (result2.type === 'regex') {
expect(result2.value.global).toBe(true)
}
// Test m flag
const str3 = `
PUSH /pattern/m
`
const result3 = await run(toBytecode(str3))
expect(result3.type).toBe('regex')
if (result3.type === 'regex') {
expect(result3.value.multiline).toBe(true)
}
// Test combined flags
const str4 = `
PUSH /pattern/gim
`
const result4 = await run(toBytecode(str4))
expect(result4.type).toBe('regex')
if (result4.type === 'regex') {
expect(result4.value.global).toBe(true)
expect(result4.value.ignoreCase).toBe(true)
expect(result4.value.multiline).toBe(true)
}
})
test("complex patterns", async () => {
// Character class
const str1 = `
PUSH /[a-z0-9]+/
`
const result1 = await run(toBytecode(str1))
expect(result1.type).toBe('regex')
if (result1.type === 'regex') {
expect(result1.value.source).toBe('[a-z0-9]+')
}
// Quantifiers
const str2 = `
PUSH /a{2,4}/
`
const result2 = await run(toBytecode(str2))
expect(result2.type).toBe('regex')
if (result2.type === 'regex') {
expect(result2.value.source).toBe('a{2,4}')
}
// Groups and alternation
const str3 = `
PUSH /(foo|bar)/
`
const result3 = await run(toBytecode(str3))
expect(result3.type).toBe('regex')
if (result3.type === 'regex') {
expect(result3.value.source).toBe('(foo|bar)')
}
// Anchors and special chars
const str4 = `
PUSH /^[a-z]+$/
`
const result4 = await run(toBytecode(str4))
expect(result4.type).toBe('regex')
if (result4.type === 'regex') {
expect(result4.value.source).toBe('^[a-z]+$')
}
})
test("escaping special characters", async () => {
const str = `
PUSH /\\d+\\.\\d+/
`
const result = await run(toBytecode(str))
expect(result.type).toBe('regex')
if (result.type === 'regex') {
expect(result.value.source).toBe('\\d+\\.\\d+')
}
})
test("store and load", async () => {
const str = `
PUSH /test/i
STORE pattern
LOAD pattern
`
const result = await run(toBytecode(str))
expect(result.type).toBe('regex')
if (result.type === 'regex') {
expect(result.value.source).toBe('test')
expect(result.value.ignoreCase).toBe(true)
}
})
test("TRY_LOAD with regex", async () => {
const str = `
PUSH /hello/g
STORE regex
TRY_LOAD regex
`
const result = await run(toBytecode(str))
expect(result.type).toBe('regex')
if (result.type === 'regex') {
expect(result.value.source).toBe('hello')
expect(result.value.global).toBe(true)
}
})
test("NEQ comparison", async () => {
const str = `
PUSH /foo/
PUSH /bar/
NEQ
`
await expect(str).toBeBoolean(true)
const str2 = `
PUSH /test/i
PUSH /test/i
NEQ
`
await expect(str2).toBeBoolean(false)
})
test("is truthy", async () => {
// Regex values should be truthy (not null or false)
const str = `
PUSH /test/
JUMP_IF_FALSE .end
PUSH 42
.end:
`
await expect(str).toBeNumber(42)
})
test("NOT returns false (regex is truthy)", async () => {
const str = `
PUSH /pattern/
NOT
`
await expect(str).toBeBoolean(false)
})
test("in arrays", async () => {
const str = `
PUSH /first/
PUSH /second/i
PUSH /third/g
MAKE_ARRAY #3
`
const result = await run(toBytecode(str))
expect(result.type).toBe('array')
if (result.type === 'array') {
expect(result.value).toHaveLength(3)
expect(result.value[0]!.type).toBe('regex')
if (result.value[0]!.type === 'regex') {
expect(result.value[0]!.value.source).toBe('first')
}
expect(result.value[1]!.type).toBe('regex')
if (result.value[1]!.type === 'regex') {
expect(result.value[1]!.value.source).toBe('second')
expect(result.value[1]!.value.ignoreCase).toBe(true)
}
expect(result.value[2]!.type).toBe('regex')
if (result.value[2]!.type === 'regex') {
expect(result.value[2]!.value.source).toBe('third')
expect(result.value[2]!.value.global).toBe(true)
}
}
})
test("retrieve from array", async () => {
const str = `
PUSH /pattern/i
PUSH /test/g
MAKE_ARRAY #2
PUSH 1
ARRAY_GET
`
const result = await run(toBytecode(str))
expect(result.type).toBe('regex')
if (result.type === 'regex') {
expect(result.value.source).toBe('test')
expect(result.value.global).toBe(true)
}
})
test("in dicts", async () => {
const str = `
PUSH 'email'
PUSH /^[a-z@.]+$/i
PUSH 'phone'
PUSH /\\d{3}-\\d{4}/
MAKE_DICT #2
`
const result = await run(toBytecode(str))
expect(result.type).toBe('dict')
if (result.type === 'dict') {
expect(result.value.size).toBe(2)
const email = result.value.get('email')
expect(email?.type).toBe('regex')
if (email?.type === 'regex') {
expect(email.value.source).toBe('^[a-z@.]+$')
expect(email.value.ignoreCase).toBe(true)
}
const phone = result.value.get('phone')
expect(phone?.type).toBe('regex')
if (phone?.type === 'regex') {
expect(phone.value.source).toBe('\\d{3}-\\d{4}')
}
}
})
test("retrieve from dict", async () => {
const str = `
PUSH 'pattern'
PUSH /test/gim
MAKE_DICT #1
PUSH 'pattern'
DICT_GET
`
const result = await run(toBytecode(str))
expect(result.type).toBe('regex')
if (result.type === 'regex') {
expect(result.value.source).toBe('test')
expect(result.value.global).toBe(true)
expect(result.value.ignoreCase).toBe(true)
expect(result.value.multiline).toBe(true)
}
})
test("with STR_CONCAT converts to string", async () => {
const str = `
PUSH "Pattern: "
PUSH /test/gi
STR_CONCAT #2
`
const result = await run(toBytecode(str))
expect(result.type).toBe('string')
if (result.type === 'string') {
expect(result.value).toBe('Pattern: /test/gi')
}
})
test("multiple regex in STR_CONCAT", async () => {
const str = `
PUSH /foo/
PUSH " and "
PUSH /bar/i
STR_CONCAT #3
`
await expect(str).toBeString('/foo/ and /bar/i')
})
test("DUP with regex", async () => {
const str = `
PUSH /pattern/i
DUP
EQ
`
// Same regex duplicated should be equal
await expect(str).toBeBoolean(true)
})
test("empty pattern", async () => {
const str = `
PUSH //
`
const result = await run(toBytecode(str))
expect(result.type).toBe('regex')
if (result.type === 'regex') {
expect(result.value.source).toBe('(?:)')
}
})
test("pattern with forward slashes escaped", async () => {
const str = `
PUSH /https:\\/\\//
`
const result = await run(toBytecode(str))
expect(result.type).toBe('regex')
if (result.type === 'regex') {
expect(result.value.source).toBe('https:\\/\\/')
}
})
test("unicode patterns", async () => {
const str = `
PUSH //
`
const result = await run(toBytecode(str))
expect(result.type).toBe('regex')
if (result.type === 'regex') {
expect(result.value.source).toBe('こんにちは')
}
})
test("emoji in pattern", async () => {
const str = `
PUSH /🎉+/
`
const result = await run(toBytecode(str))
expect(result.type).toBe('regex')
if (result.type === 'regex') {
expect(result.value.source).toBe('🎉+')
}
})
test("comparing different regex types", async () => {
// Different patterns
const str1 = `
PUSH /abc/
PUSH /xyz/
EQ
`
await expect(str1).toBeBoolean(false)
// Same pattern, different flags
const str2 = `
PUSH /test/
PUSH /test/i
EQ
`
await expect(str2).toBeBoolean(false)
// Different order of flags (should be equal)
const str3 = `
PUSH /test/ig
PUSH /test/gi
EQ
`
await expect(str3).toBeBoolean(true)
})
test("with native functions", async () => {
const { VM } = await import("#vm")
const bytecode = toBytecode(`
LOAD match
PUSH "hello world"
PUSH /world/
PUSH 2
PUSH 0
CALL
HALT
`)
const vm = new VM(bytecode)
// Register a native function that takes a string and regex
vm.set('match', (str: string, pattern: RegExp) => {
return pattern.test(str)
})
const result = await vm.run()
expect(result).toEqual({ type: 'boolean', value: true })
})
test("native function with regex replacement", async () => {
const { VM } = await import("#vm")
const bytecode = toBytecode(`
LOAD replace
PUSH "hello world"
PUSH /o/g
PUSH "0"
PUSH 3
PUSH 0
CALL
HALT
`)
const vm = new VM(bytecode)
vm.set('replace', (str: string, pattern: RegExp, replacement: string) => {
return str.replace(pattern, replacement)
})
const result = await vm.run()
expect(result).toEqual({ type: 'string', value: 'hell0 w0rld' })
})
test("native function extracting matches", async () => {
const { VM } = await import("#vm")
const bytecode = toBytecode(`
LOAD extractNumbers
PUSH "test123abc456"
PUSH /\\d+/g
PUSH 2
PUSH 0
CALL
HALT
`)
const vm = new VM(bytecode)
vm.set('extractNumbers', (str: string, pattern: RegExp) => {
return str.match(pattern) || []
})
const result = await vm.run()
expect(result.type).toBe('array')
if (result.type === 'array') {
expect(result.value).toHaveLength(2)
expect(result.value[0]).toEqual({ type: 'string', value: '123' })
expect(result.value[1]).toEqual({ type: 'string', value: '456' })
}
})
})

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

119
tests/unicode.test.ts Normal file
View File

@ -0,0 +1,119 @@
import { test, expect, describe } from "bun:test"
import { run } from "#index"
import { toBytecode } from "#bytecode"
describe("Unicode and Emoji", () => {
test("emoji variable names - string format", async () => {
const bytecode = toBytecode(`
PUSH 5
STORE 💎
LOAD 💎
HALT
`)
const result = await run(bytecode)
expect(result).toEqual({ type: 'number', value: 5 })
})
test("emoji variable names - array format", async () => {
const bytecode = toBytecode([
["PUSH", 100],
["STORE", "💰"],
["LOAD", "💰"],
["PUSH", 50],
["ADD"],
["HALT"]
])
const result = await run(bytecode)
expect(result).toEqual({ type: 'number', value: 150 })
})
test("unicode variable names - Japanese", async () => {
const bytecode = toBytecode(`
PUSH 42
STORE
LOAD
HALT
`)
const result = await run(bytecode)
expect(result).toEqual({ type: 'number', value: 42 })
})
test("unicode variable names - Chinese", async () => {
const bytecode = toBytecode([
["PUSH", 888],
["STORE", "数字"],
["LOAD", "数字"],
["HALT"]
])
const result = await run(bytecode)
expect(result).toEqual({ type: 'number', value: 888 })
})
test("emoji in function parameters", async () => {
const bytecode = toBytecode(`
MAKE_FUNCTION (💎 🌟) .add
STORE add
JUMP .after
.add:
LOAD 💎
LOAD 🌟
ADD
RETURN
.after:
LOAD add
PUSH 10
PUSH 20
PUSH 2
PUSH 0
CALL
HALT
`)
const result = await run(bytecode)
expect(result).toEqual({ type: 'number', value: 30 })
})
test("emoji with defaults and variadic", async () => {
const bytecode = toBytecode([
["MAKE_FUNCTION", ["🎯=100", "...🎨"], ".fn"],
["STORE", "fn"],
["JUMP", ".after"],
[".fn:"],
["LOAD", "🎯"],
["RETURN"],
[".after:"],
["LOAD", "fn"],
["PUSH", 0],
["PUSH", 0],
["CALL"],
["HALT"]
])
const result = await run(bytecode)
expect(result).toEqual({ type: 'number', value: 100 })
})
test("mixed emoji and regular names", async () => {
const bytecode = toBytecode([
["PUSH", 10],
["STORE", "💎"],
["PUSH", 20],
["STORE", "value"],
["PUSH", 30],
["STORE", "🌟"],
["LOAD", "💎"],
["LOAD", "value"],
["ADD"],
["LOAD", "🌟"],
["ADD"],
["HALT"]
])
const result = await run(bytecode)
expect(result).toEqual({ type: 'number', value: 60 })
})
})

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