Compare commits

...

6 Commits

14 changed files with 814 additions and 163 deletions

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).
@ -70,7 +69,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**: 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
@ -372,8 +371,6 @@ 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,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
@ -247,9 +239,6 @@ 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
@ -589,7 +578,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, ...)
@ -597,11 +586,12 @@ 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, {
@ -613,14 +603,78 @@ const result = await run(bytecode, {
const vm = new VM(bytecode, { add, greet })
```
**Method 2**: Register manually
**Method 2**: Register after construction
```typescript
const vm = new VM(bytecode)
vm.registerFunction('add', (a, b) => a + b)
vm.registerFunction('add', (a: number, b: number) => a + b)
await vm.run()
```
Functions are auto-wrapped to convert between native TypeScript and ReefVM Value types. Both sync and async functions work.
**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.
### Empty Stack
- RETURN with empty stack returns null

View File

@ -46,7 +46,8 @@ 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 (CALL_NATIVE) with auto-wrapping for native TypeScript types
- Native function interop with auto-wrapping for native TypeScript types
- Native functions stored in scope, called via LOAD + CALL
- Pass functions directly to `run(bytecode, { fnName: fn })` or `new VM(bytecode, { fnName: fn })`
## Design Decisions

152
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
@ -357,15 +357,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)
@ -377,8 +382,9 @@ 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
**Errors**: Throws if top of stack is not a function (or native function)
#### TAIL_CALL
**Operand**: None
@ -606,28 +612,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.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!"
```
### Special
@ -787,10 +858,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
@ -835,11 +905,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.registerFunction('multiply', (a: number, b: number) => a * b)
// Or use Value-based functions
vm.registerValueFunction('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

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

109
src/function.ts Normal file
View File

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

View File

@ -65,9 +65,6 @@ 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,7 +45,6 @@ const OPCODES_WITH_OPERANDS = new Set([
OpCode.MAKE_DICT,
OpCode.STR_CONCAT,
OpCode.MAKE_FUNCTION,
OpCode.CALL_NATIVE,
])
const OPCODES_WITHOUT_OPERANDS = new Set([
@ -77,6 +76,7 @@ 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,5 +1,7 @@
import { Scope } from "./scope"
export type NativeFunction = (...args: Value[]) => Promise<Value> | Value
export type Value =
| { type: 'null', value: null }
| { type: 'boolean', value: boolean }
@ -8,6 +10,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[],
@ -37,10 +40,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) {
@ -101,6 +104,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 +126,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,6 +147,7 @@ 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
@ -167,6 +171,7 @@ export function fromValue(v: Value): any {
case 'regex':
return v.value
case 'function':
case 'native':
return '<function>'
}
}
@ -174,22 +179,3 @@ 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,9 +3,8 @@ 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, type NativeFunction, toValue, toNumber, isTrue, isEqual, toString, fromValue } from "./value"
import { extractParamInfo, wrapNative, isWrapped, getOriginalFunction } from "./function"
export class VM {
pc = 0
@ -32,11 +31,11 @@ export class VM {
registerFunction(name: string, fn: Function) {
const wrapped = isWrapped(fn) ? fn as NativeFunction : wrapNative(fn)
this.nativeFunctions.set(name, wrapped)
this.scope.set(name, { type: 'native', fn: wrapped, value: '<function>' })
}
registerValueFunction(name: string, fn: NativeFunction) {
this.nativeFunctions.set(name, fn)
this.scope.set(name, { type: 'native', fn, value: '<function>' })
}
async run(): Promise<Value> {
@ -383,7 +382,6 @@ export class VM {
})
break
// @ts-ignore
case OpCode.TRY_CALL: {
const varName = instruction.operand as string
const value = this.scope.get(varName)
@ -392,7 +390,9 @@ export class VM {
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
@ -402,8 +402,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()!)
@ -431,6 +429,71 @@ 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')
@ -592,28 +655,6 @@ 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,9 +5,12 @@ import { toBytecode } from "#bytecode"
describe("functions parameter", () => {
test("pass functions to run()", async () => {
const bytecode = toBytecode(`
LOAD add
PUSH 5
PUSH 3
CALL_NATIVE add
PUSH 2
PUSH 0
CALL
HALT
`)
@ -20,9 +23,12 @@ describe("functions parameter", () => {
test("pass functions to VM constructor", async () => {
const bytecode = toBytecode(`
LOAD multiply
PUSH 10
PUSH 2
CALL_NATIVE multiply
PUSH 2
PUSH 0
CALL
HALT
`)
@ -36,11 +42,19 @@ describe("functions parameter", () => {
test("pass multiple functions", async () => {
const bytecode = toBytecode(`
LOAD add
PUSH 10
PUSH 5
CALL_NATIVE add
PUSH 2
PUSH 0
CALL
STORE sum
LOAD multiply
LOAD sum
PUSH 3
CALL_NATIVE multiply
PUSH 2
PUSH 0
CALL
HALT
`)
@ -54,9 +68,12 @@ describe("functions parameter", () => {
test("auto-wraps native functions", async () => {
const bytecode = toBytecode(`
LOAD concat
PUSH "hello"
PUSH "world"
CALL_NATIVE concat
PUSH 2
PUSH 0
CALL
HALT
`)
@ -69,8 +86,11 @@ describe("functions parameter", () => {
test("works with async functions", async () => {
const bytecode = toBytecode(`
LOAD delay
PUSH 100
CALL_NATIVE delay
PUSH 1
PUSH 0
CALL
HALT
`)
@ -86,11 +106,19 @@ describe("functions parameter", () => {
test("can combine with manual registerFunction", async () => {
const bytecode = toBytecode(`
LOAD add
PUSH 5
PUSH 3
CALL_NATIVE add
PUSH 2
CALL_NATIVE subtract
PUSH 0
CALL
STORE sum
LOAD subtract
LOAD sum
PUSH 2
PUSH 2
PUSH 0
CALL
HALT
`)
@ -127,8 +155,11 @@ describe("functions parameter", () => {
test("function throws error", async () => {
const bytecode = toBytecode(`
LOAD divide
PUSH 0
CALL_NATIVE divide
PUSH 1
PUSH 0
CALL
HALT
`)
@ -147,16 +178,25 @@ describe("functions parameter", () => {
test("complex workflow with multiple function calls", async () => {
const bytecode = toBytecode(`
LOAD add
PUSH 5
PUSH 3
CALL_NATIVE add
PUSH 2
PUSH 0
CALL
STORE result
LOAD multiply
LOAD result
PUSH 2
CALL_NATIVE multiply
PUSH 2
PUSH 0
CALL
STORE final
LOAD format
LOAD final
CALL_NATIVE format
PUSH 1
PUSH 0
CALL
HALT
`)
@ -171,8 +211,11 @@ describe("functions parameter", () => {
test("function overriding - later registration wins", async () => {
const bytecode = toBytecode(`
LOAD getValue
PUSH 5
CALL_NATIVE getValue
PUSH 1
PUSH 0
CALL
HALT
`)

View File

@ -3,11 +3,14 @@ import { VM } from "#vm"
import { toBytecode } from "#bytecode"
import { toValue, toNumber, toString } from "#value"
test("CALL_NATIVE - basic function call", async () => {
test("LOAD - basic function call", async () => {
const bytecode = toBytecode(`
LOAD add
PUSH 5
PUSH 10
CALL_NATIVE add
PUSH 2
PUSH 0
CALL
`)
const vm = new VM(bytecode)
@ -21,11 +24,14 @@ test("CALL_NATIVE - basic function call", async () => {
expect(result).toEqual({ type: 'number', value: 15 })
})
test("CALL_NATIVE - function with string manipulation", async () => {
test("LOAD - function with string manipulation", async () => {
const bytecode = toBytecode(`
LOAD concat
PUSH "hello"
PUSH "world"
CALL_NATIVE concat
PUSH 2
PUSH 0
CALL
`)
const vm = new VM(bytecode)
@ -40,10 +46,13 @@ test("CALL_NATIVE - function with string manipulation", async () => {
expect(result).toEqual({ type: 'string', value: 'hello world' })
})
test("CALL_NATIVE - async function", async () => {
test("LOAD - async function", async () => {
const bytecode = toBytecode(`
LOAD asyncDouble
PUSH 42
CALL_NATIVE asyncDouble
PUSH 1
PUSH 0
CALL
`)
const vm = new VM(bytecode)
@ -58,9 +67,12 @@ test("CALL_NATIVE - async function", async () => {
expect(result).toEqual({ type: 'number', value: 84 })
})
test("CALL_NATIVE - function with no arguments", async () => {
test("LOAD - function with no arguments", async () => {
const bytecode = toBytecode(`
CALL_NATIVE getAnswer
LOAD getAnswer
PUSH 0
PUSH 0
CALL
`)
const vm = new VM(bytecode)
@ -73,12 +85,15 @@ test("CALL_NATIVE - function with no arguments", async () => {
expect(result).toEqual({ type: 'number', value: 42 })
})
test("CALL_NATIVE - function with multiple arguments", async () => {
test("LOAD - function with multiple arguments", async () => {
const bytecode = toBytecode(`
LOAD sum
PUSH 2
PUSH 3
PUSH 4
CALL_NATIVE sum
PUSH 3
PUSH 0
CALL
`)
const vm = new VM(bytecode)
@ -92,10 +107,13 @@ test("CALL_NATIVE - function with multiple arguments", async () => {
expect(result).toEqual({ type: 'number', value: 9 })
})
test("CALL_NATIVE - function returns array", async () => {
test("LOAD - function returns array", async () => {
const bytecode = toBytecode(`
LOAD makeRange
PUSH 3
CALL_NATIVE makeRange
PUSH 1
PUSH 0
CALL
`)
const vm = new VM(bytecode)
@ -121,20 +139,26 @@ test("CALL_NATIVE - function returns array", async () => {
}
})
test("CALL_NATIVE - function not found", async () => {
test("LOAD - function not found", async () => {
const bytecode = toBytecode(`
CALL_NATIVE nonexistent
LOAD nonexistent
PUSH 0
PUSH 0
CALL
`)
const vm = new VM(bytecode)
expect(vm.run()).rejects.toThrow('CALL_NATIVE: function not found: nonexistent')
expect(vm.run()).rejects.toThrow('Undefined variable: nonexistent')
})
test("CALL_NATIVE - using result in subsequent operations", async () => {
test("LOAD - using result in subsequent operations", async () => {
const bytecode = toBytecode(`
LOAD triple
PUSH 5
CALL_NATIVE triple
PUSH 1
PUSH 0
CALL
PUSH 10
ADD
`)
@ -151,9 +175,12 @@ test("CALL_NATIVE - 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
CALL_NATIVE add
PUSH 2
PUSH 0
CALL
`)
const vm = new VM(bytecode)
@ -169,8 +196,11 @@ 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
CALL_NATIVE asyncDouble
PUSH 1
PUSH 0
CALL
`)
const vm = new VM(bytecode)
@ -187,9 +217,12 @@ 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"
CALL_NATIVE concat
PUSH 2
PUSH 0
CALL
`)
const vm = new VM(bytecode)
@ -205,8 +238,11 @@ test("Native function wrapping - string manipulation", async () => {
test("Native function wrapping - with default parameters", async () => {
const bytecode = toBytecode(`
LOAD ls
PUSH "/home/user"
CALL_NATIVE ls
PUSH 1
PUSH 0
CALL
`)
const vm = new VM(bytecode)
@ -222,8 +258,11 @@ test("Native function wrapping - with default parameters", async () => {
test("Native function wrapping - returns array", async () => {
const bytecode = toBytecode(`
LOAD makeRange
PUSH 3
CALL_NATIVE makeRange
PUSH 1
PUSH 0
CALL
`)
const vm = new VM(bytecode)
@ -247,9 +286,12 @@ 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
CALL_NATIVE makeUser
PUSH 2
PUSH 0
CALL
`)
const vm = new VM(bytecode)
@ -269,9 +311,17 @@ 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
CALL_NATIVE nativeAdd
CALL_NATIVE manualDouble
PUSH 1
PUSH 0
CALL
STORE sum
LOAD manualDouble
LOAD sum
PUSH 1
PUSH 0
CALL
`)
const vm = new VM(bytecode)
@ -287,3 +337,287 @@ 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,9 +387,12 @@ describe("RegExp", () => {
test("with native functions", async () => {
const { VM } = await import("#vm")
const bytecode = toBytecode(`
LOAD match
PUSH "hello world"
PUSH /world/
CALL_NATIVE match
PUSH 2
PUSH 0
CALL
HALT
`)
@ -407,10 +410,13 @@ 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"
CALL_NATIVE replace
PUSH 3
PUSH 0
CALL
HALT
`)
@ -427,9 +433,12 @@ describe("RegExp", () => {
test("native function extracting matches", async () => {
const { VM } = await import("#vm")
const bytecode = toBytecode(`
LOAD extractNumbers
PUSH "test123abc456"
PUSH /\\d+/g
CALL_NATIVE extractNumbers
PUSH 2
PUSH 0
CALL
HALT
`)