ReefVM/src/vm.ts

713 lines
23 KiB
TypeScript

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, wrapNative, isWrapped, getOriginalFunction } from "./function"
export class VM {
pc = 0
stopped = false
stack: Value[] = []
callStack: Frame[] = []
exceptionHandlers: ExceptionHandler[] = []
scope: Scope
constants: Constant[] = []
instructions: Instruction[] = []
labels: Map<number, string> = new Map()
nativeFunctions: Map<string, NativeFunction> = new Map()
constructor(bytecode: Bytecode, functions?: Record<string, TypeScriptFunction>) {
this.instructions = bytecode.instructions
this.constants = bytecode.constants
this.labels = bytecode.labels || new Map()
this.scope = new Scope()
if (functions)
for (const name of Object.keys(functions))
this.registerFunction(name, functions[name]!)
}
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)
}
}
registerFunction(name: string, fn: TypeScriptFunction) {
const wrapped = isWrapped(fn) ? fn as NativeFunction : wrapNative(this, fn)
this.scope.set(name, { type: 'native', fn: wrapped, value: '<function>' })
}
registerValueFunction(name: string, fn: NativeFunction) {
this.scope.set(name, { type: 'native', fn, value: '<function>' })
}
async run(): Promise<Value> {
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)
}
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.ADD:
this.binaryOp((a, b) => toNumber(a) + toNumber(b))
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))
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<string, Value>()
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)]))
else if (target.type === 'dict')
this.stack.push(toValue(target.value?.get(String(index.value))))
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: '<function>'
})
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<string, Value>()
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<string, Value>()
for (const [key, value] of namedArgs) {
namedDict.set(key, value)
}
// Convert dict to plain JavaScript object for the native function
const namedObj = fromValue({ type: 'dict', value: namedDict }, this)
nativeArgs.push(toValue(namedObj))
}
// Handle variadic parameter (TypeScript rest parameters)
// For TypeScript functions with ...rest, we spread the remaining args
// rather than wrapping them in an array
if (paramInfo.variadic) {
const remainingArgs = positionalArgs.slice(nativePositionalArgIndex)
nativeArgs.push(...remainingArgs)
}
// Call the native function with bound args
const result = await fn.fn.call(this, ...nativeArgs)
this.stack.push(result)
break
}
if (fn.type !== 'function')
throw new Error('CALL: 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<string, Value>()
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<string, Value>()
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: 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<string, Value>()
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 `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<Value> {
const originalFn = getOriginalFunction(nativeFn)
const lastArg = args[args.length - 1]
if (lastArg && !Array.isArray(lastArg) && typeof lastArg === 'object') {
const paramInfo = extractParamInfo(originalFn)
const positional = args.slice(0, -1)
const named = lastArg
args = [...positional]
for (let i = positional.length; i < paramInfo.params.length; i++) {
const paramName = paramInfo.params[i]!
if (named[paramName] !== undefined) {
args[i] = named[paramName]
}
}
}
const result = await originalFn.call(this, ...args)
return toValue(result)
}
}