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//! - A [`DiagnosticCode`] answers "which rule fired?" It is an opaque,
7//!   namespaced, severity-free identifier rendered as `MOS0010`. The
8//!   number has no semantic meaning — it does not encode severity,
9//!   owner crate, category, or lint group. Numbers are globally unique
10//!   and stable; new codes get the next free integer regardless of
11//!   what they describe.
12//! - A [`DiagnosticDef`] pairs that code with its slug, *default*
13//!   severity, category, owning crate, and a one-line summary. The
14//!   catalog groups by [`DiagnosticCategory`], not by numeric range —
15//!   so a rule that moves phases (parser → eval, fonts → text shaping)
16//!   keeps its stable ID and just updates its `category`.
17//!
18//! Both `DiagnosticCode` and `DiagnosticDef` have crate-private fields
19//! and crate-private constructors, so the only place a code or def can
20//! be minted is the `define_codes!` invocation below. Outside crates
21//! reference the `pub static` defs (`&codes::MOS0010`) and can neither
22//! forge new ones nor disagree with a code's registered severity.
23
24use crate::Severity;
25
26/// Stable, severity-free diagnostic identifier (manifest §16).
27///
28/// Rendered as a namespace followed by a zero-padded four-digit number,
29/// e.g. `MOS0010`. Equality and hashing use the `(namespace, number)`
30/// pair, so the display width can grow past four digits without breaking
31/// tooling that keyed off the structured value.
32///
33/// # Examples
34///
35/// ```
36/// use mos_core::codes;
37///
38/// assert_eq!(codes::MOS0010.code().to_string(), "MOS0010");
39/// assert_eq!(codes::MOS0010.code().number(), 10);
40/// ```
41#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
42pub struct DiagnosticCode {
43    namespace: &'static str,
44    number: u32,
45}
46
47impl DiagnosticCode {
48    /// The namespace segment (always `"MOS"` for compiler-native codes).
49    ///
50    /// # Examples
51    ///
52    /// ```
53    /// use mos_core::codes;
54    ///
55    /// assert_eq!(codes::MOS0033.code().namespace(), "MOS");
56    /// ```
57    #[must_use]
58    pub const fn namespace(self) -> &'static str {
59        self.namespace
60    }
61
62    /// The numeric portion, without zero-padding.
63    ///
64    /// # Examples
65    ///
66    /// ```
67    /// use mos_core::codes;
68    ///
69    /// assert_eq!(codes::MOS0033.code().number(), 33);
70    /// ```
71    #[must_use]
72    pub const fn number(self) -> u32 {
73        self.number
74    }
75
76    pub(crate) const fn new(namespace: &'static str, number: u32) -> Self {
77        Self { namespace, number }
78    }
79}
80
81impl std::fmt::Display for DiagnosticCode {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        write!(f, "{}{:04}", self.namespace, self.number)
84    }
85}
86
87/// What *kind* of thing a diagnostic describes.
88///
89/// Category is metadata, never identity. The catalog groups by this so a
90/// rule can change phase (parser → evaluator, fonts → text shaping)
91/// without breaking its stable [`DiagnosticCode`].
92///
93/// # Examples
94///
95/// ```
96/// use mos_core::{DiagnosticCategory, codes};
97///
98/// assert_eq!(codes::MOS0033.category(), DiagnosticCategory::Semantic);
99/// ```
100#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
101pub enum DiagnosticCategory {
102    /// Surface syntax: tokenisation, directive shape, inline grammar.
103    Syntax,
104    /// Semantic lowering: name resolution, schema validation, references.
105    Semantic,
106    /// Page geometry, paper sizes, style application.
107    Layout,
108    /// Text shaping, glyph coverage, font selection.
109    Text,
110    /// PDF backend emission and packaging.
111    Pdf,
112    /// Filesystem and asset I/O (read failure, decode failure, …).
113    Io,
114    /// Compiler-internal invariants (should never reach end users).
115    Internal,
116}
117
118impl std::fmt::Display for DiagnosticCategory {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        let s = match self {
121            Self::Syntax => "Syntax",
122            Self::Semantic => "Semantic",
123            Self::Layout => "Layout",
124            Self::Text => "Text",
125            Self::Pdf => "Pdf",
126            Self::Io => "Io",
127            Self::Internal => "Internal",
128        };
129        f.write_str(s)
130    }
131}
132
133/// Registry entry: one code, its slug, default severity, category, owner, summary.
134///
135/// Constructed only by `define_codes!`. Fields are read through the
136/// accessors; there is no public constructor and no public field, so an
137/// outside crate cannot forge a def that reuses an existing code with a
138/// different slug or severity.
139///
140/// # Examples
141///
142/// ```
143/// use mos_core::{DiagnosticCategory, Severity, codes};
144///
145/// assert_eq!(codes::MOS0018.default_severity(), Severity::Notice);
146/// assert_eq!(codes::MOS0018.category(), DiagnosticCategory::Text);
147/// assert_eq!(codes::MOS0018.owner(), "mos-fonts");
148/// ```
149#[derive(Debug)]
150pub struct DiagnosticDef {
151    code: DiagnosticCode,
152    slug: &'static str,
153    default_severity: Severity,
154    category: DiagnosticCategory,
155    owner: &'static str,
156    summary: &'static str,
157}
158
159impl DiagnosticDef {
160    /// The stable identifier.
161    ///
162    /// # Examples
163    ///
164    /// ```
165    /// use mos_core::codes;
166    ///
167    /// assert_eq!(codes::MOS0033.code().to_string(), "MOS0033");
168    /// ```
169    #[must_use]
170    pub const fn code(&self) -> DiagnosticCode {
171        self.code
172    }
173
174    /// The machine-readable kebab-case handle (e.g. `"label-duplicate"`).
175    ///
176    /// # Examples
177    ///
178    /// ```
179    /// use mos_core::codes;
180    ///
181    /// assert_eq!(codes::MOS0033.slug(), "label-missing");
182    /// ```
183    #[must_use]
184    pub const fn slug(&self) -> &'static str {
185        self.slug
186    }
187
188    /// The severity this code carries unless overridden by future config.
189    ///
190    /// # Examples
191    ///
192    /// ```
193    /// use mos_core::{Severity, codes};
194    ///
195    /// assert_eq!(codes::MOS0033.default_severity(), Severity::Error);
196    /// ```
197    #[must_use]
198    pub const fn default_severity(&self) -> Severity {
199        self.default_severity
200    }
201
202    /// What kind of thing this code describes. Used by the catalog to
203    /// group rules; never folded into identity.
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// use mos_core::{DiagnosticCategory, codes};
209    ///
210    /// assert_eq!(codes::MOS0033.category(), DiagnosticCategory::Semantic);
211    /// ```
212    #[must_use]
213    pub const fn category(&self) -> DiagnosticCategory {
214        self.category
215    }
216
217    /// The crate that owns the emit site(s).
218    ///
219    /// # Examples
220    ///
221    /// ```
222    /// use mos_core::codes;
223    ///
224    /// assert_eq!(codes::MOS0033.owner(), "mos-eval");
225    /// ```
226    #[must_use]
227    pub const fn owner(&self) -> &'static str {
228        self.owner
229    }
230
231    /// One-line human summary, mirrored verbatim into the catalog.
232    ///
233    /// # Examples
234    ///
235    /// ```
236    /// use mos_core::codes;
237    ///
238    /// assert!(codes::MOS0033.summary().contains("@reference"));
239    /// ```
240    #[must_use]
241    pub const fn summary(&self) -> &'static str {
242        self.summary
243    }
244
245    pub(crate) const fn new(
246        code: DiagnosticCode,
247        slug: &'static str,
248        default_severity: Severity,
249        category: DiagnosticCategory,
250        owner: &'static str,
251        summary: &'static str,
252    ) -> Self {
253        Self {
254            code,
255            slug,
256            default_severity,
257            category,
258            owner,
259            summary,
260        }
261    }
262}
263
264/// Define the entire diagnostic registry.
265///
266/// Each line expands to a `pub static DiagnosticDef` plus an entry in
267/// [`ALL`]. The macro is the *only* mint site for codes and defs, and it
268/// generates the invariant tests (unique numbers, unique slugs, the
269/// static's name matches its rendered code, `MOS` + four digits).
270macro_rules! define_codes {
271    (
272        $(
273            $(#[$meta:meta])*
274            $name:ident = $num:literal, $sev:ident, $cat:ident, $slug:literal, $owner:literal, $summary:literal;
275        )*
276    ) => {
277        $(
278            $(#[$meta])*
279            pub static $name: DiagnosticDef = DiagnosticDef::new(
280                DiagnosticCode::new("MOS", $num),
281                $slug,
282                Severity::$sev,
283                DiagnosticCategory::$cat,
284                $owner,
285                $summary,
286            );
287        )*
288
289        /// Every registered diagnostic definition, in declaration order.
290        ///
291        /// The catalog drift test (`crates/mos/tests/catalog.rs`) walks
292        /// this slice; keep it as the single machine-readable source.
293        pub static ALL: &[&DiagnosticDef] = &[ $( &$name ),* ];
294
295        #[cfg(test)]
296        mod generated_tests {
297            use super::*;
298
299            #[test]
300            fn numbers_are_globally_unique() {
301                let mut seen = std::collections::BTreeSet::new();
302                for def in ALL {
303                    let key = (def.code().namespace(), def.code().number());
304                    assert!(
305                        seen.insert(key),
306                        "duplicate diagnostic number: {}",
307                        def.code()
308                    );
309                }
310            }
311
312            #[test]
313            fn slugs_are_unique_and_kebab_case() {
314                let mut seen = std::collections::BTreeSet::new();
315                for def in ALL {
316                    assert!(seen.insert(def.slug()), "duplicate slug: {}", def.slug());
317                    assert!(
318                        !def.slug().is_empty()
319                            && def.slug().bytes().all(|b| {
320                                b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'
321                            }),
322                        "slug {:?} must be non-empty kebab-case",
323                        def.slug()
324                    );
325                }
326            }
327
328            #[test]
329            fn rendered_code_is_namespace_plus_four_digits() {
330                for def in ALL {
331                    let rendered = def.code().to_string();
332                    assert!(rendered.starts_with("MOS"), "code {rendered} must start with MOS");
333                    assert_eq!(rendered.len(), 7, "code {rendered} must be MOS + 4 digits");
334                    assert!(
335                        rendered[3..].bytes().all(|b| b.is_ascii_digit()),
336                        "code {rendered} tail must be all digits"
337                    );
338                }
339            }
340
341            #[test]
342            fn static_name_matches_rendered_code() {
343                $(
344                    assert_eq!(
345                        stringify!($name),
346                        $name.code().to_string(),
347                        "the static's name must equal its rendered code"
348                    );
349                )*
350            }
351        }
352    };
353}
354
355// Numbers are opaque. They do not encode category, severity, owner, or
356// phase. Current assignments intentionally interleave categories to avoid
357// accidental range semantics. Declaration order groups by category here
358// for source-reading convenience only — the catalog (and any consumer)
359// groups by `category()`, not by numeric range.
360define_codes! {
361    // ── syntax (mos-parse) ────────────────────────────────────────────
362    /// `#set` not followed by an identifier.
363    MOS0010 = 10, Error, Syntax, "set-missing-identifier", "mos-parse",
364        "syntax: #set not followed by an identifier";
365    /// Missing `(` after `#set NAME`, `#image`, or `#figure`.
366    MOS0013 = 13, Error, Syntax, "directive-missing-paren", "mos-parse",
367        "syntax: directive missing opening parenthesis";
368    /// Unterminated `#NAME(...)` or `#NAME[[...]]` block.
369    MOS0016 = 16, Error, Syntax, "directive-unterminated", "mos-parse",
370        "syntax: unterminated directive block";
371    /// Unexpected trailing content after a directive on the same line.
372    MOS0019 = 19, Error, Syntax, "directive-trailing-content", "mos-parse",
373        "syntax: unexpected trailing content after directive";
374    /// Malformed directive argument value (bad escape, unknown unit,
375    /// unterminated string, lone `-`, malformed number/length).
376    MOS0022 = 22, Error, Syntax, "directive-malformed-arg", "mos-parse",
377        "syntax: malformed directive argument value";
378    /// Argument-list shape error (missing `:`, missing `,`/`)`,
379    /// positional where named expected).
380    MOS0025 = 25, Error, Syntax, "arglist-shape", "mos-parse",
381        "syntax: malformed argument list";
382    /// Unterminated `**strong**` run; treated as literal text.
383    MOS0028 = 28, Warning, Syntax, "unterminated-strong", "mos-parse",
384        "syntax: unterminated **strong** run; treated as text";
385    /// Unterminated `*emphasis*` run; treated as literal text.
386    MOS0031 = 31, Warning, Syntax, "unterminated-emphasis", "mos-parse",
387        "syntax: unterminated *emphasis* run; treated as text";
388    /// Unterminated `` `code` `` run; treated as literal text.
389    MOS0034 = 34, Warning, Syntax, "unterminated-code", "mos-parse",
390        "syntax: unterminated `code` run; treated as text";
391    /// Stray `@` not followed by a label identifier; treated as text.
392    MOS0036 = 36, Warning, Syntax, "stray-at-sign", "mos-parse",
393        "syntax: stray @ not followed by a label; treated as text";
394    /// Lone trailing `\` at end of input; treated as literal text.
395    MOS0038 = 38, Warning, Syntax, "lone-trailing-backslash", "mos-parse",
396        "syntax: lone trailing backslash at end of input; treated as text";
397    /// Malformed citation group; treated as literal text.
398    MOS0039 = 39, Warning, Syntax, "malformed-citation", "mos-parse",
399        "syntax: malformed citation group; treated as text";
400    /// BibTeX database could not be parsed (`mos-bib`).
401    MOS0043 = 43, Error, Syntax, "bibtex-parse-failed", "mos-bib",
402        "syntax: BibTeX database could not be parsed";
403    /// CSL style could not be parsed (`mos-csl`).
404    MOS0044 = 44, Error, Syntax, "csl-parse-failed", "mos-csl",
405        "syntax: CSL style could not be parsed";
406
407    // ── semantic (mos-eval) ───────────────────────────────────────────
408    /// Unknown `#set` target (only `page`, `text`, `document`, `image`).
409    MOS0011 = 11, Error, Semantic, "set-unknown-target", "mos-eval",
410        "semantic: unknown #set target";
411    /// Unknown keyword argument for `#set TARGET`, `#image`, or `#figure`.
412    MOS0015 = 15, Error, Semantic, "unknown-kwarg", "mos-eval",
413        "semantic: unknown keyword argument";
414    /// Argument type mismatch or non-positive length.
415    MOS0020 = 20, Error, Semantic, "arg-type-mismatch", "mos-eval",
416        "semantic: argument type mismatch or non-positive length";
417    /// `#set` rejecting a positional argument where named is required.
418    MOS0024 = 24, Error, Semantic, "set-positional-rejected", "mos-eval",
419        "semantic: #set rejects positional argument";
420    /// `#set` value passes typing but trips a sanity floor; still applied.
421    MOS0027 = 27, Warning, Semantic, "set-sanity-floor", "mos-eval",
422        "semantic: #set value trips a sanity floor; value still applied";
423    /// Label declared more than once; first declaration wins.
424    MOS0030 = 30, Error, Semantic, "label-duplicate", "mos-eval",
425        "semantic: label declared more than once";
426    /// `@label` reference to a label that does not exist.
427    MOS0033 = 33, Error, Semantic, "label-missing", "mos-eval",
428        "semantic: @reference to a label that does not exist";
429    /// `#image(...)`/`#figure(...)` missing a path argument.
430    MOS0037 = 37, Error, Semantic, "image-missing-path", "mos-eval",
431        "semantic: #image/#figure missing a path argument";
432    /// `#bibliography(...)` missing a path argument.
433    MOS0040 = 40, Error, Semantic, "bibliography-missing-path", "mos-eval",
434        "semantic: #bibliography missing a path argument";
435    /// `#bibliography(...)` path declared more than once; first wins.
436    MOS0042 = 42, Error, Semantic, "bibliography-duplicate-path", "mos-eval",
437        "semantic: #bibliography path argument declared more than once";
438
439    // ── filesystem / asset I/O ────────────────────────────────────────
440    /// Image file cannot be read from disk.
441    MOS0012 = 12, Error, Io, "image-read-failed", "mos-eval",
442        "io: image file cannot be read from disk";
443    /// Image file cannot be decoded (unsupported or corrupt).
444    MOS0029 = 29, Error, Io, "image-decode-failed", "mos-eval",
445        "io: image file cannot be decoded";
446    /// Declared `#bibliography(...)` source file is not on disk.
447    MOS0041 = 41, Warning, Io, "bibliography-source-missing", "mos-eval",
448        "io: declared bibliography source file not found";
449
450    // ── layout (mos-layout) ───────────────────────────────────────────
451    /// Unknown paper size in `#set page(paper: ...)`.
452    MOS0017 = 17, Error, Layout, "paper-size-unknown", "mos-layout",
453        "layout: unknown paper size";
454    /// Well-typed `#set` value breaks page geometry; previous value kept.
455    MOS0023 = 23, Error, Layout, "geometry-breaks-page", "mos-layout",
456        "layout: value breaks page geometry; previous value retained";
457    /// Image reached layout without decoded pixels; skipped on the page.
458    MOS0035 = 35, Warning, Layout, "image-skipped-no-pixels", "mos-layout",
459        "layout: image reached layout without decoded pixels; skipped";
460
461    // ── text / fonts / shaping ────────────────────────────────────────
462    /// Unknown font family; falling back to bundled Noto Sans.
463    MOS0018 = 18, Notice, Text, "font-family-substituted", "mos-fonts",
464        "text: substituted bundled Noto Sans for unknown font family";
465    /// Base-14 `/Differences` glyph budget exhausted for a face.
466    MOS0032 = 32, Warning, Text, "glyph-budget-exhausted", "mos-pdf",
467        "text: Base-14 /Differences glyph budget exhausted";
468
469    // ── PDF emission (mos-pdf) ────────────────────────────────────────
470    /// PDF backend I/O failure (cannot create dir or write bytes).
471    MOS0014 = 14, Error, Pdf, "pdf-io-failed", "mos-pdf",
472        "pdf: backend I/O failure";
473    /// Font subsetting failure for an embedded face.
474    MOS0026 = 26, Error, Pdf, "font-subset-failed", "mos-pdf",
475        "pdf: font subsetting failure for an embedded face";
476
477    // ── compiler-internal invariants ──────────────────────────────────
478    /// Internal: missing embedded font plan for a shaped run.
479    MOS0021 = 21, Error, Internal, "internal-missing-font-plan", "mos-pdf",
480        "internal: missing embedded font plan for a shaped run";
481}