diff --git a/CLAUDE.md b/CLAUDE.md index 5b6c52d..e08c940 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,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**: Native functions are stored in scope and called via LOAD + CALL, using the same calling convention as Reef functions. They do not support named arguments. +**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 diff --git a/GUIDE.md b/GUIDE.md index 287b247..befa4a8 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -621,15 +621,26 @@ vm.registerValueFunction('customOp', (a: Value, b: Value): Value => { **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!" ``` -**Limitations**: Native functions do not support named arguments (namedCount must be 0). +**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). ### Empty Stack - RETURN with empty stack returns null diff --git a/SPEC.md b/SPEC.md index 15259a1..acc01d1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -382,7 +382,7 @@ 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 do not support named arguments** - if namedCount > 0 for a native function, CALL will throw an error +- **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) @@ -647,9 +647,10 @@ CALL ; Call it like any other function - Supports sync and async functions - Objects convert to dicts, arrays convert to Value arrays -**Limitations**: -- Native functions do not support named arguments -- If called with named arguments (namedCount > 0), CALL throws an error +**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 @@ -658,6 +659,16 @@ 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) } @@ -670,6 +681,26 @@ vm.registerFunction('fetchData', async (url: string) => { }) ``` +**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 #### HALT diff --git a/src/function.ts b/src/function.ts new file mode 100644 index 0000000..28b3606 --- /dev/null +++ b/src/function.ts @@ -0,0 +1,101 @@ +import { type Value, type NativeFunction, fromValue, toValue } from "./value" + +export type ParamInfo = { + params: string[] + defaults: Record + variadic: boolean + named: boolean +} + +const WRAPPED_MARKER = Symbol('reef-wrapped') + +export function wrapNative(fn: Function): (...args: Value[]) => Promise { + 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 = {} + 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) + + // 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, we skip them and let the function's own default be used + } catch { + // If parsing fails, ignore the default + } + } else { + // Regular parameter + params.push(part) + } + } + + // Note: We don't support @named syntax in TypeScript functions + // Users would need to manually handle named args via an options object + + return { params, defaults, variadic, named } +} diff --git a/src/index.ts b/src/index.ts index a8b17e3..99c2a72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,5 +8,6 @@ export async function run(bytecode: Bytecode, functions?: Record Promise | Value +export type NativeFunction = (...args: Value[]) => Promise | Value export type Value = | { type: 'null', value: null } @@ -10,6 +10,7 @@ export type Value = | { type: 'array', value: Value[] } | { type: 'dict', value: Dict } | { type: 'regex', value: RegExp } + | { type: 'native', fn: NativeFunction, value: '' } | { type: 'function', params: string[], @@ -20,7 +21,6 @@ export type Value = named: boolean, value: '' } - | { type: 'native', fn: NativeFunction, value: '' } export type Dict = Map @@ -179,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 { - 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] -} diff --git a/src/vm.ts b/src/vm.ts index 93cc1a3..247cf0c 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -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 +import { type Value, type NativeFunction, toValue, toNumber, isTrue, isEqual, toString } from "./value" +import { extractParamInfo, wrapNative, isWrapped, getOriginalFunction } from "./function" export class VM { pc = 0 @@ -432,15 +431,54 @@ export class VM { // Handle native functions if (fn.type === 'native') { - if (namedCount > 0) - throw new Error('CALL: native functions do not support named arguments') - // Mark current frame as break target (like regular CALL does) if (this.callStack.length > 0) this.callStack[this.callStack.length - 1]!.isBreakTarget = true - // Call the native function with positional args - const result = await fn.fn(...positionalArgs) + // 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) + let nativeFixedParamCount = paramInfo.params.length + if (paramInfo.variadic) 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 so it doesn't cause issues + } 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) + } + + // Native functions don't support @named parameter - extra named args are ignored + + // Call the native function with bound args + const result = await fn.fn(...nativeArgs) this.stack.push(result) break } diff --git a/tests/native.test.ts b/tests/native.test.ts index 24ddc84..2ea2dcc 100644 --- a/tests/native.test.ts +++ b/tests/native.test.ts @@ -337,3 +337,173 @@ 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 +})