Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions examples/chained-nilcoalesce.ilo
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Chained `??`: bare `f a ?? g b ?? d` parses as `(f a) ?? (g b) ?? d`.
-- The arity-aware call parser stops each call at its arg count, then
-- `??` falls out as left-associative infix. First non-nil wins.

lookup k1:t k2:t>n;m=mset (mset mmap "a" 1) "b" 2;mget m k1 ?? mget m k2 ?? 99

-- run: lookup "a" "b"
-- out: 1

-- run: lookup "x" "b"
-- out: 2

-- run: lookup "x" "y"
-- out: 99
10 changes: 8 additions & 2 deletions src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1870,10 +1870,16 @@ impl Parser {
let arg_idx = args.len();
let in_fn_pos = self.is_fn_ref_position(&name, arg_idx);
args.push(self.parse_call_arg(in_fn_pos)?);
// After each arg, if next is infix, stop
// After each arg, if next is infix, stop. `??` is always
// infix once we've already collected at least one arg —
// `f a ?? b` means `(f a) ?? b`, never `f a (??b ...)`.
// Without this, chained `f a ?? g b ?? d` mis-parses as
// `f a (?? g b) (?? d)` because the prefix-binary scanner
// sees `?? g b` as a valid prefix nil-coalesce form.
if let Some(tok) = self.peek()
&& Self::is_infix_or_suffix_op(tok)
&& !self.looks_like_prefix_binary(self.pos)
&& (matches!(tok, Token::NilCoalesce)
|| !self.looks_like_prefix_binary(self.pos))
{
break;
}
Expand Down
63 changes: 58 additions & 5 deletions tests/regression_mget_default.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,29 @@ const PATHKEY_HIT: &str = r#"f>n;m=mset mmap "k" 5;ks=["k"];mget m ks.0 ?? 0"#;
const CALLKEY_HIT: &str = r#"f>n;m=mset mmap "5" 7;mget m str 5 ?? 0"#;
// Parenthesised key — defensive lower bound on the precedence.
const PARENKEY_HIT: &str = r#"f>n;m=mset mmap "5" 7;mget m (str 5) ?? 0"#;
// Note: bare-chained `mget m "a" ?? mget m "b" ?? 99` does NOT parse
// today — the arity-aware call parser doesn't extend through a `??`
// boundary, so the second `mget` slurps `m "b" ?? 99` as three args.
// Bind each lookup into a temp first (`a=mget m "a";b=mget m "b";a??b??99`)
// or parenthesise. Logged as a separate adjacent finding.

// --- chained `mget m k ?? mget m k2 ?? d` ---
//
// Earlier, the post-arg break in `parse_call_or_atom` let `??` through
// when followed by 2+ atoms (the prefix-binary lookahead matched), so
// `mget m "a" ?? mget m "b" ?? 99` parsed as `mget m "a" (?? mget m) (?? "b" ...)`
// and failed with ILO-T006 "expects 2 args, got 3". Now `??` is always
// infix once at least one call arg has been collected.

// First lookup hits — short-circuits before second `mget` runs.
const CHAIN_FIRST_HIT: &str = r#"f>n;m=mset mmap "a" 1;mget m "a" ?? mget m "b" ?? 99"#;
// First miss, second hits.
const CHAIN_SECOND_HIT: &str = r#"f>n;m=mset mmap "b" 2;mget m "a" ?? mget m "b" ?? 99"#;
// Both miss — default wins.
const CHAIN_BOTH_MISS: &str = r#"f>n;m=mmap;mget m "a" ?? mget m "b" ?? 99"#;
// Two-element chain with no trailing default.
const CHAIN_NO_DEFAULT_HIT: &str = r#"f>O n;m=mset mmap "a" 1;mget m "a" ?? mget m "b""#;
const CHAIN_NO_DEFAULT_MISS: &str = r#"f>O n;m=mset mmap "b" 2;mget m "a" ?? mget m "b""#;
// `at` is another arity-2 builtin — confirm the fix isn't `mget`-specific.
// (Skip the out-of-bounds case: engines disagree on whether `at` returns nil
// or raises ILO-R004/ILO-R009, which is orthogonal to the parser fix.)
const CHAIN_AT_FIRST_HIT: &str = r#"f>n;xs=[10 20 30];at xs 0 ?? at xs 1 ?? 0"#;
const CHAIN_AT_SECOND_HIT: &str = r#"f>n;xs=[10 20 30];at xs 1 ?? at xs 2 ?? 0"#;

fn check_all(engine: &str) {
assert_eq!(
Expand Down Expand Up @@ -113,6 +131,41 @@ fn check_all(engine: &str) {
"7",
"parenkey hit engine={engine}"
);
assert_eq!(
run_file(engine, CHAIN_FIRST_HIT, "f"),
"1",
"chain first hit engine={engine}"
);
assert_eq!(
run_file(engine, CHAIN_SECOND_HIT, "f"),
"2",
"chain second hit engine={engine}"
);
assert_eq!(
run_file(engine, CHAIN_BOTH_MISS, "f"),
"99",
"chain both miss engine={engine}"
);
assert_eq!(
run_file(engine, CHAIN_NO_DEFAULT_HIT, "f"),
"1",
"chain no-default hit engine={engine}"
);
assert_eq!(
run_file(engine, CHAIN_NO_DEFAULT_MISS, "f"),
"2",
"chain no-default miss engine={engine}"
);
assert_eq!(
run_file(engine, CHAIN_AT_FIRST_HIT, "f"),
"10",
"chain at first hit engine={engine}"
);
assert_eq!(
run_file(engine, CHAIN_AT_SECOND_HIT, "f"),
"20",
"chain at second hit engine={engine}"
);
}

#[test]
Expand Down
Loading