Skip to main content

praxis_source/
diagnostic.rs

1//! Diagnostics: structured problems reported against source spans.
2//!
3//! A [`Diagnostic`] always carries a [`Severity`], a structured [`DiagnosticCode`],
4//! a message, and a primary [`FileSpan`]. There is no such thing as a diagnostic
5//! without a code or a primary location: those fields are non-optional, so the
6//! thing you most want to know about an error (where, what kind, what message)
7//! can never be missing.
8//!
9//! The [`Renderer`] produces the §8.2/§8.3 layout:
10//!
11//! ```text
12//! error[T012]: expected Int, found Text
13//!
14//!   day03.px:18:14
15//!   18 | total += line
16//!      |          ^^^^ this value is Text
17//!
18//! hint: parse it with the input parser or call line.int()
19//! ```
20
21use std::fmt::Write;
22
23use crate::file::SourceMap;
24use crate::span::{BytePos, FileSpan};
25use crate::style;
26
27/// How serious a diagnostic is. Non-exhaustive so future severities (e.g. an
28/// "advice" level for inlay context) don't break match exhaustiveness downstream.
29#[derive(Clone, Copy, PartialEq, Eq, Debug)]
30#[non_exhaustive]
31pub enum Severity {
32    Error,
33    Warning,
34    Note,
35    Hint,
36}
37
38impl Severity {
39    /// The lowercase label used in the rendered header (`error`, `warning`...).
40    pub fn label(self) -> &'static str {
41        match self {
42            Severity::Error => "error",
43            Severity::Warning => "warning",
44            Severity::Note => "note",
45            Severity::Hint => "hint",
46        }
47    }
48}
49
50/// The broad category a diagnostic belongs to. The category + a per-category
51/// number together form the user-facing code (`T012`, `P003`, ...). Categories
52/// are closed and compiler-owned, matching the design's "closed tables"
53/// philosophy (§4.8).
54#[derive(Clone, Copy, PartialEq, Eq, Debug)]
55pub enum DiagnosticCategory {
56    /// Lexical errors (`T0xx`). `T` for Token.
57    Lex,
58    /// Syntax / parse errors (`P0xx`).
59    Parse,
60    /// Name-resolution errors (`N0xx`).
61    Name,
62    /// Type-inference errors (`Y0xx`). `Y` for tYpe.
63    Type,
64    /// Input-parser errors (`I0xx`).
65    Input,
66    /// Runtime faults surfaced as compile-time-relevant diagnostics (`R0xx`).
67    Runtime,
68}
69
70impl DiagnosticCategory {
71    /// The single-letter prefix used in the rendered code.
72    pub fn prefix(self) -> char {
73        match self {
74            DiagnosticCategory::Lex => 'T',
75            DiagnosticCategory::Parse => 'P',
76            DiagnosticCategory::Name => 'N',
77            DiagnosticCategory::Type => 'Y',
78            DiagnosticCategory::Input => 'I',
79            DiagnosticCategory::Runtime => 'R',
80        }
81    }
82}
83
84/// A structured diagnostic code: a category plus a per-category number.
85///
86/// Because the category is a closed enum and the number is a `u32`, arbitrary
87/// free-text codes are unrepresentable. The `Display` impl renders the §8.2
88/// `T012`-style form (prefix + zero-padded three-digit number; numbers ≥ 1000
89/// are not zero-padded so they stay readable).
90#[derive(Clone, Copy, PartialEq, Eq, Debug)]
91pub struct DiagnosticCode {
92    category: DiagnosticCategory,
93    number: u32,
94}
95
96impl DiagnosticCode {
97    /// Create a code. The number is per-category: `Lex`/1 and `Parse`/1 are two
98    /// distinct codes and both are valid.
99    ///
100    /// `pub(crate)` on purpose: the only way to reach a code from outside is
101    /// [`DiagCode::code`], so a number nobody registered in [`DiagCode`] has no
102    /// way into a diagnostic.
103    #[inline]
104    pub(crate) const fn new(category: DiagnosticCategory, number: u32) -> DiagnosticCode {
105        DiagnosticCode { category, number }
106    }
107
108    #[inline]
109    pub const fn category(self) -> DiagnosticCategory {
110        self.category
111    }
112
113    #[inline]
114    pub const fn number(self) -> u32 {
115        self.number
116    }
117}
118
119impl std::fmt::Display for DiagnosticCode {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        let n = self.number;
122        if n < 1000 {
123            write!(f, "{}{:03}", self.category.prefix(), n)
124        } else {
125            write!(f, "{}{}", self.category.prefix(), n)
126        }
127    }
128}
129
130/// The closed set of diagnostics the compiler can emit.
131///
132/// Every `(category, number)` pair is written in exactly one place —
133/// [`DiagCode::code`]'s exhaustive match — so allocating a code is a
134/// compile-time act with a name rather than an integer literal at a call site.
135/// [`DiagnosticCode::new`] is `pub(crate)` for the same reason: an unregistered
136/// number has no route into a [`Diagnostic`].
137///
138/// **The allocation is ADR-051.** Adding a variant means amending it first;
139/// `every_code_is_distinct` is what catches a collision if you do not.
140///
141/// The numbers are not contiguous and are not meant to be: `Y09x` is internal
142/// errors, `Y11x` member errors, `Y12x` match errors. Renumbering them would
143/// change identifiers users have already seen.
144#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
145pub enum DiagCode {
146    // --- Lex (`T0xx`) ---
147    /// `T001` — a `/*` with no matching `*/`.
148    UnterminatedBlockComment,
149    /// `T002` — a backtick template with no closing backtick.
150    UnterminatedTemplate,
151    /// `T003` — a character the lexer cannot classify.
152    UnexpectedCharacter,
153    /// `T004` — a text literal with no closing quote.
154    UnterminatedTextLiteral,
155    /// `T005` — a `\` escape the lexer does not recognize. Shared by both
156    /// literal spellings, with one message each: the escape tables of `"…"` and
157    /// `'…'` are the same table (ADR-141), so a `\x` is the same mistake in
158    /// either.
159    InvalidEscape,
160    /// `T006` — a character literal with no closing quote.
161    UnterminatedCharLiteral,
162    /// `T007` — a character literal that does not name exactly one character.
163    ///
164    /// Two messages under one code, because `''` and `'ab'` are one rule broken
165    /// in two directions. This is the code that closes `"##"[0]`'s silent
166    /// truncation at the front end (ADR-141 Decision 2).
167    CharLiteralIsNotOneCharacter,
168
169    // --- Parse (`P0xx`) ---
170    /// `P001` — a token that cannot appear here.
171    UnexpectedToken,
172    /// `P002` — two statements with no `;` and no line break between them.
173    ExpectedStatementSeparator,
174
175    // --- Name (`N0xx`) ---
176    /// `N000` — internal: the parse tree's root is not a `SOURCE_FILE`.
177    InternalNotASourceFile,
178    /// `N001` — a name that is not in scope.
179    UnknownName,
180    /// `N002` — a type annotation naming a type that does not exist.
181    UnknownType,
182    /// `N003` — a name used in type position that names a value.
183    NameIsNotAType,
184    /// `N004` — one name declared twice in one scope.
185    DuplicateDeclaration,
186    /// `N005` — a function declared inside a function.
187    NestedFunction,
188    /// `N006` — a `struct`/`enum` declaration that refers to itself, directly or
189    /// through a cycle (ADR-063).
190    ///
191    /// A declaration mistake, so it is in this category next to `N004`/`N005`
192    /// rather than in `Y0xx`: the mistake is what was *declared*, and there is no
193    /// pair of types to have failed to unify.
194    RecursiveTypeDeclaration,
195    /// `N007` — a `fn` body naming a binding declared outside it (ADR-068).
196    ///
197    /// A declaration mistake in the same sense `N005` is: the name resolves, and
198    /// what is wrong is *where* it was declared relative to what reads it. A `fn`
199    /// does not capture (§4.9/§4.10 — closures do, functions do not), so the
200    /// binding has no storage the body can reach.
201    ///
202    /// It has two message forms. The usual one names both ways out, a parameter
203    /// or a closure. When the `fn` is recursive — directly or mutually — it names
204    /// only the parameter and carries an advisory `help:` line saying why: a
205    /// closure cannot name itself, which is `N001`. One code either way, because
206    /// it is the same mistake with one fewer way out.
207    FunctionReadsOuterBinding,
208    /// `N008` — a record literal whose head does not name a `struct`.
209    ///
210    /// A declaration mistake in `N003`'s sense: a record literal's head is a type
211    /// position, and the name reaches the wrong sort of declaration. Reported in
212    /// inference and not at lowering, so `praxis check` rejects a literal on a
213    /// non-`struct` head rather than letting it produce a value with no
214    /// representation.
215    NotARecordLiteralHead,
216    /// `N009` — a **retired keyword** written where a statement starts.
217    ///
218    /// `let` is the only one so far: it was the binding keyword before ADR-125
219    /// chose `var`, so it is the first thing a reader of an old example meets.
220    ///
221    /// Not `N001`: it is not a name that happens to be missing, and treating it
222    /// as one gives the wrong help. The suggestion budget is `max(1, len/3)`,
223    /// `let` is three characters, so the budget is 1 — and the nearest name in
224    /// scope one edit away is `Set`. The rule is right in general (it is
225    /// rustc's); the outcome for a retired keyword is not, because the answer is
226    /// known exactly and is not a spelling correction.
227    ///
228    /// `let` stays a legal **identifier** (`var let = 5` compiles), which is why
229    /// this is raised where a statement starts rather than in the lexer.
230    RetiredKeyword,
231
232    // --- Type (`Y0xx`), the user block ---
233    /// `Y001` — two types that could not be unified.
234    TypeMismatch,
235    /// `Y002` — an occurs-check failure.
236    InfiniteType,
237    /// `Y003` — an annotation that conflicts with what inference derived.
238    AnnotationConflict,
239    /// `Y004` — a type whose values cannot be compared with `==`.
240    NotEquatable,
241    /// `Y005` — a type that cannot be iterated.
242    NotIterable,
243    /// `Y006` — a type that has no ordering.
244    NotOrderable,
245    /// `Y007` — a type constructor given the wrong number of type arguments.
246    /// `Option[Int, Text]` is the same mistake.
247    WrongTypeArgumentCount,
248    /// `Y008` — a `struct`/`enum` declaring one field or variant twice.
249    DuplicateMember,
250    // `Y009` is **retired** (ADR-125). It reported an assignment to something
251    // that was not a `var`, and the language no longer has a binding that
252    // cannot be written. The number stays spent: a code is a permanent
253    // user-facing identifier, and re-issuing one is how an old message and a new
254    // one come to share a name.
255    /// `Y010` — a compound assignment whose operands are not numeric.
256    CompoundAssignNonNumeric,
257    /// `Y011` — `return` outside a function.
258    ReturnOutsideFunction,
259    /// `Y012` — `break`/`continue` outside a loop.
260    BreakOutsideLoop,
261    /// `Y013` — an integer literal outside the representable range.
262    IntLiteralOutOfRange,
263    /// `Y014` — a `Map`/`Set` key type that cannot be hashed.
264    NotHashable,
265    /// `Y015` — a non-numeric type where a numeric one is required.
266    NotNumeric,
267    /// `Y016` — an operator not defined for these operand types.
268    OperatorNotDefined,
269    /// `Y017` — a `break` carrying a value out of a `while`/`for`.
270    ValueBreakOutsideLoopExpression,
271    /// `Y018` — a **generic** `fn` used as a value (ADR-061).
272    ///
273    /// A monomorphic one is a closure over its adapter; a generic one has no
274    /// instantiation to adapt, because monomorphization is driven by call sites
275    /// and a value has none. `|x| id(x)` is the spelling that works — the
276    /// closure's body *is* a call site.
277    GenericFunctionAsValue,
278    /// `Y019` — a `.0` element access on something that has no such element: a
279    /// receiver that is not a tuple, or an index past its arity.
280    ///
281    /// Not `Y112` ("no field on this type"): a tuple has no field *names*, so a
282    /// message about a missing field would name the wrong thing. Both are
283    /// emitted in inference and both reach `praxis check` (ADR-093); the reason
284    /// for the separate code is the *message*.
285    NoTupleElement,
286    /// `Y020` — a subscript on a type that has none, in either direction: `s[0]`
287    /// on a `Set`, `t[0] = c` on a `Text` (which can be read through a subscript
288    /// and is immutable, so it has no element store), or `grid[x]` — the wrong
289    /// *arity* for a receiver that does index, since `grid[x, y]` is the
290    /// spelling §6.4 gives.
291    ///
292    /// Not `Y110` ("no such method"): a subscript names no method, so a message
293    /// about one would name something the program did not write. Both are
294    /// emitted in inference and both reach `praxis check` (ADR-093); the reason
295    /// for the separate code is the *message*.
296    NotIndexable,
297    /// `Y021` — an assignment whose left side is not a place at all: `f() = 1`,
298    /// `a + b[0] = 1`. A **field** is a place and is not among them: `p.x = 5`
299    /// stores (§4.5).
300    NotAnAssignmentTarget,
301    /// `Y022` — a prelude builtin or an enum constructor named without being
302    /// called.
303    ///
304    /// [`GenericFunctionAsValue`](DiagCode::GenericFunctionAsValue)'s neighbour,
305    /// one symbol kind over. A user `fn` in value position becomes a closure
306    /// over its adapter (ADR-061); a builtin and a constructor have no adapter to
307    /// close over, so there is nothing for the name to lower to — without this
308    /// code `var h = abs` then `out(h(-3))` prints nothing and exits 0.
309    ///
310    /// `out(pi)` is the shape a reader meets first: `pi` is a nullary function,
311    /// so the missing parentheses are the whole mistake.
312    NameHasNoFunctionValue,
313    /// `Y023` — a backtick parser template written where a value is expected
314    /// (ADR-084). §7.1 says the parser-expression sublanguage is entered
315    /// at `read` or at `parse(text, …)` and nowhere else, so `` `n = {int}` ``
316    /// standing alone is a template with nothing to parse.
317    ///
318    /// Reported from inference, not the parser, so `praxis check` sees it. The
319    /// token still parses to a `LITERAL` node so the tree round-trips the source
320    /// and one mistake produces one diagnostic.
321    ParserTemplateOutsideRead,
322    /// `Y024` — a call whose argument count does not match the function's
323    /// (ADR-089).
324    ///
325    /// A name in Praxis has exactly one signature — no arity-based overloading,
326    /// no optional or default parameters — so a count mismatch is never a
327    /// candidate for some other overload and can be reported as the mistake it
328    /// is. It sits next to `Y007`, which names collection arity, and `Y110`,
329    /// which names method arity; without it the mistake arrives as a `Y001`
330    /// showing two whole function types to diff by eye.
331    ///
332    /// Raised from `TypeDb::unify`, which compares the two lengths anyway, so
333    /// every function-to-function unification reports it rather than just a
334    /// direct call.
335    CallArityMismatch,
336
337    // --- Type (`Y09x`), internal ---
338    /// `Y099` — internal: a type the compiler expected was absent.
339    InternalMissingType,
340
341    // --- Type (`Y11x`), member errors ---
342    /// `Y110` — no such method on this type at this arity.
343    NoMethodOnType,
344    /// `Y112` — no such field on this type.
345    NoFieldOnType,
346    /// `Y113` — a record literal missing one or more fields.
347    MissingRecordFields,
348    /// `Y114` — a record literal *or pattern* naming a field the type does not
349    /// have.
350    UnknownRecordField,
351    /// `Y115` — a record literal *or pattern* naming one field twice. In a
352    /// pattern the second sub-pattern would silently replace the first, so one
353    /// of the two bindings the program wrote would never happen.
354    DuplicateRecordField,
355
356    // --- Type (`Y12x`), match errors ---
357    /// `Y120` — a `match` that does not cover every value.
358    NonExhaustiveMatch,
359    /// `Y121` — a `match` arm an earlier arm already covers.
360    UnreachableArm,
361    /// `Y122` — a pattern naming a variant the scrutinee's type has not.
362    UnknownEnumVariant,
363    /// `Y123` — a pattern whose shape cannot match the scrutinee, or one no
364    /// value can have at all: a one-element tuple pattern, or a record pattern
365    /// whose head names something that is not a record.
366    NotAPatternForType,
367    /// `Y125` — a pattern that must match every value but can fail: a literal or
368    /// a variant in a **binding** position, such as a `for` header.
369    ///
370    /// A binding has no second arm for an item to fall through to, so a pattern
371    /// that tests would silently skip the steps it does not match.
372    RefutableBinding,
373    /// `Y124` — a pattern whose sub-patterns do not fit the variant's payload
374    /// (ADR-134).
375    ///
376    /// Two shapes reach this code:
377    ///
378    /// - **More** sub-patterns than the variant has slots. `Wrap(a, b)` against
379    ///   a one-slot variant would read a payload the object does not have.
380    /// - A **bare variant name** for a variant that carries a payload. `A => …`
381    ///   against `A(Int)` says nothing about the value `A` holds, and it reads
382    ///   like a payload-less variant to anyone who did not check the
383    ///   declaration. Write `A(_)` to say "any payload" out loud.
384    ///
385    /// Naming *fewer* inside parentheses is legal and is padded with wildcards,
386    /// so `Some(_)` and `Some(n)` are one test. Bare `Some` is not a third
387    /// spelling of it.
388    PayloadArityMismatch,
389
390    // --- Input (`I0xx`) ---
391    /// `I000` — a parser expression the lowerer cannot read at all.
392    MalformedParserExpression,
393    /// `I001` — a parser AST that could not be converted to a type or plan.
394    ParserConversion,
395    /// `I010` — an atomic parser name that does not exist.
396    UnknownAtomic,
397    /// `I011` — an invalid capture name in a template.
398    InvalidCaptureName,
399    /// `I012` — a capture kind that does not exist.
400    UnknownCaptureKind,
401    /// `I013` — a parser constructor that does not exist.
402    UnknownConstructor,
403    /// `I014` — a constructor argument that is invalid or in excess.
404    InvalidConstructorArgument,
405    /// `I020` — named and anonymous captures mixed in one template (§7.3).
406    MixedCaptureNaming,
407    /// `I021` — one capture name used twice in a template.
408    DuplicateCaptureName,
409    /// `I022` — a constructor called with the wrong number of arguments.
410    ConstructorArity,
411    /// `I023` — an empty separator, which cannot advance a cursor.
412    EmptySeparator,
413    /// `I024` — a section or block field declared twice.
414    DuplicateSectionField,
415    /// `I025` — a `sections`/`choice` with no field or case at all.
416    EmptyFieldList,
417    /// `I026` — a positional `block` item returning a scalar with no name.
418    UnnamedScalarBlockItem,
419    /// `I027` — a `choice` case declared twice.
420    DuplicateChoiceCase,
421    /// `I028` — a misplaced or repeated `repeated(...)` tail.
422    MisplacedRepeatedTail,
423    /// `I030` — a backtick template the scanner could not read.
424    TemplateScan,
425}
426
427impl DiagCode {
428    /// The rendered code. **The one place a `(category, number)` pair exists.**
429    #[must_use]
430    pub const fn code(self) -> DiagnosticCode {
431        use DiagCode::*;
432        use DiagnosticCategory::{Input, Lex, Name, Parse, Type};
433        match self {
434            UnterminatedBlockComment => DiagnosticCode::new(Lex, 1),
435            UnterminatedTemplate => DiagnosticCode::new(Lex, 2),
436            UnexpectedCharacter => DiagnosticCode::new(Lex, 3),
437            UnterminatedTextLiteral => DiagnosticCode::new(Lex, 4),
438            InvalidEscape => DiagnosticCode::new(Lex, 5),
439            UnterminatedCharLiteral => DiagnosticCode::new(Lex, 6),
440            CharLiteralIsNotOneCharacter => DiagnosticCode::new(Lex, 7),
441
442            UnexpectedToken => DiagnosticCode::new(Parse, 1),
443            ExpectedStatementSeparator => DiagnosticCode::new(Parse, 2),
444
445            InternalNotASourceFile => DiagnosticCode::new(Name, 0),
446            UnknownName => DiagnosticCode::new(Name, 1),
447            UnknownType => DiagnosticCode::new(Name, 2),
448            NameIsNotAType => DiagnosticCode::new(Name, 3),
449            DuplicateDeclaration => DiagnosticCode::new(Name, 4),
450            NestedFunction => DiagnosticCode::new(Name, 5),
451            RecursiveTypeDeclaration => DiagnosticCode::new(Name, 6),
452            FunctionReadsOuterBinding => DiagnosticCode::new(Name, 7),
453            NotARecordLiteralHead => DiagnosticCode::new(Name, 8),
454            RetiredKeyword => DiagnosticCode::new(Name, 9),
455
456            TypeMismatch => DiagnosticCode::new(Type, 1),
457            InfiniteType => DiagnosticCode::new(Type, 2),
458            AnnotationConflict => DiagnosticCode::new(Type, 3),
459            NotEquatable => DiagnosticCode::new(Type, 4),
460            NotIterable => DiagnosticCode::new(Type, 5),
461            NotOrderable => DiagnosticCode::new(Type, 6),
462            WrongTypeArgumentCount => DiagnosticCode::new(Type, 7),
463            DuplicateMember => DiagnosticCode::new(Type, 8),
464            // 9 is retired (ADR-125) and deliberately not reissued.
465            CompoundAssignNonNumeric => DiagnosticCode::new(Type, 10),
466            ReturnOutsideFunction => DiagnosticCode::new(Type, 11),
467            BreakOutsideLoop => DiagnosticCode::new(Type, 12),
468            IntLiteralOutOfRange => DiagnosticCode::new(Type, 13),
469            NotHashable => DiagnosticCode::new(Type, 14),
470            NotNumeric => DiagnosticCode::new(Type, 15),
471            OperatorNotDefined => DiagnosticCode::new(Type, 16),
472            ValueBreakOutsideLoopExpression => DiagnosticCode::new(Type, 17),
473            GenericFunctionAsValue => DiagnosticCode::new(Type, 18),
474            NoTupleElement => DiagnosticCode::new(Type, 19),
475            NotIndexable => DiagnosticCode::new(Type, 20),
476            NotAnAssignmentTarget => DiagnosticCode::new(Type, 21),
477            NameHasNoFunctionValue => DiagnosticCode::new(Type, 22),
478            ParserTemplateOutsideRead => DiagnosticCode::new(Type, 23),
479            CallArityMismatch => DiagnosticCode::new(Type, 24),
480
481            InternalMissingType => DiagnosticCode::new(Type, 99),
482
483            NoMethodOnType => DiagnosticCode::new(Type, 110),
484            NoFieldOnType => DiagnosticCode::new(Type, 112),
485            MissingRecordFields => DiagnosticCode::new(Type, 113),
486            UnknownRecordField => DiagnosticCode::new(Type, 114),
487            DuplicateRecordField => DiagnosticCode::new(Type, 115),
488
489            NonExhaustiveMatch => DiagnosticCode::new(Type, 120),
490            UnreachableArm => DiagnosticCode::new(Type, 121),
491            UnknownEnumVariant => DiagnosticCode::new(Type, 122),
492            NotAPatternForType => DiagnosticCode::new(Type, 123),
493            PayloadArityMismatch => DiagnosticCode::new(Type, 124),
494            RefutableBinding => DiagnosticCode::new(Type, 125),
495
496            MalformedParserExpression => DiagnosticCode::new(Input, 0),
497            ParserConversion => DiagnosticCode::new(Input, 1),
498            UnknownAtomic => DiagnosticCode::new(Input, 10),
499            InvalidCaptureName => DiagnosticCode::new(Input, 11),
500            UnknownCaptureKind => DiagnosticCode::new(Input, 12),
501            UnknownConstructor => DiagnosticCode::new(Input, 13),
502            InvalidConstructorArgument => DiagnosticCode::new(Input, 14),
503            MixedCaptureNaming => DiagnosticCode::new(Input, 20),
504            DuplicateCaptureName => DiagnosticCode::new(Input, 21),
505            ConstructorArity => DiagnosticCode::new(Input, 22),
506            EmptySeparator => DiagnosticCode::new(Input, 23),
507            DuplicateSectionField => DiagnosticCode::new(Input, 24),
508            EmptyFieldList => DiagnosticCode::new(Input, 25),
509            UnnamedScalarBlockItem => DiagnosticCode::new(Input, 26),
510            DuplicateChoiceCase => DiagnosticCode::new(Input, 27),
511            MisplacedRepeatedTail => DiagnosticCode::new(Input, 28),
512            TemplateScan => DiagnosticCode::new(Input, 30),
513        }
514    }
515
516    /// Every code, so a test can assert the allocation is injective.
517    ///
518    /// [`code`](DiagCode::code)'s exhaustive match forces a new variant to be
519    /// *numbered*; only `all_lists_every_variant` forces it to be listed here,
520    /// and a variant missing from this list is one the injectivity test never
521    /// checks.
522    pub const ALL: &'static [DiagCode] = {
523        use DiagCode::*;
524        &[
525            UnterminatedBlockComment,
526            UnterminatedTemplate,
527            UnexpectedCharacter,
528            UnterminatedTextLiteral,
529            InvalidEscape,
530            UnterminatedCharLiteral,
531            CharLiteralIsNotOneCharacter,
532            UnexpectedToken,
533            ExpectedStatementSeparator,
534            InternalNotASourceFile,
535            UnknownName,
536            UnknownType,
537            NameIsNotAType,
538            DuplicateDeclaration,
539            NestedFunction,
540            RecursiveTypeDeclaration,
541            FunctionReadsOuterBinding,
542            NotARecordLiteralHead,
543            RetiredKeyword,
544            TypeMismatch,
545            InfiniteType,
546            AnnotationConflict,
547            NotEquatable,
548            NotIterable,
549            NotOrderable,
550            WrongTypeArgumentCount,
551            DuplicateMember,
552            CompoundAssignNonNumeric,
553            ReturnOutsideFunction,
554            BreakOutsideLoop,
555            IntLiteralOutOfRange,
556            NotHashable,
557            NotNumeric,
558            OperatorNotDefined,
559            ValueBreakOutsideLoopExpression,
560            GenericFunctionAsValue,
561            NoTupleElement,
562            NotIndexable,
563            NotAnAssignmentTarget,
564            NameHasNoFunctionValue,
565            ParserTemplateOutsideRead,
566            CallArityMismatch,
567            InternalMissingType,
568            NoMethodOnType,
569            NoFieldOnType,
570            MissingRecordFields,
571            UnknownRecordField,
572            DuplicateRecordField,
573            NonExhaustiveMatch,
574            UnreachableArm,
575            UnknownEnumVariant,
576            NotAPatternForType,
577            PayloadArityMismatch,
578            RefutableBinding,
579            MalformedParserExpression,
580            ParserConversion,
581            UnknownAtomic,
582            InvalidCaptureName,
583            UnknownCaptureKind,
584            UnknownConstructor,
585            InvalidConstructorArgument,
586            MixedCaptureNaming,
587            DuplicateCaptureName,
588            ConstructorArity,
589            EmptySeparator,
590            DuplicateSectionField,
591            EmptyFieldList,
592            UnnamedScalarBlockItem,
593            DuplicateChoiceCase,
594            MisplacedRepeatedTail,
595            TemplateScan,
596        ]
597    };
598}
599
600impl std::fmt::Display for DiagCode {
601    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
602        self.code().fmt(f)
603    }
604}
605
606/// A secondary span attached to a diagnostic, with its own message.
607///
608/// Used for the "related spans when inference connects distant expressions"
609/// case in §8.2: a type error's primary span is the failing expression, and a
610/// note can point at where the conflicting type was first inferred.
611#[derive(Clone, Debug)]
612pub struct DiagnosticNote {
613    pub span: FileSpan,
614    pub message: String,
615}
616
617/// A fix or piece of advice attached to a diagnostic.
618///
619/// When `replacement` is `Some`, it is a machine-applicable fix: replace `span`
620/// with the given text (a "fix-it"). When `None`, the suggestion is advisory —
621/// a `help:` line that explains how to resolve the problem without offering an
622/// automatic rewrite (§8.2: "a concrete suggestion when available").
623#[derive(Clone, Debug)]
624pub struct Suggestion {
625    pub span: FileSpan,
626    /// `None` for advisory hints with no automatic replacement.
627    pub replacement: Option<String>,
628    pub label: String,
629}
630
631/// A structured diagnostic.
632///
633/// Construction goes through [`Diagnostic::new`] (required fields only) or the
634/// [`DiagnosticBuilder`] (fluent, for the optional notes/suggestions). This
635/// keeps the "a diagnostic always has severity + code + message + primary span"
636/// invariant structural rather than conventional.
637#[derive(Clone, Debug)]
638pub struct Diagnostic {
639    severity: Severity,
640    /// The registered code. Stored as a [`DiagCode`] rather than a rendered
641    /// pair so that a diagnostic cannot exist for a number nobody allocated;
642    /// [`Diagnostic::code`] renders it on demand.
643    code: DiagCode,
644    message: String,
645    primary: FileSpan,
646    notes: Vec<DiagnosticNote>,
647    suggestions: Vec<Suggestion>,
648}
649
650impl Diagnostic {
651    /// The minimal complete diagnostic: severity, code, message, primary span.
652    #[inline]
653    pub fn new(
654        severity: Severity,
655        code: DiagCode,
656        message: impl Into<String>,
657        primary: FileSpan,
658    ) -> Diagnostic {
659        Diagnostic {
660            severity,
661            code,
662            message: message.into(),
663            primary,
664            notes: Vec::new(),
665            suggestions: Vec::new(),
666        }
667    }
668
669    /// Begin a fluent build, starting from `new`'s required fields.
670    #[inline]
671    pub fn build(
672        severity: Severity,
673        code: DiagCode,
674        message: impl Into<String>,
675        primary: FileSpan,
676    ) -> DiagnosticBuilder {
677        DiagnosticBuilder {
678            diag: Diagnostic::new(severity, code, message, primary),
679        }
680    }
681
682    #[inline]
683    pub fn severity(&self) -> Severity {
684        self.severity
685    }
686
687    /// The rendered `T012`-style code.
688    #[inline]
689    pub fn code(&self) -> DiagnosticCode {
690        self.code.code()
691    }
692
693    /// Which diagnostic this is, as the registered name.
694    #[inline]
695    pub fn kind(&self) -> DiagCode {
696        self.code
697    }
698
699    #[inline]
700    pub fn message(&self) -> &str {
701        &self.message
702    }
703
704    #[inline]
705    pub fn primary(&self) -> FileSpan {
706        self.primary
707    }
708
709    /// The key that puts diagnostics in source order: the primary span's start,
710    /// then its end.
711    ///
712    /// **The one comparator.** Every stage of the front end concatenates its own
713    /// diagnostics onto the previous stage's and re-sorts, and they all sort by
714    /// this key.
715    ///
716    /// The file is not part of the key: every list sorted this way is one file's
717    /// diagnostics. [`sort_by_position`] is how a caller normally reaches this;
718    /// the key itself is public for a caller sorting diagnostics that are
719    /// decorated with something else.
720    #[inline]
721    pub fn sort_key(&self) -> (BytePos, BytePos) {
722        (self.primary.span.start(), self.primary.span.end())
723    }
724
725    #[inline]
726    pub fn notes(&self) -> &[DiagnosticNote] {
727        &self.notes
728    }
729
730    #[inline]
731    pub fn suggestions(&self) -> &[Suggestion] {
732        &self.suggestions
733    }
734
735    /// Attach a secondary span with a message to an already-built diagnostic.
736    ///
737    /// The same operation [`DiagnosticBuilder::note`] performs, for a caller
738    /// that received a finished `Diagnostic` from a wording helper and knows one
739    /// thing the helper did not: where the requirement it violated was written
740    /// (§8.2 "related spans when inference connects distant expressions").
741    #[must_use]
742    pub fn with_note(mut self, span: FileSpan, message: impl Into<String>) -> Diagnostic {
743        self.notes.push(DiagnosticNote {
744            span,
745            message: message.into(),
746        });
747        self
748    }
749
750    /// Attach a machine-applicable fix to an already-built diagnostic.
751    ///
752    /// [`DiagnosticBuilder::suggestion`]'s operation, for the same reason
753    /// [`with_note`](Self::with_note) exists: the wording helper says what is
754    /// wrong, and the caller is the one that knows where the fix goes. A
755    /// zero-width `span` is an insertion.
756    #[must_use]
757    pub fn with_suggestion(
758        mut self,
759        span: FileSpan,
760        replacement: impl Into<String>,
761        label: impl Into<String>,
762    ) -> Diagnostic {
763        self.suggestions.push(Suggestion {
764            span,
765            replacement: Some(replacement.into()),
766            label: label.into(),
767        });
768        self
769    }
770
771    /// Offer `near` as a fix over `at`, with the compiler's one "did you mean"
772    /// wording (ADR-132).
773    ///
774    /// The near-miss fix is emitted from five places — an atomic parser, a
775    /// capture's parser, a parser constructor, an unresolved name, a method —
776    /// and every one of them writes `` did you mean `x`? `` over the span the
777    /// report already underlines. The threshold that decides *whether* to offer
778    /// a candidate is [`crate::nearest`]'s; this is where the sentence the user
779    /// reads lives, so the five cannot drift apart. `praxis-lsp` asserts on this
780    /// text, for one path at a time.
781    #[must_use]
782    pub fn with_did_you_mean(self, at: FileSpan, near: impl Into<String>) -> Diagnostic {
783        let near = near.into();
784        let label = format!("did you mean `{near}`?");
785        self.with_suggestion(at, near, label)
786    }
787}
788
789/// Fluent builder for the optional parts of a [`Diagnostic`].
790pub struct DiagnosticBuilder {
791    diag: Diagnostic,
792}
793
794impl DiagnosticBuilder {
795    /// Attach a secondary span with a message.
796    pub fn note(mut self, span: FileSpan, message: impl Into<String>) -> Self {
797        self.diag.notes.push(DiagnosticNote {
798            span,
799            message: message.into(),
800        });
801        self
802    }
803
804    /// Attach a machine-applicable suggestion: replace `span` with `replacement`.
805    pub fn suggestion(
806        mut self,
807        span: FileSpan,
808        replacement: impl Into<String>,
809        label: impl Into<String>,
810    ) -> Self {
811        self.diag.suggestions.push(Suggestion {
812            span,
813            replacement: Some(replacement.into()),
814            label: label.into(),
815        });
816        self
817    }
818
819    /// Attach an advisory `help:` line (no automatic replacement). Use when the
820    /// fix is not mechanical (e.g. "remove this expression" or "change the
821    /// return type") — §8.2 names these as explanations rather than fix-its.
822    pub fn help(mut self, span: FileSpan, label: impl Into<String>) -> Self {
823        self.diag.suggestions.push(Suggestion {
824            span,
825            replacement: None,
826            label: label.into(),
827        });
828        self
829    }
830
831    /// Finish building.
832    #[inline]
833    pub fn finish(self) -> Diagnostic {
834        self.diag
835    }
836}
837
838/// Put `diags` in source order, by [`Diagnostic::sort_key`].
839///
840/// This runs at every stage boundary of the front end, because each stage
841/// appends its diagnostics to the previous stage's and the merged list has to be
842/// re-ordered: parse onto lex, inference onto name resolution, analysis onto
843/// parse.
844///
845/// **The sort is stable, and that is load-bearing.** Two diagnostics on the same
846/// span keep the order the stages produced them in, so the earlier stage's is
847/// still printed first — a lex error before the parse error it caused. Do not
848/// reach for `sort_unstable_by_key` here.
849pub fn sort_by_position(diags: &mut [Diagnostic]) {
850    diags.sort_by_key(Diagnostic::sort_key);
851}
852
853// ---------------------------------------------------------------------
854// Rendering.
855// ---------------------------------------------------------------------
856
857/// Renders diagnostics in the §8.2 layout.
858///
859/// The renderer borrows a [`SourceMap`] for source snippets and line/column
860/// conversion; it holds a [`style::Palette`] that decides whether the output is
861/// plain (the default, for snapshot-stable tests) or ANSI-styled. It is cheap to
862/// construct per render.
863pub struct Renderer<'a> {
864    source: &'a SourceMap,
865    palette: style::Palette,
866}
867
868impl<'a> Renderer<'a> {
869    /// A plain-text renderer (no ANSI). The default for snapshot tests, which
870    /// must stay byte-stable regardless of terminal state.
871    pub fn new(source: &'a SourceMap) -> Renderer<'a> {
872        Renderer {
873            source,
874            palette: style::Palette::plain(),
875        }
876    }
877
878    /// A renderer that styles its output when `palette` is [`style::Palette::styled`].
879    pub fn new_styled(source: &'a SourceMap, palette: style::Palette) -> Renderer<'a> {
880        Renderer { source, palette }
881    }
882
883    /// The diagnostic's severity in the [`style`] module's terms.
884    fn style_severity(sev: Severity) -> style::Severity {
885        match sev {
886            Severity::Error => style::Severity::Error,
887            Severity::Warning => style::Severity::Warning,
888            Severity::Note => style::Severity::Note,
889            Severity::Hint => style::Severity::Help,
890        }
891    }
892
893    /// Render one diagnostic into `out`.
894    pub fn render(&self, diag: &Diagnostic, out: &mut String) {
895        self.render_header(diag, out);
896        // §8.2 puts a blank line between the header and the location snippet.
897        out.push('\n');
898
899        // Primary location + source snippet, with the diagnostic message as the
900        // caret-line label (§8.2: `^^^^ this value is Text`).
901        self.render_location_and_snippet(
902            diag.primary,
903            Some(diag.message.as_str()),
904            diag.severity,
905            out,
906        );
907
908        // Related notes: each carries its own message + span snippet, set off by
909        // a blank line so a multi-span diagnostic reads as distinct blocks.
910        for note in &diag.notes {
911            out.push('\n');
912            let label = self
913                .palette
914                .paint(style::Style::Severity(style::Severity::Note), "note:");
915            let _ = writeln!(out, "{label} {}", note.message);
916            self.render_location_and_snippet(note.span, None, Severity::Note, out);
917        }
918
919        // Suggestions as rustc-style `help:` lines. A machine-applicable fix
920        // shows its replacement on the next indented line; an advisory hint
921        // shows only the explanation.
922        for sugg in &diag.suggestions {
923            out.push('\n');
924            let label = self
925                .palette
926                .paint(style::Style::Severity(style::Severity::Help), "help:");
927            let _ = writeln!(out, "{label} {}", sugg.label);
928            if let Some(repl) = &sugg.replacement {
929                // Line by line, skipping the leading break an *insertion* starts
930                // with: a fix that adds a line writes `"\n        B => …"`, so
931                // that break belongs to where the text goes rather than to what
932                // it says, and printing it raw emits a line of trailing spaces.
933                for line in repl.trim_start_matches('\n').lines() {
934                    let _ = writeln!(out, "      {line}");
935                }
936            }
937        }
938    }
939
940    /// Render `error[code]: message` (no trailing newline; the caller frames it).
941    fn render_header(&self, diag: &Diagnostic, out: &mut String) {
942        let sev = Self::style_severity(diag.severity);
943        let label = self
944            .palette
945            .paint(style::Style::Severity(sev), diag.severity.label());
946        let code = self
947            .palette
948            .paint(style::Style::Code, &format!("[{}]", diag.code));
949        let _ = write!(out, "{label}{code}: {}", diag.message);
950    }
951
952    /// Render the `path:line:col` header followed by the source line(s) the
953    /// span touches, with a clamped caret underline. `label` (when `Some`)
954    /// trails the carets on the first underlined line. The caret is colored in
955    /// the diagnostic's severity color when the palette is styled. Delegates the
956    /// actual line/caret drawing to the shared
957    /// [`snippet::render_span_snippet_styled`] so the compiler and crash debugger
958    /// render spans identically.
959    fn render_location_and_snippet(
960        &self,
961        span: FileSpan,
962        label: Option<&str>,
963        sev: Severity,
964        out: &mut String,
965    ) {
966        let Some(file) = self.source.get(span.file) else {
967            // Synthetic / unknown file: fall back to a location-only line.
968            let _ = writeln!(out, "  <unknown file> [{:?}]", span);
969            return;
970        };
971        let caret_label = match label {
972            Some(s) if !s.is_empty() => crate::snippet::CaretLabel::Labelled(s),
973            _ => crate::snippet::CaretLabel::Plain,
974        };
975        crate::snippet::render_span_snippet_styled(
976            &file,
977            span,
978            caret_label,
979            out,
980            crate::snippet::MAX_SNIPPET_LINES,
981            &self.palette,
982            Some(Self::style_severity(sev)),
983        );
984    }
985}
986
987/// Helper used by tests and the CLI to render a single diagnostic to a string.
988pub fn render_one(source: &SourceMap, diag: &Diagnostic) -> String {
989    let mut out = String::new();
990    Renderer::new(source).render(diag, &mut out);
991    out
992}
993
994#[cfg(test)]
995mod tests {
996    use super::*;
997    use crate::file::FileId;
998    use crate::span::Span;
999
1000    fn span(file: FileId, start: u32, end: u32) -> FileSpan {
1001        FileSpan::new(file, Span::new(start, end))
1002    }
1003
1004    #[test]
1005    fn code_renders_zero_padded() {
1006        let code = DiagnosticCode::new(DiagnosticCategory::Lex, 12);
1007        assert_eq!(code.to_string(), "T012");
1008    }
1009
1010    /// Two diagnostics must never render the same code.
1011    #[test]
1012    fn every_code_is_distinct() {
1013        let mut seen = std::collections::HashMap::new();
1014        for &code in DiagCode::ALL {
1015            if let Some(other) = seen.insert(code.to_string(), code) {
1016                panic!("{other:?} and {code:?} both render {code}");
1017            }
1018        }
1019    }
1020
1021    /// …and `ALL` really is all of them. A variant left out of the list is a
1022    /// variant the injectivity test never checks.
1023    ///
1024    /// A count assertion cannot state this: `code()`'s exhaustive match forces a
1025    /// new variant to be *numbered*, nothing forces it into `ALL`, so a variant
1026    /// left out leaves the list and any expected length agreeing with each
1027    /// other. The match below forces the list instead, the way `CapKind::ALL` is
1028    /// guarded in `praxis-stdlib`: a new variant stops this test compiling, in
1029    /// the test whose whole subject is `ALL`.
1030    #[test]
1031    fn all_lists_every_variant() {
1032        use DiagCode::*;
1033
1034        let unique: std::collections::HashSet<_> = DiagCode::ALL.iter().collect();
1035        assert_eq!(
1036            unique.len(),
1037            DiagCode::ALL.len(),
1038            "a variant is listed twice"
1039        );
1040
1041        for &code in DiagCode::ALL {
1042            // Exhaustive on purpose, and the exhaustiveness is the whole of it:
1043            // adding a variant fails to compile here rather than passing
1044            // quietly out of `ALL`.
1045            match code {
1046                UnterminatedBlockComment
1047                | UnterminatedTemplate
1048                | UnexpectedCharacter
1049                | UnterminatedTextLiteral
1050                | InvalidEscape
1051                | UnterminatedCharLiteral
1052                | CharLiteralIsNotOneCharacter
1053                | UnexpectedToken
1054                | ExpectedStatementSeparator
1055                | InternalNotASourceFile
1056                | UnknownName
1057                | UnknownType
1058                | NameIsNotAType
1059                | DuplicateDeclaration
1060                | NestedFunction
1061                | RecursiveTypeDeclaration
1062                | FunctionReadsOuterBinding
1063                | NotARecordLiteralHead
1064                | RetiredKeyword
1065                | TypeMismatch
1066                | InfiniteType
1067                | AnnotationConflict
1068                | NotEquatable
1069                | NotIterable
1070                | NotOrderable
1071                | WrongTypeArgumentCount
1072                | DuplicateMember
1073                | CompoundAssignNonNumeric
1074                | ReturnOutsideFunction
1075                | BreakOutsideLoop
1076                | IntLiteralOutOfRange
1077                | NotHashable
1078                | NotNumeric
1079                | OperatorNotDefined
1080                | ValueBreakOutsideLoopExpression
1081                | GenericFunctionAsValue
1082                | NoTupleElement
1083                | NotIndexable
1084                | NotAnAssignmentTarget
1085                | NameHasNoFunctionValue
1086                | ParserTemplateOutsideRead
1087                | CallArityMismatch
1088                | InternalMissingType
1089                | NoMethodOnType
1090                | NoFieldOnType
1091                | MissingRecordFields
1092                | UnknownRecordField
1093                | DuplicateRecordField
1094                | NonExhaustiveMatch
1095                | UnreachableArm
1096                | UnknownEnumVariant
1097                | NotAPatternForType
1098                | RefutableBinding
1099                | PayloadArityMismatch
1100                | MalformedParserExpression
1101                | ParserConversion
1102                | UnknownAtomic
1103                | InvalidCaptureName
1104                | UnknownCaptureKind
1105                | UnknownConstructor
1106                | InvalidConstructorArgument
1107                | MixedCaptureNaming
1108                | DuplicateCaptureName
1109                | ConstructorArity
1110                | EmptySeparator
1111                | DuplicateSectionField
1112                | EmptyFieldList
1113                | UnnamedScalarBlockItem
1114                | DuplicateChoiceCase
1115                | MisplacedRepeatedTail
1116                | TemplateScan => {}
1117            }
1118        }
1119    }
1120
1121    #[test]
1122    fn code_distinguishes_categories() {
1123        let lex = DiagnosticCode::new(DiagnosticCategory::Lex, 3);
1124        let parse = DiagnosticCode::new(DiagnosticCategory::Parse, 3);
1125        assert_eq!(lex.to_string(), "T003");
1126        assert_eq!(parse.to_string(), "P003");
1127        assert_ne!(lex, parse);
1128    }
1129
1130    #[test]
1131    fn code_large_number_not_padded() {
1132        let code = DiagnosticCode::new(DiagnosticCategory::Type, 1234);
1133        assert_eq!(code.to_string(), "Y1234");
1134    }
1135
1136    #[test]
1137    fn diagnostic_carries_required_fields() {
1138        let d = Diagnostic::new(
1139            Severity::Error,
1140            DiagCode::BreakOutsideLoop,
1141            "expected Int, found Text",
1142            span(FileId::SYNTHETIC, 0, 1),
1143        );
1144        assert_eq!(d.severity(), Severity::Error);
1145        // `Type` category renders as `Y`, matching the prefix table.
1146        assert_eq!(d.code().to_string(), "Y012");
1147        assert_eq!(d.kind(), DiagCode::BreakOutsideLoop);
1148        assert_eq!(d.message(), "expected Int, found Text");
1149        assert!(d.notes().is_empty());
1150        assert!(d.suggestions().is_empty());
1151    }
1152
1153    #[test]
1154    fn builder_adds_notes_and_suggestions() {
1155        let d = Diagnostic::build(
1156            Severity::Error,
1157            DiagCode::UnknownName,
1158            "undefined name",
1159            span(FileId::SYNTHETIC, 0, 1),
1160        )
1161        .note(span(FileId::SYNTHETIC, 5, 6), "defined here")
1162        .suggestion(span(FileId::SYNTHETIC, 0, 1), "value", "did you mean")
1163        .finish();
1164        assert_eq!(d.notes().len(), 1);
1165        assert_eq!(d.suggestions().len(), 1);
1166        assert_eq!(d.suggestions()[0].replacement.as_deref(), Some("value"));
1167    }
1168
1169    /// The near-miss wording, pinned where it is now written (ADR-132). Five
1170    /// front-end sites reach it through this one method, and `praxis-lsp`'s
1171    /// quick-fix tests each cover one of them.
1172    #[test]
1173    fn did_you_mean_labels_the_fix() {
1174        let at = span(FileId::SYNTHETIC, 0, 4);
1175        let d = Diagnostic::new(
1176            Severity::Error,
1177            DiagCode::UnknownName,
1178            "cannot find `lien`",
1179            at,
1180        )
1181        .with_did_you_mean(at, "line");
1182        assert_eq!(d.suggestions()[0].replacement.as_deref(), Some("line"));
1183        assert_eq!(d.suggestions()[0].label, "did you mean `line`?");
1184    }
1185
1186    /// Source order by primary span, and **stable** — the property the front end
1187    /// relies on when it appends one stage's diagnostics to the previous
1188    /// stage's: two on the same span keep the order they were produced in.
1189    #[test]
1190    fn sort_by_position_is_stable_source_order() {
1191        let f = FileId::SYNTHETIC;
1192        let d = |start, end, msg: &str| {
1193            Diagnostic::new(
1194                Severity::Error,
1195                DiagCode::UnknownName,
1196                msg,
1197                span(f, start, end),
1198            )
1199        };
1200        let mut diags = vec![d(10, 12, "c"), d(0, 5, "a"), d(0, 3, "b"), d(0, 5, "a2")];
1201        sort_by_position(&mut diags);
1202        let order: Vec<&str> = diags.iter().map(Diagnostic::message).collect();
1203        assert_eq!(order, ["b", "a", "a2", "c"]);
1204    }
1205
1206    #[test]
1207    fn render_snapshot_single_line() {
1208        let map = SourceMap::new();
1209        let id = map.intern("day03.px", "total += line\n");
1210        // "line" starts at byte 9, length 4.
1211        let d = Diagnostic::build(
1212            Severity::Error,
1213            DiagCode::BreakOutsideLoop,
1214            "expected Int, found Text",
1215            span(id, 9, 13),
1216        )
1217        .suggestion(
1218            span(id, 9, 13),
1219            "line.int()",
1220            "parse it with the input parser",
1221        )
1222        .finish();
1223        let rendered = render_one(&map, &d);
1224        insta::assert_snapshot!(rendered, @r"
1225error[Y012]: expected Int, found Text
1226
1227  day03.px:1:10
1228  1 | total += line
1229    |          ^^^^ expected Int, found Text
1230
1231help: parse it with the input parser
1232      line.int()
1233");
1234    }
1235
1236    #[test]
1237    fn render_snapshot_two_lines_with_note() {
1238        let map = SourceMap::new();
1239        let id = map.intern("f.px", "var a = value\nvar b = a + 1\n");
1240        // Primary: "value" at 8..13 on line 1.
1241        let primary = span(id, 8, 13);
1242        let d = Diagnostic::build(
1243            Severity::Error,
1244            DiagCode::UnknownName,
1245            "undefined name `value`",
1246            primary,
1247        )
1248        .note(span(id, 23, 24), "the name `a` is defined here")
1249        .finish();
1250        let rendered = render_one(&map, &d);
1251        insta::assert_snapshot!(rendered, @r"
1252error[N001]: undefined name `value`
1253
1254  f.px:1:9
1255  1 | var a = value
1256    |         ^^^^^ undefined name `value`
1257
1258note: the name `a` is defined here
1259
1260  f.px:2:10
1261  2 | var b = a + 1
1262    |          ^
1263");
1264    }
1265
1266    #[test]
1267    fn styled_renderer_emits_ansi() {
1268        // The styled renderer wraps the severity label, code, carets, location,
1269        // and help label in ANSI escapes. The plain path (default) emits none.
1270        let map = SourceMap::new();
1271        let id = map.intern("f.px", "x = 1\n");
1272        let d = Diagnostic::build(
1273            Severity::Error,
1274            DiagCode::TypeMismatch,
1275            "expected Int, found Text",
1276            span(id, 0, 1),
1277        )
1278        .help(span(id, 0, 1), "call .int()")
1279        .finish();
1280
1281        let mut plain = String::new();
1282        Renderer::new(&map).render(&d, &mut plain);
1283        assert!(
1284            !plain.contains("\x1b["),
1285            "plain output has no ANSI: {plain:?}"
1286        );
1287
1288        let mut styled = String::new();
1289        Renderer::new_styled(&map, style::Palette::styled()).render(&d, &mut styled);
1290        // Header: bold-red `error` + bold `[Y001]`.
1291        assert!(
1292            styled.contains("\x1b[1;31merror\x1b[0m"),
1293            "styled error label: {styled:?}"
1294        );
1295        assert!(
1296            styled.contains("\x1b[1m[Y001]\x1b[0m"),
1297            "styled code: {styled:?}"
1298        );
1299        // Caret in the error color (red, not bold).
1300        assert!(
1301            styled.contains("\x1b[31m^\x1b[0m"),
1302            "styled caret: {styled:?}"
1303        );
1304        // help label in cyan.
1305        assert!(
1306            styled.contains("\x1b[1;36mhelp:\x1b[0m"),
1307            "styled help label: {styled:?}"
1308        );
1309    }
1310}