add named args to native functions

This commit is contained in:
Chris Wanstrath 2025-10-17 13:11:47 -07:00
parent fe7586a5fa
commit 1cf14636ff
8 changed files with 369 additions and 36 deletions

View File

@ -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

View File

@ -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

39
SPEC.md
View File

@ -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

101
src/function.ts Normal file
View File

@ -0,0 +1,101 @@
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)
// 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 }
}

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

@ -1,6 +1,6 @@
import { Scope } from "./scope"
type NativeFunction = (...args: Value[]) => Promise<Value> | Value
export type NativeFunction = (...args: Value[]) => Promise<Value> | 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: '<function>' }
| {
type: 'function',
params: string[],
@ -20,7 +21,6 @@ export type Value =
named: boolean,
value: '<function>'
}
| { type: 'native', fn: NativeFunction, value: '<function>' }
export type Dict = Map<string, Value>
@ -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<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]
}

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 } 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
}

View File

@ -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
})