From 3e2e68b31f504347225a4d705c7568a0957d629e Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Sat, 8 Nov 2025 10:53:38 -0800 Subject: [PATCH] Passing NULL to a function triggers its default value --- GUIDE.md | 28 +++++- README.md | 4 +- SPEC.md | 31 ++++++ src/vm.ts | 30 +++++- tests/functions.test.ts | 203 ++++++++++++++++++++++++++++++++++++++++ tests/native.test.ts | 118 +++++++++++++++++++++++ 6 files changed, 407 insertions(+), 7 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index 5a4434a..f9a386d 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -179,6 +179,17 @@ PUSH 1 ; Named count CALL ``` +**Null triggers defaults**: Pass `null` to use default values: +``` +; Function: greet(name='Guest', msg='Hello') +LOAD greet +PUSH null ; Use default for 'name' +PUSH "Hi" ; Provide 'msg' +PUSH 2 +PUSH 0 +CALL ; → "Hi, Guest" +``` + ## Opcodes ### Stack @@ -748,11 +759,24 @@ Variable and function parameter names support Unicode and emoji: ### Parameter Binding Priority For function calls, parameters bound in order: -1. Positional argument (if provided) -2. Named argument (if provided and matches param name) +1. Named argument (if provided and matches param name) +2. Positional argument (if provided) 3. Default value (if defined) 4. Null +**Null Triggering Defaults**: Passing `null` as an argument (positional or named) triggers the default value if one exists. This allows callers to explicitly "opt-in" to defaults: +``` +# Function with defaults: greet(name='Guest', greeting='Hello') +LOAD greet +PUSH null # Triggers default: name='Guest' +PUSH 'Hi' # Provided: greeting='Hi' +PUSH 2 +PUSH 0 +CALL # Returns "Hi, Guest" +``` + +This works for both ReefVM functions and native TypeScript functions. If no default exists, `null` is bound as-is. + ### Exception Handlers - PUSH_TRY uses absolute addresses for catch blocks - Nested try blocks form a stack diff --git a/README.md b/README.md index 094f599..7d80210 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Commands: `clear`, `reset`, `exit`. - Variadic functions with positional rest parameters (`...rest`) - Named arguments (named) that collect unmatched named args into a dict (`@named`) - Mixed positional and named arguments with proper priority binding +- Default parameter values with null-triggering: passing `null` explicitly uses the default value - Tail call optimization with unbounded recursion (10,000+ iterations without stack overflow) - Exception handling (PUSH_TRY, PUSH_FINALLY, POP_TRY, THROW) with nested try/finally blocks and call stack unwinding - Native function interop with auto-wrapping for native TypeScript types @@ -59,4 +60,5 @@ Commands: `clear`, `reset`, `exit`. - Short-circuiting via compiler: No AND/OR opcodes—compilers use JUMP patterns for proper short-circuit evaluation - Variadic parameters: Functions can collect remaining positional arguments into an array using `...rest` syntax - Named parameters: Functions can collect unmatched named arguments into a dict using `@named` syntax -- Argument binding priority: Named args bind to regular params first, with unmatched ones going to `@named` \ No newline at end of file +- Argument binding priority: Named args bind to regular params first, with unmatched ones going to `@named` +- Null triggers defaults: Passing `null` to a parameter with a default value explicitly uses that default (applies to both ReefVM and native functions) \ No newline at end of file diff --git a/SPEC.md b/SPEC.md index 86cb794..222d5e1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -474,11 +474,19 @@ The created function captures `currentScope` as its `parentScope`. 3. Default value (if defined) 4. Null +**Null Value Semantics**: +- Passing `null` as an argument explicitly triggers the default value (if one exists) +- This allows callers to "opt-in" to defaults even when providing arguments positionally +- If no default exists, `null` is bound as-is +- This applies to both ReefVM functions and native TypeScript functions +- Example: `fn(null, 20)` where `fn(x=10, y)` binds `x=10` (default triggered), `y=20` + **Named Args Handling**: - 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 support named arguments** - parameter names are extracted from the function signature at call time +- Passing `null` via named args also triggers defaults: `fn(x=null)` triggers `x`'s default **Errors**: Throws if top of stack is not a function (or native function) @@ -908,6 +916,29 @@ PUSH 1 # namedCount CALL ``` +### Null Triggering Default Values +``` +# Function: greet(name='Guest', greeting='Hello') +MAKE_FUNCTION (name='Guest' greeting='Hello') .greet_body +STORE 'greet' +JUMP .main +.greet_body: + LOAD 'greeting' + PUSH ', ' + ADD + LOAD 'name' + ADD + RETURN +.main: + # Call with null for first param - triggers default + LOAD 'greet' + PUSH null # name will use default 'Guest' + PUSH 'Hi' # greeting='Hi' (provided) + PUSH 2 # positionalCount + PUSH 0 # namedCount + CALL # Returns "Hi, Guest" +``` + ### Tail Recursive Function ``` MAKE_FUNCTION (n acc) .factorial_body diff --git a/src/vm.ts b/src/vm.ts index aae6be6..45e2ac0 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -602,16 +602,25 @@ export class VM { let nativePositionalArgIndex = 0 // Bind fixed parameters using priority: named arg > positional arg > default > null + // Note: null values trigger defaults (null acts as "use default") for (let i = 0; i < nativeFixedParamCount; i++) { const paramName = paramInfo.params[i]! + let paramValue: Value | undefined // Check if named argument was provided for this param if (namedArgs.has(paramName)) { - nativeArgs.push(namedArgs.get(paramName)!) + paramValue = 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]!) + paramValue = positionalArgs[nativePositionalArgIndex]! nativePositionalArgIndex++ + } + + // If the parameter value is null and a default exists, use the default + if (paramValue?.type === 'null' && paramInfo.defaults[paramName] !== undefined) { + nativeArgs.push(paramInfo.defaults[paramName]!) + } else if (paramValue) { + nativeArgs.push(paramValue) } else if (paramInfo.defaults[paramName] !== undefined) { nativeArgs.push(paramInfo.defaults[paramName]!) } else { @@ -696,16 +705,29 @@ export class VM { let positionalArgIndex = 0 // Bind fixed parameters using priority: named arg > positional arg > default > null + // Note: null values trigger defaults (null acts as "use default") for (let i = 0; i < fixedParamCount; i++) { const paramName = fn.params[i]! + let paramValue: Value | undefined // Check if named argument was provided for this param if (namedArgs.has(paramName)) { - this.scope.set(paramName, namedArgs.get(paramName)!) + paramValue = 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]!) + paramValue = positionalArgs[positionalArgIndex]! positionalArgIndex++ + } + + // If the parameter value is null and a default exists, use the default + if (paramValue && paramValue.type === 'null' && 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 if (paramValue) { + this.scope.set(paramName, paramValue) } else if (fn.defaults[paramName] !== undefined) { const defaultIdx = fn.defaults[paramName]! const defaultValue = this.constants[defaultIdx]! diff --git a/tests/functions.test.ts b/tests/functions.test.ts index 5a8bbf4..70c8415 100644 --- a/tests/functions.test.ts +++ b/tests/functions.test.ts @@ -519,3 +519,206 @@ test("TRY_CALL - with string format", async () => { const result = await run(bytecode) expect(result).toEqual({ type: 'number', value: 100 }) }) + +test("CALL - passing null triggers default value for single parameter", async () => { + const bytecode = toBytecode([ + ["MAKE_FUNCTION", ["x=42"], ".body"], + ["STORE", "func"], + ["JUMP", ".end"], + [".body:"], + ["LOAD", "x"], + ["RETURN"], + [".end:"], + ["LOAD", "func"], + ["PUSH", null], + ["PUSH", 1], + ["PUSH", 0], + ["CALL"], + ["HALT"] + ]) + + const result = await run(bytecode) + // Passing null should trigger the default value of 42 + expect(result).toEqual({ type: 'number', value: 42 }) +}) + +test("CALL - passing null triggers default value for multiple parameters", async () => { + const bytecode = toBytecode([ + ["MAKE_FUNCTION", ["a=10", "b=20", "c=30"], ".body"], + ["STORE", "func"], + ["JUMP", ".end"], + [".body:"], + ["LOAD", "a"], + ["LOAD", "b"], + ["ADD"], + ["LOAD", "c"], + ["ADD"], + ["RETURN"], + [".end:"], + ["LOAD", "func"], + ["PUSH", 5], + ["PUSH", null], + ["PUSH", null], + ["PUSH", 3], + ["PUSH", 0], + ["CALL"], + ["HALT"] + ]) + + const result = await run(bytecode) + // a=5 (provided), b=20 (null triggers default), c=30 (null triggers default) + // Result: 5 + 20 + 30 = 55 + expect(result).toEqual({ type: 'number', value: 55 }) +}) + +test("CALL - null in middle parameter triggers default", async () => { + const bytecode = toBytecode([ + ["MAKE_FUNCTION", ["x=100", "y=200", "z=300"], ".body"], + ["STORE", "func"], + ["JUMP", ".end"], + [".body:"], + ["LOAD", "x"], + ["LOAD", "y"], + ["ADD"], + ["LOAD", "z"], + ["ADD"], + ["RETURN"], + [".end:"], + ["LOAD", "func"], + ["PUSH", 1], + ["PUSH", null], + ["PUSH", 3], + ["PUSH", 3], + ["PUSH", 0], + ["CALL"], + ["HALT"] + ]) + + const result = await run(bytecode) + // x=1, y=200 (null triggers default), z=3 + // Result: 1 + 200 + 3 = 204 + expect(result).toEqual({ type: 'number', value: 204 }) +}) + +test("CALL - null with named arguments triggers default", async () => { + const bytecode = toBytecode([ + ["MAKE_FUNCTION", ["x=50", "y=75"], ".body"], + ["STORE", "func"], + ["JUMP", ".end"], + [".body:"], + ["LOAD", "x"], + ["LOAD", "y"], + ["ADD"], + ["RETURN"], + [".end:"], + ["LOAD", "func"], + ["PUSH", "x"], + ["PUSH", null], + ["PUSH", "y"], + ["PUSH", 25], + ["PUSH", 0], + ["PUSH", 2], + ["CALL"], + ["HALT"] + ]) + + const result = await run(bytecode) + // x=50 (null triggers default), y=25 (provided via named arg) + // Result: 50 + 25 = 75 + expect(result).toEqual({ type: 'number', value: 75 }) +}) + +test("CALL - null with string default value", async () => { + const bytecode = toBytecode([ + ["MAKE_FUNCTION", ["name='Guest'"], ".body"], + ["STORE", "greet"], + ["JUMP", ".end"], + [".body:"], + ["PUSH", "Hello, "], + ["LOAD", "name"], + ["ADD"], + ["RETURN"], + [".end:"], + ["LOAD", "greet"], + ["PUSH", null], + ["PUSH", 1], + ["PUSH", 0], + ["CALL"], + ["HALT"] + ]) + + const result = await run(bytecode) + // Passing null should trigger the default value 'Guest' + expect(result).toEqual({ type: 'string', value: 'Hello, Guest' }) +}) + +test("CALL - null with no default still results in null", async () => { + const bytecode = toBytecode([ + ["MAKE_FUNCTION", ["x"], ".body"], + ["STORE", "func"], + ["JUMP", ".end"], + [".body:"], + ["LOAD", "x"], + ["RETURN"], + [".end:"], + ["LOAD", "func"], + ["PUSH", null], + ["PUSH", 1], + ["PUSH", 0], + ["CALL"], + ["HALT"] + ]) + + const result = await run(bytecode) + // No default value, so null should be returned + expect(result).toEqual({ type: 'null', value: null }) +}) + +test("CALL - null triggers default with variadic parameters", async () => { + const bytecode = toBytecode([ + ["MAKE_FUNCTION", ["x=99", "...rest"], ".body"], + ["STORE", "func"], + ["JUMP", ".end"], + [".body:"], + ["LOAD", "x"], + ["RETURN"], + [".end:"], + ["LOAD", "func"], + ["PUSH", null], + ["PUSH", 1], + ["PUSH", 2], + ["PUSH", 3], + ["PUSH", 4], + ["PUSH", 0], + ["CALL"], + ["HALT"] + ]) + + const result = await run(bytecode) + // x should be 99 (null triggers default), rest gets [1, 2, 3] + expect(result).toEqual({ type: 'number', value: 99 }) +}) + +test("CALL - null triggers default with @named parameter", async () => { + const bytecode = toBytecode([ + ["MAKE_FUNCTION", ["x=777", "@named"], ".body"], + ["STORE", "func"], + ["JUMP", ".end"], + [".body:"], + ["LOAD", "x"], + ["RETURN"], + [".end:"], + ["LOAD", "func"], + ["PUSH", null], + ["PUSH", "foo"], + ["PUSH", "bar"], + ["PUSH", 1], + ["PUSH", 1], + ["CALL"], + ["HALT"] + ]) + + const result = await run(bytecode) + // x should be 777 (null triggers default) + expect(result).toEqual({ type: 'number', value: 777 }) +}) diff --git a/tests/native.test.ts b/tests/native.test.ts index ed44e26..2844939 100644 --- a/tests/native.test.ts +++ b/tests/native.test.ts @@ -2360,4 +2360,122 @@ test('uncaught native function error crashes VM', async () => { }) await expect(vm.run()).rejects.toThrow('uncaught error') +}) + +test('native function - null triggers default value for single parameter', async () => { + const bytecode = toBytecode([ + ["LOAD", "greet"], + ["PUSH", null], + ["PUSH", 1], + ["PUSH", 0], + ["CALL"], + ["HALT"] + ]) + + const vm = new VM(bytecode) + vm.set('greet', (name = 'Guest') => `Hello, ${name}!`) + + const result = await vm.run() + // Passing null should trigger the default value 'Guest' + expect(result).toEqual({ type: 'string', value: 'Hello, Guest!' }) +}) + +test('native function - null triggers default value for multiple parameters', async () => { + const bytecode = toBytecode([ + ["LOAD", "add"], + ["PUSH", 5], + ["PUSH", null], + ["PUSH", null], + ["PUSH", 3], + ["PUSH", 0], + ["CALL"], + ["HALT"] + ]) + + const vm = new VM(bytecode) + vm.set('add', (a, b = 10, c = 20) => a + b + c) + + const result = await vm.run() + // a=5 (provided), b=10 (null triggers default), c=20 (null triggers default) + // Result: 5 + 10 + 20 = 35 + expect(result).toEqual({ type: 'number', value: 35 }) +}) + +test('native function - null in middle parameter triggers default', async () => { + const bytecode = toBytecode([ + ["LOAD", "calculate"], + ["PUSH", 100], + ["PUSH", null], + ["PUSH", 50], + ["PUSH", 3], + ["PUSH", 0], + ["CALL"], + ["HALT"] + ]) + + const vm = new VM(bytecode) + vm.set('calculate', (x, y = 200, z) => x + y + z) + + const result = await vm.run() + // x=100, y=200 (null triggers default), z=50 + // Result: 100 + 200 + 50 = 350 + expect(result).toEqual({ type: 'number', value: 350 }) +}) + +test('native function - null with named arguments triggers default', async () => { + const bytecode = toBytecode([ + ["LOAD", "format"], + ["PUSH", "name"], + ["PUSH", null], + ["PUSH", "age"], + ["PUSH", 30], + ["PUSH", 0], + ["PUSH", 2], + ["CALL"], + ["HALT"] + ]) + + const vm = new VM(bytecode) + vm.set('format', (name = 'Anonymous', age) => `${name} is ${age} years old`) + + const result = await vm.run() + // name='Anonymous' (null triggers default), age=30 (provided via named arg) + expect(result).toEqual({ type: 'string', value: 'Anonymous is 30 years old' }) +}) + +test('native function - null with no default still passes null', async () => { + const bytecode = toBytecode([ + ["LOAD", "checkNull"], + ["PUSH", null], + ["PUSH", 1], + ["PUSH", 0], + ["CALL"], + ["HALT"] + ]) + + const vm = new VM(bytecode) + vm.set('checkNull', (value) => value === null ? 'got null' : 'got value') + + const result = await vm.run() + // No default value, so null should be passed through + expect(result).toEqual({ type: 'string', value: 'got null' }) +}) + +test('native function - mix of null and provided values with defaults', async () => { + const bytecode = toBytecode([ + ["LOAD", "build"], + ["PUSH", null], + ["PUSH", "Bob"], + ["PUSH", 2], + ["PUSH", 0], + ["CALL"], + ["HALT"] + ]) + + const vm = new VM(bytecode) + vm.set('build', (prefix = 'Mr.', name, suffix = 'Jr.') => `${prefix} ${name} ${suffix}`) + + const result = await vm.run() + // prefix='Mr.' (null triggers default), name='Bob', suffix='Jr.' (default, not provided) + expect(result).toEqual({ type: 'string', value: 'Mr. Bob Jr.' }) }) \ No newline at end of file