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}