From 4f406e6177b944ad6ee7055f1d2a7c3352eed2e3 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Sat, 16 May 2026 17:54:09 +0100 Subject: [PATCH 1/3] parser: prefix `??` accepts call expression as value side Both prefix-`??` arms (parse_expr_inner and parse_operand) previously used parse_operand for the value, which only consumes an atom. So `??mget m "k" 0` mis-parsed as `??mget (m "k" 0)`: `mget` was the value, and `m "k" 0` was the default expression, which then failed because `m` is a Map, not a function. Switching to parse_call_arg(false, None) reuses the arity-cap pattern already used in no_whitespace_call and the nested-call block at parse_call_arg:2193: a known-arity ident consumes exactly its declared arity, leaving the remainder for the default. Identifier-only `??x default`, parenthesised `??(expr) default`, and infix `c ?? d` all keep working because parse_call_arg falls through to parse_operand for non-known-arity idents. Bare `??mget m "k" 0` is the most common nil-coalesce site in agent-written ilo, so removing the parens/bind-first tax pays off on every map lookup with a fallback. --- src/parser/mod.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 125053fd..92647069 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1898,10 +1898,15 @@ impl Parser { | Some(Token::Amp) | Some(Token::Pipe) | Some(Token::PlusEq) => self.parse_prefix_binop(), - // Prefix nil-coalesce: ??a b — mirror of infix `a ?? b` + // Prefix nil-coalesce: ??a b, mirror of infix `a ?? b`. + // The value side uses `parse_call_arg` (not `parse_operand`) so + // a known-arity function with args expands into a call expression, + // consuming exactly its declared arity. This lets `??mget m "k" 0` + // parse as `??(mget m "k") 0` without forcing parens or a bind-first + // line. The most common nil-coalesce site is `??mget ... default`. Some(Token::NilCoalesce) => { self.advance(); - let value = self.parse_operand()?; + let value = self.parse_call_arg(false, None)?; let default = self.parse_expr_inner()?; Ok(Expr::NilCoalesce { value: Box::new(value), @@ -2571,8 +2576,11 @@ or write `({fmt_name} \"...\" ...)` so its args are grouped." | Some(Token::Pipe) | Some(Token::PlusEq) => self.parse_prefix_binop(), Some(Token::NilCoalesce) => { + // See `parse_expr_inner` for the rationale: `parse_call_arg` + // expands a known-arity function into a call so `??mget m "k" 0` + // parses as `??(mget m "k") 0`. self.advance(); - let value = self.parse_operand()?; + let value = self.parse_call_arg(false, None)?; let default = self.parse_expr_inner()?; Ok(Expr::NilCoalesce { value: Box::new(value), From f6cb243000eab54f955bac3e097d63ec57abe0d0 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Sat, 16 May 2026 17:54:10 +0100 Subject: [PATCH 2/3] test: prefix `??` with call-expression value across all engines Adds four cases to the existing prefix-nil-coalesce regression file: hit (mget returns value), miss (mget returns nil, default fires), two-call chain (first miss + second hit), and paren workaround (still works post-fix). Cross-cuts tree/VM/Cranelift since the parser change affects every backend identically. --- tests/regression_prefix_nil_coalesce.rs | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/regression_prefix_nil_coalesce.rs b/tests/regression_prefix_nil_coalesce.rs index 45831868..d53e45e3 100644 --- a/tests/regression_prefix_nil_coalesce.rs +++ b/tests/regression_prefix_nil_coalesce.rs @@ -58,6 +58,22 @@ const PREFIX_INFIX_DEFAULT_VAL: &str = "f>n;a=3;b=4;c=5;?? c a+b"; // Nested prefix nil-coalesce — confirm right-associativity matches infix. const PREFIX_NESTED_NIL: &str = "f>n;c=nil;d=nil;??c ??d 0"; const PREFIX_NESTED_INNER: &str = "f>n;c=nil;d=9;??c ??d 0"; +// Prefix `??` where the value side is a CALL expression. Previously the +// value side used `parse_operand` (atom-only), so `??mget m "k" 0` mis-parsed: +// `mget` was taken as the value atom and `m "k" 0` as the default expression +// (which then failed because `m` is a Map, not a function). With the arity-cap +// fix, the value side is parsed via `parse_call_arg`, so a known-arity +// function consumes exactly its declared arity and the remaining tokens +// become the default. Workarounds (`??(mget m "k") 0`, bind-first) keep +// working; the new shape is purely additive. +const PREFIX_CALL_HIT: &str = "f>n;m=mset mmap \"k\" 42;??mget m \"k\" 0"; +const PREFIX_CALL_MISS: &str = "f>n;m=mset mmap \"k\" 42;??mget m \"missing\" 99"; +// Chained prefix `??` with two call value-sides: +// `??mget m "x" ??mget m "a" 0` reads as `??(mget m "x") (??(mget m "a") 0)`, +// first miss, second hit, expect `1`. +const PREFIX_CALL_CHAIN: &str = "f>n;m=mset mmap \"a\" 1;??mget m \"x\" ??mget m \"a\" 0"; +// Paren workaround still parses correctly post-fix. +const PREFIX_CALL_PAREN: &str = "f>n;m=mset mmap \"k\" 42;??(mget m \"k\") 0"; fn check_all(engine: &str) { assert_eq!( @@ -110,6 +126,26 @@ fn check_all(engine: &str) { "9", "prefix nested inner engine={engine}" ); + assert_eq!( + run(engine, PREFIX_CALL_HIT, "f"), + "42", + "prefix call hit engine={engine}" + ); + assert_eq!( + run(engine, PREFIX_CALL_MISS, "f"), + "99", + "prefix call miss engine={engine}" + ); + assert_eq!( + run(engine, PREFIX_CALL_CHAIN, "f"), + "1", + "prefix call chain engine={engine}" + ); + assert_eq!( + run(engine, PREFIX_CALL_PAREN, "f"), + "42", + "prefix call paren workaround engine={engine}" + ); } #[test] From 925c15021b6c20d4c90b9e61cdce27aee5194545 Mon Sep 17 00:00:00 2001 From: Daniel Morris Date: Sat, 16 May 2026 17:54:10 +0100 Subject: [PATCH 3/3] example: prefix `??` with map lookup and default Pinned by the engine harness in tests/examples_engines.rs. Three entries (hit, miss, two-call chain) cover the call-expression value side and serve as an in-context demo for agents reading the examples directory before writing nil-coalesce code. --- examples/qq-call-default.ilo | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 examples/qq-call-default.ilo diff --git a/examples/qq-call-default.ilo b/examples/qq-call-default.ilo new file mode 100644 index 00000000..ed26135a --- /dev/null +++ b/examples/qq-call-default.ilo @@ -0,0 +1,22 @@ +-- Prefix `??` accepts a CALL expression as its value side. +-- Common case: nil-coalesce on a map lookup with a default. +-- `??mget m "k" 0` parses as `??(mget m "k") 0`, the arity-aware +-- parser stops after `mget`'s 2 args and leaves `0` for the default. +-- Previously you had to write `??(mget m "k") 0` or bind first; now +-- the bare form Just Works, which is the most common nil-coalesce +-- shape in agent-written ilo (mget lookups with a fallback). + +hit>n;m=mset mmap "k" 42;??mget m "k" 0 +miss>n;m=mset mmap "k" 42;??mget m "missing" 99 + +-- Chained: first non-nil wins, mirroring infix chaining. +chain>n;m=mset mmap "a" 1;??mget m "x" ??mget m "a" 0 + +-- run: hit +-- out: 42 + +-- run: miss +-- out: 99 + +-- run: chain +-- out: 1