mos_core/codes.rs
1//! Diagnostic code registry; the single source of truth for every
2//! diagnostic the compiler can emit.
3//!
4//! Identity and severity are deliberately *separate axes*:
5//!
6//! - [`DiagnosticDef::id`] is the canonical semantic identifier shown to users.
7//! Its prefix comes from the category; the registry supplies only the condition slug.
8//! - A [`DiagnosticCode`] answers "which rule fired?" It is an opaque,
9//! namespaced, severity-free identifier rendered as `MOS0010`. The
10//! number has no semantic meaning: it does not encode severity,
11//! owner crate, category, or lint group. Numbers are globally unique
12//! and stable; new codes get the next free integer regardless of
13//! what they describe.
14//! - A [`DiagnosticDef`] pairs that code with its slug, *default*
15//! severity, category, owning crate, and a one-line summary. The
16//! catalog groups by [`DiagnosticCategory`], not by numeric range,
17//! so a rule that moves phases (parser → eval, fonts → text shaping)
18//! keeps its stable ID and just updates its `category`.
19//!
20//! Both `DiagnosticCode` and `DiagnosticDef` have crate-private fields
21//! and crate-private constructors, so the only place a code or def can
22//! be minted is the `define_codes!` invocation below. Outside crates
23//! reference the `pub static` defs (`&codes::MOS0010`) and can neither
24//! forge new ones nor disagree with a code's registered severity.
25
26use crate::Severity;
27
28/// Stable, severity-free diagnostic identifier (manifest §16).
29///
30/// Rendered as a namespace followed by a zero-padded four-digit number,
31/// e.g. `MOS0010`. Equality and hashing use the `(namespace, number)`
32/// pair, so the display width can grow past four digits without breaking
33/// tooling that keyed off the structured value.
34///
35/// # Examples
36///
37/// ```
38/// use mos_core::codes;
39///
40/// assert_eq!(codes::MOS0010.code().to_string(), "MOS0010");
41/// assert_eq!(codes::MOS0010.code().number(), 10);
42/// ```
43#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
44pub struct DiagnosticCode {
45 namespace: &'static str,
46 number: u32,
47}
48
49impl DiagnosticCode {
50 /// The namespace segment (always `"MOS"` for compiler-native codes).
51 ///
52 /// # Examples
53 ///
54 /// ```
55 /// use mos_core::codes;
56 ///
57 /// assert_eq!(codes::MOS0033.code().namespace(), "MOS");
58 /// ```
59 #[must_use]
60 pub const fn namespace(self) -> &'static str {
61 self.namespace
62 }
63
64 /// The numeric portion, without zero-padding.
65 ///
66 /// # Examples
67 ///
68 /// ```
69 /// use mos_core::codes;
70 ///
71 /// assert_eq!(codes::MOS0033.code().number(), 33);
72 /// ```
73 #[must_use]
74 pub const fn number(self) -> u32 {
75 self.number
76 }
77
78 pub(crate) const fn new(namespace: &'static str, number: u32) -> Self {
79 Self { namespace, number }
80 }
81}
82
83impl std::fmt::Display for DiagnosticCode {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 write!(f, "{}{:04}", self.namespace, self.number)
86 }
87}
88
89/// Define category variants and their canonical ID prefixes together.
90macro_rules! define_categories {
91 ($( $(#[$meta:meta])* $variant:ident => $prefix:literal, )*) => {
92 /// Diagnostic grouping and source of the diagnostic ID prefix.
93 ///
94 /// Changing a category changes the descriptive ID, but preserves its
95 /// numeric compatibility alias.
96 ///
97 /// ```
98 /// use mos_core::{DiagnosticCategory, codes};
99 /// assert_eq!(codes::MOS0033.category(), DiagnosticCategory::Resolution);
100 /// ```
101 #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
102 pub enum DiagnosticCategory {
103 $( $(#[$meta])* $variant, )*
104 }
105
106 impl DiagnosticCategory {
107 /// Canonical prefix used in diagnostic identifiers.
108 #[must_use]
109 pub const fn prefix(self) -> &'static str {
110 match self {
111 $( Self::$variant => $prefix, )*
112 }
113 }
114 }
115
116 impl std::fmt::Display for DiagnosticCategory {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 match self {
119 $( Self::$variant => f.write_str(stringify!($variant)), )*
120 }
121 }
122 }
123 };
124}
125
126define_categories! {
127 /// Surface syntax: tokenisation, directive shape, inline grammar.
128 Syntax => "syntax",
129 /// Name and reference resolution, directive and argument validation.
130 Resolution => "resolution",
131 /// Page geometry, paper sizes, style application.
132 Layout => "layout",
133 /// Text shaping, glyph coverage, font selection.
134 Text => "text",
135 /// PDF backend emission and packaging.
136 Pdf => "pdf",
137 /// Filesystem and asset I/O.
138 Io => "io",
139 /// Compiler-internal invariants.
140 Internal => "internal",
141}
142
143/// Registry entry: one code, its slug, default severity, category, owner, summary.
144///
145/// Constructed only by `define_codes!`. Fields are read through the
146/// accessors; there is no public constructor and no public field, so an
147/// outside crate cannot forge a def that reuses an existing code with a
148/// different slug or severity.
149///
150/// # Examples
151///
152/// ```
153/// use mos_core::{DiagnosticCategory, Severity, codes};
154///
155/// assert_eq!(codes::MOS0018.default_severity(), Severity::Notice);
156/// assert_eq!(codes::MOS0018.category(), DiagnosticCategory::Text);
157/// assert_eq!(codes::MOS0018.owner(), "mos-fonts");
158/// ```
159#[derive(Clone, Copy, Debug)]
160pub struct DiagnosticDef {
161 code: DiagnosticCode,
162 slug: &'static str,
163 default_severity: Severity,
164 category: DiagnosticCategory,
165 owner: &'static str,
166 summary: &'static str,
167}
168
169impl DiagnosticDef {
170 /// Canonical diagnostic identifier derived from the category and condition slug.
171 ///
172 /// ```
173 /// use mos_core::codes;
174 /// assert_eq!(codes::MOS0033.id(), "resolution.label-missing");
175 /// ```
176 #[must_use]
177 pub fn id(&self) -> String {
178 format!("{}.{}", self.category.prefix(), self.slug)
179 }
180
181 /// Catalog URL with an explicit stable anchor for this rule.
182 #[must_use]
183 pub fn documentation_url(&self) -> String {
184 format!(
185 "https://github.com/kjanat/mosaic/blob/master/docs/diagnostic-codes.md#{}",
186 self.id()
187 )
188 }
189
190 /// The stable numeric compatibility alias.
191 ///
192 /// # Examples
193 ///
194 /// ```
195 /// use mos_core::codes;
196 ///
197 /// assert_eq!(codes::MOS0033.code().to_string(), "MOS0033");
198 /// ```
199 #[must_use]
200 pub const fn code(&self) -> DiagnosticCode {
201 self.code
202 }
203
204 /// The machine-readable kebab-case handle (e.g. `"label-duplicate"`).
205 ///
206 /// # Examples
207 ///
208 /// ```
209 /// use mos_core::codes;
210 ///
211 /// assert_eq!(codes::MOS0033.slug(), "label-missing");
212 /// ```
213 #[must_use]
214 pub const fn slug(&self) -> &'static str {
215 self.slug
216 }
217
218 /// The severity this code carries unless overridden by future config.
219 ///
220 /// # Examples
221 ///
222 /// ```
223 /// use mos_core::{Severity, codes};
224 ///
225 /// assert_eq!(codes::MOS0033.default_severity(), Severity::Error);
226 /// ```
227 #[must_use]
228 pub const fn default_severity(&self) -> Severity {
229 self.default_severity
230 }
231
232 /// What kind of thing this code describes. Used by the catalog to
233 /// group rules; never folded into identity.
234 ///
235 /// # Examples
236 ///
237 /// ```
238 /// use mos_core::{DiagnosticCategory, codes};
239 ///
240 /// assert_eq!(codes::MOS0033.category(), DiagnosticCategory::Resolution);
241 /// ```
242 #[must_use]
243 pub const fn category(&self) -> DiagnosticCategory {
244 self.category
245 }
246
247 /// The crate that owns the emit site(s).
248 ///
249 /// # Examples
250 ///
251 /// ```
252 /// use mos_core::codes;
253 ///
254 /// assert_eq!(codes::MOS0033.owner(), "mos-eval");
255 /// ```
256 #[must_use]
257 pub const fn owner(&self) -> &'static str {
258 self.owner
259 }
260
261 /// One-line human summary, mirrored verbatim into the catalog.
262 ///
263 /// # Examples
264 ///
265 /// ```
266 /// use mos_core::codes;
267 ///
268 /// assert!(codes::MOS0033.summary().contains("@reference"));
269 /// ```
270 #[must_use]
271 pub const fn summary(&self) -> &'static str {
272 self.summary
273 }
274
275 pub(crate) const fn new(
276 code: DiagnosticCode,
277 slug: &'static str,
278 default_severity: Severity,
279 category: DiagnosticCategory,
280 owner: &'static str,
281 summary: &'static str,
282 ) -> Self {
283 Self {
284 code,
285 slug,
286 default_severity,
287 category,
288 owner,
289 summary,
290 }
291 }
292}
293
294/// Define the entire diagnostic registry.
295///
296/// Each line expands to a `pub static DiagnosticDef` plus an entry in
297/// [`ALL`]. The macro is the *only* mint site for codes and defs, and it
298/// generates the invariant tests (unique numbers, unique slugs, the
299/// static's name matches its rendered code, `MOS` + four digits).
300macro_rules! define_codes {
301 (
302 $(
303 $(#[$meta:meta])*
304 $name:ident = $num:literal, $sev:ident, $cat:ident, $slug:literal, $owner:literal, $summary:literal;
305 )*
306 ) => {
307 $(
308 $(#[$meta])*
309 pub static $name: DiagnosticDef = DiagnosticDef::new(
310 DiagnosticCode::new("MOS", $num),
311 $slug,
312 Severity::$sev,
313 DiagnosticCategory::$cat,
314 $owner,
315 $summary,
316 );
317 )*
318
319 /// Every registered diagnostic definition, in declaration order.
320 ///
321 /// The catalog drift test (`crates/mos/tests/catalog.rs`) walks
322 /// this slice; keep it as the single machine-readable source.
323 pub static ALL: &[&DiagnosticDef] = &[ $( &$name ),* ];
324
325 /// Find a definition by its exact semantic ID or numeric compatibility alias.
326 /// Bare slugs, alternate casing, and noncanonical numeric spellings are rejected.
327 ///
328 /// ```
329 /// use mos_core::codes;
330 /// assert!(std::ptr::eq(codes::lookup("resolution.label-missing").unwrap(), &codes::MOS0033));
331 /// assert!(std::ptr::eq(codes::lookup("MOS0033").unwrap(), &codes::MOS0033));
332 /// assert!(codes::lookup("label-missing").is_none());
333 /// ```
334 #[must_use]
335 pub fn lookup(identifier: &str) -> Option<&'static DiagnosticDef> {
336 match identifier {
337 $( stringify!($name) => Some(&$name), )*
338 _ => {
339 let (prefix, slug) = identifier.split_once('.')?;
340 ALL.iter().copied().find(|def| {
341 def.category().prefix() == prefix && def.slug() == slug
342 })
343 }
344 }
345 }
346
347 #[cfg(test)]
348 mod generated_tests {
349 use super::*;
350
351 #[test]
352 fn numbers_are_globally_unique() {
353 let mut seen = std::collections::BTreeSet::new();
354 for def in ALL {
355 let key = (def.code().namespace(), def.code().number());
356 assert!(
357 seen.insert(key),
358 "duplicate diagnostic number: {}",
359 def.code()
360 );
361 }
362 }
363
364 #[test]
365 fn slugs_are_unique_and_kebab_case() {
366 let mut seen = std::collections::BTreeSet::new();
367 for def in ALL {
368 assert!(seen.insert(def.slug()), "duplicate slug: {}", def.slug());
369 assert!(
370 !def.slug().is_empty()
371 && def.slug().bytes().all(|b| {
372 b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'
373 }),
374 "slug {:?} must be non-empty kebab-case",
375 def.slug()
376 );
377 }
378 }
379
380 #[test]
381 fn rendered_code_is_namespace_plus_four_digits() {
382 for def in ALL {
383 let rendered = def.code().to_string();
384 assert!(rendered.starts_with("MOS"), "code {rendered} must start with MOS");
385 assert_eq!(rendered.len(), 7, "code {rendered} must be MOS + 4 digits");
386 assert!(
387 rendered[3..].bytes().all(|b| b.is_ascii_digit()),
388 "code {rendered} tail must be all digits"
389 );
390 }
391 }
392
393 #[test]
394 fn static_name_matches_rendered_code() {
395 $(
396 assert_eq!(
397 stringify!($name),
398 $name.code().to_string(),
399 "the static's name must equal its rendered code"
400 );
401 )*
402 }
403 }
404 };
405}
406
407// Numbers are opaque. They do not encode category, severity, owner, or
408// phase. Current assignments intentionally interleave categories to avoid
409// accidental range semantics. Declaration order groups by category here
410// for source-reading convenience only; the catalog (and any consumer)
411// groups by `category()`, not by numeric range.
412define_codes! {
413 // ── syntax (mos-parse) ────────────────────────────────────────────
414 /// `#set` not followed by an identifier.
415 MOS0010 = 10, Error, Syntax, "set-missing-identifier", "mos-parse",
416 "#set not followed by an identifier";
417 /// Missing `(` after `#set NAME`, `#image`, or `#figure`.
418 MOS0013 = 13, Error, Syntax, "directive-missing-paren", "mos-parse",
419 "directive missing opening parenthesis";
420 /// Unterminated `#NAME(...)` or `#NAME[[...]]` block.
421 MOS0016 = 16, Error, Syntax, "directive-unterminated", "mos-parse",
422 "unterminated directive block";
423 /// Unexpected trailing content after a directive on the same line.
424 MOS0019 = 19, Error, Syntax, "directive-trailing-content", "mos-parse",
425 "unexpected trailing content after directive";
426 /// Malformed directive argument value (bad escape, unknown unit,
427 /// unterminated string, lone `-`, malformed number/length).
428 MOS0022 = 22, Error, Syntax, "directive-malformed-arg", "mos-parse",
429 "malformed directive argument value";
430 /// Argument-list shape error (missing `:`, missing `,`/`)`,
431 /// positional where named expected).
432 MOS0025 = 25, Error, Syntax, "arglist-shape", "mos-parse",
433 "malformed argument list";
434 /// Unterminated `**strong**` run; treated as literal text.
435 MOS0028 = 28, Warning, Syntax, "unterminated-strong", "mos-parse",
436 "unterminated **strong** run; treated as text";
437 /// Unterminated `*emphasis*` run; treated as literal text.
438 MOS0031 = 31, Warning, Syntax, "unterminated-emphasis", "mos-parse",
439 "unterminated *emphasis* run; treated as text";
440 /// Unterminated `` `code` `` run; treated as literal text.
441 MOS0034 = 34, Warning, Syntax, "unterminated-code", "mos-parse",
442 "unterminated `code` run; treated as text";
443 /// Stray `@` not followed by a label identifier; treated as text.
444 MOS0036 = 36, Warning, Syntax, "stray-at-sign", "mos-parse",
445 "stray @ not followed by a label; treated as text";
446 /// Lone trailing `\` at end of input; treated as literal text.
447 MOS0038 = 38, Warning, Syntax, "lone-trailing-backslash", "mos-parse",
448 "lone trailing backslash at end of input; treated as text";
449 /// Malformed citation group; treated as literal text.
450 MOS0039 = 39, Warning, Syntax, "malformed-citation", "mos-parse",
451 "malformed citation group; treated as text";
452 /// Heading `<label>` is not trailing.
453 MOS0048 = 48, Warning, Syntax, "heading-label-not-trailing", "mos-parse",
454 "heading label is not the last element on the line; treated as text";
455 /// Unterminated `/*` block comment; consumed to end of input.
456 MOS0050 = 50, Warning, Syntax, "unterminated-block-comment", "mos-parse",
457 "unterminated /* block comment; consumed to end of input";
458 /// BibTeX database could not be parsed (`mos-bib`).
459 MOS0043 = 43, Error, Syntax, "bibtex-parse-failed", "mos-bib",
460 "BibTeX database could not be parsed";
461 /// CSL style could not be parsed (`mos-csl`).
462 MOS0044 = 44, Error, Syntax, "csl-parse-failed", "mos-csl",
463 "CSL style could not be parsed";
464
465 // ── resolution (mos-eval) ───────────────────────────────────────────
466 /// Unknown `#set` target (only `page`, `text`, `document`, `image`).
467 MOS0011 = 11, Error, Resolution, "set-unknown-target", "mos-eval",
468 "unknown #set target";
469 /// Unknown keyword argument for `#set TARGET`, `#image`, or `#figure`.
470 MOS0015 = 15, Error, Resolution, "unknown-kwarg", "mos-eval",
471 "unknown keyword argument";
472 /// Argument type mismatch or non-positive length.
473 MOS0020 = 20, Error, Resolution, "arg-type-mismatch", "mos-eval",
474 "argument type mismatch or non-positive length";
475 /// `#set` rejecting a positional argument where named is required.
476 MOS0024 = 24, Error, Resolution, "set-positional-rejected", "mos-eval",
477 "#set rejects positional argument";
478 /// `#set` value passes typing but trips a sanity floor; still applied.
479 MOS0027 = 27, Warning, Resolution, "set-sanity-floor", "mos-eval",
480 "#set value trips a sanity floor; value still applied";
481 /// Label declared more than once; first declaration wins.
482 MOS0030 = 30, Error, Resolution, "label-duplicate", "mos-eval",
483 "label declared more than once";
484 /// `@label` reference to a label that does not exist.
485 MOS0033 = 33, Error, Resolution, "label-missing", "mos-eval",
486 "@reference to a label that does not exist";
487 /// `#image(...)`/`#figure(...)` missing a path argument.
488 MOS0037 = 37, Error, Resolution, "image-missing-path", "mos-eval",
489 "#image/#figure missing a path argument";
490 /// `#bibliography(...)` missing a path argument.
491 MOS0040 = 40, Error, Resolution, "bibliography-missing-path", "mos-eval",
492 "#bibliography missing a path argument";
493 /// `#bibliography(...)` path declared more than once; first wins.
494 MOS0042 = 42, Error, Resolution, "bibliography-duplicate-path", "mos-eval",
495 "#bibliography path argument declared more than once";
496 /// `[@key]` citation to a bibliography record that does not exist.
497 MOS0045 = 45, Error, Resolution, "citation-missing", "mos-eval",
498 "citation key does not exist in bibliography records";
499 /// Citation key appears in more than one declared bibliography source.
500 MOS0046 = 46, Error, Resolution, "bibliography-duplicate-key", "mos-eval",
501 "citation key appears in more than one bibliography source";
502 /// Path contains a non-portable segment.
503 MOS0049 = 49, Error, Resolution, "path-unsafe-segment", "mos-eval",
504 "path segment is not a portable name (manifest paths use `/` only)";
505
506 // ── filesystem / asset I/O ────────────────────────────────────────
507 /// Image file cannot be read from disk.
508 MOS0012 = 12, Error, Io, "image-read-failed", "mos-eval",
509 "image file cannot be read from disk";
510 /// Image file cannot be decoded (unsupported or corrupt).
511 MOS0029 = 29, Error, Io, "image-decode-failed", "mos-eval",
512 "image file cannot be decoded";
513 /// Declared `#bibliography(...)` source file is not on disk.
514 MOS0041 = 41, Warning, Io, "bibliography-source-missing", "mos-eval",
515 "declared bibliography source file not found";
516
517 // ── layout (mos-layout) ───────────────────────────────────────────
518 /// Unknown paper size in `#set page(paper: ...)`.
519 MOS0017 = 17, Error, Layout, "paper-size-unknown", "mos-layout",
520 "unknown paper size";
521 /// Well-typed `#set` value breaks page geometry; previous value kept.
522 MOS0023 = 23, Error, Layout, "geometry-breaks-page", "mos-layout",
523 "value breaks page geometry; previous value retained";
524 /// Image reached layout without decoded pixels; skipped on the page.
525 MOS0035 = 35, Warning, Layout, "image-skipped-no-pixels", "mos-layout",
526 "image reached layout without decoded pixels; skipped";
527 /// `@page(...)` references did not converge.
528 MOS0047 = 47, Warning, Layout, "page-fixpoint-nonconvergence", "mos-eval",
529 "page references did not converge; last computed page numbers used";
530
531 // ── text / fonts / shaping ────────────────────────────────────────
532 /// Unknown font family; falling back to bundled Noto Sans.
533 MOS0018 = 18, Notice, Text, "font-family-substituted", "mos-fonts",
534 "substituted bundled Noto Sans for unknown font family";
535 /// Base-14 `/Differences` glyph budget exhausted for a face.
536 MOS0032 = 32, Warning, Text, "glyph-budget-exhausted", "mos-pdf",
537 "Base-14 /Differences glyph budget exhausted";
538
539 // ── PDF emission (mos-pdf) ────────────────────────────────────────
540 /// PDF backend I/O failure (cannot create dir or write bytes).
541 MOS0014 = 14, Error, Pdf, "pdf-io-failed", "mos-pdf",
542 "backend I/O failure";
543 /// Font subsetting failure for an embedded face.
544 MOS0026 = 26, Error, Pdf, "font-subset-failed", "mos-pdf",
545 "font subsetting failure for an embedded face";
546
547 // ── compiler-internal invariants ──────────────────────────────────
548 /// Internal: missing embedded font plan for a shaped run.
549 MOS0021 = 21, Error, Internal, "internal-missing-font-plan", "mos-pdf",
550 "missing embedded font plan for a shaped run";
551 /// Internal: debug layout report does not match the emitted page graph.
552 MOS0051 = 51, Error, Internal, "internal-debug-layout-mismatch", "mos-pdf",
553 "debug layout report does not match page geometry";
554}
555
556#[cfg(test)]
557mod semantic_tests {
558 use super::*;
559
560 #[test]
561 fn semantic_ids_are_unique_and_round_trip_with_aliases() {
562 let mut seen = std::collections::BTreeSet::new();
563 for def in ALL {
564 assert!(seen.insert(def.id()), "duplicate ID: {}", def.id());
565 let id = def.id();
566 let segments: Vec<_> = id.split('.').collect();
567 assert_eq!(segments, [def.category().prefix(), def.slug()]);
568 for segment in segments {
569 assert!(segment.split('-').all(|word| {
570 !word.is_empty()
571 && word
572 .bytes()
573 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
574 }));
575 }
576 for spelling in [def.id(), def.code().to_string()] {
577 assert!(lookup(&spelling).is_some_and(|found| std::ptr::eq(found, *def)));
578 }
579 }
580 for invalid in [
581 "",
582 "label-missing",
583 "Resolution.label-missing",
584 "MOS33",
585 "mos0033",
586 "MOS9999",
587 "resolution.unknown",
588 " MOS0033",
589 ] {
590 assert!(lookup(invalid).is_none(), "accepted {invalid:?}");
591 }
592 }
593
594 #[test]
595 fn category_changes_update_id_and_preserve_numeric_alias() {
596 let changed = DiagnosticDef {
597 category: DiagnosticCategory::Internal,
598 default_severity: Severity::Notice,
599 owner: "mos-core",
600 ..MOS0033
601 };
602 assert_eq!(changed.id(), "internal.label-missing");
603 assert!(
604 changed
605 .documentation_url()
606 .ends_with("#internal.label-missing")
607 );
608 assert_eq!(changed.code(), MOS0033.code());
609 }
610}