import type { Bytecode, Constant, Instruction } from "./bytecode" import type { ExceptionHandler } from "./exception" import { type Frame } from "./frame" import { OpCode } from "./opcode" import { Scope } from "./scope" 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 stopped = false stack: Value[] = [] callStack: Frame[] = [] exceptionHandlers: ExceptionHandler[] = [] scope: Scope constants: Constant[] = [] instructions: Instruction[] = [] labels: Map = new Map() nativeFunctions: Map = new Map() constructor(bytecode: Bytecode, globals?: Record) { 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) } } 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) } } 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: '' }) } pushScope(locals?: Record) { 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 { this.pc = 0 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) } // Resume execution from current PC without resetting // Useful for REPL mode where you append bytecode incrementally async continue(): Promise { 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: const constIdx = instruction.operand as number const constant = this.constants[constIdx] if (!constant || constant.type === 'function_def') throw new Error(`Invalid constant index: ${constIdx}`) this.stack.push(constant) break case OpCode.POP: this.stack.pop() break case OpCode.DUP: 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: 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: this.binaryOp((a, b) => toNumber(a) - toNumber(b)) break case OpCode.MUL: this.binaryOp((a, b) => toNumber(a) * toNumber(b)) break case OpCode.DIV: this.binaryOp((a, b) => toNumber(a) / toNumber(b)) break case OpCode.MOD: this.binaryOp((a, b) => toNumber(a) % toNumber(b)) break case OpCode.EQ: this.comparisonOp((a, b) => isEqual(a, b)) break case OpCode.NEQ: this.comparisonOp((a, b) => !isEqual(a, b)) break case OpCode.LT: this.comparisonOp((a, b) => toNumber(a) < toNumber(b)) break case OpCode.GT: this.comparisonOp((a, b) => toNumber(a) > toNumber(b)) break case OpCode.LTE: this.comparisonOp((a, b) => toNumber(a) <= toNumber(b)) break case OpCode.GTE: this.comparisonOp((a, b) => toNumber(a) >= toNumber(b)) break case OpCode.NOT: const val = this.stack.pop()! this.stack.push({ type: 'boolean', value: !isTrue(val) }) break case OpCode.HALT: this.stopped = true break case OpCode.LOAD: { const varName = instruction.operand as string const value = this.scope.get(varName) if (value === undefined) throw new Error(`Undefined variable: ${varName}`) this.stack.push(value) break } case OpCode.TRY_LOAD: { const varName = instruction.operand as string const value = this.scope.get(varName) if (value === undefined) this.stack.push(toValue(varName, this)) else this.stack.push(value) break } case OpCode.STORE: const name = instruction.operand as string const toStore = this.stack.pop()! this.scope.set(name, toStore) break case OpCode.JUMP: this.pc += (instruction.operand as number) break case OpCode.JUMP_IF_FALSE: const cond = this.stack.pop()! if (!isTrue(cond)) this.pc += (instruction.operand as number) break case OpCode.JUMP_IF_TRUE: const condTrue = this.stack.pop()! if (isTrue(condTrue)) this.pc += (instruction.operand as number) break case OpCode.BREAK: // Unwind call stack until we find a frame marked as break target let foundBreakTarget = false while (this.callStack.length) { const frame = this.callStack.pop()! this.scope = frame.returnScope this.pc = frame.returnAddress if (frame.isBreakTarget) { foundBreakTarget = true break } } if (!foundBreakTarget) throw new Error('BREAK: no break target found') break case OpCode.PUSH_TRY: const catchAddress = instruction.operand as number this.exceptionHandlers.push({ catchAddress, callStackDepth: this.callStack.length, scope: this.scope }) break case OpCode.PUSH_FINALLY: const finallyAddress = instruction.operand as number if (this.exceptionHandlers.length === 0) throw new Error('PUSH_FINALLY: no exception handler to modify') this.exceptionHandlers[this.exceptionHandlers.length - 1]!.finallyAddress = finallyAddress break case OpCode.POP_TRY: if (this.exceptionHandlers.length === 0) throw new Error('POP_TRY: no exception handler to pop') this.exceptionHandlers.pop() break case OpCode.THROW: const errorValue = this.stack.pop()! if (this.exceptionHandlers.length === 0) { const errorMsg = toString(errorValue) throw new Error(`Uncaught exception: ${errorMsg}`) } 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 case OpCode.MAKE_ARRAY: const arraySize = instruction.operand as number const items: Value[] = [] for (let i = 0; i < arraySize; i++) items.unshift(this.stack.pop()!) this.stack.push({ type: 'array', value: items }) break case OpCode.ARRAY_GET: const index = this.stack.pop()! const array = this.stack.pop()! if (array.type !== 'array') throw new Error('ARRAY_GET: not an array') const idx = Math.floor(toNumber(index)) if (idx < 0 || idx >= array.value.length) throw new Error(`ARRAY_GET: index ${idx} out of bounds`) this.stack.push(array.value[idx]!) break case OpCode.ARRAY_SET: const setValue = this.stack.pop()! const setIndex = this.stack.pop()! const setArray = this.stack.pop()! if (setArray.type !== 'array') throw new Error('ARRAY_SET: not an array') const setIdx = Math.floor(toNumber(setIndex)) if (setIdx < 0 || setIdx >= setArray.value.length) throw new Error(`ARRAY_SET: index ${setIdx} out of bounds`) setArray.value[setIdx] = setValue break case OpCode.ARRAY_PUSH: const pushValue = this.stack.pop()! const pushArray = this.stack.pop()! if (pushArray.type !== 'array') throw new Error('ARRAY_PUSH: not an array') pushArray.value.push(pushValue) break case OpCode.ARRAY_LEN: const lenArray = this.stack.pop()! if (lenArray.type !== 'array') throw new Error('ARRAY_LEN: not an array') this.stack.push({ type: 'number', value: lenArray.value.length }) break case OpCode.MAKE_DICT: const dictPairs = instruction.operand as number const dict = new Map() for (let i = 0; i < dictPairs; i++) { const value = this.stack.pop()! const key = this.stack.pop()! dict.set(toString(key), value) } this.stack.push({ type: 'dict', value: dict }) break case OpCode.DICT_GET: const getKey = this.stack.pop()! const getDict = this.stack.pop()! if (getDict.type !== 'dict') throw new Error('DICT_GET: not a dict') this.stack.push(getDict.value.get(toString(getKey)) || toValue(null)) break case OpCode.DICT_SET: const dictSetValue = this.stack.pop()! const dictSetKey = this.stack.pop()! const dictSet = this.stack.pop()! if (dictSet.type !== 'dict') throw new Error('DICT_SET: not a dict') dictSet.value.set(toString(dictSetKey), dictSetValue) break case OpCode.DICT_HAS: const hasKey = this.stack.pop()! const hasDict = this.stack.pop()! if (hasDict.type !== 'dict') throw new Error('DICT_HAS: not a dict') 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 = [] while (count-- > 0 && this.stack.length) parts.unshift(toString(this.stack.pop()!)) this.stack.push(toValue(parts.join(''))) break case OpCode.MAKE_FUNCTION: const fnDefIdx = instruction.operand as number const fnDef = this.constants[fnDefIdx] if (!fnDef || fnDef.type !== 'function_def') throw new Error(`Invalid function definition index: ${fnDefIdx}`) this.stack.push({ type: 'function', params: fnDef.params, defaults: fnDef.defaults, body: fnDef.body, variadic: fnDef.variadic, named: fnDef.named, parentScope: this.scope, value: '' }) break case OpCode.TRY_CALL: { const varName = instruction.operand as string const value = this.scope.get(varName) if (value?.type === 'function' || value?.type === 'native') { this.stack.push(value) this.stack.push(toValue(0)) this.stack.push(toValue(0)) this.instructions[this.pc] = { op: OpCode.CALL } this.pc-- break } else if (value) { this.stack.push(value) break } else { this.stack.push(toValue(varName)) break } } case OpCode.CALL: { // Pop named count from stack (top) const namedCount = toNumber(this.stack.pop()!) // Pop positional count from stack const positionalCount = toNumber(this.stack.pop()!) // Pop named arguments (name-value pairs) from stack // Stack has: ... key1 value1 key2 value2 (top) // So we pop value2, key2, value1, key1 const namedArgs = new Map() const namedPairs: Array<{ key: string; value: Value }> = [] for (let i = 0; i < namedCount; i++) { const value = this.stack.pop()! const key = this.stack.pop()! namedPairs.unshift({ key: toString(key), value }) } for (const pair of namedPairs) namedArgs.set(pair.key, pair.value) // Pop positional arguments from stack const positionalArgs: Value[] = [] for (let i = 0; i < positionalCount; i++) positionalArgs.unshift(this.stack.pop()!) 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 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() 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: ${fn.value} is not a function`) if (this.callStack.length > 0) this.callStack[this.callStack.length - 1]!.isBreakTarget = true this.callStack.push({ returnAddress: this.pc, returnScope: this.scope, isBreakTarget: false }) this.scope = new Scope(fn.parentScope) // Determine how many params are fixed (excluding variadic and named) let fixedParamCount = fn.params.length if (fn.variadic) fixedParamCount-- if (fn.named) fixedParamCount-- // Track which positional args have been consumed let positionalArgIndex = 0 // Bind fixed parameters using priority: named arg > positional arg > default > null for (let i = 0; i < fixedParamCount; i++) { const paramName = fn.params[i]! // Check if named argument was provided for this param if (namedArgs.has(paramName)) { this.scope.set(paramName, 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]!) positionalArgIndex++ } else if (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 { this.scope.set(paramName, toValue(null)) } } // Handle variadic parameter (collect remaining positional args) if (fn.variadic) { const variadicParamName = fn.params[fn.params.length - (fn.named ? 2 : 1)]! const remainingArgs = positionalArgs.slice(positionalArgIndex) this.scope.set(variadicParamName, { type: 'array', value: remainingArgs }) } // Handle named parameter (collect remaining named args that didn't match params) if (fn.named) { const namedParamName = fn.params[fn.params.length - 1]! const namedDict = new Map() for (const [key, value] of namedArgs) { namedDict.set(key, value) } this.scope.set(namedParamName, { type: 'dict', value: namedDict }) } // subtract 1 because pc was incremented this.pc = fn.body - 1 break } case OpCode.TAIL_CALL: { // Pop named count from stack (top) const tailNamedCount = toNumber(this.stack.pop()!) // Pop positional count from stack const tailPositionalCount = toNumber(this.stack.pop()!) // Pop named arguments (name-value pairs) from stack const tailNamedArgs = new Map() const tailNamedPairs: Array<{ key: string; value: Value }> = [] for (let i = 0; i < tailNamedCount; i++) { const value = this.stack.pop()! const key = this.stack.pop()! tailNamedPairs.unshift({ key: toString(key), value }) } for (const pair of tailNamedPairs) { tailNamedArgs.set(pair.key, pair.value) } // Pop positional arguments from stack const tailPositionalArgs: Value[] = [] for (let i = 0; i < tailPositionalCount; i++) tailPositionalArgs.unshift(this.stack.pop()!) const tailFn = this.stack.pop()! if (tailFn.type !== 'function') throw new Error(`TAIL_CALL: ${tailFn.value} is not a function`) this.scope = new Scope(tailFn.parentScope) // Determine how many params are fixed (excluding variadic and named) let tailFixedParamCount = tailFn.params.length if (tailFn.variadic) tailFixedParamCount-- if (tailFn.named) tailFixedParamCount-- // Track which positional args have been consumed let tailPositionalArgIndex = 0 // Bind fixed parameters for (let i = 0; i < tailFixedParamCount; i++) { const paramName = tailFn.params[i]! if (tailNamedArgs.has(paramName)) { this.scope.set(paramName, tailNamedArgs.get(paramName)!) tailNamedArgs.delete(paramName) } else if (tailPositionalArgIndex < tailPositionalArgs.length) { this.scope.set(paramName, tailPositionalArgs[tailPositionalArgIndex]!) tailPositionalArgIndex++ } else if (tailFn.defaults[paramName] !== undefined) { const defaultIdx = tailFn.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 { this.scope.set(paramName, toValue(null)) } } // Handle variadic parameter if (tailFn.variadic) { const variadicParamName = tailFn.params[tailFn.params.length - (tailFn.named ? 2 : 1)]! const remainingArgs = tailPositionalArgs.slice(tailPositionalArgIndex) this.scope.set(variadicParamName, { type: 'array', value: remainingArgs }) } // Handle named parameter if (tailFn.named) { const namedParamName = tailFn.params[tailFn.params.length - 1]! const namedDict = new Map() for (const [key, value] of tailNamedArgs) { namedDict.set(key, value) } this.scope.set(namedParamName, { type: 'dict', value: namedDict }) } // subtract 1 because PC was incremented this.pc = tailFn.body - 1 break } case OpCode.RETURN: const returnValue = this.stack.length ? this.stack.pop()! : toValue(null) const frame = this.callStack.pop() if (!frame) throw new Error('RETURN: no call frame to return from') this.scope = frame.returnScope this.pc = frame.returnAddress this.stack.push(returnValue) break default: throw new Error(`Unknown op: ${instruction.op}`) } } binaryOp(fn: (a: Value, b: Value) => number) { const b = this.stack.pop()! const a = this.stack.pop()! const result = fn(a, b) this.stack.push({ type: 'number', value: result }) } comparisonOp(fn: (a: Value, b: Value) => boolean) { const b = this.stack.pop()! const a = this.stack.pop()! const result = fn(a, b) this.stack.push({ type: 'boolean', value: result }) } async callNative(nativeFn: NativeFunction, args: any[]): Promise { 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) } }