Skip to main content

ridl_core/
diag.rs

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