forked from defunkt/ReefVM
Passing NULL to a function triggers its default value
This commit is contained in:
parent
d7a971db24
commit
3e2e68b31f
28
GUIDE.md
28
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
|
||||
|
|
|
|||
|
|
@ -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
31
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
|
||||
|
|
|
|||
30
src/vm.ts
30
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]!
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.' })
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user