Skip to content

fix: suppress top-level auto-print when entry body ends in a loop#231

Merged
danieljohnmorris merged 3 commits into
mainfrom
fix/loop-no-double-print
May 13, 2026
Merged

fix: suppress top-level auto-print when entry body ends in a loop#231
danieljohnmorris merged 3 commits into
mainfrom
fix/loop-no-double-print

Conversation

@danieljohnmorris
Copy link
Copy Markdown
Collaborator

Summary

Print-loops like @x xs{prnt x} at the function tail used to double-print at the top level: each item from the loop body via prnt, then the loop's last-body-value bubbled up as the program result and got auto-printed again. The well-known persona workaround was a trailing +0 0 sentinel after every print-loop, called out as a "consistent irritant across all 5 programs" in the assessment doc (line 834) and as one of the top friction items at line 854.

Print-layer-only fix, scoped to src/main.rs. When the program's syntactic entry-function body ends with @/wh AND has no early-return path, the plain-text top-level auto-print is suppressed. Loop-as-expression semantics inside functions are completely unchanged - a caller that consumes such a function's return value still sees the loop's last body value. JSON mode is untouched so machine-readable consumers always get a structured result.

The check is syntactic, not value-based: a function that explicitly returns nil from a non-loop tail (e.g. nothing>O n;nil) still prints "nil" because the user asked for it.

Repro

Before:

$ cat /tmp/p.ilo
print-each>n;xs=[1 2 3];@x xs{prnt x}
$ ilo /tmp/p.ilo print-each
1
2
3
3       <-- the loop's last-body-value, auto-printed again

After:

$ ilo /tmp/p.ilo print-each
1
2
3

Cross-engine: tree, VM, and Cranelift all agree.

What's in the diff

Three commits, each one logical step:

  1. suppress top-level auto-print when entry body ends in a loop - the helper (program_result_should_suppress) walks the entry function's AST tail and decides whether to suppress; print_value gains a suppress_loop_tail flag, plain mode honours it, JSON mode ignores it. All four engine runners (run_default, run_cranelift_engine, run_vm_with_provider, run_interp_with_provider) compute the flag once at dispatch and thread it to print_value. A separate body_has_early_return walker is the conservative safety valve: if the body contains any ret or braceless guard anywhere (including inside nested guards/match/loops), suppression is off, because we can't tell at print time whether the program's value came from the loop tail or from an explicit return.

  2. update bare-loop-tail tests and example to use sentinel form - three pre-existing tests asserted the previous "function ends with a bare loop, auto-print the loop value" shape. They're updated to bind the loop value and end with a trailing expression, the same pattern examples/loops.ilo uses for every existing loop example. Inline comments explain the convention so future readers don't think the rewrite was cosmetic.

  3. cross-engine regression test and example for print-loop suppression - tests/regression_loop_print.rs runs nine scenarios across tree/VM/Cranelift: top-level print-loop, nested print-loop (must still return the value), empty-loop, brk-loop, while-loop, trailing-sentinel non-regression, early-return + loop tail (conservative rule), explicit-nil return (non-regression), and JSON mode (non-regression). examples/print-loop.ilo documents the same scenarios with -- run:/-- out: annotations so tests/examples_engines.rs exercises them as higher-level engine-parity checks.

Test plan

  • cargo fmt --all -- --check clean
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo test --release --features cranelift - full suite passes
  • New tests/regression_loop_print.rs passes on all three engines
  • tests/examples_engines.rs picks up the new examples/print-loop.ilo and runs it on tree + VM
  • Manual repro confirmed: 1\n2\n3\n instead of 1\n2\n3\n3\n across all three engines
  • JSON mode unchanged: --json still emits {"ok":3} for the print-loop case and {"ok":null} for empty-loop
  • Nested case verified: outer>n;v=inner();+v 0 where inner ends in a print-loop still returns 3 to outer

Follow-ups

None blocking. If we ever want to revisit the early-return-present-plus-loop-tail edge case, the right place is runtime-flagging "did this value come from the loop tail?" inside the interpreter/VM/Cranelift - significant cross-backend plumbing, deferred unless a persona report surfaces the pain.

Print-loops like `@x xs{prnt x}` at the function tail were double-printing
at the top level: each item from the loop body via `prnt`, then the loop's
last-body-value bubbled up as the program result and got auto-printed
again. The well-known workaround was a trailing `+0 0` sentinel after
every print-loop, which every persona had to learn.

This is a print-layer-only fix in main.rs. When the program's syntactic
entry-function body ends with `@` or `wh` AND has no early-return path
(no `ret`, no braceless guard, no nested `ret` in guards/match/loops),
the plain-text top-level auto-print is suppressed.

The check is syntactic, not value-based: a function that explicitly
returns `nil` from a non-loop tail still prints "nil", because the user
asked for it. Loop-as-expression semantics inside functions are unchanged,
so a caller that consumes such a function's return value still sees the
loop's last body value. JSON mode is untouched so machine-readable
consumers always get a structured result line.

The early-return guard is conservative: if the body contains any `ret` or
braceless guard, we can't tell at print time whether the program's value
came from the loop tail or from an explicit return, so we err on the side
of printing.
Three pre-existing tests asserted that a function whose body ends with a
bare loop auto-prints the loop's last-body-value at top level. That shape
is now suppressed (it's the same shape that caused the print-loop double-
output). The tests still want to assert the loop value reaches stdout,
just via the trailing-expression pattern the rest of the codebase already
uses (see examples/loops.ilo: `range-sum`, `range-list`, `brk-at-3`,
`cnt-evens` all bind the loop value and end with a trailing expression).

- examples/lists.ilo:sq-last binds the loop value into `r` and ends with
  `+r 0` so the function tail is an expression, not a loop.
- tests/eval_inline.rs:range_basic and range_with_arg get the same
  treatment with inline comments explaining the convention.
Pins the new behaviour across tree, VM, and Cranelift:

- top-level print-loop emits only the loop body's prints (no trailing dupe)
- nested print-loop: caller still observes the loop's last body value as
  the callee's return, so loop-as-expression semantics inside functions
  are unchanged
- empty-list loop at top level produces no output (previously leaked `nil`)
- brk-loop and while-loop tails are suppressed
- trailing-expression sentinel still auto-prints (non-regression)
- early-return present + loop tail: value still prints (conservative rule)
- explicit `nil` return from a non-loop tail still prints (suppression is
  syntactic loop-tail, never blanket Nil)
- `--json` mode is unaffected: always emits a structured result

`examples/print-loop.ilo` documents the same scenarios with `-- run:`/
`-- out:` annotations so tests/examples_engines.rs exercises them as
higher-level engine-parity checks.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 13, 2026

Codecov Report

❌ Patch coverage is 94.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/main.rs 94.66% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

@danieljohnmorris danieljohnmorris merged commit a82b3dc into main May 13, 2026
5 checks passed
@danieljohnmorris danieljohnmorris deleted the fix/loop-no-double-print branch May 13, 2026 12:46
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