Skip to content

Failing assertions report the caller's file:line:column (#65) - #152

Merged
assapir merged 2 commits into
mainfrom
feat/assert-call-site-65
Aug 21, 2026
Merged

Failing assertions report the caller's file:line:column (#65)#152
assapir merged 2 commits into
mainfrom
feat/assert-call-site-65

Conversation

@assapir

@assapir assapir commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Closes #65.

A failing core.test assertion 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 inside core.test:

examples/assert_location.ql:23:3: assertion failed: expected 42, got 41
   |
23 |   assertEq(answer(), 42)
   |   ^^^^^^^^^^^^^^^^^^^^^^

Colored when stderr is a terminal, plain when redirected or under NO_COLOR / TERM=dumb. Identical under quilon run (JIT) and a native quilon 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 backtrace crate plus a __panic primitive, 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:

  • A built-in record type Sitefile, line, column, excerpt, width — nameable in any signature with no import.
  • A function whose LAST parameter is a Site receives the location of the call that left that argument off. The caller's visible arity never counts it.
  • Passing one explicitly forwards it. That is the whole propagation rule, and it is what makes a chain of wrappers report the outermost caller (#[track_caller], expressed as an ordinary argument). assertEqfailAt therefore blames your assertEq call, including when it sits inside a helper rather than ^.
  • General, not corelib magic: no stdlib names are hardcoded in the compiler, and user code can build its own located assertions.
  • Fail loud on an unfillable Site: before another parameter, or on a lambda, a nested declaration, or a record method — all compile errors, rather than a parameter nothing supplies.
  • Output shape: the compiler-diagnostic form (position, message, gutter, source line, caret run), rendered in pure .ql inside core.test.

Supporting surface (all documented)

  • core.test's failAt(message) — the reporting primitive the assertions are built from, and what a custom assertion forwards its own site to.
  • Text.repeat(count) — needed for the caret run; fail-loud on a negative or fractional count (compile error when literal, runtime abort otherwise), like replace's contract.
  • core.io's colorEnabled(fd) — tty + NO_COLOR + TERM check, lowered to a new __color_enabled intrinsic.
  • The \e string escape (ESC) — without it .ql code cannot write an ANSI sequence at all.
  • Internally: a SourceMap (every file's path and text, keyed by the FileId its spans already carried) threaded front-end → codegen, and compiler diagnostics now resolve a span through the same locate_in a Site does, 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 a Site; the four rejection cases; Site is 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 (no core.test hop in the output), caret width, failAt from 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: colorEnabled lowering. src/source_map.rs: unit tests for span resolution.
  • Examples: examples/call_site.ql (passing, self-asserting — demonstrates the general facility) and examples/assert_location.ql, which fails on purpose to show the report. tests/examples_test.rs gained an EXPECT_RUNTIME_FAILURE registry 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: 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, and CHANGELOG.md.

Two bugs the /simplify review caught in this change, both fixed with tests:

  • A recursive function taking a Site lost 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.
  • A Site parameter 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 through check_item rather than ambient checker state, so a body-descending path cannot forget it.

And two things worth calling out from the same pass:

  • Resolving a position walked the file from byte 0 for every call site — quadratic in file size (2000 assertions after a long prologue: 8.8 s). Now a per-file line index plus binary search: the same file compiles in 0.11 s. benches/compile_speed.rs gained a call_sites corpus (and now installs the source map, without which it could not have measured any of this).
  • A compile error inside an imported module used to be rendered against the ROOT file's text — wrong file, wrong line, wrong excerpt. The map fixes it; tests/diagnostics_test.rs covers it.

Full gate green: cargo fmt --check, cargo clippy --all-targets --all-features -D warnings, and cargo test under RUSTFLAGS=-D warnings (34 test binaries), plus the native-AOT gates under both linkers.

Not in this PR — and one thing for you to decide

  • Runtime fail-loud checks (index out of bounds, the replace contract) do not carry locations yet. Codegen can hand those helpers a site the same way — it already knows each arr[i] span — but it is a separable slice, filed as a follow-up rather than widening this change.
  • A decision, not an omission: each call builds its Site with 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, so s := site followed by s.line := 1 would write into .rodata. That is only sound if a Site is immutable by rule ("a Site is 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 a Site, then emit the record as a constant global.
  • Pre-existing, noted: colorEnabled joins write and now as a name codegen intercepts unconditionally, so a user function of that name is shadowed rather than preferred (print/eprint do probe for a user overload). Worth one issue covering all three.
  • Also pre-existing: DWARF still keeps its own root-source string, line index, and imported_items boundary, so imported modules get no line info. Unifying it on the SourceMap (a DIFile per FileId) is its own change.

🤖 Generated with Claude Code

assapir and others added 2 commits August 21, 2026 11:54
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
assapir force-pushed the feat/assert-call-site-65 branch from ef10b98 to 84d065f Compare August 21, 2026 09:01
@assapir
assapir merged commit c787666 into main Aug 21, 2026
2 checks passed
@assapir
assapir deleted the feat/assert-call-site-65 branch August 21, 2026 09:13
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.

assert failures should include the call-site file:line:column (track-caller)

1 participant