Passing NULL to a function triggers its default value

This commit is contained in:
Chris Wanstrath 2025-11-08 10:53:38 -08:00
parent d7a971db24
commit 3e2e68b31f
6 changed files with 407 additions and 7 deletions

View File

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

View File

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

31
SPEC.md
View File

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

View File

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

View File

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

View File

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