diff --git a/GUIDE.md b/GUIDE.md index befa4a8..fa1e4c9 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -642,6 +642,40 @@ 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 - HALT with empty stack returns null diff --git a/src/function.ts b/src/function.ts index 28b3606..eea025c 100644 --- a/src/function.ts +++ b/src/function.ts @@ -71,6 +71,11 @@ export function extractParamInfo(fn: Function): ParamInfo { 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') { @@ -84,18 +89,21 @@ export function extractParamInfo(fn: Function): ParamInfo { } 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 + // 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 - params.push(part) + const paramName = part.trim() + params.push(paramName) + + // Check if this is a named parameter (atXxx pattern) + if (/^at[A-Z]/.test(paramName)) { + named = true + } } } - // 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/vm.ts b/src/vm.ts index 247cf0c..493723b 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -3,7 +3,7 @@ import type { ExceptionHandler } from "./exception" import { type Frame } from "./frame" import { OpCode } from "./opcode" import { Scope } from "./scope" -import { type Value, type NativeFunction, toValue, toNumber, isTrue, isEqual, toString } from "./value" +import { type Value, type NativeFunction, toValue, toNumber, isTrue, isEqual, toString, fromValue } from "./value" import { extractParamInfo, wrapNative, isWrapped, getOriginalFunction } from "./function" export class VM { @@ -442,9 +442,10 @@ export class VM { // Bind parameters using the same priority as Reef functions const nativeArgs: Value[] = [] - // Determine how many params are fixed (excluding variadic) + // 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 @@ -456,7 +457,7 @@ export class VM { // 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 + namedArgs.delete(paramName) // Remove from named args so it won't go to @named } else if (nativePositionalArgIndex < positionalArgs.length) { nativeArgs.push(positionalArgs[nativePositionalArgIndex]!) nativePositionalArgIndex++ @@ -475,7 +476,17 @@ export class VM { nativeArgs.push(...remainingArgs) } - // Native functions don't support @named parameter - extra named args are ignored + // 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 }) + nativeArgs.push(toValue(namedObj)) + } // Call the native function with bound args const result = await fn.fn(...nativeArgs) diff --git a/tests/native.test.ts b/tests/native.test.ts index 2ea2dcc..f750dda 100644 --- a/tests/native.test.ts +++ b/tests/native.test.ts @@ -507,3 +507,117 @@ test("Named arguments - works with both wrapped and non-wrapped functions", asyn 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')) + } +})