Skip to content

Accept prefix-binop expressions as call arguments#159

Merged
danieljohnmorris merged 2 commits into
mainfrom
fix/prefix-arg-depth
May 11, 2026
Merged

Accept prefix-binop expressions as call arguments#159
danieljohnmorris merged 2 commits into
mainfrom
fix/prefix-arg-depth

Conversation

@danieljohnmorris
Copy link
Copy Markdown
Collaborator

Summary

Resolves the most-cited ergonomic friction in the assessment doc: call args bailing too eagerly when a position started with a prefix-binary expression. The doc calls this "the single most repetitive ilo friction across both sessions; every program has 10+ intermediate names just for this."

Across 11 rounds of feedback and 4 personas, agents kept hitting:

slc ls i +i 1            → arity error, must bind ip=+i 1 first
prnt pct s 10            → same shape
i = flr */n 0.25         → same shape
slc xs fi +fi 1          → same shape (bioinformatics persona)

Every fix produces one or two saved intermediate bindings per call. Across a typical program that compounds into 10+ named temporaries the agent wouldn't otherwise need. Through the manifesto lens this is a recurring per-program token tax — exactly the kind of friction the language exists to remove.

Root cause

parse_call_or_atom's arg-collection loop (src/parser/mod.rs:1571-1585):

let mut args = Vec::new();
while self.can_start_operand() {
    args.push(self.parse_operand()?);
    if let Some(tok) = self.peek()
        && Self::infix_binding_power(tok).is_some()
    {
        break;          // ← too eager
    }
}

The break fires whenever the next token is infix-eligible, even when it actually starts a prefix-binary expression. The parser already has looks_like_prefix_binary and uses it at the top of the arg-collection block to disambiguate prefix vs infix when the first token after the function name is an operator. The inner break check didn't.

The fix

  1. parser: accept prefix-binop expressions as call arguments — mirror the pre-loop guard inside the break. Only break when the operator looks infix (1 atom ahead), not when it looks prefix-binary (2+ atoms ahead). Also extended looks_like_prefix_binary to recognise nested prefix-binops as atoms via a new scan_prefix_binary_end helper that walks the lookahead recursively — this is needed for shapes like h +a +b c (two back-to-back prefix-binop args). Recursion is bounded because each step consumes at least one operator + one atom.

  2. tests + example: pin prefix-binop call-arg behaviour across engines — 15 cross-engine regression tests covering the canonical repro, 3-arg user functions with prefix-binops in each position, nested h +a +b c, real infix on a call result still parsing as (f a) + b, guard expressions, and a characterisation test for the f +x single-atom-after-operator shape. examples/prefix-arg.ilo demonstrates the now-allowed pattern with two distinct run:/out: cases that exercise the prefix arithmetic at both ends of a list.

Test plan

  • cargo test --release --features cranelift — full suite green (baseline 3419 + 15 new = 3434)
  • cargo fmt --all -- --check clean
  • cargo clippy --all-targets --features cranelift -- -D warnings clean
  • All four canonical doc-cited shapes now parse correctly across tree, vm, cranelift
  • Real infix (f a + b) still parses as BinOp(Add, Call(f, a), b) — no regression
  • Code-reviewed by subagent; the looks_like_prefix_binary shallow-counting gap (nested prefix-binops) was caught and fixed before commit

Follow-ups

  • Function-as-arg variant (prnt str ncprnt(str(nc)), flr +*n 0.25 etc) — needs the parser to know callee arities at parse time. Different fix shape than this PR. Separate design decision (symbol-table lookup vs required parens).
  • Multi-fn single-line parsing quirk (two function declarations separated by ; can swallow the second's return type into the first's body) — surfaced while writing tests; flagged as a // FOLLOW-UP: line in tests/regression_prefix_arg_depth.rs.

The most-cited ergonomic friction in the assessment doc was call
args bailing too eagerly when a position started with a
prefix-binary expression. `slc ls i +i 1` produced an arity error
because parse_call_or_atom broke the arg loop on seeing `+`
(infix-eligible) without checking that `+` actually looks
prefix-binary. Every program ended up with 5-10 intermediate names
like `ip=+i 1;slc ls i ip` just to satisfy this.

The pre-loop guard at the top of the arg-collection block already
used looks_like_prefix_binary to distinguish prefix vs infix when
the first token after the function name was an operator. The
inner break check inside the loop didn't, so once one arg had
been consumed, any following operator broke out unconditionally.

Mirror the pre-loop guard inside the break: only break when the
operator looks infix (1 atom ahead), not when it looks
prefix-binary (2+ atoms ahead). parse_operand already handles a
leading prefix-binop correctly via its Plus/Star/etc arm calling
parse_prefix_binop, so once the loop doesn't break, the next
iteration consumes the prefix-binop expression as one arg.

Also extended looks_like_prefix_binary to recognise nested
prefix-binops as atoms. Previously it only counted simple
terminals, so a shape like `h +a +b c` (two prefix-binop args
back-to-back) would still bail at the second operator. Refactored
into scan_prefix_binary_end which walks the lookahead recursively;
when an operator-headed sub-expression is itself prefix-binary, it
counts as one atom and the scan advances past it. Recursion is
bounded because each step consumes at least one operator plus one
atom.

Resolves the doc-cited pattern across `slc xs i +i 1`,
`slc xs fi +fi 1`, `*/n 0.25` inside calls, and the broader class
of "intermediate-name tax on every program". The separate
function-as-arg variant (`prnt str nc` -> `prnt(str(nc))`) needs
parser-time arity lookup and is a follow-up.
15 cross-engine regression tests covering the canonical repro
(`slc ls i +i 1`), 3-arg user functions with a prefix-binop in
each position, nested `h +a +b c` shape that exercises the
recursive lookahead in scan_prefix_binary_end, real infix on a
call result still parsing as `(f a) + b`, guard expressions like
`>a 0{a}{- 0 a}` for abs, and a characterisation test pinning the
behaviour of `f +x` (single atom after operator, falls through as
infix).

examples/prefix-arg.ilo demonstrates the now-allowed pattern with
a `window` helper that slices a list using `+lo 2` as the
computed end index. Two run/out cases use distinct `lo` values
(0 and 3) so the prefix arithmetic actually evaluates at both
ends of the list rather than constant-folding.

The infix-on-call test routes around a separate pre-existing
parser quirk where two function declarations on one line can
swallow the second's return type into the first's body, by
writing multi-fn source to a temp .ilo file. Flagged as a
follow-up at the bottom of the test file.
@danieljohnmorris danieljohnmorris merged commit bd13901 into main May 11, 2026
4 checks passed
@danieljohnmorris danieljohnmorris deleted the fix/prefix-arg-depth branch May 11, 2026 17:45
@codecov
Copy link
Copy Markdown

codecov Bot commented May 11, 2026

Codecov Report

❌ Patch coverage is 66.66667% with 4 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/parser/mod.rs 66.66% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant