Failing assertions report the caller's file:line:column (#65) - #152
Merged
Conversation
This was referenced Aug 21, 2026
A failing `core.test` assertion now reports in the shape of a compiler error — the failing call's own `file:line:column`, the message, the source line, and a caret run under the call — and the location is the USER's call site, not an internal hop: `assertEq` fails several calls deep inside `core.test` and still points at the line where the program called `assertEq`, including inside a helper rather than `^`. Colored when stderr is a terminal, plain when redirected or under NO_COLOR/TERM=dumb. The mechanism is a built-in `Site` record (file/line/column/excerpt/width) usable in any signature with no import: a top-level function whose LAST parameter is a `Site` receives the location of each call that leaves that argument off, and passing one explicitly forwards it — which is the whole propagation rule, and what makes a chain of wrappers blame the outermost caller. It is compile-time only: the fields are constants, so there is no unwinder, no debug info to keep, and JIT and native builds report identically. A `Site` parameter nothing could fill in (before another parameter, or on a lambda, a nested declaration, or a record method) is a compile error, reported at that parameter. Supporting surface, all documented: `core.test`'s `failAt(message)`, `Text.repeat`, `core.io`'s `colorEnabled(fd)`, and the `\e` (ESC) string escape — without which `.ql` code could not write an ANSI sequence at all. Internally the front end now carries a `SourceMap` (each file's path and text, keyed by the `FileId` its spans already had) through to codegen, with a per-file line index so resolving a position is a binary search rather than a scan from the top of the file. Two things the map fixes on the way past: a compile error inside an imported module is now reported against that module (it used to be rendered against the root file's text — wrong file, wrong line, wrong excerpt), and a failing assertion inside an imported module names that module too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two maintainer decisions on the call-site facility. A `Site` is READ-ONLY: a location is a value, not a variable, so writing one of its fields is a compile error however the value was reached (records are handles that alias, so a write through a `:=` rebinding is a write to the same thing). That is what makes the second decision sound — each call site is now emitted as a read-only constant whose address the call passes, instead of a GC-allocated record per call. A passing assertion costs its comparison and a pointer argument: 1M asserted iterations went 18ms -> 3ms, which is the difference between "assert freely" and "assert sparingly in hot code". One global per distinct call site (keyed by the whole span, since the caret width is the span's length), the path/excerpt byte constants interned by content, natural alignment set by hand so a site is not padded to LLVM's preferred 16 bytes, and the struct layout taken from the shared record definition the field reads GEP through so construction cannot skew against them. The terminal check behind colored reports becomes an INTERNAL primitive: raw file descriptors gain no user-facing API, since the language's IO direction is `@` leaf primitives rather than `fd`-taking functions, and a user-facing color story waits for that design. `colorEnabled` is gone from `core.io`'s exports and the docs; `__color_enabled` takes its place beside `__exit` — exported by no module, called only by `core.test`. Behavior is unchanged: colored on a terminal, plain when redirected or under NO_COLOR/TERM=dumb. The `\e` escape stays public — it is a string literal escape, not an fd API. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
assapir
force-pushed
the
feat/assert-call-site-65
branch
from
August 21, 2026 09:01
ef10b98 to
84d065f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #65.
A failing
core.testassertion now says where it failed, in the shape of a compiler error — and the location is the user's call site, not an internal hop insidecore.test:Colored when stderr is a terminal, plain when redirected or under
NO_COLOR/TERM=dumb. Identical underquilon run(JIT) and a nativequilon build— no debug info, no unwinder.The mechanism (designed with the maintainer first)
The issue's body carried an earlier locked design (a runtime backtrace via the
backtracecrate plus a__panicprimitive, explicitly not track-caller). That was raised with the maintainer before any code was written and superseded: a backtrace degrades exactly where Quilon runs most (the JIT has no on-disk DWARF; TCO and inlining eat frames), while a compile-time location is exact by construction. The issue body has been updated so the next reader doesn't hit the contradiction.What was decided, and is what this implements:
Site—file,line,column,excerpt,width— nameable in any signature with no import.Sitereceives the location of the call that left that argument off. The caller's visible arity never counts it.#[track_caller], expressed as an ordinary argument).assertEq→failAttherefore blames yourassertEqcall, including when it sits inside a helper rather than^.Site: before another parameter, or on a lambda, a nested declaration, or a record method — all compile errors, rather than a parameter nothing supplies..qlinsidecore.test.Supporting surface (all documented)
core.test'sfailAt(message)— the reporting primitive the assertions are built from, and what a custom assertion forwards its ownsiteto.Text.repeat(count)— needed for the caret run; fail-loud on a negative or fractional count (compile error when literal, runtime abort otherwise), likereplace's contract.core.io'scolorEnabled(fd)— tty +NO_COLOR+TERMcheck, lowered to a new__color_enabledintrinsic.\estring escape (ESC) — without it.qlcode cannot write an ANSI sequence at all.SourceMap(every file's path and text, keyed by theFileIdits spans already carried) threaded front-end → codegen, and compiler diagnostics now resolve a span through the samelocate_inaSitedoes, so both agree on position and caret width. A side benefit: a failure inside an imported module reports that module's own path and line.Tests, examples, docs
tests/call_site_test.rs(new, 14 cases): the location is the call's own; column/width frame the call; forwarding propagates through a chain while a non-forwarding hop reports itself; overload members each taking aSite; the four rejection cases;Siteis a reserved built-in name; codegen survives having no source map.tests/assert_test.rs: the full report pinned line-for-line, the wrapper case (nocore.testhop in the output), caret width,failAtfrom user code, no ANSI when redirected — plus the location contract added to the existing native-AOT gate.tests/text_methods_test.rs:repeat(basic, zero, grapheme-safe, chaining, literal compile errors, runtime aborts).tests/lexer_tests.rs:\e.tests/io_codegen_test.rs:colorEnabledlowering.src/source_map.rs: unit tests for span resolution.examples/call_site.ql(passing, self-asserting — demonstrates the general facility) andexamples/assert_location.ql, which fails on purpose to show the report.tests/examples_test.rsgained anEXPECT_RUNTIME_FAILUREregistry that runs such an example as a subprocess and checks both its exit code and that its own header documents the report it prints.docs/LANGUAGE.md(a new "Call-site locations —Site" section, the rewritten assertions section,Text.repeat,colorEnabled, the escape list, feature-matrix rows),corelib/test.ql's header, andCHANGELOG.md.Two bugs the
/simplifyreview caught in this change, both fixed with tests:Sitelost its tail-call lowering — the self-call is one argument short of the parameter slots, so it was emitted as a real call and overflowed the stack at 500k. The language has no loop construct, so that silently broke iteration for anything adopting the facility. The filling rule now has one home (ast::fills_call_site) that call lowering and the tail-call detector both consume.Siteparameter on a declaration nested in a method or lambda body reached codegen and failed module verification instead of being rejected. The nesting is now an argument threaded throughcheck_itemrather than ambient checker state, so a body-descending path cannot forget it.And two things worth calling out from the same pass:
benches/compile_speed.rsgained acall_sitescorpus (and now installs the source map, without which it could not have measured any of this).tests/diagnostics_test.rscovers it.Full gate green:
cargo fmt --check,cargo clippy --all-targets --all-features -D warnings, andcargo testunderRUSTFLAGS=-D warnings(34 test binaries), plus the native-AOT gates under both linkers.Not in this PR — and one thing for you to decide
replacecontract) do not carry locations yet. Codegen can hand those helpers a site the same way — it already knows eacharr[i]span — but it is a separable slice, filed as a follow-up rather than widening this change.Sitewith one GC allocation (~15 ns and 48 bytes per executed assertion, measured; a 1M-iteration loop with an assert costs 18 ms vs 2.9 ms without). Every field is a constant, so a read-only global would make it free — but records are pointers and alias, sos := sitefollowed bys.line := 1would write into.rodata. That is only sound if aSiteis immutable by rule ("aSiteis a compile-time constant; it cannot be mutated"), which is a language decision and yours to make. Say the word and it is a small follow-up: reject writes through aSite, then emit the record as a constant global.colorEnabledjoinswriteandnowas a name codegen intercepts unconditionally, so a user function of that name is shadowed rather than preferred (print/eprintdo probe for a user overload). Worth one issue covering all three.imported_itemsboundary, so imported modules get no line info. Unifying it on theSourceMap(aDIFileperFileId) is its own change.🤖 Generated with Claude Code