RFD 0049 — Error-tolerant diagnostics: recovery, source-faithful expansion, and the What/Where/Why/Fix model
- State: discussion
- Depends on: RFD 0037 (declarative macros + carrier-based hygiene — the expansion pipeline this preserves source through), RFD 0025 (check discharge — the user-check diagnostic channel), RFD 0027 (the
#[order]/meta-property decorators whose mis-binding exposed the gap), the editor experience epic (#723 — the LSP surfaces these renderers feed), the teaching-diagnostics arc (#705/#727 — the What/Where/Why/Fix model this completes). - Prior art: orca-mvp’s compiler/LSP — granular parser recovery (
error_here/error_and_bump/err_and_recover+ a vocabulary-agnosticIDENT IDENTsync point,oxc/src/cst/parser.rs), diagnostic attribution maps that never surface synthesized forms (oxc/src/elaborate/diagnostics.rs), a shipped LSP quickfix system (lsp/src/convert.rs,snippet_resolver.rs), and partial-analysis caching that survives parse errors (lsp/src/analysis.rs). rustc/rust-analyzer (rowan ERROR nodes, structuredSuggestions feeding both rendered help and code actions), Roslyn (red-green trees, skipped-token/missing-node recovery), forward source maps (TS→JS, Sass→CSS). The vault: Projectional Editing Taxonomy (Hazel — “a program with holes is never a parse-error/type-error wall”) and decision D-012 (the OE/OW/OI severity-prefix scheme).
Question
How should Argon’s compiler, runtime, and language server behave on broken or incomplete models so that diagnostics genuinely guide the modeler — and what invariant prevents the class of failures where an error is rendered against text the user never wrote?
Context
A real bug surfaced the gap. This input (note the missing ; after Top):
use mlt::*;
pub category Top
#[order(2)]
pub category Kind <: Top
fails with OE0001: unexpected token BANG at module level and renders synthesized post-expansion source — pub category Toporder! { (2) pub category Kind <: Top } — at line numbers that do not exist in the file. The #[order(2)] decorator was re-serialized as a module-level macro-bang. The reported cause (order!/MLT) is wrong; the actual fault is a missing terminator on the preceding declaration, and the rendering shows text no one wrote.
This is not a wording problem (those are addressed by the citation scrub and the source-frame work that already landed). It is three structural defects in the layers beneath the message:
-
Expansion destroys source fidelity.
oxc-workspace/src/expand.rsexpands one invocation per round byformat!-building a synthetic string ("{name}! {{ {args} {body} }}"), replacing the module text, and re-parsing. After any expansion, every span is an offset into synthetic text; a downstream error renders that synthetic text at phantom line numbers. The renderer already distinguishes the pieces it emits (Piece::Sub— a metavariable substitution carrying the user’s argument tokens — vsPiece::Lit/Tok— macro-body tokens), but that origin information is discarded the moment the pieces are joined into a flat string. -
The grammar is ambiguous at declaration boundaries. A bodyless concept declaration (
pub category X <: Y, no{ … }body and no= union) is accepted with an optional terminator (p.eat(SEMI)). With no;, the parser cannot tell the declaration ended before the next item, so a following#[…]attribute is silently mis-attached, and the only diagnostic — generic “unexpected token” — never says what was expected. -
Broken input is a wall. Recovery is coarse (skip-to-item-start); there are no per-construct “expected X” diagnostics, no machine-applicable fixes, and the LSP’s structural features (completion, document-symbol, references — all recently shipped) collapse on a file that does not fully parse rather than degrading over the recoverable region.
The substrate to do this correctly already exists or is proven. orca-mvp shipped the whole error-tolerant spine — granular recovery, attribution maps that assert every diagnostic resolves to real source, a one-diagnostic→many-renderers model with quickfixes, and partial-analysis caching. The current parser is rowan-based (lossless green trees with ERROR nodes are natural). The expansion pipeline expands one contiguous invocation per round, which means a forward provenance map is exactly composable. The vault’s north star is Hazel’s principle that a program with holes is never a wall; for a textual language the achievable, complete form of that is an error-tolerant spine: lossless parse → recovery → faithful provenance → partial analysis → actionable fixes.
Decision
The load-bearing invariant, from which everything else follows:
Every diagnostic span resolves to a location the user actually wrote, and the user is never shown text they did not write. A diagnostic span that cannot resolve to real source is a loud compiler bug — an internal-error diagnostic — never a phantom rendered at the user.
The architecture is five layers. Each is built as a complete vertical slice; none is optional or stubbed.
D1 — Lossless, error-tolerant parsing with granular recovery
The parser produces a lossless rowan green tree for any input, with ERROR nodes wrapping unparseable spans and explicit MISSING markers where a required token is absent. Recovery is per-construct, not module-level: each construct emits a specific expected-token diagnostic (“expected ; to terminate category Top”, “expected } to close this body”) and resynchronizes at the nearest construct boundary, including the vocabulary-agnostic IDENT IDENT concept-declaration sync point (so recovery does not hard-code domain metatype names — a concept can be introduced by any in-scope pub metatype). This recovers orca-mvp’s error_here / error_and_bump / err_and_recover discipline. Parsing always continues past an error; the rest of the file yields a usable tree.
D2 — The terminator rule (the grammar is made unambiguous)
A declaration is terminated by exactly one of: its body’s closing }, the end of its = union, or — when it has neither — a required ;. This is precisely Rust’s item-termination rule (struct Foo; requires ;; struct Foo { … } self-terminates), and it is the project’s stated Rust-aesthetic default. p.eat(SEMI) becomes p.expect(SEMI) with recovery: a missing terminator on a bodyless declaration emits “expected ; to terminate <decl>” with an add-; fix and continues. This removes the ambiguity that silently mis-attaches a trailing attribute — the defect was the optional terminator, and an ambiguous grammar is a defect, not a stylistic choice.
D3 — Source-faithful expansion via a forward, piece-granular provenance map
Expansion preserves source provenance through a forward-built provenance map, not by reverse-engineering offsets and not by rebuilding the pipeline around per-token SyntaxContext. As each expansion round renders, it records, for every output range, its origin:
- a
Subpiece → the invocation argument’s real source range (the user’s text); - a
Lit/Tokpiece → the macro definition-site range (with the macro identity); - a synthesized wrapper (e.g. the
order! { … }an attribute rewrite produces) → the attribute/invocation site.
Because each round replaces a single contiguous source range, per-round maps compose: round N’s map composes through round N−1’s, so any final-text offset resolves transitively to either a real user-source location or “synthesized inside the expansion of M, invoked at S”.
Diagnostic resolution walks this map: an error on copied-through user code lands on the user’s real declaration; an error on macro-body output lands at the invocation site with a secondary “in expansion of M, defined here” label. The synthesized macro-bang text becomes structurally unrenderable to the user. The orca-mvp attribution-map assert is adopted: if resolution finds no real-source anchor, that is an internal-error diagnostic.
Hygiene is unaffected. Argon’s carrier/oracle hygiene (RFD 0037) resolves names at render time; the provenance map resolves spans for diagnostics. They are orthogonal — there is no hygiene case that forces per-token provenance — so the provenance map is added alongside the existing hygiene oracle, which is left intact.
D4 — One structured diagnostic, What/Where/Why/Fix
A single Diagnostic value, structured rather than prose:
- What — the headline (citation-free; guarded).
- Where — a primary span resolved through D3, plus secondary labels (the prior declaration, a macro definition site, the conflicting decl).
- Why — the justification chain: the axiom-trace provenance chain for semantic checks, the grammar expectation for syntax errors.
- Fix —
Vec<Suggestion>, each a span + replacement text, machine-applicable.
Every renderer consumes this one value: terminal (miette), LSP, ox explain, the mdBook appendix, the InfoView inspector. This completes the #727 model.
D5 — Quickfixes and code actions, from the same Suggestions
The Suggestions of D4 are the LSP code actions — one source, two surfaces (the rendered “help:” line and the editor quickfix are never two implementations). This recovers orca-mvp’s shipped set (add missing pub, add else, disambiguate glob) plus the cases this RFD creates (add ;, add a use for an unimported vocabulary classifier — the OE0605 cliff). A “did you mean” producer (bounded edit-distance over in-scope names) emits Suggestions for unresolved paths and metatypes.
D6 — LSP graceful degradation over the recoverable region
A salsa-cached partial analysis — symbol table, reference index, resolved names over the non-ERROR parts of the CST — that survives parse errors (orca-mvp’s persisted AnalysisResult). Hover, completion, document-symbol, semantic tokens, and references all operate over the parts that parsed, even when other parts are broken. A missing ; never blanks the whole server; it produces a precise diagnostic and the rest of the file stays live.
D7 — The invariant is enforced, not aspirational
Two guards keep the spine from eroding: (a) a test asserting that a diagnostic produced over expanded input resolves to a real source span (never synthesized text) — the D3 attribution-map assert, exercised on the motivating bug; (b) the LSP degradation is covered by tests that feed a deliberately broken file and assert hover/completion/symbols still answer over the recoverable region.
Rationale
The defects are architectural, so the fixes are too. D1+D2 make a syntax error a precise, recoverable event instead of a silent mis-parse. D3 is the keystone: the worst confusion (“what is order! { } and why is that line number wrong?”) is a fidelity violation, and once provenance is preserved that entire class is gone. D4+D5 make every diagnostic teach (Why) and act (Fix) from one structured source. D6 makes the editor useful precisely when the model is broken — which is most of the time during authoring.
The piece-granular forward map (D3) is chosen over a per-token SyntaxContext/ExpnId rebuild because it is the correct fit for Argon’s actual architecture, not because it is simpler. rustc unifies provenance and hygiene in SyntaxContext because its hygiene is per-identifier scope-sets; Argon’s hygiene is carrier/oracle-based and works at the name level (RFD 0037), so a per-token model would rewrite a working subsystem to buy provenance it can get exactly from the map. The current renderer already computes piece origin (Sub vs Lit/Tok); the map records what is already known. Forward source maps are the standard, exact technique for “spans must survive a text-rewriting transform” (TS→JS, Sass→CSS) — and being built forward, with full structural knowledge of what each expansion replaced, they are exact, not the fragile reverse-offset-guessing they are sometimes confused with.
Alternatives
- Per-token
SyntaxContext/ExpnId, tree-to-tree expansion (the rustc model). Strictly more powerful (per-identifier hygiene + provenance unified) but requires rebuilding the expansion pipeline, lexer token, and parser integration, and supplants Argon’s working carrier hygiene. Rejected: it buys no correctness over D3 for Argon’s hygiene model, at large cost. If a future hygiene requirement ever needs per-identifier scope-sets (macro-defines-macro with capture across expansions the carrier cannot express), D3’s map is forward-compatible — the ExpnId is the same content-derivedexpansion_idalready minted. - Reverse offset remapping (recover origin by diffing expanded vs original text post hoc). Rejected: lossy and fragile under nesting and substitution; cannot distinguish user-argument tokens from macro-body tokens. D3 records origin forward at render time instead.
- Layout / automatic-semicolon-insertion instead of D2’s required
;. Rejected: ASI has well-known footguns (Go, JavaScript) and trades one ambiguity for a subtler one; the required terminator is unambiguous and matches the Rust default. - Leave expansion text-based, fix only the message wording. Rejected: it cannot satisfy the invariant — any error over expanded input still renders synthetic text.
Consequences
- Macro/attribute expansion gains a provenance map threaded through the per-round driver; the per-round text pipeline (a correct nesting design) is retained, hygiene is untouched.
- The
Diagnostictype is restructured once to carry structured What/Where/Why/Fix; all existing renderers (frames, axiom-trace,ox explain, the citation-clean catalog) plug into it. Recently-shipped LSP features (completion, symbols, references) gain graceful degradation. ;becomes required after a bodyless declaration. Existing corpora with bodyless declarations missing terminators will get a precise, auto-fixable diagnostic; a one-timeox fmt/quickfix sweep adds the terminators. This is a surface change and is recorded as such.- Two regression guards (D7) make the source-fidelity invariant and the LSP degradation permanent.
Open questions
None blocking. The expansion_id already minted for hygiene is reused as the provenance map’s expansion identity, so no new identity scheme is needed. The build order is dependency-driven (D1/D2 → D3 → D4 → D5 → D6); each lands as a complete slice.