Compare commits

..

No commits in common. "fa55eb7170f788f0720684262f4f30a5594d2d83" and "1a18a713d7ae86b03a6bef38cc53d12ecfbf9627" have entirely different histories.

14 changed files with 163 additions and 814 deletions

View File

@ -42,7 +42,8 @@ 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 (includes native functions)
- Lexical scope chain with parent references
- Native function registry for TypeScript interop
**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).
@ -69,7 +70,7 @@ 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**: 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.
**Native function calling**: CALL_NATIVE consumes the entire stack as arguments (different from CALL which pops specific argument counts).
## Testing Strategy
@ -371,6 +372,8 @@ Run `bun test` to verify all tests pass before committing.
**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.

View File

@ -42,6 +42,9 @@ 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:
@ -96,6 +99,11 @@ 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
@ -239,6 +247,9 @@ 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
### If-Else
@ -578,7 +589,7 @@ For function calls, parameters bound in order:
- Finally execution in all cases is compiler's responsibility, not VM's
### Calling Convention
All calls (including native functions) push arguments in order:
All calls push arguments in order:
1. Function
2. Positional args (in order)
3. Named args (key1, val1, key2, val2, ...)
@ -586,12 +597,11 @@ All calls (including native functions) push arguments in order:
5. Named count (as number)
6. CALL or TAIL_CALL
Native functions use the same calling convention as Reef functions. They are registered into scope and called via LOAD + 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.
### 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, {
@ -603,78 +613,14 @@ const result = await run(bytecode, {
const vm = new VM(bytecode, { add, greet })
```
**Method 2**: Register after construction
**Method 2**: Register manually
```typescript
const vm = new VM(bytecode)
vm.registerFunction('add', (a: number, b: number) => a + b)
vm.registerFunction('add', (a, b) => a + b)
await vm.run()
```
**Method 3**: Value-based functions (for full control)
```typescript
vm.registerValueFunction('customOp', (a: Value, b: Value): Value => {
return { type: 'number', value: toNumber(a) + toNumber(b) }
})
```
**Auto-wrapping**: `registerFunction` automatically converts between native TypeScript types and ReefVM Value types. Both sync and async functions work.
**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.registerFunction('greet', (atNamed: any = {}) => {
return `Hello, ${atNamed.name || 'World'}!`
})
// Mixed positional and @named
vm.registerFunction('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.
Functions are auto-wrapped to convert between native TypeScript and ReefVM Value types. Both sync and async functions work.
### Empty Stack
- RETURN with empty stack returns null

View File

@ -46,8 +46,7 @@ Commands: `clear`, `reset`, `exit`.
- Mixed positional and named arguments with proper priority binding
- Tail call optimization with unbounded recursion (10,000+ iterations without stack overflow)
- Exception handling (PUSH_TRY, PUSH_FINALLY, POP_TRY, THROW) with nested try/finally blocks and call stack unwinding
- Native function interop with auto-wrapping for native TypeScript types
- Native functions stored in scope, called via LOAD + CALL
- Native function interop (CALL_NATIVE) with auto-wrapping for native TypeScript types
- Pass functions directly to `run(bytecode, { fnName: fn })` or `new VM(bytecode, { fnName: fn })`
## Design Decisions

152
SPEC.md
View File

@ -13,9 +13,10 @@ 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 (includes native functions)
- **Scope Chain**: Linked scopes for lexical variable resolution
- **Program Counter (PC)**: Current instruction index
- **Constants Pool**: Immutable values and function metadata
- **Native Function Registry**: External functions callable from Shrimp
### Execution Model
@ -39,7 +40,6 @@ 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
@ -357,20 +357,15 @@ 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. **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:
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:
- 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
11. Set currentScope to new scope
12. Jump to function body
10. Set currentScope to new scope
11. Jump to function body
**Parameter Binding Priority** (for fixed params):
1. Named argument (if provided and matches param name)
@ -382,9 +377,8 @@ The created function captures `currentScope` as its `parentScope`.
- 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
**Errors**: Throws if top of stack is not a function (or native function)
**Errors**: Throws if top of stack is not a function
#### TAIL_CALL
**Operand**: None
@ -612,93 +606,28 @@ STR_CONCAT #4 ; → "Count: 42, Active: true"
### TypeScript Interop
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.
#### CALL_NATIVE
**Operand**: Function name (string)
**Effect**: Call registered TypeScript function
**Stack**: [...args] → [returnValue]
**Registration**:
**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**:
```typescript
const vm = new VM(bytecode, {
add: (a: number, b: number) => a + b,
greet: (name: string) => `Hello, ${name}!`
})
// Or after construction:
vm.registerFunction('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 `registerFunction`): Accept and return native TypeScript types (number, string, boolean, array, object, etc.). The VM automatically converts between Value types and native types.
2. **Value-based functions** (via `registerValueFunction`): Accept and return `Value` types directly for full control over type handling.
**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.registerFunction('add', (a: number, b: number) => a + b)
vm.registerFunction('greet', (name: string) => `Hello, ${name}!`)
vm.registerFunction('range', (n: number) => Array.from({ length: n }, (_, i) => i))
// With defaults
vm.registerFunction('greet', (name: string, greeting = 'Hello') => {
return `${greeting}, ${name}!`
})
// Variadic functions
vm.registerFunction('sum', (...nums: number[]) => {
return nums.reduce((acc, n) => acc + n, 0)
})
// Value-based for custom logic
vm.registerValueFunction('customOp', (a: Value, b: Value): Value => {
return { type: 'number', value: toNumber(a) + toNumber(b) }
})
// Async functions
vm.registerFunction('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!"
type TypeScriptFunction = (...args: Value[]) => Promise<Value> | Value;
```
### Special
@ -858,9 +787,10 @@ 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. **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
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
## Edge Cases
@ -905,21 +835,11 @@ All of these should throw errors:
## VM Initialization
```typescript
// 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.registerFunction('multiply', (a: number, b: number) => a * b)
// Or use Value-based functions
vm.registerValueFunction('customOp', (a: Value, b: Value): Value => {
const vm = new VM(bytecode);
vm.registerFunction('add', (a, b) => {
return { type: 'number', value: toNumber(a) + toNumber(b) }
})
const result = await vm.run()
const result = await vm.execute()
```
## Testing Considerations

View File

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

View File

@ -1,109 +0,0 @@
import { type Value, type NativeFunction, fromValue, toValue } from "./value"
export type ParamInfo = {
params: string[]
defaults: Record<string, Value>
variadic: boolean
named: boolean
}
const WRAPPED_MARKER = Symbol('reef-wrapped')
export function wrapNative(fn: Function): (...args: Value[]) => Promise<Value> {
const wrapped = async (...values: Value[]) => {
const nativeArgs = values.map(fromValue)
const result = await fn(...nativeArgs)
return toValue(result)
}
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

@ -8,6 +8,5 @@ export async function run(bytecode: Bytecode, functions?: Record<string, Functio
}
export { type Bytecode, toBytecode, type ProgramItem } from "./bytecode"
export { wrapNative } from "./function"
export { type Value, toValue, toString, toNumber, fromValue, toNull } from "./value"
export { type Value, toValue, toString, toNumber, fromValue, toNull, wrapNative } from "./value"
export { VM } from "./vm"

View File

@ -65,6 +65,9 @@ export enum OpCode {
// 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

@ -45,6 +45,7 @@ const OPCODES_WITH_OPERANDS = new Set([
OpCode.MAKE_DICT,
OpCode.STR_CONCAT,
OpCode.MAKE_FUNCTION,
OpCode.CALL_NATIVE,
])
const OPCODES_WITHOUT_OPERANDS = new Set([
@ -76,7 +77,6 @@ const OPCODES_WITHOUT_OPERANDS = new Set([
OpCode.DICT_GET,
OpCode.DICT_SET,
OpCode.DICT_HAS,
OpCode.DOT_GET,
])
// immediate = immediate number, eg #5

View File

@ -1,7 +1,5 @@
import { Scope } from "./scope"
export type NativeFunction = (...args: Value[]) => Promise<Value> | Value
export type Value =
| { type: 'null', value: null }
| { type: 'boolean', value: boolean }
@ -10,7 +8,6 @@ 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[],
@ -40,10 +37,10 @@ export function toValue(v: any): Value /* throws */ {
if (v && typeof v === 'object' && 'type' in v && 'value' in v)
return v as Value
if (Array.isArray(v))
if (Array.isArray(v))
return { type: 'array', value: v.map(toValue) }
if (v instanceof RegExp)
if (v instanceof RegExp)
return { type: 'regex', value: v }
switch (typeof v) {
@ -104,8 +101,6 @@ 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': {
@ -126,7 +121,9 @@ 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': {
@ -147,7 +144,6 @@ 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
@ -171,7 +167,6 @@ export function fromValue(v: Value): any {
case 'regex':
return v.value
case 'function':
case 'native':
return '<function>'
}
}
@ -179,3 +174,22 @@ export function fromValue(v: Value): any {
export function toNull(): Value {
return toValue(null)
}
const WRAPPED_MARKER = Symbol('reef-wrapped')
export function wrapNative(fn: Function): (...args: Value[]) => Promise<Value> {
const wrapped = async (...values: Value[]) => {
const nativeArgs = values.map(fromValue)
const result = await fn(...nativeArgs)
return toValue(result)
}
const wrappedObj = wrapped as any
wrappedObj[WRAPPED_MARKER] = true
return wrapped
}
export function isWrapped(fn: Function): boolean {
return !!(fn as any)[WRAPPED_MARKER]
}

103
src/vm.ts
View File

@ -3,8 +3,9 @@ import type { ExceptionHandler } from "./exception"
import { type Frame } from "./frame"
import { OpCode } from "./opcode"
import { Scope } from "./scope"
import { type Value, type NativeFunction, toValue, toNumber, isTrue, isEqual, toString, fromValue } from "./value"
import { extractParamInfo, wrapNative, isWrapped, getOriginalFunction } from "./function"
import { type Value, toValue, toNumber, isTrue, isEqual, toString, wrapNative, isWrapped } from "./value"
type NativeFunction = (...args: Value[]) => Promise<Value> | Value
export class VM {
pc = 0
@ -31,11 +32,11 @@ export class VM {
registerFunction(name: string, fn: Function) {
const wrapped = isWrapped(fn) ? fn as NativeFunction : wrapNative(fn)
this.scope.set(name, { type: 'native', fn: wrapped, value: '<function>' })
this.nativeFunctions.set(name, wrapped)
}
registerValueFunction(name: string, fn: NativeFunction) {
this.scope.set(name, { type: 'native', fn, value: '<function>' })
this.nativeFunctions.set(name, fn)
}
async run(): Promise<Value> {
@ -382,6 +383,7 @@ export class VM {
})
break
// @ts-ignore
case OpCode.TRY_CALL: {
const varName = instruction.operand as string
const value = this.scope.get(varName)
@ -390,9 +392,7 @@ export class VM {
this.stack.push(value)
this.stack.push(toValue(0))
this.stack.push(toValue(0))
this.instructions[this.pc] = { op: OpCode.CALL }
this.pc--
break
// No `break` here -- we want to fall through to OpCode.CALL!
} else if (value) {
this.stack.push(value)
break
@ -402,6 +402,8 @@ 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()!)
@ -429,71 +431,6 @@ 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
for (let i = 0; i < nativeFixedParamCount; i++) {
const paramName = paramInfo.params[i]!
// Check if named argument was provided for this param
if (namedArgs.has(paramName)) {
nativeArgs.push(namedArgs.get(paramName)!)
namedArgs.delete(paramName) // Remove from named args so it won't go to @named
} else if (nativePositionalArgIndex < positionalArgs.length) {
nativeArgs.push(positionalArgs[nativePositionalArgIndex]!)
nativePositionalArgIndex++
} else if (paramInfo.defaults[paramName] !== undefined) {
nativeArgs.push(paramInfo.defaults[paramName]!)
} else {
nativeArgs.push(toValue(null))
}
}
// Handle variadic parameter (TypeScript rest parameters)
// For TypeScript functions with ...rest, we spread the remaining args
// rather than wrapping them in an array
if (paramInfo.variadic) {
const remainingArgs = positionalArgs.slice(nativePositionalArgIndex)
nativeArgs.push(...remainingArgs)
}
// Handle named parameter (collect remaining unmatched named args)
// Parameter names matching atXxx pattern (e.g., atOptions, atNamed) collect extra named args
if (paramInfo.named) {
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 })
nativeArgs.push(toValue(namedObj))
}
// Call the native function with bound args
const result = await fn.fn(...nativeArgs)
this.stack.push(result)
break
}
if (fn.type !== 'function')
throw new Error('CALL: not a function')
@ -655,6 +592,28 @@ 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}`
}

View File

@ -5,12 +5,9 @@ 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
CALL_NATIVE add
HALT
`)
@ -23,12 +20,9 @@ describe("functions parameter", () => {
test("pass functions to VM constructor", async () => {
const bytecode = toBytecode(`
LOAD multiply
PUSH 10
PUSH 2
PUSH 2
PUSH 0
CALL
CALL_NATIVE multiply
HALT
`)
@ -42,19 +36,11 @@ describe("functions parameter", () => {
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
CALL_NATIVE add
PUSH 3
PUSH 2
PUSH 0
CALL
CALL_NATIVE multiply
HALT
`)
@ -68,12 +54,9 @@ describe("functions parameter", () => {
test("auto-wraps native functions", async () => {
const bytecode = toBytecode(`
LOAD concat
PUSH "hello"
PUSH "world"
PUSH 2
PUSH 0
CALL
CALL_NATIVE concat
HALT
`)
@ -86,11 +69,8 @@ describe("functions parameter", () => {
test("works with async functions", async () => {
const bytecode = toBytecode(`
LOAD delay
PUSH 100
PUSH 1
PUSH 0
CALL
CALL_NATIVE delay
HALT
`)
@ -106,19 +86,11 @@ describe("functions parameter", () => {
test("can combine with manual registerFunction", async () => {
const bytecode = toBytecode(`
LOAD add
PUSH 5
PUSH 3
CALL_NATIVE add
PUSH 2
PUSH 0
CALL
STORE sum
LOAD subtract
LOAD sum
PUSH 2
PUSH 2
PUSH 0
CALL
CALL_NATIVE subtract
HALT
`)
@ -155,11 +127,8 @@ describe("functions parameter", () => {
test("function throws error", async () => {
const bytecode = toBytecode(`
LOAD divide
PUSH 0
PUSH 1
PUSH 0
CALL
CALL_NATIVE divide
HALT
`)
@ -178,25 +147,16 @@ describe("functions parameter", () => {
test("complex workflow with multiple function calls", async () => {
const bytecode = toBytecode(`
LOAD add
PUSH 5
PUSH 3
PUSH 2
PUSH 0
CALL
CALL_NATIVE add
STORE result
LOAD multiply
LOAD result
PUSH 2
PUSH 2
PUSH 0
CALL
CALL_NATIVE multiply
STORE final
LOAD format
LOAD final
PUSH 1
PUSH 0
CALL
CALL_NATIVE format
HALT
`)
@ -211,11 +171,8 @@ describe("functions parameter", () => {
test("function overriding - later registration wins", async () => {
const bytecode = toBytecode(`
LOAD getValue
PUSH 5
PUSH 1
PUSH 0
CALL
CALL_NATIVE getValue
HALT
`)

View File

@ -3,14 +3,11 @@ import { VM } from "#vm"
import { toBytecode } from "#bytecode"
import { toValue, toNumber, toString } from "#value"
test("LOAD - basic function call", async () => {
test("CALL_NATIVE - basic function call", async () => {
const bytecode = toBytecode(`
LOAD add
PUSH 5
PUSH 10
PUSH 2
PUSH 0
CALL
CALL_NATIVE add
`)
const vm = new VM(bytecode)
@ -24,14 +21,11 @@ test("LOAD - basic function call", async () => {
expect(result).toEqual({ type: 'number', value: 15 })
})
test("LOAD - function with string manipulation", async () => {
test("CALL_NATIVE - function with string manipulation", async () => {
const bytecode = toBytecode(`
LOAD concat
PUSH "hello"
PUSH "world"
PUSH 2
PUSH 0
CALL
CALL_NATIVE concat
`)
const vm = new VM(bytecode)
@ -46,13 +40,10 @@ test("LOAD - function with string manipulation", async () => {
expect(result).toEqual({ type: 'string', value: 'hello world' })
})
test("LOAD - async function", async () => {
test("CALL_NATIVE - async function", async () => {
const bytecode = toBytecode(`
LOAD asyncDouble
PUSH 42
PUSH 1
PUSH 0
CALL
CALL_NATIVE asyncDouble
`)
const vm = new VM(bytecode)
@ -67,12 +58,9 @@ test("LOAD - async function", async () => {
expect(result).toEqual({ type: 'number', value: 84 })
})
test("LOAD - function with no arguments", async () => {
test("CALL_NATIVE - function with no arguments", async () => {
const bytecode = toBytecode(`
LOAD getAnswer
PUSH 0
PUSH 0
CALL
CALL_NATIVE getAnswer
`)
const vm = new VM(bytecode)
@ -85,15 +73,12 @@ test("LOAD - function with no arguments", async () => {
expect(result).toEqual({ type: 'number', value: 42 })
})
test("LOAD - function with multiple arguments", async () => {
test("CALL_NATIVE - function with multiple arguments", async () => {
const bytecode = toBytecode(`
LOAD sum
PUSH 2
PUSH 3
PUSH 4
PUSH 3
PUSH 0
CALL
CALL_NATIVE sum
`)
const vm = new VM(bytecode)
@ -107,13 +92,10 @@ test("LOAD - function with multiple arguments", async () => {
expect(result).toEqual({ type: 'number', value: 9 })
})
test("LOAD - function returns array", async () => {
test("CALL_NATIVE - function returns array", async () => {
const bytecode = toBytecode(`
LOAD makeRange
PUSH 3
PUSH 1
PUSH 0
CALL
CALL_NATIVE makeRange
`)
const vm = new VM(bytecode)
@ -139,26 +121,20 @@ test("LOAD - function returns array", async () => {
}
})
test("LOAD - function not found", async () => {
test("CALL_NATIVE - function not found", async () => {
const bytecode = toBytecode(`
LOAD nonexistent
PUSH 0
PUSH 0
CALL
CALL_NATIVE nonexistent
`)
const vm = new VM(bytecode)
expect(vm.run()).rejects.toThrow('Undefined variable: nonexistent')
expect(vm.run()).rejects.toThrow('CALL_NATIVE: function not found: nonexistent')
})
test("LOAD - using result in subsequent operations", async () => {
test("CALL_NATIVE - using result in subsequent operations", async () => {
const bytecode = toBytecode(`
LOAD triple
PUSH 5
PUSH 1
PUSH 0
CALL
CALL_NATIVE triple
PUSH 10
ADD
`)
@ -175,12 +151,9 @@ test("LOAD - using result in subsequent operations", async () => {
test("Native function wrapping - basic sync function with native types", async () => {
const bytecode = toBytecode(`
LOAD add
PUSH 5
PUSH 10
PUSH 2
PUSH 0
CALL
CALL_NATIVE add
`)
const vm = new VM(bytecode)
@ -196,11 +169,8 @@ test("Native function wrapping - basic sync function with native types", async (
test("Native function wrapping - async function with native types", async () => {
const bytecode = toBytecode(`
LOAD asyncDouble
PUSH 42
PUSH 1
PUSH 0
CALL
CALL_NATIVE asyncDouble
`)
const vm = new VM(bytecode)
@ -217,12 +187,9 @@ test("Native function wrapping - async function with native types", async () =>
test("Native function wrapping - string manipulation", async () => {
const bytecode = toBytecode(`
LOAD concat
PUSH "hello"
PUSH "world"
PUSH 2
PUSH 0
CALL
CALL_NATIVE concat
`)
const vm = new VM(bytecode)
@ -238,11 +205,8 @@ test("Native function wrapping - string manipulation", async () => {
test("Native function wrapping - with default parameters", async () => {
const bytecode = toBytecode(`
LOAD ls
PUSH "/home/user"
PUSH 1
PUSH 0
CALL
CALL_NATIVE ls
`)
const vm = new VM(bytecode)
@ -258,11 +222,8 @@ test("Native function wrapping - with default parameters", async () => {
test("Native function wrapping - returns array", async () => {
const bytecode = toBytecode(`
LOAD makeRange
PUSH 3
PUSH 1
PUSH 0
CALL
CALL_NATIVE makeRange
`)
const vm = new VM(bytecode)
@ -286,12 +247,9 @@ test("Native function wrapping - returns array", async () => {
test("Native function wrapping - returns object (becomes dict)", async () => {
const bytecode = toBytecode(`
LOAD makeUser
PUSH "Alice"
PUSH 30
PUSH 2
PUSH 0
CALL
CALL_NATIVE makeUser
`)
const vm = new VM(bytecode)
@ -311,17 +269,9 @@ test("Native function wrapping - returns object (becomes dict)", async () => {
test("Native function wrapping - mixed with manual Value functions", async () => {
const bytecode = toBytecode(`
LOAD nativeAdd
PUSH 5
PUSH 1
PUSH 0
CALL
STORE sum
LOAD manualDouble
LOAD sum
PUSH 1
PUSH 0
CALL
CALL_NATIVE nativeAdd
CALL_NATIVE manualDouble
`)
const vm = new VM(bytecode)
@ -337,287 +287,3 @@ test("Native function wrapping - mixed with manual Value functions", async () =>
const result = await vm.run()
expect(result).toEqual({ type: 'number', value: 30 })
})
test("Named arguments - basic named arg", async () => {
const bytecode = toBytecode(`
LOAD greet
PUSH "name"
PUSH "Alice"
PUSH 0
PUSH 1
CALL
`)
const vm = new VM(bytecode)
vm.registerFunction('greet', (name: string) => `Hello, ${name}!`)
const result = await vm.run()
expect(result).toEqual({ type: 'string', value: 'Hello, Alice!' })
})
test("Named arguments - mixed positional and named", async () => {
const bytecode = toBytecode(`
LOAD makeUser
PUSH "Alice"
PUSH "age"
PUSH 30
PUSH 1
PUSH 1
CALL
`)
const vm = new VM(bytecode)
vm.registerFunction('makeUser', (name: string, age: number) => {
return { name, age }
})
const result = await vm.run()
expect(result.type).toBe('dict')
if (result.type === 'dict') {
expect(result.value.get('name')).toEqual(toValue('Alice'))
expect(result.value.get('age')).toEqual(toValue(30))
}
})
test("Named arguments - named takes priority over positional", async () => {
const bytecode = toBytecode(`
LOAD add
PUSH 100
PUSH "a"
PUSH 5
PUSH "b"
PUSH 10
PUSH 1
PUSH 2
CALL
`)
const vm = new VM(bytecode)
vm.registerFunction('add', (a: number, b: number) => a + b)
const result = await vm.run()
// Named args should be: a=5, b=10
// Positional arg (100) is provided but named args take priority
expect(result).toEqual({ type: 'number', value: 15 })
})
test("Named arguments - with defaults", async () => {
const bytecode = toBytecode(`
LOAD greet
PUSH "name"
PUSH "Bob"
PUSH 0
PUSH 1
CALL
`)
const vm = new VM(bytecode)
vm.registerFunction('greet', (name: string, greeting = 'Hello') => {
return `${greeting}, ${name}!`
})
const result = await vm.run()
expect(result).toEqual({ type: 'string', value: 'Hello, Bob!' })
})
test("Named arguments - override defaults with named args", async () => {
const bytecode = toBytecode(`
LOAD greet
PUSH "name"
PUSH "Bob"
PUSH "greeting"
PUSH "Hi"
PUSH 0
PUSH 2
CALL
`)
const vm = new VM(bytecode)
vm.registerFunction('greet', (name: string, greeting = 'Hello') => {
return `${greeting}, ${name}!`
})
const result = await vm.run()
expect(result).toEqual({ type: 'string', value: 'Hi, Bob!' })
})
test("Named arguments - with variadic function", async () => {
const bytecode = toBytecode(`
LOAD sum
PUSH 1
PUSH 2
PUSH 3
PUSH "multiplier"
PUSH 2
PUSH 3
PUSH 1
CALL
`)
const vm = new VM(bytecode)
vm.registerFunction('sum', (multiplier: number, ...nums: number[]) => {
const total = nums.reduce((acc, n) => acc + n, 0)
return total * multiplier
})
const result = await vm.run()
expect(result).toEqual({ type: 'number', value: 12 }) // (1 + 2 + 3) * 2
})
test("Named arguments - works with both wrapped and non-wrapped functions", async () => {
const bytecode = toBytecode(`
; Test wrapped function (registerFunction)
LOAD wrappedAdd
PUSH "a"
PUSH 5
PUSH "b"
PUSH 10
PUSH 0
PUSH 2
CALL
STORE result1
; Test non-wrapped function (registerValueFunction)
LOAD valueAdd
PUSH "a"
PUSH 3
PUSH "b"
PUSH 7
PUSH 0
PUSH 2
CALL
STORE result2
; Return both results
LOAD result1
LOAD result2
ADD
`)
const vm = new VM(bytecode)
// Wrapped function - auto-converts types
vm.registerFunction('wrappedAdd', (a: number, b: number) => a + b)
// Non-wrapped function - works directly with Values
vm.registerValueFunction('valueAdd', (a, b) => {
return toValue(toNumber(a) + toNumber(b))
})
const result = await vm.run()
expect(result).toEqual({ type: 'number', value: 25 }) // 15 + 10
})
test("@named pattern - basic atNamed parameter", async () => {
const bytecode = toBytecode(`
LOAD greet
PUSH "name"
PUSH "Alice"
PUSH "greeting"
PUSH "Hi"
PUSH "extra"
PUSH "value"
PUSH 0
PUSH 3
CALL
`)
const vm = new VM(bytecode)
vm.registerFunction('greet', (atNamed: any = {}) => {
const name = atNamed.name || 'Unknown'
const greeting = atNamed.greeting || 'Hello'
const extra = atNamed.extra || ''
return `${greeting}, ${name}! Extra: ${extra}`
})
const result = await vm.run()
expect(result).toEqual({ type: 'string', value: 'Hi, Alice! Extra: value' })
})
test("@named pattern - mixed positional and atOptions", async () => {
const bytecode = toBytecode(`
LOAD configure
PUSH "app"
PUSH "debug"
PUSH true
PUSH "port"
PUSH 8080
PUSH 1
PUSH 2
CALL
`)
const vm = new VM(bytecode)
vm.registerFunction('configure', (name: string, atOptions: any = {}) => {
return {
name,
debug: atOptions.debug || false,
port: atOptions.port || 3000
}
})
const result = await vm.run()
expect(result.type).toBe('dict')
if (result.type === 'dict') {
expect(result.value.get('name')).toEqual(toValue('app'))
expect(result.value.get('debug')).toEqual(toValue(true))
expect(result.value.get('port')).toEqual(toValue(8080))
}
})
test("@named pattern - with default empty object", async () => {
const bytecode = toBytecode(`
LOAD build
PUSH "project"
PUSH 1
PUSH 0
CALL
`)
const vm = new VM(bytecode)
vm.registerFunction('build', (name: string, atConfig: any = {}) => {
return `Building ${name} with ${Object.keys(atConfig).length} options`
})
const result = await vm.run()
expect(result).toEqual({ type: 'string', value: 'Building project with 0 options' })
})
test("@named pattern - collects only unmatched named args", async () => {
const bytecode = toBytecode(`
LOAD process
PUSH "file"
PUSH "test.txt"
PUSH "mode"
PUSH "read"
PUSH "extra1"
PUSH "value1"
PUSH "extra2"
PUSH "value2"
PUSH 0
PUSH 4
CALL
`)
const vm = new VM(bytecode)
vm.registerFunction('process', (file: string, mode: string, atOptions: any = {}) => {
// file and mode are matched, extra1 and extra2 go to atOptions
return {
file,
mode,
optionCount: Object.keys(atOptions).length,
extra1: atOptions.extra1,
extra2: atOptions.extra2
}
})
const result = await vm.run()
expect(result.type).toBe('dict')
if (result.type === 'dict') {
expect(result.value.get('file')).toEqual(toValue('test.txt'))
expect(result.value.get('mode')).toEqual(toValue('read'))
expect(result.value.get('optionCount')).toEqual(toValue(2))
expect(result.value.get('extra1')).toEqual(toValue('value1'))
expect(result.value.get('extra2')).toEqual(toValue('value2'))
}
})

View File

@ -387,12 +387,9 @@ describe("RegExp", () => {
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
CALL_NATIVE match
HALT
`)
@ -410,13 +407,10 @@ describe("RegExp", () => {
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
CALL_NATIVE replace
HALT
`)
@ -433,12 +427,9 @@ describe("RegExp", () => {
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
CALL_NATIVE extractNumbers
HALT
`)