Skip to main content

ridl_core/
diag.rs

1//! The coded diagnostic model every compiler pass emits (docs/ROADMAP.md epic
2//! E1.10, ADR-0004 §5, ADR-0007 decision 2).
3//!
4//! A [`Diagnostic`] is a first-class homegrown value — a stable [`DiagCode`], a
5//! [`Severity`], a message, a primary source [`Span`], secondary [`Label`]s, and
6//! optional [`FixIt`]s — held as the single source of truth and accumulated in a
7//! `Vec`, never modeled as an error return (ADR-0004 §5). The struct maps two
8//! ways: to a terminal renderer for the CLI ([`render`](render::render), over
9//! `codespan-reporting`) and, in a later epic, to LSP `Diagnostic` for editors.
10//!
11//! # Namespaces (ADR-0007 decision 2)
12//!
13//! Codes are grouped by hundreds and never renumbered or reused; a code
14//! retired from a catalogue is listed in [`RETIRED_RIDL_CODES`], and a guard
15//! keeps it out. Five namespaces are in play across the family, one catalogue
16//! each:
17//!
18//! - `FORM-…` — the shared family grammar: lexical `0xx`, parse `1xx`, and the
19//!   general form §4.3 attribute rules. Named after the general form's own
20//!   "shared form namespace". [`FORM_CATALOG`].
21//! - `TYPL-…` — typl semantic rules, defined by the typl reference §16.
22//!   [`TYPL_CATALOG`].
23//! - `RIDL-…` — ridl interaction rules, defined by the ridl reference §16.
24//!   [`RIDL_CATALOG`].
25//! - `RSDL-…` — rsdl system rules, defined by the rsdl reference §16.
26//!   [`RSDL_CATALOG`].
27//! - `MANI-…` — manifest, lockfile, cache, and fetch: the manifest `0xx` codes
28//!   (E1.5) and the distribution `1xx` codes (E1.6). [`MANI_CATALOG`].
29//!
30//! This module is the SSOT the error index (E4.2) reads. Every code is declared
31//! once, by [`diag_codes!`], which expands one entry into both the `DiagCode`
32//! constant and its catalogue row — a code with no entry cannot be written
33//! (ADR-0008 decision 21). A catalogue lists a code even when no pass emits it
34//! yet.
35//!
36//! # The [`FileId`] bridge
37//!
38//! A [`Span`] locates its range inside a file identified by an interned
39//! [`FileId`]. [`SourceMap`] issues those ids: a pass that holds a file's path
40//! and text calls [`SourceMap::file_id`] to obtain the id it stamps into its
41//! spans. The [`SourceMap`] is the only bridge between a diagnostic and the
42//! source text the renderer needs.
43
44use rowan::{TextRange, TextSize};
45use serde::{Serialize, Serializer};
46
47pub mod render;
48
49pub use render::render;
50
51/// A stable diagnostic code, e.g. `"FORM-101"` or `"TYPL-108"` (ADR-0007
52/// decision 2). The empty string means "no code yet" — a diagnostic that has
53/// not been assigned a catalogue code renders as a plain message.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
55#[serde(transparent)]
56pub struct DiagCode(pub &'static str);
57
58impl DiagCode {
59    /// The code string.
60    pub fn as_str(&self) -> &'static str {
61        self.0
62    }
63
64    /// Whether this is the sentinel "no code yet" value.
65    pub fn is_empty(&self) -> bool {
66        self.0.is_empty()
67    }
68
69    /// The sentinel for a diagnostic that carries no catalogue code yet, such
70    /// as the checker's "expected a type, but `X` names a constant / an
71    /// interface" errors (typl §16 defines no code for either) or the Rust
72    /// backend's codegen error. A diagnostic carrying it renders with no code
73    /// after its severity word (`error:`, `warning:`), and the book harness
74    /// can never allow one.
75    pub const NONE: DiagCode = DiagCode("");
76}
77
78/// Declares every diagnostic code exactly once (ADR-0008 decision 21).
79///
80/// One entry expands to both the `DiagCode` constant and the [`CatalogEntry`]
81/// that names it, so a code with no catalogue entry cannot be written: there is
82/// no second list to forget.
83///
84/// The macro also generates [`ALL_CATALOGS`], which the guards read to find
85/// every catalogue. That makes it invocable only once **per module** — a second
86/// invocation beside this one redefines the constant and the crate stops
87/// compiling. It does **not** make it invocable once per crate: a second
88/// invocation inside a child module of `diag` compiles, and its catalogue is
89/// invisible to `ALL_CATALOGS` here. What covers that case is
90/// `codes_written_as_string_literals_are_all_catalogued`, and it covers it for a
91/// structural reason rather than by luck — `$code:literal` guarantees every code
92/// the macro declares is spelled as a literal in a `.rs` file, so a catalogue
93/// the guards cannot see still puts its codes where the scan can.
94///
95/// This replaces the pair of hand-maintained arrays `FORM_CATALOG` and
96/// `MANI_CATALOG` carried until E2 close-out, each guarded by a test that
97/// compared it against a *second hand-written list inside the test*. That pair
98/// checked that two lists agreed; it never checked either against the codes
99/// actually declared, so a constant added to neither compiled and turned nothing
100/// red. `RIDL` and `TYPL` had no catalogue at all.
101///
102/// The remedy available in `crates/ridl-diff` — make the guard an exhaustive `match`
103/// and let the compiler enforce totality — does not exist here: `DiagCode` is a
104/// newtype over `&'static str`, not an enum, so there is no variant set to match
105/// on. Declaring the constant and its entry from one line is the shape that
106/// works for a newtype.
107macro_rules! diag_codes {
108    (
109        $(
110            $(#[$catalog_doc:meta])*
111            $catalog:ident {
112                $(
113                    $(#[$code_doc:meta])*
114                    $konst:ident = $code:literal, $severity:ident,
115                        $summary:literal;
116                )+
117            }
118        )+
119    ) => {
120        impl DiagCode {
121            $($(
122                $(#[$code_doc])*
123                pub const $konst: DiagCode = DiagCode($code);
124            )+)+
125        }
126
127        $(
128            $(#[$catalog_doc])*
129            pub const $catalog: &[CatalogEntry] = &[
130                $(CatalogEntry {
131                    code: DiagCode::$konst,
132                    severity: Severity::$severity,
133                    summary: $summary,
134                },)+
135            ];
136        )+
137
138        /// Every catalogue this module declares, each paired with its constant's
139        /// name. The error index (E4.2) reads this rather than naming the
140        /// catalogues one at a time, and so do the guards below.
141        pub const ALL_CATALOGS: &[(&str, &[CatalogEntry])] = &[
142            $((stringify!($catalog), $catalog),)+
143        ];
144
145        /// Each constant's name paired with the code string it expands to, so a
146        /// guard can check the two agree. Generated rather than written down —
147        /// a hand-written copy would be the shadow this macro exists to remove.
148        #[cfg(test)]
149        const CODE_CONSTANT_NAMES: &[(&str, &str)] = &[
150            $($((stringify!($konst), $code),)+)+
151        ];
152    };
153}
154
155/// The `RIDL-` codes retired by the interface lock (lock design §9). A code is
156/// never renumbered or reused (ADR-0008 decision 13): RIDL-146 to RIDL-148
157/// guarded the slot model of a service's list — a shape re-declared under a
158/// service-level `reserved` name, one interface name on two shapes, and a
159/// nameless service-level tombstone — and left the catalogue with that model
160/// on 2026-09-15. Held as integers rather than `"RIDL-146"` literals, because
161/// a string literal of a code that is in no catalogue fails
162/// `codes_written_as_string_literals_are_all_catalogued`; the guard
163/// `retired_ridl_codes_are_never_redeclared` keeps the numbers out of
164/// [`RIDL_CATALOG`].
165pub const RETIRED_RIDL_CODES: &[u16] = &[146, 147, 148];
166
167diag_codes! {
168    /// The FORM catalogue (ADR-0007 decision 2): lexical `0xx`, parse `1xx`, and
169    /// the attribute-semantics codes 106-108 the checker emits for the general
170    /// form §4.3 allow-list (E2 task 5). Every FORM code is listed even when no
171    /// pass emits it yet, so the error index has one authoritative source. FORM
172    /// diagnostics are all errors.
173    FORM_CATALOG {
174        /// Invalid character.
175        FORM_001 = "FORM-001", Error,
176            "invalid character";
177
178        /// Unterminated string literal.
179        FORM_002 = "FORM-002", Error,
180            "unterminated string literal";
181
182        /// Unterminated regex literal.
183        FORM_003 = "FORM-003", Error,
184            "unterminated regex literal";
185
186        /// Unterminated block comment.
187        FORM_004 = "FORM-004", Error,
188            "unterminated block comment";
189
190        /// Leading zeros in an integer literal.
191        FORM_005 = "FORM-005", Error,
192            "leading zeros in integer literal";
193
194        /// Expected a specific token, or a construct the grammar admits here.
195        /// Most call sites name a token (`` expected `]` ``); the interaction
196        /// positions name the shapes instead — a return type is one of four,
197        /// and saying which is the point of the message.
198        FORM_101 = "FORM-101", Error,
199            "expected a specific token, or a construct the grammar admits here";
200
201        /// Unexpected token — also the code for nesting past the depth the
202        /// parser follows (`MAX_TYPE_DEPTH` in `ridl-syntax`): a type, an
203        /// attribute value, a parenthesised group, or an expression tree,
204        /// whose height each binary operator, member access, group and
205        /// prefix raises by one level.
206        FORM_102 = "FORM-102", Error,
207            "unexpected token";
208
209        /// Unclosed delimiter.
210        FORM_103 = "FORM-103", Error,
211            "unclosed delimiter";
212
213        /// Missing `package` declaration.
214        FORM_104 = "FORM-104", Error,
215            "missing `package` declaration";
216
217        /// Reserved word used as an identifier.
218        FORM_105 = "FORM-105", Error,
219            "reserved word used as an identifier";
220
221        /// Unknown attribute key — not a key the general form §4.3 table defines.
222        FORM_106 = "FORM-106", Error,
223            "unknown attribute key";
224
225        /// Attribute key not allowed on this declaration kind (general form §4.3).
226        FORM_107 = "FORM-107", Error,
227            "attribute key not allowed on this declaration kind";
228
229        /// Duplicate attribute key in one `[ ]` block (general form §4.3).
230        FORM_108 = "FORM-108", Error,
231            "duplicate attribute key in one block";
232    }
233
234    /// The typl catalogue (ADR-0008 decision 21): every `TYPL-` code declared in
235    /// this module, with the severity the typl reference §16 tables classify it
236    /// at. Six codes the reference documents are absent because no constant
237    /// declares them and no pass emits them — TYPL-107, TYPL-112, TYPL-205, and
238    /// the three `@labels` assurance codes TYPL-401 to TYPL-403. That inventory
239    /// is recorded in issue #172; closing it means minting the constants, which
240    /// is a change to what the compiler declares, not a catalogue edit.
241    TYPL_CATALOG {
242        /// More than one `package` declaration in a single file (typl §16.1).
243        /// Emitted by the package loader (E1.3).
244        TYPL_001 = "TYPL-001", Error,
245            "more than one `package` declaration in a file";
246
247        /// Package name does not mirror the directory path relative to the
248        /// manifest root (typl §16.1, ADR-0002 §1). Emitted by the package loader
249        /// (E1.3); single-file mode is exempt.
250        TYPL_002 = "TYPL-002", Error,
251            "package name does not mirror the directory path";
252
253        /// Wildcard, relative, or re-exporting import (typl §16.1, ADR-0002 §2).
254        /// Emitted by the resolver (E1.4).
255        TYPL_003 = "TYPL-003", Error,
256            "wildcard, relative, or re-exporting import";
257
258        /// Circular package imports (typl §16.1, ADR-0002 §6). Emitted by the
259        /// resolver (E1.4) from a depth-first walk over package import edges.
260        TYPL_004 = "TYPL-004", Error,
261            "circular package imports";
262
263        /// A public declaration exposes an `internal` type in its fields, arms,
264        /// backing, or a range-bound constant (typl §3.3, §16.1). Emitted by the
265        /// checker (E1.7b) over every top-level declaration, the ridl `interface`
266        /// and `service` included: an interaction payload, parameter, return arm,
267        /// or stream element is an exposure position exactly as a struct field is.
268        /// A `service` naming an `internal` interface is RIDL-143 instead.
269        TYPL_005 = "TYPL-005", Error,
270            "a public declaration exposes an `internal` type";
271
272        /// Conflicting imports without an alias (typl §16.1, ADR-0002 §2).
273        /// Emitted by the resolver (E1.4).
274        TYPL_006 = "TYPL-006", Error,
275            "conflicting imports without an alias";
276
277        /// Unused import (typl §16.1). Emitted by the resolver (E1.4) as a warning.
278        TYPL_007 = "TYPL-007", Warning,
279            "unused import";
280
281        /// Import alias without an actual collision (typl §16.1, ADR-0002 §2).
282        /// Emitted by the resolver (E1.4) as a warning.
283        TYPL_008 = "TYPL-008", Warning,
284            "import alias without an actual collision";
285
286        /// Duplicate definition of the same name in a package (typl §16.1).
287        TYPL_009 = "TYPL-009", Error,
288            "duplicate definition of the same name in a package";
289
290        /// A package declaring a name the compiler provides itself (typl
291        /// §3.1, §16.1). Error, and not a warning, because such a package can
292        /// never work: `ridl.std` is implicitly imported by every package
293        /// (typl §3.2) rather than resolved through an import, so a user copy
294        /// is unreachable by its own name. In package and workspace mode the
295        /// generated artifact is overwritten by the standard package's too,
296        /// because the output base is the package name; in single-file mode
297        /// the base is the file stem, so that second consequence follows only
298        /// when the stem is itself `ridl.std`, whatever the extension.
299        /// Reported on the `package` declaration, in a workspace member and
300        /// in single-file mode alike.
301        TYPL_010 = "TYPL-010", Error,
302            "package name is reserved for a package the compiler provides";
303
304        /// A type reference names no visible declaration (typl §3.2, §3.3,
305        /// §16.1): the path resolves neither in the package's own scope, nor
306        /// in `ridl.std`, nor through an import, nor as a qualified
307        /// `pkg.Name` — a qualified reference to another package's `internal`
308        /// declaration included. Emitted by the checker on the written path,
309        /// in every position that takes a type reference — for example a
310        /// field, a parameter, a query return, a stream element, a payload,
311        /// a union arm, a map key or a constant's type. A reference that resolves to a declaration of the
312        /// wrong kind (a constant, an interface) is a different error and
313        /// still carries no code (driftsys/ridl#543).
314        TYPL_011 = "TYPL-011", Error,
315            "type reference names no visible declaration";
316
317        /// `integer` without a range constraint (typl §16.2). Warning.
318        TYPL_101 = "TYPL-101", Warning,
319            "`integer` without a range constraint";
320
321        /// `float` without both a range and a `step` (typl §16.2). Warning.
322        TYPL_102 = "TYPL-102", Warning,
323            "`float` without both a range and a `step`";
324
325        /// `string`/`bytes` without explicit bounds — the default `[0..256]` is
326        /// applied (typl §4.4–§4.5, §16.2). Warning.
327        TYPL_103 = "TYPL-103", Warning,
328            "`string`/`bytes` without explicit bounds";
329
330        /// Range `min > max` (typl §16.2).
331        TYPL_104 = "TYPL-104", Error,
332            "range `min > max`";
333
334        /// `step` type mismatch, non-positive, or larger than the range
335        /// (typl §16.2). Also borrowed by the checker (E1.7b) for a range bound
336        /// that references a non-numeric constant, a malformed bound const for
337        /// which §16.2 defines no dedicated code.
338        TYPL_105 = "TYPL-105", Error,
339            "`step` type mismatch, non-positive, or larger than the range";
340
341        /// Invalid regex syntax in a `match` constraint or a regex `const`
342        /// (typl §16.2). Validated with the `regress` ECMA-262 engine (ADR-0007
343        /// decision 10). Emitted by the checker (E1.7b).
344        TYPL_106 = "TYPL-106", Error,
345            "invalid regex syntax in `match` or a regex `const`";
346
347        /// `const` value violates its declared type constraints (typl §16.2).
348        TYPL_108 = "TYPL-108", Error,
349            "`const` value violates its declared type constraints";
350
351        /// Init (`= value`) incompatible with the type/field constraints
352        /// (typl §16.2).
353        TYPL_109 = "TYPL-109", Error,
354            "init `= value` incompatible with the type or field constraints";
355
356        /// Unknown or malformed UCUM unit expression (typl §16.2).
357        TYPL_110 = "TYPL-110", Error,
358            "unknown or malformed UCUM unit expression";
359
360        /// Integer range bound (or enumset bit position) outside the `int64`
361        /// domain (typl §4.2, §16.2).
362        TYPL_111 = "TYPL-111", Error,
363            "integer range bound outside the `int64` domain";
364
365        /// Type has no derivable init value and no declared `= value`
366        /// (typl §5.8, §16.2). Info — escalated to an error only by consumers
367        /// that require an init (e.g. a ridl signal payload).
368        TYPL_115 = "TYPL-115", Info,
369            "type has no derivable init value and no declared `= value`";
370
371        /// Array without explicit bounds (typl §16.3).
372        TYPL_201 = "TYPL-201", Error,
373            "array without explicit bounds";
374
375        /// Map without explicit bounds (typl §16.3).
376        TYPL_202 = "TYPL-202", Error,
377            "map without explicit bounds";
378
379        /// Enum values not unique / not explicitly assigned (typl §16.3).
380        TYPL_203 = "TYPL-203", Error,
381            "enum values not unique or not explicitly assigned";
382
383        /// Union arm with a primitive type (typl §16.3).
384        TYPL_204 = "TYPL-204", Error,
385            "union arm with a primitive type";
386
387        /// Recursive composite reference, direct or transitive (typl §16.3).
388        TYPL_206 = "TYPL-206", Error,
389            "recursive composite reference, direct or transitive";
390
391        /// Enumset bit positions not unique (typl §16.3).
392        TYPL_207 = "TYPL-207", Error,
393            "enumset bit positions not unique";
394
395        /// `string`/`bytes` used directly as a field type (typl §16.3).
396        TYPL_208 = "TYPL-208", Error,
397            "`string`/`bytes` used directly as a field type";
398
399        /// Map key is not a named string type or a primitive (typl §16.3).
400        TYPL_209 = "TYPL-209", Error,
401            "map key is not a named string type or a primitive";
402
403        /// Field, arm, or enum value re-declared under a `reserved` name or value
404        /// (typl §16.3).
405        TYPL_210 = "TYPL-210", Error,
406            "field, arm, or enum value re-declared under a `reserved` entry";
407
408        /// Duplicate `reserved` entry (typl §16.3). Warning. The "dangling"
409        /// half of the §16.3 rule (a name/value never previously used) needs the
410        /// previous IR snapshot and belongs to `ridl-diff` (E2.8).
411        TYPL_211 = "TYPL-211", Warning,
412            "duplicate `reserved` entry";
413
414        /// `error` modifier on a declaration other than `enum`, `struct`, `union`
415        /// (typl §16.3).
416        TYPL_212 = "TYPL-212", Error,
417            "`error` modifier on a declaration other than `enum`, `struct`, `union`";
418
419        /// Union mixing error and non-error arms without the result-union shape
420        /// (typl §16.3).
421        TYPL_213 = "TYPL-213", Error,
422            "union mixes error and non-error arms without the result-union shape";
423
424        /// `error union` containing a non-error-typed arm (typl §16.3).
425        TYPL_214 = "TYPL-214", Error,
426            "`error union` contains a non-error-typed arm";
427
428        /// A field name declared twice in one struct (typl §7, §16.3). Distinct
429        /// from RIDL-149: that code fires when two distinct source names
430        /// collide only after a pinned name transform, and this one fires
431        /// when the two source names are already the same, before any
432        /// transform runs. Both fields still lower — this check reports and
433        /// does not drop.
434        TYPL_215 = "TYPL-215", Error,
435            "field name declared twice in one struct";
436
437        /// An enum value name declared twice in one `enum` (typl §8, §16.3).
438        /// Distinct from RIDL-149: that code fires when two distinct source
439        /// names collide only after a pinned name transform, and this one
440        /// fires when the two source names are already the same, before any
441        /// transform runs. TYPL-203 checks the values' integers, not their
442        /// names, so it does not cover this case.
443        TYPL_216 = "TYPL-216", Error,
444            "enum value name declared twice in one enum";
445
446        /// A union arm name declared twice in one `union` (typl §10, §16.3).
447        /// Distinct from RIDL-149: that code fires when two distinct source
448        /// names collide only after a pinned name transform, and this one
449        /// fires when the two source names are already the same, before any
450        /// transform runs.
451        TYPL_217 = "TYPL-217", Error,
452            "union arm name declared twice in one union";
453
454        /// A bit name declared twice in one standalone `enumset` (typl §9.1,
455        /// §16.3). TYPL-207 checks the bits' positions, not their names, so
456        /// it does not cover this case. A derived `enumset` copies its bits
457        /// from the backing enum, where a repeated name is TYPL-216.
458        TYPL_218 = "TYPL-218", Error,
459            "enumset bit name declared twice in one enumset";
460
461        /// Stream type `<T>` outside interaction position (typl §16.4, ridl
462        /// §12.3). Emitted by the parser in a `.typl` parse (E2 task 2) and by
463        /// the checker for struct fields and collections in a `.ridl` file
464        /// (E2 task 5).
465        TYPL_301 = "TYPL-301", Error,
466            "stream type `<T>` outside interaction position";
467
468        /// Timing annotation or duration literal in a typl context (typl §16.4).
469        TYPL_302 = "TYPL-302", Error,
470            "timing annotation or duration literal in a typl context";
471
472        /// `require`/`ensure` attribute in a typl context (typl §16.4): the two
473        /// contract attributes at declaration-start position in a `.typl` parse.
474        /// Emitted by the parser (E2 task 2) as a bare string literal rather than
475        /// through this constant — see `codes_written_as_string_literals_are_all
476        /// _catalogued`.
477        TYPL_303 = "TYPL-303", Error,
478            "`require`/`ensure` attribute in a typl context";
479
480        /// Interaction declaration in a typl context (typl §16.4, ADR-0007
481        /// decision 10): one of the nine ridl words at declaration-start position
482        /// in a `.typl` parse. Emitted by the parser (E2 task 2).
483        TYPL_304 = "TYPL-304", Error,
484            "interaction declaration in a typl context";
485
486        /// Blank line between a doc comment and its definition (typl §14, §16.5).
487        /// Warning. Emitted by the checker (E1.7b).
488        TYPL_404 = "TYPL-404", Warning,
489            "blank line between a doc comment and its definition";
490
491        /// `@deprecated` doc tag without a reason string (typl §14.2, §16.5).
492        /// Warning. Emitted by the checker (E1.7b).
493        TYPL_405 = "TYPL-405", Warning,
494            "`@deprecated` doc tag without a reason string";
495    }
496
497    /// The ridl catalogue (ADR-0008 decision 21): every `RIDL-` code declared in
498    /// this module, with the severity the ridl reference §16 tables classify it
499    /// at. RIDL-140, RIDL-141, and RIDL-143 to RIDL-145 sit in the 1xx band
500    /// while the reference lists them under the §16.4 evolution table — a
501    /// documented anomaly kept as written (ADR-0008 decision 6). RIDL-111 and
502    /// RIDL-142 are reserved by ADR-0008 decision 21 and are not declared yet,
503    /// so they are absent here too.
504    ///
505    /// Adding a code here does **not** make it show up in a corpus fixture.
506    /// `RIDL_PROFILE_CODES` in `crates/ridlc/tests/corpus.rs` — the list that
507    /// gives every ridl code a living example — is a list of code *strings*
508    /// with no link to these constants, so a code minted here and omitted there
509    /// is caught by a string-set equality standing beside the declaration
510    /// (`ridl_profile_codes_match_the_catalogue`) rather than by the declaration
511    /// itself. Decision 21 asks the declare-once mechanism to cover that list as
512    /// well, and it does not: the list carries a `Provoked` discriminator this
513    /// catalogue has no equivalent of. Stated here as decision 21 requires; the
514    /// gap is separate work, tracked in issue #172.
515    RIDL_CATALOG {
516        /// `signal` or `event` without a timing annotation — the default
517        /// `[100ms..1000ms]` (or the configured `[defaults].timing`) is applied
518        /// (ridl §9.1, §16.1). Warning. Emitted by the checker (E2 task 9).
519        RIDL_100 = "RIDL-100", Warning,
520            "`signal` or `event` without a timing annotation";
521
522        /// A range annotation `@[X..Y]` whose lower bound exceeds its upper bound
523        /// (ridl §9.2, §16.1). Emitted by the checker (E2 task 9).
524        RIDL_101 = "RIDL-101", Error,
525            "timing range `@[X..Y]` with `X > Y`";
526
527        /// A zero or negative timing duration (ridl §9.2, §16.1). Emitted by the
528        /// checker (E2 task 9).
529        RIDL_102 = "RIDL-102", Error,
530            "zero or negative timing duration";
531
532        /// A strict-periodic `@Xms` annotation on a kind other than `signal` —
533        /// the isochronous mode belongs to state alone (ridl §9.2, §16.1).
534        /// Widened from "on an `event`" by ADR-0015 decision 6 when `command`
535        /// and `query` gained the range form (E9.4): the same rule, stated
536        /// over the three kinds it excludes instead of one. Emitted by the
537        /// checker (E2 task 9).
538        RIDL_103 = "RIDL-103", Error,
539            "strict-periodic `@Xms` on a kind other than `signal`";
540
541        /// Explicit return type on a `command` — a command always returns `()`
542        /// (ridl §6.1, §16.1). Emitted by the checker (E2 task 5).
543        RIDL_104 = "RIDL-104", Error,
544            "explicit return type on a `command`";
545
546        /// `query` returning `()` — use `command` (ridl §7.1, §16.1). Emitted by
547        /// the checker (E2 task 5).
548        RIDL_105 = "RIDL-105", Error,
549            "`query` returning `()`";
550
551        /// A timing annotation on `fixed` — the one kind that carries none —
552        /// or an attribute block on `fixed` (ridl §8, §9, §16.1). Emitted by
553        /// the checker (E2 task 5).
554        ///
555        /// Narrowed by ADR-0015 decision 6 (E9.4): `command` and `query` admit
556        /// the range form now, so the two RPC kinds left this rule and only
557        /// `fixed` remains in both halves. The callables drew FORM-102 until
558        /// the E2 close-out, so one rule sat under two codes and one of them
559        /// was a parse code whose catalogue meaning is "unexpected token" —
560        /// for a token the grammar accepts on purpose, precisely so the
561        /// narrowing can be a semantic rule with a semantic message.
562        RIDL_106 = "RIDL-106", Error,
563            "timing annotation on `fixed`, or attribute block on `fixed`";
564
565        /// Type declaration inside an `interface` or `service` body — typl
566        /// declarations live at package level (ridl §14.1, §16.1).
567        ///
568        /// Emitted by the **parser**, as a bare string literal (`ridl-syntax`
569        /// cannot reference [`DiagCode`]), at the point where it recognises the
570        /// keyword and recovers the declaration into an `ErrorNode`. The
571        /// checker used to code that node a second time, so every RIDL-107
572        /// arrived paired with a contradicting FORM-102 at the same span; the
573        /// parser knows exactly what the construct is, so it is the one that
574        /// names it. RIDL-403 and TYPL-304 are parser-raised for the same
575        /// reason. The constant is kept for the catalogue and for the error
576        /// index.
577        RIDL_107 = "RIDL-107", Error,
578            "type declaration inside an `interface` or `service` body";
579
580        /// A range annotation `@[X..X]` whose bounds are equal — a degenerate
581        /// range, the rate floor equal to its staleness bound, on a `signal` and an
582        /// `event` alike (ridl §9.2, §16.1; ADR-0008 decision 17). Not a spelling
583        /// of the strict-periodic `@Xms`, which is a separate `TimingMode`.
584        /// Warning. Emitted by the checker (E2 task 9).
585        RIDL_108 = "RIDL-108", Warning,
586            "degenerate timing range `@[X..X]`";
587
588        /// Signal payload type has no derivable init value and no `= value`
589        /// override (ridl §4.4, §16.1). Emitted by the checker (E2 task 5).
590        RIDL_109 = "RIDL-109", Error,
591            "signal payload has no derivable init and no `= value` override";
592
593        /// Signal `= value` init override violates the payload type's constraints
594        /// (ridl §4.4, §16.1). Emitted by the checker (E2 task 5).
595        RIDL_110 = "RIDL-110", Error,
596            "signal `= value` init override violates the payload constraints";
597
598        /// A `command` or `query` with no declared response bound — no `@`
599        /// annotation at all, or the half-open `@[min..]` that declares a
600        /// throttle only (ridl §9, §16.1; ADR-0015 decisions 4 and 6). Warning;
601        /// an active profile may escalate it to an error, the same two-step
602        /// §9.1 gives an untimed signal or event. The RPC counterpart of
603        /// RIDL-100, and deliberately not RIDL-100 itself: that text turns on
604        /// a default having been applied, which is exactly what an RPC never
605        /// gets — absent means undeclared in the IR. RIDL-111 is reserved for
606        /// the interface-used-as-a-type error (ADR-0008 decision 21), so 112
607        /// is the first free code in the band. Emitted by the checker (E9.4).
608        RIDL_112 = "RIDL-112", Warning,
609            "`command` or `query` with no declared response bound";
610
611        /// Duplicate `service` name across the whole workspace — the service
612        /// catalog is a flat global namespace (ridl §14.5, §16.4). Emitted
613        /// workspace-wide by `service_catalog` (E2 task 8). The reference numbers
614        /// it in the 1xx band while listing it under the §16.4 evolution/profile
615        /// table — a documented anomaly kept as written (ADR-0008 decision 6).
616        RIDL_140 = "RIDL-140", Error,
617            "duplicate `service` name across the workspace";
618
619        /// A `service` names a type that is not an `interface`, and has no inline
620        /// shape (ridl §14.5, §16.4). Emitted per-package by the checker (E2 task
621        /// 8). Kept in the 1xx band per ADR-0008 decision 6 (see RIDL-140).
622        /// Applies per shape in the service's shape list since ADR-0015
623        /// decision 18 (E9.6): the rule is unchanged, the span reports against
624        /// the offending list element.
625        RIDL_141 = "RIDL-141", Error,
626            "`service` names a type that is not an `interface`";
627
628        /// A `service` publishes an `internal` interface (ridl §14.5, §16.4).
629        /// Emitted per-package by the checker's exposure pass. Distinct from
630        /// TYPL-005: what leaks is an interface rather than a type, and a service
631        /// takes no `internal` modifier, so the TYPL-005 remedy — make the
632        /// exposing declaration internal too — does not exist here. Kept in the
633        /// 1xx band beside RIDL-140/-141 per ADR-0008 decision 6. RIDL-111 and
634        /// RIDL-142 are reserved by decision 21 and not yet implemented, so 143 is
635        /// the next free code; decision 13's allocation ledger needs the ninth
636        /// entry (issue #169). Applies per shape in the service's shape list
637        /// since ADR-0015 decision 18 (E9.6): the rule is unchanged, the span
638        /// reports against the offending list element.
639        RIDL_143 = "RIDL-143", Error,
640            "`service` publishes an `internal` interface";
641
642        /// Duplicate member name across a service's interfaces (ridl §14.5,
643        /// §16.4; ADR-0015 decisions 16 and 18): two composed interfaces both
644        /// declaring `status` would give `service.status` two referents, which
645        /// flat addressing cannot express. Emitted per-package by the checker
646        /// (E9.6). The service codes sit in the 1xx band (see RIDL-140);
647        /// RIDL-112 is minted by ADR-0015 decision 6, so 144 is the first free
648        /// code.
649        RIDL_144 = "RIDL-144", Error,
650            "duplicate member name across a service's interfaces";
651
652        /// The same interface named twice in one service (ridl §14.5, §16.4;
653        /// ADR-0015 decision 18). Its own code rather than a fall-through to
654        /// RIDL-144: listing a shape twice makes every member collide, so
655        /// RIDL-144 alone would emit one diagnostic per member and bury the
656        /// actual mistake. Emitted per-package by the checker (E9.6); lowering
657        /// keeps the first listing only, which holds the slot, and the
658        /// diagnostic's secondary label points at it.
659        RIDL_145 = "RIDL-145", Error,
660            "the same interface named twice in one service";
661
662        /// Two names in one scope that collide after a pinned name transform
663        /// (ridl §11, §16.4; ADR-0016 decision 3). Neither transform is
664        /// injective and no case-folding transform can be, so
665        /// `parseHTTPResponse` and `parseHttpResponse` both project to
666        /// `parse_http_response` and a target whose namespace is snake_case
667        /// would carry one identifier twice — in Rust, one trait with two
668        /// methods of the same name, or one function with two identically
669        /// named arguments. The projection contract's injectivity obligation
670        /// is discharged here, on the package, because it cannot be carried
671        /// by the function. Its own code rather than RIDL-402 — that rule is
672        /// the same name declared twice — because the remedy differs: these
673        /// names are distinct in source and only their projections collide.
674        /// Scoped to the members of one interface, the parameters of one
675        /// interaction (decision 4), the fields of one struct, which joined
676        /// in the commit where E9.8 started projecting them onto proto3, the
677        /// arms of one union, which joined with the ADR-0016 amendment of
678        /// 2026-09-20, and the values of one enum, which joined with the
679        /// amendment of 2026-09-26. The first three namespaces are checked
680        /// under `snake_case` alone; a union's arms are checked under
681        /// `snake_case` and `camel_case` both, because a union arm reaches
682        /// both namespaces and the two collision sets are incomparable; an
683        /// enum's values are checked under `pascal_case` alone, because its
684        /// collision set contains `snake_case`'s. The message names the
685        /// transform that collided. Emitted per-package by the checker (E9.7).
686        RIDL_149 = "RIDL-149", Error,
687            "two names in one scope collide after a pinned name transform";
688
689        /// Stream `<T>` on a `signal` or `event` payload (ridl §12.3, §16.2).
690        /// Emitted by the checker (E2 task 5).
691        RIDL_201 = "RIDL-201", Error,
692            "stream `<T>` on a `signal` or `event` payload";
693
694        /// Stream element type not a named type, `string`, or `bytes` (ridl
695        /// §12.2, §16.2). Emitted by the checker (E2 task 5).
696        RIDL_202 = "RIDL-202", Error,
697            "stream element type not a named type, `string`, or `bytes`";
698
699        /// `require` or `ensure` on `signal`, `event`, or `fixed` (ridl §13,
700        /// §16.3). Emitted by the checker (E2 task 5).
701        RIDL_301 = "RIDL-301", Error,
702            "`require` or `ensure` on `signal`, `event`, or `fixed`";
703
704        /// `ensure` on `command` — a command has no result to observe (ridl §6.1,
705        /// §16.3). Emitted by the checker (E2 task 5).
706        RIDL_302 = "RIDL-302", Error,
707            "`ensure` on `command`";
708
709        /// A fallible query return with no success path (ridl §10.1, §16.3; general
710        /// form §6.1): a bare `error` type in return position, an `error`-typed
711        /// success (left) arm of an inline `T | E`, or a non-error error (right)
712        /// arm. Error. Emitted by the checker (E2 task 10).
713        RIDL_303 = "RIDL-303", Error,
714            "fallible query return with no success path";
715
716        /// An `error`-typed or result-union parameter on a `command` or `query` —
717        /// failure flowing toward a provider (ridl §10.1, §16.3). Warning. Emitted
718        /// by the checker (E2 task 10).
719        RIDL_304 = "RIDL-304", Warning,
720            "`error`-typed or result-union parameter on a `command` or `query`";
721
722        /// An `ensure` clause that never references `result` — well-typed but
723        /// suspicious (ridl §13, §16.3; expr-core specification §8). Warning.
724        /// Emitted by the checker (E2 task 11).
725        RIDL_305 = "RIDL-305", Warning,
726            "`ensure` clause that never references `result`";
727
728        /// A `require`/`ensure` expression outside the guaranteed subset (ridl §13,
729        /// §16.3; expr-core specification §8 — one code for the whole boundary,
730        /// with a message naming the offending form). Error. Emitted by the checker
731        /// (E2 task 11).
732        RIDL_306 = "RIDL-306", Error,
733            "`require`/`ensure` expression outside the guaranteed subset";
734
735        /// An `error` enum declares a Stratum-2 contract-error category name
736        /// (`INVALID_VALUE`, `PRECONDITION_FAILED`, `CONTRACT_BROKEN`,
737        /// `UNKNOWN_INTERACTION`) — reserved vocabulary (ridl §10.2, §16.3).
738        /// Warning. Emitted by the checker (E2 task 10).
739        RIDL_307 = "RIDL-307", Warning,
740            "contract-error category name declared in an `error` enum";
741
742        /// A named result union in query return position — the inline `T | E`
743        /// spelling is canonical there (general form §6.1, ADR-0008 decision 13).
744        /// Warning; the named spelling stays legal typl data, so this is a lint,
745        /// not an error. Emitted by the lint pass (E2 task 19).
746        RIDL_308 = "RIDL-308", Warning,
747            "named result union in query return position";
748
749        /// Interaction re-declared under a `reserved` name (ridl §11, §16.4).
750        /// Emitted by the checker (E2 task 5).
751        RIDL_401 = "RIDL-401", Error,
752            "interaction re-declared under a `reserved` name";
753
754        /// Duplicate interaction name in one interaction body — an `interface`
755        /// or a service's inline shape (ridl §14.1, §16.4). Emitted by the
756        /// checker (E2 task 5); lowering keeps the first declaration only, and
757        /// the diagnostic's secondary label points at it.
758        RIDL_402 = "RIDL-402", Error,
759            "duplicate interaction name in one interaction body";
760
761        /// Behaviour, user-interaction, or architecture declaration in a ridl
762        /// context (ridl §16.4): a reserved word of the uxdl/rmdl/rsdl profiles at
763        /// declaration-start position in a `.ridl` parse. Emitted by the parser
764        /// (E2 task 2).
765        RIDL_403 = "RIDL-403", Error,
766            "behaviour, user-interaction, or architecture declaration in a ridl context";
767
768        /// A query named like a mutation — `set…`, `reset…`, and the rest of the
769        /// mutating verb set (ridl §7.2, §16.4): a state-mutating request belongs
770        /// to `command`. Warning. Emitted by the lint pass (E2 task 19).
771        RIDL_404 = "RIDL-404", Warning,
772            "query named like a mutation";
773
774        /// One `error` type used as the failure arm of queries in three or more
775        /// distinct interfaces — the "shared across unrelated failure domains"
776        /// heuristic (ridl §10.1, §16.4). Info. Emitted by the lint pass (E2 task
777        /// 19); the threshold is three, so two interfaces stay silent.
778        RIDL_405 = "RIDL-405", Info,
779            "one `error` type shared across unrelated failure domains";
780
781        /// A `signal` or `event` payload whose struct re-declares envelope
782        /// metadata — publication time or a frame counter (ridl §3.1, §16.4). Info;
783        /// domain time distinct from transport time is legitimate, so the message
784        /// says so. Emitted by the lint pass (E2 task 19).
785        RIDL_406 = "RIDL-406", Info,
786            "payload struct re-declares envelope metadata";
787
788        /// An interaction's, struct field's or union arm's ordinal (typl §7.4)
789        /// changed against a published baseline snapshot
790        /// (ridl §11, general form §6.3). Warning. Emitted by the `ridl check`
791        /// desk check (E2 task 18), never by the compiler: the comparison
792        /// reads a workspace-local baseline, which is outside `ridlc`'s
793        /// source→IR function (ADR-0008 decisions 9 and 13). For a struct
794        /// field or union arm the desk reads the verdict `ridl diff` gates
795        /// on, so the two agree by construction (driftsys/ridl#533): one
796        /// warning per change the diff reports as breaking. That is a member
797        /// inserted above a live one or into a retired slot; a member
798        /// removed, with or without a tombstone, because `ridl diff` matches
799        /// a composite member by name and does not yet read the body's
800        /// `reserved` entries; a member whose ordinal moved in an edit that
801        /// added or removed none, by a reorder or by a `reserved` entry
802        /// added, moved or removed above it; and a member appended beside
803        /// any of those, which the diff reports as breaking although the
804        /// append is compatible on its own. An append alone draws nothing,
805        /// except an arm added to a result union, whose arms are its
806        /// transport identity (ADR-0008 decision 4).
807        /// The diff reports no reorder beside an addition or a removal, so
808        /// the warning for an added or removed member names the siblings
809        /// whose ordinal changed. An enum value's or enum-set bit's reorder
810        /// is not a change (typl §8, §9) and does not draw this warning
811        /// (driftsys/ridl#335); an enum value added or removed is `ridl
812        /// diff`'s alone.
813        RIDL_407 = "RIDL-407", Warning,
814            "interaction, struct field, or union arm ordinal changed against the published \
815             baseline";
816
817        /// An interaction of an interface body the baseline being replaced
818        /// declares is not carried forward as the tombstone rule requires
819        /// (ridl §11), in one of four shapes: it is gone from the source with
820        /// no `reserved` tombstone; the source retires it with a tombstone at
821        /// an ordinal other than its own; the baseline already retired it and
822        /// the source dropped the tombstone; or the source declares a live
823        /// interaction under the name a tombstone retires. Error. Emitted by
824        /// `ridl baseline` alone, for the interaction level only — a whole
825        /// interface or service removed, and a named-form service's shape
826        /// list, are outside it. Publication is the last point at which the
827        /// change can still be refused, because the snapshot about to be
828        /// overwritten is the only record that the ordinal was ever taken.
829        /// Distinct from RIDL-407, which is the desk-time warning that an
830        /// ordinal moved and which neither classifies nor gates.
831        RIDL_408 = "RIDL-408", Error,
832            "interaction removed, its tombstone dropped or moved, or its retired name redeclared";
833
834        /// A live entry of the package's `interfaces.lock` names an interface
835        /// the package no longer declares (lock design §4, §8) — the interface
836        /// was renamed or removed, and the lock does not record which. Error.
837        /// Emitted by the checker on the entry's own line of the lock file,
838        /// `ridlc` and `ridl` alike. The fix is `ridl lock <pkg> --retire Old`
839        /// when the interface is gone, or `ridl lock <pkg> --rename Old=New`
840        /// when a declaration without an entry is the same interface under a
841        /// new name; the message names only `--retire` when the package has
842        /// no declaration without an entry. `ridl check` adds a label naming
843        /// the one `--rename` command when the published baseline shows
844        /// exactly one declaration without an entry with the old interface's
845        /// shape.
846        RIDL_409 = "RIDL-409", Error,
847            "live `interfaces.lock` entry with no declaration";
848
849        /// The package's `interfaces.lock` is malformed (lock design §2, §8):
850        /// no `next` line, `next` not greater than every entry's number, one
851        /// number on two entries, one live key on two entries, or a line that
852        /// does not parse — git conflict markers included. Error. Emitted by
853        /// the loader, on the offending line of the lock file itself (or at
854        /// the start of an empty file), so `ridlc` and `ridl` alike stop on
855        /// it; the package then compiles without its lock. The fix is to
856        /// resolve the conflict or restore the file from version control and
857        /// run `ridl lock`.
858        RIDL_410 = "RIDL-410", Error,
859            "`interfaces.lock` is malformed";
860
861        /// An interface in the snapshot `ridl baseline` is about to publish
862        /// carries a provisional number — a declaration with no entry in the
863        /// package's `interfaces.lock` (lock design §3, §8). A provisional
864        /// number is no identity: `ridl diff` never matches on it, so a
865        /// snapshot holding one records nothing a later comparison can hold
866        /// the interface to. Error. Emitted by `ridl baseline` alone, at the
867        /// declaration's name, for a first publication as for a replacement.
868        /// The fix is plain `ridl lock`, which allocates and records the
869        /// number, then publish.
870        RIDL_411 = "RIDL-411", Error,
871            "provisional interface number refused at publication";
872
873        /// An interface number the published baseline holds is absent from
874        /// the fresh snapshot and not among its `interfaces.lock` retired
875        /// entries (lock design §4, §8) — a lock line deleted by hand, since a
876        /// live entry with no declaration already fails the build with
877        /// RIDL-409. Publishing would lose the only record that the number was
878        /// allocated, and `next` could hand it to a later interface. Error.
879        /// Emitted by `ridl baseline` alone, for a published number other
880        /// than 0: a snapshot published before the lock existed carries 0,
881        /// which is never allocated, and is matched by name. The fix is to
882        /// restore the entry's line in `interfaces.lock` from version
883        /// control, with `retired` after the number when the interface is
884        /// gone.
885        RIDL_412 = "RIDL-412", Error,
886            "published interface number dropped without a retired entry";
887
888        /// A parameter name declared twice in one `command` or `query`
889        /// parameter list (ridl §6.1, §7.1, §16.4). Distinct from RIDL-149:
890        /// that code fires when two distinct source names collide only after
891        /// a pinned name transform, and this one fires when the two source
892        /// names are already the same, before any transform runs. Both
893        /// parameters still lower — this check reports and does not drop.
894        RIDL_413 = "RIDL-413", Error,
895            "parameter name declared twice in one parameter list";
896    }
897
898    /// The rsdl catalogue: every `RSDL-` code declared in this module, with the
899    /// severity the rsdl reference §16.1 table classifies it at. A code is
900    /// declared with the pass that raises it. The §16.2 reserved codes and the
901    /// §16.3 retired codes are never declared. `RSDL_PROFILE_CODES` in
902    /// `crates/ridlc/tests/corpus.rs` gives every code here a living example,
903    /// and `rsdl_profile_codes_match_the_catalogue` holds the two lists equal.
904    RSDL_CATALOG {
905        /// `instances` is not a parenthesised list of one or more camelCase
906        /// names — `()`, `instances = solo` (rsdl §5, §7, §16.1). Error. Raised
907        /// by the rsdl attribute check.
908        RSDL_305 = "RSDL-305", Error,
909            "`instances` is not a parenthesised list of one or more camelCase names";
910
911        /// A duplicate instance name in one component (rsdl §7, §16.1). Error.
912        /// Raised by the rsdl closure check.
913        RSDL_306 = "RSDL-306", Error,
914            "duplicate instance name in one component";
915
916        /// `Unit` written in source — as a declared instance name, or as the
917        /// instance segment of a reference (rsdl §7, §16.1). Error. Raised by
918        /// the rsdl closure check.
919        RSDL_307 = "RSDL-307", Error,
920            "`Unit` written in source";
921
922        /// A component requires an interface listed by a service it offers
923        /// (rsdl §3.2, §16.1). Error. Raised by the rsdl closure check.
924        RSDL_308 = "RSDL-308", Error,
925            "a component requires an interface listed by a service it offers";
926
927        /// The same service on two `offers` lines, or the same interface on two
928        /// `requires` lines, of one component (rsdl §3.2, §16.1). Error. Raised
929        /// by the rsdl closure check.
930        RSDL_309 = "RSDL-309", Error,
931            "the same service or interface on two lines of one component";
932
933        /// An `offers` line names something that is not a service, or nothing
934        /// (rsdl §3.2, §16.1). Error. Raised by the rsdl closure check.
935        RSDL_310 = "RSDL-310", Error,
936            "an `offers` line names something that is not a service";
937
938        /// A `requires` line names a service whose shape is a list of
939        /// interfaces; the diagnostic lists them (rsdl §3.2, §16.1). Error.
940        /// Raised by the rsdl closure check.
941        RSDL_311 = "RSDL-311", Error,
942            "a `requires` line names a service whose shape is a list of interfaces";
943
944        /// A `requires` line names something that is neither an interface nor
945        /// an inline-shape service, or nothing (rsdl §3.2, §16.1). Error. Raised
946        /// by the rsdl closure check.
947        RSDL_312 = "RSDL-312", Error,
948            "a `requires` line names neither an interface nor an inline-shape service";
949
950        /// `external` written with a value — it is a flag (rsdl §5, §16.1).
951        /// Error. Raised by the rsdl attribute check.
952        RSDL_313 = "RSDL-313", Error,
953            "`external` written with a value";
954
955        /// A closure component requires an interface that no closure service
956        /// lists — a missing provider (rsdl §8, §16.1). Error. Raised by the rsdl
957        /// resolution.
958        RSDL_403 = "RSDL-403", Error,
959            "a closure component requires an interface no closure service lists";
960
961        /// An interface listed by two services of the closure, raised for every
962        /// interface they list (rsdl §8, §16.1). Error. Raised by the rsdl
963        /// resolution.
964        RSDL_408 = "RSDL-408", Error,
965            "an interface listed by two services of the closure";
966
967        /// A `requires` resolves to a redundant provider set — an offering
968        /// component with more than one instance (rsdl §7, §16.1). Warning, not
969        /// yet realizable: the lowering proceeds. Raised by the rsdl resolution.
970        RSDL_409 = "RSDL-409", Warning,
971            "a `requires` resolves to a redundant provider set";
972
973        /// Two closure components offer one service (rsdl §8, §16.1). Error.
974        /// Raised by the rsdl resolution.
975        RSDL_502 = "RSDL-502", Error,
976            "two closure components offer one service";
977
978        /// A member line names a service by its name while a declared component
979        /// offers it; the diagnostic names the offerer (rsdl §6, §16.1). Error.
980        /// Raised by the rsdl closure check.
981        RSDL_504 = "RSDL-504", Error,
982            "a member line names a service that a declared component offers";
983
984        /// More than one `system` in the workspace (rsdl §3.1, §16.1). Error.
985        /// Raised by the rsdl closure check.
986        RSDL_601 = "RSDL-601", Error,
987            "more than one `system` in the workspace";
988
989        /// A `system` member line names nothing that is a component or a service
990        /// (rsdl §3.1, §16.1). Error. Raised by the rsdl closure check.
991        RSDL_602 = "RSDL-602", Error,
992            "a `system` member line names neither a component nor a service";
993
994        /// A name listed twice in one `system` body (rsdl §3.1, §16.1). Error.
995        /// Raised by the rsdl closure check.
996        RSDL_603 = "RSDL-603", Error,
997            "a name listed twice in one `system` body";
998
999        /// A declaration of another profile — a type, an interface, a service —
1000        /// at the top level of an `.rsdl` file (rsdl §2, §16.1). Error. Raised
1001        /// by the parser.
1002        RSDL_604 = "RSDL-604", Error,
1003            "a declaration of another profile in an `.rsdl` file";
1004
1005        /// An instance of a closure component with no placement in a deployment
1006        /// (rsdl §9, §16.1). Error; blocks that deployment only (§13). Raised by
1007        /// the rsdl placement check.
1008        RSDL_701 = "RSDL-701", Error,
1009            "an instance of a closure component with no placement in a deployment";
1010
1011        /// A placement line names a component, instance or service outside the
1012        /// closure, or a name that resolves to nothing (rsdl §9, §16.1). Error.
1013        /// Raised by the rsdl placement check.
1014        RSDL_702 = "RSDL-702", Error,
1015            "a placement line names something outside the closure, or nothing";
1016
1017        /// `deployment … for Y` where `Y` resolves to no declared `system`
1018        /// (rsdl §3.4, §16.1). Error. Raised by the rsdl placement check.
1019        RSDL_704 = "RSDL-704", Error,
1020            "a deployment is `for` no declared `system`";
1021
1022        /// Two machines with one name in one deployment (rsdl §3.5, §16.1).
1023        /// Error. Raised by the rsdl placement check.
1024        RSDL_705 = "RSDL-705", Error,
1025            "two machines with one name in one deployment";
1026
1027        /// An instance placed twice in one deployment, also `Cruise` together
1028        /// with `Cruise.primary` (rsdl §9, §16.1). Error. Raised by the rsdl
1029        /// placement check.
1030        RSDL_706 = "RSDL-706", Error,
1031            "an instance placed twice in one deployment";
1032
1033        /// An `external` machine lists an implemented component (rsdl §9,
1034        /// §16.1). Error. Raised by the rsdl placement check.
1035        RSDL_707 = "RSDL-707", Error,
1036            "an `external` machine lists an implemented component";
1037
1038        /// Two deployments with one name in the workspace (rsdl §3.4, §16.1).
1039        /// Error. Raised by the rsdl placement check.
1040        RSDL_708 = "RSDL-708", Error,
1041            "two deployments with one name in the workspace";
1042
1043        /// A backend key whose namespace no configured backend claims (rsdl §5,
1044        /// §16.1). Warning: the key is still carried. Raised by `ridlc`, which
1045        /// knows the configured backends (plan decision P-B4).
1046        RSDL_804 = "RSDL-804", Warning,
1047            "a backend key whose namespace no configured backend claims";
1048
1049        /// A `PLATFORM` distribution holds a component whose `requires` resolves
1050        /// into an `APPLICATION` distribution — tier inversion; a distribution
1051        /// without `tier` is exempt (rsdl §3.3, §16.1). Error. Raised by the
1052        /// rsdl distribution check.
1053        RSDL_901 = "RSDL-901", Error,
1054            "a `PLATFORM` distribution requires into an `APPLICATION` distribution";
1055
1056        /// A distribution member line names something outside the closure, or
1057        /// nothing (rsdl §3.3, §16.1). Error. Raised by the rsdl distribution
1058        /// check.
1059        RSDL_903 = "RSDL-903", Error,
1060            "a distribution member line names something outside the closure, or nothing";
1061
1062        /// An implemented closure component in no distribution, while the
1063        /// workspace declares at least one (rsdl §3.3, §16.1). Error. Raised by
1064        /// the rsdl distribution check.
1065        RSDL_904 = "RSDL-904", Error,
1066            "an implemented closure component in no distribution";
1067
1068        /// A component listed by two distributions (rsdl §3.3, §16.1). Error.
1069        /// Raised by the rsdl distribution check.
1070        RSDL_905 = "RSDL-905", Error,
1071            "a component listed by two distributions";
1072
1073        /// A name listed twice in one distribution body (rsdl §3.3, §16.1).
1074        /// Error. Raised by the rsdl distribution check.
1075        RSDL_906 = "RSDL-906", Error,
1076            "a name listed twice in one distribution body";
1077
1078        /// An `external` component listed by a distribution (rsdl §3.3, §16.1).
1079        /// Error. Raised by the rsdl distribution check.
1080        RSDL_907 = "RSDL-907", Error,
1081            "an `external` component listed by a distribution";
1082
1083        /// A `tier` value other than `PLATFORM` or `APPLICATION` (rsdl §5,
1084        /// §16.1). Error. Raised by the rsdl attribute check.
1085        RSDL_908 = "RSDL-908", Error,
1086            "`tier` value other than `PLATFORM` or `APPLICATION`";
1087    }
1088
1089    /// The manifest catalogue (ADR-0007 decision 2): the manifest `0xx` codes the
1090    /// `ridl.toml` parser (E1.5) and the package loader (E1.3) emit, and the
1091    /// distribution `1xx` codes the import materializer (E1.6) emits. Listed here
1092    /// even for `MANI-004`, whose emission site is the loader rather than the
1093    /// standalone parser, so the error index (E4.2) has one authoritative source.
1094    MANI_CATALOG {
1095        /// The `ridl.toml` text is not valid TOML.
1096        MANI_001 = "MANI-001", Error,
1097            "invalid manifest TOML";
1098
1099        /// The manifest declares both `[package]` and `[workspace]`; the two modes
1100        /// are mutually exclusive (ADR-0002 §4).
1101        MANI_002 = "MANI-002", Error,
1102            "manifest declares both `[package]` and `[workspace]`";
1103
1104        /// The manifest declares neither `[package]` nor `[workspace]` (ADR-0002 §4).
1105        MANI_003 = "MANI-003", Error,
1106            "manifest declares neither `[package]` nor `[workspace]`";
1107
1108        /// A workspace member's own manifest declares `[workspace]`; nested
1109        /// workspaces are forbidden (ADR-0002 §4). Defined here, but emitted by the
1110        /// package loader (E1.3, task 8) when a member manifest is read — a single
1111        /// manifest parsed in isolation cannot know it is a member.
1112        MANI_004 = "MANI-004", Error,
1113            "nested workspace: a member manifest declares `[workspace]`";
1114
1115        /// An unrecognized key in the manifest or one of its sections (warning).
1116        MANI_005 = "MANI-005", Warning,
1117            "unknown manifest key";
1118
1119        /// The package name is not lowercase dot-separated segments (ADR-0002 §1).
1120        MANI_006 = "MANI-006", Error,
1121            "invalid package name";
1122
1123        /// An `[imports]` value is not a valid import URL.
1124        MANI_007 = "MANI-007", Error,
1125            "invalid import URL";
1126
1127        /// A workspace member directory is missing or has no `ridl.toml`. Emitted
1128        /// by the package loader (E1.3), which is where member paths are resolved
1129        /// against the filesystem.
1130        MANI_008 = "MANI-008", Error,
1131            "workspace member directory has no `ridl.toml`";
1132
1133        /// The manifest `[defaults].timing` value is not a valid range (ridl §9.1,
1134        /// ADR-0008 decision 13). The manifest parser stores the raw string
1135        /// unparsed — `ridl-core` cannot depend on `ridl-sem` — so the checker
1136        /// parses it and emits this code (E2 task 9).
1137        MANI_009 = "MANI-009", Error,
1138            "invalid `[defaults].timing` value";
1139
1140        /// A remote import could not be fetched (network failure, a non-2xx HTTP
1141        /// status, or a value that is not a fetchable `http(s)` URL).
1142        MANI_101 = "MANI-101", Error,
1143            "remote import fetch failed";
1144
1145        /// Fetched content hashes to a value that does not match the SHA-256 the
1146        /// lockfile pins for the same URL (ADR-0002 §7).
1147        MANI_102 = "MANI-102", Error,
1148            "fetched content hash does not match the lockfile";
1149
1150        /// `--frozen` was requested but the lockfile has no entry for a remote
1151        /// import; a frozen build never regenerates the lockfile (ADR-0002 §7).
1152        MANI_103 = "MANI-103", Error,
1153            "`--frozen`: no lockfile entry for a remote import";
1154
1155        /// `--frozen` was requested and a lockfile-pinned import is not present in
1156        /// the cache; a frozen build never fetches (ADR-0002 §7).
1157        MANI_104 = "MANI-104", Error,
1158            "`--frozen`: a lockfile-pinned import is not cached";
1159    }
1160}
1161
1162/// A diagnostic's severity. Warnings and info diagnostics arrive with later
1163/// passes; every code the E1.10 pipeline emits is an [`Error`](Severity::Error).
1164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1165pub enum Severity {
1166    Error,
1167    Warning,
1168    Info,
1169}
1170
1171/// An interned file id, issued by [`SourceMap::file_id`]. It indexes the file's
1172/// path and text inside the [`SourceMap`], which the renderer reads to draw the
1173/// source snippet.
1174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
1175#[serde(transparent)]
1176pub struct FileId(u32);
1177
1178impl FileId {
1179    /// A sentinel id for a diagnostic that is not tied to any source file. The
1180    /// lockfile, cache, and fetch diagnostics (MANI-1xx) concern a URL rather
1181    /// than a byte span, so they carry this id; [`render`](render()) draws them as a bare
1182    /// coded message with no source snippet. No [`SourceMap`] ever issues it.
1183    pub const DETACHED: FileId = FileId(u32::MAX);
1184}
1185
1186/// A source location: a byte range inside a specific file. The range is a
1187/// `rowan::TextRange` — the same coordinate space parse and semantic passes work
1188/// in — so an offset never has to be translated on the way into a diagnostic.
1189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1190pub struct Span {
1191    pub file: FileId,
1192    #[serde(serialize_with = "serialize_text_range")]
1193    pub range: TextRange,
1194}
1195
1196/// Serializes a `rowan::TextRange` as `{ "start": u32, "end": u32 }`, keeping
1197/// the byte offsets readable and exact in JSON snapshots.
1198fn serialize_text_range<S: Serializer>(
1199    range: &TextRange,
1200    serializer: S,
1201) -> Result<S::Ok, S::Error> {
1202    use serde::ser::SerializeStruct;
1203    let mut state = serializer.serialize_struct("TextRange", 2)?;
1204    state.serialize_field("start", &u32::from(range.start()))?;
1205    state.serialize_field("end", &u32::from(range.end()))?;
1206    state.end()
1207}
1208
1209/// A secondary annotation: a span with a message, drawn under the source
1210/// alongside the primary span.
1211#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1212pub struct Label {
1213    pub span: Span,
1214    pub message: String,
1215}
1216
1217/// A suggested edit: replace the text at `span` with `replacement`. `label`
1218/// describes the fix for a human. Rendered as a note under the diagnostic; a
1219/// later LSP task maps it to a code action.
1220#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1221pub struct FixIt {
1222    pub span: Span,
1223    pub replacement: String,
1224    pub label: String,
1225}
1226
1227/// One coded diagnostic. `primary` is the main span the message points at;
1228/// `labels` are secondary annotations; `fixits` are suggested edits.
1229#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1230pub struct Diagnostic {
1231    pub code: DiagCode,
1232    pub severity: Severity,
1233    pub message: String,
1234    pub primary: Span,
1235    pub labels: Vec<Label>,
1236    pub fixits: Vec<FixIt>,
1237}
1238
1239/// The path and text of one interned file.
1240#[derive(Debug)]
1241struct SourceEntry {
1242    path: String,
1243    text: String,
1244}
1245
1246/// The file table the renderer reads: it maps every [`FileId`] to the path and
1247/// text a diagnostic's spans point into. Ids are interned by path, so asking for
1248/// the same path twice returns the same id.
1249#[derive(Debug, Default)]
1250pub struct SourceMap {
1251    files: Vec<SourceEntry>,
1252}
1253
1254impl SourceMap {
1255    /// An empty source map.
1256    pub fn new() -> Self {
1257        Self::default()
1258    }
1259
1260    /// The [`FileId`] for `path`, interning `path` and `text` on first sight.
1261    /// A pass holding a file's path and text calls this to stamp its spans.
1262    pub fn file_id(&mut self, path: &str, text: &str) -> FileId {
1263        if let Some(index) = self.files.iter().position(|entry| entry.path == path) {
1264            return FileId(index as u32);
1265        }
1266        let id = FileId(self.files.len() as u32);
1267        self.files.push(SourceEntry {
1268            path: path.to_string(),
1269            text: text.to_string(),
1270        });
1271        id
1272    }
1273
1274    /// The path an interned [`FileId`] stands for, or `None` for an id this map
1275    /// never issued (including [`FileId::DETACHED`]). This is the reverse of
1276    /// [`file_id`](Self::file_id): a caller holding a diagnostic's `FileId` reads
1277    /// back the file it points at without probing candidate paths.
1278    pub fn path(&self, id: FileId) -> Option<&str> {
1279        self.files
1280            .get(id.0 as usize)
1281            .map(|entry| entry.path.as_str())
1282    }
1283
1284    /// The text an interned [`FileId`] stands for, or `None` for an id this map
1285    /// never issued (including [`FileId::DETACHED`]).
1286    pub fn text(&self, id: FileId) -> Option<&str> {
1287        self.files
1288            .get(id.0 as usize)
1289            .map(|entry| entry.text.as_str())
1290    }
1291}
1292
1293/// A 1-based line and column. The column counts Unicode scalar values — Rust
1294/// `char`s — not UTF-8 bytes and not the UTF-16 code units LSP positions use.
1295#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1296pub struct LineCol {
1297    pub line: u32,
1298    pub column: u32,
1299}
1300
1301/// The line and column of a byte `offset` into `text`. An offset past the end
1302/// of the text, or inside a multi-byte character, is moved back to the nearest
1303/// character boundary at or before it.
1304pub fn line_col(text: &str, offset: TextSize) -> LineCol {
1305    let mut offset = usize::from(offset).min(text.len());
1306    while !text.is_char_boundary(offset) {
1307        offset -= 1;
1308    }
1309    let before = &text[..offset];
1310    let line = before.matches('\n').count() as u32 + 1;
1311    let column = match before.rfind('\n') {
1312        Some(newline) => before[newline + 1..].chars().count(),
1313        None => before.chars().count(),
1314    } as u32
1315        + 1;
1316    LineCol { line, column }
1317}
1318
1319/// A span in the JSON diagnostic contract: the file's path as registered in the
1320/// [`SourceMap`] and 1-based start and end positions. `end` is exclusive: the
1321/// position one past the last character in the span, not the position of the
1322/// last character itself.
1323#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1324pub struct JsonSpan {
1325    pub path: String,
1326    pub start: LineCol,
1327    pub end: LineCol,
1328}
1329
1330/// A fix-it in the JSON diagnostic contract, verbatim from the compiler.
1331#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1332pub struct JsonFixIt {
1333    pub label: String,
1334    pub replacement: String,
1335    pub span: JsonSpan,
1336}
1337
1338/// A label in the JSON diagnostic contract, verbatim from the compiler.
1339#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1340pub struct JsonLabel {
1341    pub message: String,
1342    pub span: JsonSpan,
1343}
1344
1345/// One diagnostic in the JSON contract `ridl check --format json` and the MCP
1346/// `ridl_check` tool emit. This is the first agent-facing diagnostic contract
1347/// (ADR-0005 §7): a change to its shape is a change to an external contract.
1348/// `code` passes the diagnostic's [`DiagCode`] through verbatim, including the
1349/// empty string a diagnostic with no assigned catalogue code carries; the
1350/// field is always present, never omitted. `labels` passes the diagnostic's
1351/// secondary annotations through verbatim, in the order the diagnostic holds
1352/// them; the array is always present, empty when the diagnostic carries none.
1353#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1354pub struct JsonDiagnostic {
1355    pub code: String,
1356    pub severity: String,
1357    pub message: String,
1358    pub span: JsonSpan,
1359    pub labels: Vec<JsonLabel>,
1360    pub fixes: Vec<JsonFixIt>,
1361}
1362
1363fn json_span(span: Span, sources: &SourceMap) -> JsonSpan {
1364    let path = sources.path(span.file).unwrap_or("").to_string();
1365    let text = sources.text(span.file).unwrap_or("");
1366    JsonSpan {
1367        path,
1368        start: line_col(text, span.range.start()),
1369        end: line_col(text, span.range.end()),
1370    }
1371}
1372
1373fn severity_name(severity: Severity) -> &'static str {
1374    match severity {
1375        Severity::Error => "error",
1376        Severity::Warning => "warning",
1377        Severity::Info => "info",
1378    }
1379}
1380
1381/// Projects `diagnostics` onto the JSON contract, resolving every span through
1382/// `sources`.
1383pub fn to_json(diagnostics: &[Diagnostic], sources: &SourceMap) -> Vec<JsonDiagnostic> {
1384    diagnostics
1385        .iter()
1386        .map(|diagnostic| JsonDiagnostic {
1387            code: diagnostic.code.0.to_string(),
1388            severity: severity_name(diagnostic.severity).to_string(),
1389            message: diagnostic.message.clone(),
1390            span: json_span(diagnostic.primary, sources),
1391            labels: diagnostic
1392                .labels
1393                .iter()
1394                .map(|label| JsonLabel {
1395                    message: label.message.clone(),
1396                    span: json_span(label.span, sources),
1397                })
1398                .collect(),
1399            fixes: diagnostic
1400                .fixits
1401                .iter()
1402                .map(|fixit| JsonFixIt {
1403                    label: fixit.label.clone(),
1404                    replacement: fixit.replacement.clone(),
1405                    span: json_span(fixit.span, sources),
1406                })
1407                .collect(),
1408        })
1409        .collect()
1410}
1411
1412/// Remaps per-package diagnostics onto a renderer's [`SourceMap`] ids.
1413///
1414/// The package-scoped passes (`resolve_package`, `check_package` in
1415/// `ridl-sem`) stamp their spans with a [`FileId`] that indexes the package's
1416/// `files` **in order** — they run inside salsa queries and cannot share the
1417/// caller's [`SourceMap`]. A renderer first interns each package file into its
1418/// own map (collecting the issued ids in the same file order), then calls this
1419/// to rewrite every span onto those ids. [`FileId::DETACHED`] spans and any
1420/// index past `render_ids` are left untouched.
1421pub fn remap_diagnostics(
1422    diagnostics: impl IntoIterator<Item = Diagnostic>,
1423    render_ids: &[FileId],
1424) -> Vec<Diagnostic> {
1425    let remap_file =
1426        |file: FileId| -> FileId { render_ids.get(file.0 as usize).copied().unwrap_or(file) };
1427    diagnostics
1428        .into_iter()
1429        .map(|mut diagnostic| {
1430            diagnostic.primary.file = remap_file(diagnostic.primary.file);
1431            for label in &mut diagnostic.labels {
1432                label.span.file = remap_file(label.span.file);
1433            }
1434            for fixit in &mut diagnostic.fixits {
1435                fixit.span.file = remap_file(fixit.span.file);
1436            }
1437            diagnostic
1438        })
1439        .collect()
1440}
1441
1442/// One row of a diagnostic catalogue: a code, its default severity, and a short
1443/// human summary. The catalogue is the static SSOT the error index (E4.2) reads;
1444/// the per-diagnostic [`Severity`] a pass emits is set independently.
1445#[derive(Debug, Clone, Copy)]
1446pub struct CatalogEntry {
1447    pub code: DiagCode,
1448    pub severity: Severity,
1449    pub summary: &'static str,
1450}
1451
1452/// Polishes a raw parser message into the house diagnostic style —
1453/// description-first, with backticked names (fixes issue #102). Most parser
1454/// messages are already house-style and pass through unchanged; the parser's
1455/// `expect` path emits a `Debug`-shaped token name (`expected RBracket`), which
1456/// this maps to a backticked glyph (`` expected `]` ``).
1457///
1458/// Keeping the raw parser message as the input leaves `ridl-syntax` and its
1459/// tests untouched — they assert on codes and ranges, not message text — while
1460/// the rendered diagnostics still read in one consistent style.
1461pub fn house_style_message(raw: &str) -> String {
1462    if let Some(token) = raw.strip_prefix("expected ")
1463        && let Some(glyph) = punctuation_glyph(token)
1464    {
1465        return format!("expected {glyph}");
1466    }
1467    raw.to_string()
1468}
1469
1470/// The backticked glyph for a punctuation or operator `SyntaxKind` `Debug`
1471/// name, or `None` when the name is not a known single-glyph token. Covers every
1472/// kind the parser can name in a FORM-101 `expected` message, so the mapping
1473/// stays correct if new `expect` call sites are added — an invariant the
1474/// `every_expectable_token_has_a_glyph` test enforces against the parser source.
1475fn punctuation_glyph(debug_name: &str) -> Option<&'static str> {
1476    Some(match debug_name {
1477        "Colon" => "`:`",
1478        "Eq" => "`=`",
1479        "Semicolon" => "`;`",
1480        "Comma" => "`,`",
1481        "LBracket" => "`[`",
1482        "RBracket" => "`]`",
1483        "LBrace" => "`{`",
1484        "RBrace" => "`}`",
1485        "LParen" => "`(`",
1486        "RParen" => "`)`",
1487        "DotDot" => "`..`",
1488        "Dot" => "`.`",
1489        "Question" => "`?`",
1490        "At" => "`@`",
1491        "Pipe" => "`|`",
1492        // `>` closes a stream type `<T>` (ridl reference §12), which is the
1493        // one operator token the parser reaches through `expect`.
1494        "Gt" => "`>`",
1495        _ => return None,
1496    })
1497}
1498
1499impl SourceMap {
1500    /// The interned files, in id order — the renderer replays them into a
1501    /// `codespan_reporting` file table so `FileId(i)` lines up with codespan id
1502    /// `i`.
1503    pub(crate) fn iter_files(&self) -> impl Iterator<Item = (&str, &str)> {
1504        self.files
1505            .iter()
1506            .map(|entry| (entry.path.as_str(), entry.text.as_str()))
1507    }
1508}
1509
1510#[cfg(test)]
1511mod tests {
1512    use super::*;
1513
1514    #[test]
1515    fn source_map_interns_by_path() {
1516        let mut map = SourceMap::new();
1517        let a = map.file_id("a.typl", "type A: m");
1518        let b = map.file_id("b.typl", "type B: s");
1519        let a_again = map.file_id("a.typl", "type A: m");
1520        assert_ne!(a, b, "distinct paths get distinct ids");
1521        assert_eq!(a, a_again, "the same path interns to the same id");
1522    }
1523
1524    #[test]
1525    fn source_map_path_reverses_file_id() {
1526        let mut map = SourceMap::new();
1527        let a = map.file_id("a.typl", "type A: m");
1528        let b = map.file_id("b.typl", "type B: s");
1529        assert_eq!(map.path(a), Some("a.typl"));
1530        assert_eq!(map.path(b), Some("b.typl"));
1531        assert_eq!(
1532            map.path(FileId::DETACHED),
1533            None,
1534            "a detached id has no path"
1535        );
1536        assert_eq!(
1537            map.path(FileId(2)),
1538            None,
1539            "an id the map never issued has no path",
1540        );
1541    }
1542
1543    #[test]
1544    fn remap_diagnostics_rewrites_package_relative_ids() {
1545        // A render map that already interned an unrelated file, so the render
1546        // ids do not coincide with the package-relative indices.
1547        let mut render = SourceMap::new();
1548        let _other = render.file_id("other.typl", "");
1549        let a = render.file_id("pkg/a.typl", "type A: m");
1550        let b = render.file_id("pkg/b.typl", "type B: s");
1551        let render_ids = vec![a, b];
1552
1553        let span = |file: FileId| Span {
1554            file,
1555            range: TextRange::default(),
1556        };
1557        let diagnostic = |file: FileId| Diagnostic {
1558            code: DiagCode::TYPL_009,
1559            severity: Severity::Error,
1560            message: "duplicate declaration of `X`".to_string(),
1561            primary: span(file),
1562            labels: vec![Label {
1563                span: span(file),
1564                message: "first declared here".to_string(),
1565            }],
1566            fixits: Vec::new(),
1567        };
1568
1569        // Package-relative ids 0 and 1, plus a detached diagnostic.
1570        let mut pass_map = SourceMap::new();
1571        let pkg_a = pass_map.file_id("pkg/a.typl", "type A: m");
1572        let pkg_b = pass_map.file_id("pkg/b.typl", "type B: s");
1573        let remapped = remap_diagnostics(
1574            vec![
1575                diagnostic(pkg_a),
1576                diagnostic(pkg_b),
1577                diagnostic(FileId::DETACHED),
1578            ],
1579            &render_ids,
1580        );
1581
1582        assert_eq!(remapped[0].primary.file, a);
1583        assert_eq!(remapped[0].labels[0].span.file, a);
1584        assert_eq!(remapped[1].primary.file, b);
1585        assert_eq!(
1586            remapped[2].primary.file,
1587            FileId::DETACHED,
1588            "a detached diagnostic stays detached",
1589        );
1590    }
1591
1592    /// The rendered text for every `SyntaxKind` the parser hands to `expect`.
1593    ///
1594    /// One row per kind in the reachable set the
1595    /// `every_expectable_token_has_a_glyph` test derives, so a wrong glyph is
1596    /// caught here and a missing one is caught there.
1597    #[test]
1598    fn house_style_rewrites_debug_token_names() {
1599        assert_eq!(house_style_message("expected RBracket"), "expected `]`");
1600        assert_eq!(house_style_message("expected Colon"), "expected `:`");
1601        assert_eq!(house_style_message("expected Eq"), "expected `=`");
1602        assert_eq!(house_style_message("expected Semicolon"), "expected `;`");
1603        assert_eq!(house_style_message("expected RParen"), "expected `)`");
1604        assert_eq!(house_style_message("expected DotDot"), "expected `..`");
1605        // Reached since stream types landed: `query a(): <Speed` with no `>`.
1606        assert_eq!(house_style_message("expected Gt"), "expected `>`");
1607    }
1608
1609    /// Every `SyntaxKind` the parser hands to `expect` has a glyph.
1610    ///
1611    /// `expect` is the one call site in the workspace that formats a
1612    /// `SyntaxKind` `Debug` name into a diagnostic message, and every caller of
1613    /// [`house_style_message`] feeds it a parser message, so the set of kinds
1614    /// that can reach [`punctuation_glyph`] is exactly the set of literal
1615    /// arguments `expect` is called with. A kind outside the table renders as
1616    /// its raw `Debug` name — `expected Gt` reached users this way, because
1617    /// stream types added an `expect(SyntaxKind::Gt)` call site and nothing
1618    /// tied the two files together.
1619    ///
1620    /// The first assertion pins that single-producer premise, and pins it
1621    /// **workspace-wide**: a second `Debug`-shaped emitter anywhere would widen
1622    /// the reachable set past what the scan below reads, so it has to fail
1623    /// rather than pass silently. Reading only the parser would leave the
1624    /// premise's own scope unchecked — the same shape of gap as the one this
1625    /// test exists to close.
1626    #[test]
1627    fn every_expectable_token_has_a_glyph() {
1628        const CALL: &str = ".expect(SyntaxKind::";
1629
1630        let root = workspace_root();
1631        let mut sources = Vec::new();
1632        collect_rust_sources(&root, &mut sources);
1633        assert!(
1634            sources.len() >= 60,
1635            "the walk found only {} `.rs` files under {} — it is not reaching \
1636             the workspace",
1637            sources.len(),
1638            root.display(),
1639        );
1640
1641        let parser = root.join("crates/ridl-syntax/src/parser.rs");
1642        let emitters: Vec<String> = sources
1643            .iter()
1644            .filter(|path| {
1645                let text = std::fs::read_to_string(path)
1646                    .unwrap_or_else(|err| panic!("cannot read {}: {err}", path.display()));
1647                let stripped = strip_line_comments(&text);
1648                stripped
1649                    .match_indices(r#""expected {"#)
1650                    .any(|(at, matched)| is_whole_debug_message(&stripped[at + matched.len()..]))
1651            })
1652            .map(|path| path.display().to_string())
1653            .collect();
1654        assert_eq!(
1655            emitters,
1656            vec![parser.display().to_string()],
1657            "the workspace no longer has exactly one message that is a \
1658             `Debug`-formatted token name and nothing else, so the reachable \
1659             kinds are no longer just `expect`'s arguments in the parser. Either \
1660             route the new emitter through `expect`, or widen the scan below to \
1661             read its argument too",
1662        );
1663
1664        let text = std::fs::read_to_string(&parser)
1665            .unwrap_or_else(|err| panic!("cannot read {}: {err}", parser.display()));
1666        let despaced: String = strip_line_comments(&text)
1667            .chars()
1668            .filter(|c| !c.is_whitespace())
1669            .collect();
1670        let mut expectable = std::collections::BTreeSet::new();
1671        for (at, _) in despaced.match_indices(CALL) {
1672            let rest = &despaced[at + CALL.len()..];
1673            let end = rest
1674                .find(')')
1675                .expect("an `expect` call closes its parenthesis");
1676            expectable.insert(rest[..end].to_string());
1677        }
1678
1679        assert!(
1680            expectable.len() >= 7,
1681            "the scan found only {} expectable kinds in {} — it is not reading \
1682             the call sites",
1683            expectable.len(),
1684            parser.display(),
1685        );
1686        for kind in &expectable {
1687            assert!(
1688                punctuation_glyph(kind).is_some(),
1689                "the parser can emit `expected {kind}`, which renders the raw \
1690                 `Debug` name to the user. Add `{kind}` to `punctuation_glyph`.",
1691            );
1692        }
1693    }
1694
1695    #[test]
1696    fn house_style_passes_through_already_styled_messages() {
1697        // Already description-first with backticks — unchanged.
1698        assert_eq!(house_style_message("expected a name"), "expected a name");
1699        assert_eq!(house_style_message("unclosed `{`"), "unclosed `{`");
1700        assert_eq!(
1701            house_style_message("missing `package` declaration"),
1702            "missing `package` declaration",
1703        );
1704    }
1705
1706    /// Every catalogue entry is well formed, every catalogue is sorted, and no
1707    /// code is declared twice.
1708    ///
1709    /// This is what is left to check once [`diag_codes!`] produces the
1710    /// catalogues. Completeness is structural — the constant and its entry come
1711    /// out of the same line — so the assertions here cover only the properties
1712    /// the expansion does not fix. There is deliberately no expected list of
1713    /// codes: comparing a catalogue against a second hand-written list is the
1714    /// guard this change removed.
1715    /// A retired code is never declared again (ADR-0008 decision 13): every
1716    /// number in [`RETIRED_RIDL_CODES`] stays out of the `RIDL-` catalogue.
1717    #[test]
1718    fn retired_ridl_codes_are_never_redeclared() {
1719        for entry in RIDL_CATALOG {
1720            let code = entry.code.as_str();
1721            let number: u16 = code
1722                .strip_prefix("RIDL-")
1723                .and_then(|digits| digits.parse().ok())
1724                .unwrap_or_else(|| panic!("`{code}` is not spelled `RIDL-NNN`"));
1725            assert!(
1726                !RETIRED_RIDL_CODES.contains(&number),
1727                "`{code}` is retired and must not be declared again",
1728            );
1729        }
1730    }
1731
1732    #[test]
1733    fn catalog_entries_are_well_formed_ordered_and_unique() {
1734        let mut seen: Vec<&str> = Vec::new();
1735        for (name, catalog) in ALL_CATALOGS {
1736            let prefix = name
1737                .strip_suffix("_CATALOG")
1738                .unwrap_or_else(|| panic!("`{name}` is not named `<PREFIX>_CATALOG`"));
1739            let mut previous = "";
1740            for entry in *catalog {
1741                let code = entry.code.as_str();
1742                let (written_prefix, number) = code
1743                    .split_once('-')
1744                    .unwrap_or_else(|| panic!("`{code}` is not spelled `PREFIX-NNN`"));
1745                assert_eq!(
1746                    written_prefix, prefix,
1747                    "`{code}` is listed in {name}, which holds the `{prefix}-` namespace",
1748                );
1749                assert!(
1750                    number.len() == 3 && number.bytes().all(|byte| byte.is_ascii_digit()),
1751                    "`{code}` does not carry a three-digit number",
1752                );
1753                assert!(!entry.summary.is_empty(), "`{code}` has an empty summary");
1754                // Codes in one catalogue share a prefix and a three-digit
1755                // number, so byte order is numeric order.
1756                assert!(
1757                    previous < code,
1758                    "{name} is out of order: `{previous}` is listed before `{code}`",
1759                );
1760                previous = code;
1761                assert!(
1762                    !seen.contains(&code),
1763                    "`{code}` is declared in more than one catalogue",
1764                );
1765                seen.push(code);
1766            }
1767        }
1768        // Width. The loops above say nothing if `ALL_CATALOGS` is ever empty.
1769        // The macro cannot produce an empty one, but a floor makes a vacuous
1770        // pass unreachable rather than merely unlikely, and codes are never
1771        // withdrawn (ADR-0007 decision 2), so the bound only gets safer.
1772        assert!(
1773            seen.len() >= 90,
1774            "only {} codes reached the catalogues — the guards below are \
1775             checking almost nothing",
1776            seen.len(),
1777        );
1778    }
1779
1780    /// Each constant's name is the code string it expands to, with `-` written
1781    /// `_`.
1782    ///
1783    /// [`diag_codes!`] takes the two side by side — `TYPL_007 = "TYPL-007"` — so
1784    /// a typo can still pair a name with another code's string, and every
1785    /// emission through that constant would then render the wrong code.
1786    /// `CODE_CONSTANT_NAMES` comes out of the same entries, so this compares the
1787    /// expansion against itself and not against a list someone maintains.
1788    #[test]
1789    fn each_constant_name_is_the_code_it_expands_to() {
1790        for (name, code) in CODE_CONSTANT_NAMES {
1791            assert_eq!(
1792                &name.replace('_', "-"),
1793                code,
1794                "`DiagCode::{name}` expands to `{code}`",
1795            );
1796        }
1797        assert!(CODE_CONSTANT_NAMES.len() >= 90, "the entry list went empty");
1798    }
1799
1800    /// The two namespace-wide severity rules: every FORM code is an error, and
1801    /// every MANI code is an error but the unknown-key warning.
1802    ///
1803    /// Neither rule enumerates codes, so neither is a shadow list — one states a
1804    /// property of a whole namespace, the other adds a single named exception.
1805    /// TYPL and RIDL have no such rule: their severities are per-code, set by
1806    /// the reference §16 tables, and writing them out here would rebuild exactly
1807    /// the second list this change removed. A wrong severity on a TYPL or RIDL
1808    /// entry is not caught by anything in this module.
1809    #[test]
1810    fn form_and_mani_severities_follow_their_namespace_rule() {
1811        for entry in FORM_CATALOG {
1812            assert_eq!(
1813                entry.severity,
1814                Severity::Error,
1815                "every FORM code is an error, but {} is not",
1816                entry.code.as_str(),
1817            );
1818        }
1819        for entry in MANI_CATALOG {
1820            let expected = if entry.code == DiagCode::MANI_005 {
1821                Severity::Warning
1822            } else {
1823                Severity::Error
1824            };
1825            assert_eq!(
1826                entry.severity,
1827                expected,
1828                "unexpected severity for {}",
1829                entry.code.as_str(),
1830            );
1831        }
1832    }
1833
1834    /// Every `"PREFIX-NNN"` string literal in the workspace's Rust sources names
1835    /// a catalogued code.
1836    ///
1837    /// This is the half [`diag_codes!`] cannot reach, and it is not a handful of
1838    /// call sites that forgot to use the type — it is a layering fact.
1839    /// `crates/ridl-syntax` cannot reference [`DiagCode`] at all: `ridl-core`
1840    /// depends on `ridl-syntax`, so the edge cannot run the other way, and
1841    /// `SyntaxError::code` is a `&'static str` by construction. Every code the
1842    /// lexer and parser emit is therefore a bare string literal — 11 distinct
1843    /// codes across 74 call sites when this guard was written. Five of them have
1844    /// no `DiagCode::` reference anywhere and their constants are dead
1845    /// declarations (FORM-005, FORM-104, FORM-105, RIDL-403, TYPL-304); TYPL-303
1846    /// had no constant at all until this change added one. Repairing that means
1847    /// moving the codes somewhere both crates can see, which is its own change
1848    /// (issue #172).
1849    ///
1850    /// **What this catches.** A code emitted, asserted, or declared anywhere in
1851    /// the workspace's `.rs` files that no catalogue lists — a new parser
1852    /// diagnostic included, verified by renaming the parser's TYPL-303 emission
1853    /// and watching this fail and name the file.
1854    ///
1855    /// **What this does not catch**, and must not be read as covering:
1856    ///
1857    /// - a code assembled rather than spelled: `concat!("TYPL-", "303")`, or one
1858    ///   built at run time. The scan is textual. `diag_codes!` takes a `literal`
1859    ///   so the assembled form cannot be written inside it, and
1860    ///   `no_diagnostic_constant_is_declared_outside_the_macro` covers the
1861    ///   hand-written-constant case; a `concat!` at a *parser* emission site
1862    ///   evades both;
1863    /// - a code spelled **inside a longer literal**, such as
1864    ///   `"error[TYPL-905]: boom"`. A quote is required on both sides of the
1865    ///   code, which is what keeps prose out, and it is also what lets an
1866    ///   embedded code through;
1867    /// - a **four-digit** code, `"TYPL-9060"`. The scan takes exactly three
1868    ///   digits followed by the closing quote, matching the shape ADR-0007
1869    ///   decision 2 fixes. Neither of these two is live: an audit of all 104
1870    ///   `PREFIX-NNN` occurrences in `.rs` sources regardless of quoting found
1871    ///   the only uncatalogued ones are the reserved codes and the typl §16
1872    ///   codes named below, every one of them in prose;
1873    /// - a code written in a **comment**, which is stripped before the scan
1874    ///   runs. Nothing emits a diagnostic from a comment, and leaving comments
1875    ///   in made this file report its own prose about reserved and absent
1876    ///   codes;
1877    /// - a code written only in Markdown, in a `.typl`/`.ridl` fixture, or in a
1878    ///   snapshot. The typl reference §16 documents six codes no constant
1879    ///   declares — TYPL-107, TYPL-112, TYPL-205, and TYPL-401 to TYPL-403 — so
1880    ///   widening the scan to `.md` would fail today. That inventory belongs to
1881    ///   issue #172, not to this guard;
1882    /// - a catalogued code that nothing emits. FORM-001 to FORM-004 are declared
1883    ///   and catalogued and no pass emits them;
1884    /// - a code **withdrawn** from a catalogue. Deleting an entry deletes its
1885    ///   constant, so any code a pass emits through `DiagCode::` stops the build
1886    ///   — but a code nothing references, such as FORM-001, can be removed and
1887    ///   nothing here notices. The floor in
1888    ///   `catalog_entries_are_well_formed_ordered_and_unique` catches a bulk
1889    ///   withdrawal, not a single one;
1890    /// - a wrong severity, or a summary that describes the wrong rule, on an
1891    ///   entry that exists.
1892    #[test]
1893    fn codes_written_as_string_literals_are_all_catalogued() {
1894        let root = workspace_root();
1895        let mut sources = Vec::new();
1896        collect_rust_sources(&root, &mut sources);
1897
1898        let catalogued: std::collections::BTreeSet<&str> = ALL_CATALOGS
1899            .iter()
1900            .flat_map(|(_, catalog)| catalog.iter().map(|entry| entry.code.as_str()))
1901            .collect();
1902
1903        let mut uncatalogued: Vec<String> = Vec::new();
1904        let mut files_holding_codes = 0usize;
1905        let mut codes_outside_this_module: std::collections::BTreeSet<String> =
1906            std::collections::BTreeSet::new();
1907
1908        for path in &sources {
1909            let text = std::fs::read_to_string(path)
1910                .unwrap_or_else(|err| panic!("cannot read {}: {err}", path.display()));
1911            // Comments first: a diagnostic is never emitted from one, and prose
1912            // about a code that is deliberately absent — a reserved number, a
1913            // worked example of the shape this file rejects — would otherwise
1914            // be reported as an escape.
1915            let text = strip_line_comments(&text);
1916            let literals = code_literals(&text);
1917            if !literals.is_empty() {
1918                files_holding_codes += 1;
1919            }
1920            let is_this_module = path.ends_with("ridl-core/src/diag.rs");
1921            for code in literals {
1922                if !catalogued.contains(code) {
1923                    uncatalogued.push(format!("{}: {code}", path.display()));
1924                }
1925                if !is_this_module {
1926                    codes_outside_this_module.insert(code.to_string());
1927                }
1928            }
1929        }
1930
1931        assert!(
1932            uncatalogued.is_empty(),
1933            "these code strings are written in Rust sources but no catalogue \
1934             lists them, so the error index (E4.2) has nothing to key them on \
1935             and nothing connects them to a `DiagCode`. Declare each one in \
1936             `diag_codes!`:\n{}",
1937            uncatalogued.join("\n"),
1938        );
1939
1940        // Width. Every assertion above is vacuous if the walk finds nothing, and
1941        // a walk rooted at the wrong directory finds nothing quietly. These
1942        // floors are well under what the workspace holds today — 79 `.rs` files,
1943        // 21 of them carrying a code, 92 distinct codes outside this module —
1944        // and fail loudly if the scan stops reaching past its own crate. The
1945        // three figures are measured, not maintained: they are a comment, and
1946        // the assertions below hold whether or not they drift.
1947        assert!(
1948            sources.len() >= 60,
1949            "the walk found only {} `.rs` files under {} — it is not reaching \
1950             the workspace",
1951            sources.len(),
1952            root.display(),
1953        );
1954        assert!(
1955            files_holding_codes >= 10,
1956            "only {files_holding_codes} files carried a code literal",
1957        );
1958        assert!(
1959            codes_outside_this_module.len() >= 60,
1960            "only {} distinct codes were seen outside `diag.rs` — the scan is \
1961             checking this module against itself",
1962            codes_outside_this_module.len(),
1963        );
1964    }
1965
1966    /// No `DiagCode` constant is declared outside [`diag_codes!`], anywhere in
1967    /// the workspace.
1968    ///
1969    /// The scan above already reports a hand-written constant whose code is
1970    /// spelled as a literal, because the literal is what it looks for — but one
1971    /// whose code is assembled compiles and slips past it. This catches that
1972    /// form, and any other, by looking at the declaration rather than at the
1973    /// code string.
1974    ///
1975    /// The surface is the whole workspace, not this crate. An inherent
1976    /// `impl DiagCode` can only be written here, but the newtype's field is
1977    /// public, so any crate can construct one and give it a name. A constant of
1978    /// this type declared in `ridl-sem` with an assembled code compiles and
1979    /// passes both the suite and clippy when this walks `ridl-core` alone, so it
1980    /// walks everything.
1981    ///
1982    /// Matching is whitespace-insensitive and accepts a path-qualified type,
1983    /// because a declaration in another crate writes the type as
1984    /// `ridl_core::diag::…` and rustfmt may break it across lines. Comments are
1985    /// stripped first, so prose describing the forbidden shape — including this
1986    /// paragraph — does not report itself; the cost is that a `//` inside a
1987    /// string literal hides the rest of that line from the scan.
1988    ///
1989    /// It remains a textual check: it recognises the two shapes a `DiagCode`
1990    /// constant is written in and rejects everything else, so a declaration
1991    /// reworded to avoid both evades it. The two evasions are not symmetric,
1992    /// and only their combination gets through:
1993    ///
1994    /// - a declaration writing the type under an alias (`use … DiagCode as DC;`
1995    ///   then `const RIDL_199: DC = DC("RIDL-199");`) is invisible here, but its
1996    ///   code is spelled, so `codes_written_as_string_literals_are_all_catalogued`
1997    ///   reports it;
1998    /// - a declaration with an assembled code is invisible to that scan, but
1999    ///   names the type, so this one reports it — verified against a qualified,
2000    ///   line-broken `concat!` declaration in `ridl-sem` and against one in a
2001    ///   child module of `diag`;
2002    /// - **both at once** — an aliased type and an assembled code — passes the
2003    ///   whole workspace suite and clippy. That is a surviving escape, and it is
2004    ///   a deliberate act rather than the omission this file defends against.
2005    #[test]
2006    fn no_diagnostic_constant_is_declared_outside_the_macro() {
2007        // Assembled rather than spelled: this test lives in a file it scans, so
2008        // writing the shapes out whole would make the test report itself.
2009        let anchor = format!("DiagCode{}", '=');
2010        // The macro body, expanding one entry into its constant.
2011        let in_macro = format!("pubconst$konst:{anchor}DiagCode($code);");
2012        // The sentinel, which has no catalogue entry by design.
2013        let sentinel = format!("pubconstNONE:{anchor}DiagCode(\"\");");
2014        let accepted = [in_macro.as_str(), sentinel.as_str()];
2015
2016        let root = workspace_root();
2017        let mut sources = Vec::new();
2018        collect_rust_sources(&root, &mut sources);
2019        assert!(
2020            sources.len() >= 60,
2021            "the walk found only {} `.rs` files under {} — it is not reaching \
2022             the workspace",
2023            sources.len(),
2024            root.display(),
2025        );
2026
2027        let mut declarations = 0usize;
2028        for path in &sources {
2029            let text = std::fs::read_to_string(path)
2030                .unwrap_or_else(|err| panic!("cannot read {}: {err}", path.display()));
2031            let stripped = strip_line_comments(&text);
2032            let despaced: String = stripped.chars().filter(|c| !c.is_whitespace()).collect();
2033
2034            for (at, _) in despaced.match_indices(&anchor) {
2035                let recognised = accepted.iter().any(|form| {
2036                    let offset = form.find(&anchor).expect("each form holds the anchor");
2037                    at >= offset && despaced[at - offset..].starts_with(form)
2038                });
2039                assert!(
2040                    recognised,
2041                    "{}: `…{}…` declares a `DiagCode` constant outside \
2042                     `diag_codes!`, so it carries no catalogue entry. Move it \
2043                     into the macro.",
2044                    path.display(),
2045                    &despaced[at.saturating_sub(40)..(at + 40).min(despaced.len())],
2046                );
2047                declarations += 1;
2048            }
2049        }
2050        assert_eq!(
2051            declarations, 2,
2052            "expected exactly two `DiagCode` constant declarations in the \
2053             workspace — the macro body and the `NONE` sentinel, both in this \
2054             file",
2055        );
2056    }
2057
2058    /// The family overview's `FORM-` and `MANI-` tables list exactly the codes
2059    /// this module declares, at the same severities.
2060    ///
2061    /// The overview says of those two tables that `crates/ridl-core/src/diag.rs`
2062    /// "is the single source of truth these two tables mirror" — and until this
2063    /// test, nothing checked the mirror. A code added to one and not the other
2064    /// compiled and passed, and so did a severity that disagreed; the drift was
2065    /// found the ordinary way, by a reviewer reading both.
2066    ///
2067    /// **Codes and severities only.** The `Rule` column is prose written for a
2068    /// reader and the catalogue `summary` is written for the error index, so
2069    /// they are deliberately not compared — pinning two prose strings to each
2070    /// other would make every wording improvement a two-file edit for no gain.
2071    /// What this catches is a code present in one list and absent from the
2072    /// other, and a severity that disagrees. What it does not catch is a row
2073    /// whose prose describes the wrong rule.
2074    ///
2075    /// TYPL and RIDL have no counterpart here: their tables live in their own
2076    /// language references, and those tables carry codes no constant declares
2077    /// yet (typl §16 documents six, recorded in issue #172), so the same
2078    /// equality would fail today for a reason that is not drift.
2079    #[test]
2080    fn the_overview_form_and_mani_tables_mirror_the_catalogues() {
2081        let overview = workspace_root().join("docs/specification/ridl-family-overview.md");
2082        let text = std::fs::read_to_string(&overview)
2083            .unwrap_or_else(|err| panic!("cannot read {}: {err}", overview.display()));
2084
2085        // `| CODE | rule | severity |`, tolerant of the column padding prim
2086        // applies. A row whose severity word is unknown is a malformed table,
2087        // not something to skip quietly.
2088        let mut documented: Vec<(String, Severity)> = Vec::new();
2089        for line in text.lines() {
2090            let mut cells = line.split('|').map(str::trim);
2091            if cells.next() != Some("") {
2092                continue;
2093            }
2094            let (Some(code), Some(_rule), Some(severity)) =
2095                (cells.next(), cells.next(), cells.next())
2096            else {
2097                continue;
2098            };
2099            if !(code.starts_with("FORM-") || code.starts_with("MANI-")) {
2100                continue;
2101            }
2102            let severity = match severity {
2103                "error" => Severity::Error,
2104                "warning" => Severity::Warning,
2105                "info" => Severity::Info,
2106                other => panic!("{code} in the overview carries no severity: `{other}`"),
2107            };
2108            documented.push((code.to_string(), severity));
2109        }
2110
2111        let declared: Vec<(String, Severity)> = FORM_CATALOG
2112            .iter()
2113            .chain(MANI_CATALOG)
2114            .map(|entry| (entry.code.as_str().to_string(), entry.severity))
2115            .collect();
2116
2117        let names = |rows: &[(String, Severity)]| -> Vec<String> {
2118            let mut names: Vec<String> = rows.iter().map(|(code, _)| code.clone()).collect();
2119            names.sort();
2120            names
2121        };
2122        assert_eq!(
2123            names(&documented),
2124            names(&declared),
2125            "the family overview §7 tables and the FORM/MANI catalogues list \
2126             different codes — {} documents the two namespaces `diag.rs` owns, \
2127             so a code minted in one belongs in the other",
2128            overview.display(),
2129        );
2130
2131        for (code, severity) in &declared {
2132            let (_, documented_severity) = documented
2133                .iter()
2134                .find(|(name, _)| name == code)
2135                .expect("the code sets are equal");
2136            assert_eq!(
2137                documented_severity, severity,
2138                "{code} is {severity:?} in the catalogue and \
2139                 {documented_severity:?} in the overview",
2140            );
2141        }
2142
2143        // Width: a scan that matched nothing would satisfy both assertions
2144        // against an empty catalogue, and cannot against a real one.
2145        assert!(
2146            documented.len() >= 25,
2147            "only {} rows were read out of the overview tables — the parse is \
2148             not finding them",
2149            documented.len(),
2150        );
2151    }
2152
2153    /// Whether `rest` — the source just past a `"expected {` — closes the
2154    /// format string immediately as `ident:?}"`, making the whole message a
2155    /// `Debug`-formatted value and nothing else.
2156    ///
2157    /// That exact shape is what reaches [`punctuation_glyph`]: anything with
2158    /// prose after the placeholder (`"expected {text:?} to parse"`) is not a
2159    /// bare token name, and a `Display` placeholder (`"expected {name}"`) is not
2160    /// a `Debug` name. Both occur in the workspace and both are correctly
2161    /// ignored. What this does not catch is an emitter that formats the name
2162    /// through a variable rather than inline; no such site exists, and the
2163    /// recurrence worth guarding is a second `expect`-shaped helper.
2164    fn is_whole_debug_message(rest: &str) -> bool {
2165        let Some(colon) = rest.find(':') else {
2166            return false;
2167        };
2168        let placeholder = &rest[..colon];
2169        !placeholder.is_empty()
2170            && placeholder
2171                .chars()
2172                .all(|c| c.is_ascii_alphanumeric() || c == '_')
2173            && rest[colon..].starts_with(":?}\"")
2174    }
2175
2176    /// `text` with every `//` line comment removed, so prose about a forbidden
2177    /// code shape is not mistaken for the shape itself. A `//` inside a string
2178    /// literal takes the rest of its line with it.
2179    fn strip_line_comments(text: &str) -> String {
2180        text.lines()
2181            .map(|line| match line.find("//") {
2182                Some(at) => &line[..at],
2183                None => line,
2184            })
2185            .collect::<Vec<_>>()
2186            .join("\n")
2187    }
2188
2189    /// The workspace root: two levels above `crates/ridl-core`.
2190    fn workspace_root() -> std::path::PathBuf {
2191        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2192            .parent()
2193            .and_then(std::path::Path::parent)
2194            .expect("`crates/ridl-core` sits two levels below the workspace root")
2195            .to_path_buf();
2196        let manifest = std::fs::read_to_string(root.join("Cargo.toml"))
2197            .unwrap_or_else(|err| panic!("no manifest at {}: {err}", root.display()));
2198        assert!(
2199            manifest.contains("[workspace]"),
2200            "{} is not the workspace root",
2201            root.display(),
2202        );
2203        root
2204    }
2205
2206    /// Every `.rs` file under `dir`, skipping `target` and dot-directories — the
2207    /// latter keeps the walk out of `.git` and out of any `.claude/worktrees`
2208    /// checkout of this same repository.
2209    fn collect_rust_sources(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
2210        let entries = std::fs::read_dir(dir)
2211            .unwrap_or_else(|err| panic!("cannot read {}: {err}", dir.display()));
2212        for entry in entries {
2213            let entry = entry.unwrap_or_else(|err| panic!("cannot read {}: {err}", dir.display()));
2214            let name = entry.file_name();
2215            let name = name.to_string_lossy();
2216            if name.starts_with('.') || name == "target" {
2217                continue;
2218            }
2219            let path = entry.path();
2220            if path.is_dir() {
2221                collect_rust_sources(&path, out);
2222            } else if path.extension().is_some_and(|extension| extension == "rs") {
2223                out.push(path);
2224            }
2225        }
2226    }
2227
2228    /// Every `"PREFIX-NNN"` string literal in `text`, where `PREFIX` is two or
2229    /// more uppercase ASCII letters.
2230    ///
2231    /// The quotes are required on both sides, which is what keeps prose out: the
2232    /// doc comments in this module name RIDL-111 and RIDL-142 as reserved and
2233    /// not yet declared, and a scan that read unquoted text would report them.
2234    /// Matching is position-local rather than quote-pairing, so an escaped quote
2235    /// earlier in a string cannot shift the scan out of alignment.
2236    fn code_literals(text: &str) -> Vec<&str> {
2237        let bytes = text.as_bytes();
2238        let mut found = Vec::new();
2239        for dash in 0..bytes.len() {
2240            if bytes[dash] != b'-' || dash + 4 >= bytes.len() || bytes[dash + 4] != b'"' {
2241                continue;
2242            }
2243            if !bytes[dash + 1..dash + 4].iter().all(u8::is_ascii_digit) {
2244                continue;
2245            }
2246            let mut start = dash;
2247            while start > 0 && bytes[start - 1].is_ascii_uppercase() {
2248                start -= 1;
2249            }
2250            if dash - start < 2 || start == 0 || bytes[start - 1] != b'"' {
2251                continue;
2252            }
2253            found.push(&text[start..dash + 4]);
2254        }
2255        found
2256    }
2257}
2258
2259#[cfg(test)]
2260mod json_tests {
2261    use super::*;
2262
2263    #[test]
2264    fn line_col_is_one_based_and_counts_chars() {
2265        let text = "ab\ncé\n";
2266        assert_eq!(
2267            line_col(text, TextSize::from(0)),
2268            LineCol { line: 1, column: 1 }
2269        );
2270        assert_eq!(
2271            line_col(text, TextSize::from(2)),
2272            LineCol { line: 1, column: 3 }
2273        );
2274        assert_eq!(
2275            line_col(text, TextSize::from(3)),
2276            LineCol { line: 2, column: 1 }
2277        );
2278        // `é` is two bytes; the column after it is the third character.
2279        assert_eq!(
2280            line_col(text, TextSize::from(6)),
2281            LineCol { line: 2, column: 3 }
2282        );
2283        // An offset past the end clamps to the end of the text.
2284        assert_eq!(
2285            line_col(text, TextSize::from(99)),
2286            LineCol { line: 3, column: 1 }
2287        );
2288    }
2289
2290    #[test]
2291    fn to_json_projects_spans_and_fixits_onto_lines_and_columns() {
2292        let mut sources = SourceMap::new();
2293        let file = sources.file_id("a.typl", "package p\ntype X:\n");
2294        let span = Span {
2295            file,
2296            range: TextRange::new(TextSize::from(10), TextSize::from(17)),
2297        };
2298        // A different span from the primary one — a different line and a
2299        // different column — so a fix-it's span cannot pass this test by
2300        // being serialized from the primary span instead of its own.
2301        let fixit_span = Span {
2302            file,
2303            range: TextRange::new(TextSize::from(8), TextSize::from(9)),
2304        };
2305        let diagnostic = Diagnostic {
2306            // A real catalogued code, not a fabricated literal: the
2307            // `codes_written_as_string_literals_are_all_catalogued` workspace
2308            // scan rejects any `PREFIX-NNN` string literal that is not in a
2309            // catalogue, including one written in a test fixture.
2310            code: DiagCode::TYPL_009,
2311            severity: Severity::Error,
2312            message: "expected a type".to_string(),
2313            primary: span,
2314            labels: Vec::new(),
2315            fixits: vec![FixIt {
2316                span: fixit_span,
2317                replacement: "type X: integer".to_string(),
2318                label: "give `X` a backing type".to_string(),
2319            }],
2320        };
2321
2322        let json = to_json(&[diagnostic], &sources);
2323
2324        assert_eq!(json.len(), 1);
2325        let first = &json[0];
2326        assert_eq!(first.code, "TYPL-009");
2327        assert_eq!(first.severity, "error");
2328        assert_eq!(first.message, "expected a type");
2329        assert_eq!(first.span.path, "a.typl");
2330        assert_eq!(first.span.start, LineCol { line: 2, column: 1 });
2331        assert_eq!(first.span.end, LineCol { line: 2, column: 8 });
2332        assert_eq!(first.fixes.len(), 1);
2333        assert_eq!(first.fixes[0].label, "give `X` a backing type");
2334        assert_eq!(first.fixes[0].replacement, "type X: integer");
2335        assert_eq!(
2336            first.fixes[0].span.start,
2337            LineCol { line: 1, column: 9 },
2338            "the fix-it's span is its own, not the primary span",
2339        );
2340        assert_eq!(
2341            first.fixes[0].span.end,
2342            LineCol {
2343                line: 1,
2344                column: 10
2345            }
2346        );
2347    }
2348
2349    /// The struct-field assertions in the test above are checked against
2350    /// `JsonDiagnostic` itself and stay green through a consistent field
2351    /// rename or an added `#[serde(rename = ...)]` on the struct. This
2352    /// snapshot instead pins the serialized JSON key names — the actual wire
2353    /// contract the MCP `ridl_check` tool and `ridl check --format json` are
2354    /// both required to emit identically.
2355    #[test]
2356    fn to_json_snapshot_pins_the_wire_key_names() {
2357        let mut sources = SourceMap::new();
2358        let file = sources.file_id("a.typl", "package p\ntype X:\n");
2359        let span = Span {
2360            file,
2361            range: TextRange::new(TextSize::from(10), TextSize::from(17)),
2362        };
2363        // A different span from the primary one, so the snapshot cannot pass
2364        // by serializing the fix-it's span from the primary span instead of
2365        // its own.
2366        let fixit_span = Span {
2367            file,
2368            range: TextRange::new(TextSize::from(8), TextSize::from(9)),
2369        };
2370        let diagnostic = Diagnostic {
2371            code: DiagCode::TYPL_009,
2372            severity: Severity::Error,
2373            message: "expected a type".to_string(),
2374            primary: span,
2375            labels: Vec::new(),
2376            fixits: vec![FixIt {
2377                span: fixit_span,
2378                replacement: "type X: integer".to_string(),
2379                label: "give `X` a backing type".to_string(),
2380            }],
2381        };
2382
2383        insta::assert_json_snapshot!(to_json(&[diagnostic], &sources));
2384    }
2385
2386    #[test]
2387    fn to_json_tolerates_a_span_on_an_unknown_file() {
2388        let sources = SourceMap::new();
2389        let diagnostic = Diagnostic {
2390            code: DiagCode::MANI_101,
2391            severity: Severity::Warning,
2392            message: "detached".to_string(),
2393            primary: Span {
2394                file: FileId::DETACHED,
2395                range: TextRange::new(TextSize::from(0), TextSize::from(0)),
2396            },
2397            labels: Vec::new(),
2398            fixits: Vec::new(),
2399        };
2400        let json = to_json(&[diagnostic], &sources);
2401        assert_eq!(json[0].severity, "warning");
2402        assert_eq!(json[0].span.path, "");
2403        assert_eq!(json[0].span.start, LineCol { line: 1, column: 1 });
2404    }
2405
2406    /// A source map holding two files, with a diagnostic whose primary span
2407    /// and label span both point into the second one. Every other JSON
2408    /// fixture in this module registers exactly one file, under which
2409    /// `json_span` resolving every span against a hard-coded `FileId(0)`
2410    /// would still pass. The two files here also lay out their lines
2411    /// differently, so a wrong-file lookup reads the wrong line and column,
2412    /// not only the wrong path.
2413    #[test]
2414    fn to_json_resolves_spans_in_the_second_of_two_files() {
2415        let mut sources = SourceMap::new();
2416        let _first = sources.file_id("a.typl", "package p\n");
2417        let second = sources.file_id("b.typl", "package p\nimport a\ntype Y:\n");
2418        let primary = Span {
2419            file: second,
2420            range: TextRange::new(TextSize::from(19), TextSize::from(26)),
2421        };
2422        let label_span = Span {
2423            file: second,
2424            range: TextRange::new(TextSize::from(10), TextSize::from(18)),
2425        };
2426        let diagnostic = Diagnostic {
2427            code: DiagCode::TYPL_009,
2428            severity: Severity::Error,
2429            message: "expected a type".to_string(),
2430            primary,
2431            labels: vec![Label {
2432                span: label_span,
2433                message: "imported here".to_string(),
2434            }],
2435            fixits: Vec::new(),
2436        };
2437
2438        let json = to_json(&[diagnostic], &sources);
2439
2440        let first = &json[0];
2441        assert_eq!(first.span.path, "b.typl");
2442        assert_eq!(first.span.start, LineCol { line: 3, column: 1 });
2443        assert_eq!(first.span.end, LineCol { line: 3, column: 8 });
2444        assert_eq!(first.labels.len(), 1);
2445        assert_eq!(first.labels[0].message, "imported here");
2446        assert_eq!(first.labels[0].span.path, "b.typl");
2447        assert_eq!(first.labels[0].span.start, LineCol { line: 2, column: 1 });
2448        assert_eq!(first.labels[0].span.end, LineCol { line: 2, column: 9 });
2449    }
2450
2451    /// `Severity::Info` renders as `"info"`. The other two variants
2452    /// (`"error"`, `"warning"`) are already exercised above.
2453    #[test]
2454    fn to_json_reports_info_severity() {
2455        let sources = SourceMap::new();
2456        let diagnostic = Diagnostic {
2457            code: DiagCode::TYPL_115,
2458            severity: Severity::Info,
2459            message: "type has no derivable init value".to_string(),
2460            primary: Span {
2461                file: FileId::DETACHED,
2462                range: TextRange::new(TextSize::from(0), TextSize::from(0)),
2463            },
2464            labels: Vec::new(),
2465            fixits: Vec::new(),
2466        };
2467        let json = to_json(&[diagnostic], &sources);
2468        assert_eq!(json[0].severity, "info");
2469    }
2470
2471    /// `code` stays present on the wire, as an empty string, for a diagnostic
2472    /// carrying [`DiagCode::NONE`] — never omitted or turned into `null`. A
2473    /// snapshot pins the whole serialized object, so a `#[serde(skip_serializing_if
2474    /// = "String::is_empty")]` mutation on `code` (which a struct-field-only
2475    /// assertion would not catch) drops the key and fails this comparison.
2476    #[test]
2477    fn to_json_keeps_the_code_field_present_when_empty() {
2478        let sources = SourceMap::new();
2479        let diagnostic = Diagnostic {
2480            code: DiagCode::NONE,
2481            severity: Severity::Error,
2482            message: "expected a type, but `FROB` names a constant".to_string(),
2483            primary: Span {
2484                file: FileId::DETACHED,
2485                range: TextRange::new(TextSize::from(0), TextSize::from(0)),
2486            },
2487            labels: Vec::new(),
2488            fixits: Vec::new(),
2489        };
2490        insta::assert_json_snapshot!(to_json(&[diagnostic], &sources));
2491    }
2492}