Skip to main content

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}