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}