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