Skip to main content

quillmark_core/
error.rs

1//! # Error Handling
2//!
3//! Error types and diagnostics for parsing and rendering, with source location tracking.
4//!
5//! ## Document path anchors
6//!
7//! A [`Diagnostic`] carries two independent "where" anchors, both optional:
8//!
9//! - [`Diagnostic::location`]: source-text anchor (`file:line:column`).
10//!   Produced by parsers and backend compilers operating on raw text.
11//! - [`Diagnostic::path`]: document-model anchor into the typed
12//!   [`crate::document::Document`]. Produced by schema validation and
13//!   coercion, which run on the typed model after line spans are gone.
14//!
15//! [`DocPath`](crate::path::DocPath) is the one type that constructs, renders,
16//! and parses the path, no site assembles one with `format!`. Its module doc
17//! carries the grammar; `prose/canon/ERROR.md` tabulates the anchors.
18
19use std::collections::BTreeMap;
20
21use crate::OutputFormat;
22
23/// Build a [`Diagnostic::args`] map. Values pass through `serde_json`, so a
24/// list arrives as a list and a count as a number: the shapes a consumer
25/// needs to join and pluralize in its own locale.
26macro_rules! diag_args {
27    ($($key:literal => $value:expr),* $(,)?) => {{
28        #[allow(unused_mut)]
29        let mut map = ::std::collections::BTreeMap::<String, ::serde_json::Value>::new();
30        $(map.insert($key.to_string(), ::serde_json::json!($value));)*
31        map
32    }};
33}
34
35pub(crate) use diag_args;
36
37/// Maximum input size for markdown (10 MiB)
38pub const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024;
39
40/// Maximum YAML size (1 MiB)
41pub const MAX_YAML_SIZE: usize = 1024 * 1024;
42
43/// Maximum nesting depth for markdown structures (100 levels). Owned by the
44/// markdown codecs in `quillmark-content` (the import guard) and re-exported
45/// here so the typst backend's markup converter shares one limit: a document
46/// that imports also renders, and vice versa.
47pub use quillmark_content::MAX_NESTING_DEPTH;
48
49/// Re-exported from [`crate::document::limits::MAX_YAML_DEPTH`].
50pub use crate::document::limits::MAX_YAML_DEPTH;
51
52/// Maximum number of card blocks allowed per document
53pub const MAX_CARD_COUNT: usize = 1000;
54
55/// Maximum number of fields allowed per document
56pub const MAX_FIELD_COUNT: usize = 1000;
57
58/// A YAML parse or emit failure, owned by this crate.
59///
60/// The YAML engine is `serde-saphyr`. Returning its error types from a public
61/// signature would chain this crate's major version to that crate's, and to
62/// the choice of engine at all, so the boundary converts to this type instead
63/// and no public signature names the engine. The engine is an implementation
64/// detail; this is what the contract says it is.
65///
66/// `line`/`column` are 1-indexed and present only when the engine located the
67/// failure: always absent on the emit side, which has no input to point at.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct YamlError {
70    message: String,
71    hint: Option<String>,
72    line: Option<u32>,
73    column: Option<u32>,
74}
75
76impl YamlError {
77    /// What went wrong, in YAML terms.
78    pub fn message(&self) -> &str {
79        &self.message
80    }
81
82    /// The concrete textual fix, when the failure is one this crate recognizes.
83    pub fn hint(&self) -> Option<&str> {
84        self.hint.as_deref()
85    }
86
87    /// 1-indexed line of the failure, when the engine located one.
88    pub fn line(&self) -> Option<u32> {
89        self.line
90    }
91
92    /// 1-indexed column of the failure, paired with [`Self::line`].
93    pub fn column(&self) -> Option<u32> {
94        self.column
95    }
96
97    /// A diagnostic under `code`, carrying the hint and (when the engine
98    /// located the failure) a [`Location`] against `file`.
99    pub fn to_diagnostic(&self, code: &str, file: &str) -> Diagnostic {
100        let mut diag = Diagnostic::new(Severity::Error, self.message.clone())
101            .with_code(code.to_string());
102        if let (Some(line), Some(column)) = (self.line, self.column) {
103            diag = diag.with_location(Location::new(file.to_string(), line, column));
104        }
105        match &self.hint {
106            Some(h) => diag.with_hint(h.clone()),
107            None => diag,
108        }
109    }
110
111    /// `yaml` is the text that failed to parse: the hint derivation inspects
112    /// it to name the offending construct.
113    pub(crate) fn from_de(err: serde_saphyr::Error, yaml: &str) -> Self {
114        // The engine appends its own Rust API names to some messages
115        // (`from_multiple`, `DuplicateKeyPolicy`); the enricher strips them, so
116        // "no public signature names the engine" holds for the message too, not
117        // just the type.
118        let enriched = crate::document::yaml_hints::enrich_yaml_error(&err.to_string(), yaml);
119        // `Location`'s accessors widen to u64; the fields behind them are u32,
120        // so the narrowing is lossless.
121        let loc = err.location();
122        Self {
123            message: enriched.message,
124            hint: enriched.hint,
125            line: loc.and_then(|l| u32::try_from(l.line()).ok()),
126            column: loc.and_then(|l| u32::try_from(l.column()).ok()),
127        }
128    }
129
130    /// Emission has no input to point at, so no position and no hint.
131    pub(crate) fn from_ser(err: serde_saphyr::ser::Error) -> Self {
132        Self {
133            message: err.to_string(),
134            hint: None,
135            line: None,
136            column: None,
137        }
138    }
139}
140
141impl std::fmt::Display for YamlError {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        // The message already opens with the position and carries the engine's
144        // caret diagram; [`Self::line`]/[`Self::column`] are the structured
145        // reading of the same fact, not a second one to append.
146        f.write_str(&self.message)
147    }
148}
149
150impl std::error::Error for YamlError {}
151
152/// Fatality is this two-value ladder and nothing else: `Error` blocks the
153/// stage that emits it, `Warning` never does. There is no lint-level
154/// configuration and no warning-to-error promotion; an informational aside is
155/// a [`Diagnostic::hint`], not a severity.
156///
157/// A `_` arm over this enum has a safe direction: escalate to
158/// [`Severity::Error`]. Treating an unrecognized level as fatal over-reports;
159/// treating it as a warning could hide one. Nothing here fails silently, so the
160/// enum is open ([`COMPATIBILITY`]).
161///
162/// [`COMPATIBILITY`]: https://github.com/borb-sh/quillmark/blob/main/prose/canon/COMPATIBILITY.md
163#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
164#[serde(rename_all = "lowercase")]
165#[non_exhaustive]
166pub enum Severity {
167    /// Fatal error that prevents completion
168    Error,
169    /// Non-fatal issue that may need attention
170    Warning,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
174#[serde(rename_all = "camelCase")]
175#[non_exhaustive]
176pub struct Location {
177    /// Source file name (e.g., "plate.typ", "template.typ", "input.md")
178    pub file: String,
179    /// Line number (1-indexed)
180    pub line: u32,
181    /// Column number (1-indexed)
182    pub column: u32,
183}
184
185impl Location {
186    /// The three coordinates a text anchor always carries. `line` and `column`
187    /// are 1-indexed.
188    pub fn new(file: String, line: u32, column: u32) -> Self {
189        Self { file, line, column }
190    }
191}
192
193/// Structured diagnostic information.
194///
195/// `source_chain` is a flat list of error messages from any attached
196/// `std::error::Error` cause chain, eagerly walked at construction time so
197/// the diagnostic remains trivially `Clone` and fully serializable across
198/// every binding boundary.
199#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
200#[serde(rename_all = "camelCase")]
201#[non_exhaustive]
202pub struct Diagnostic {
203    pub severity: Severity,
204    /// Optional error code (e.g., "E001", "typst::syntax")
205    #[serde(skip_serializing_if = "Option::is_none", default)]
206    pub code: Option<String>,
207    pub message: String,
208    /// Primary source location (text anchor: file/line/column).
209    ///
210    /// Set by parsers and backend compilers. May co-exist with [`Self::path`]:
211    /// the two anchors are independent.
212    #[serde(skip_serializing_if = "Option::is_none", default)]
213    pub location: Option<Location>,
214    /// Document-model anchor: a dotted/bracketed path into the typed
215    /// [`crate::document::Document`].
216    ///
217    /// Set by schema validation and coercion. See the module-level docs for
218    /// the path grammar and conventions. May co-exist with [`Self::location`].
219    #[serde(skip_serializing_if = "Option::is_none", default)]
220    pub path: Option<String>,
221    #[serde(skip_serializing_if = "Option::is_none", default)]
222    pub hint: Option<String>,
223    /// The facts [`Self::message`] interpolates, keyed by name: with
224    /// [`Self::code`], the substitution unit a consumer needs to word this
225    /// diagnostic in its own language.
226    ///
227    /// One code carries one key set, tabulated per code in
228    /// `prose/canon/ERROR.md` § "Diagnostic args" and tested against it.
229    /// Values keep their JSON shape, so joining and pluralizing stay the
230    /// consumer's locale decisions.
231    ///
232    /// Empty either because the code is outside the structured surface or
233    /// because its sentence needs no facts beyond the code; canon tells the
234    /// two apart. Engine prose never rides under a key: a consumer's sentence
235    /// may be coarser than ours, never half-translated.
236    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
237    pub args: BTreeMap<String, serde_json::Value>,
238    /// Flattened cause chain (outermost first). Upstream English, and
239    /// untranslatable for the same reason the prose it wraps is.
240    #[serde(skip_serializing_if = "Vec::is_empty", default)]
241    pub source_chain: Vec<String>,
242}
243
244impl Diagnostic {
245    pub fn new(severity: Severity, message: String) -> Self {
246        Self {
247            severity,
248            code: None,
249            message,
250            location: None,
251            path: None,
252            hint: None,
253            args: BTreeMap::new(),
254            source_chain: Vec::new(),
255        }
256    }
257
258    pub fn with_code(mut self, code: String) -> Self {
259        self.code = Some(code);
260        self
261    }
262
263    pub fn with_location(mut self, location: Location) -> Self {
264        self.location = Some(location);
265        self
266    }
267
268    /// Set the document-model path anchor.
269    ///
270    /// See the module-level docs for the path grammar and conventions.
271    pub fn with_path(mut self, path: String) -> Self {
272        self.path = Some(path);
273        self
274    }
275
276    pub fn with_hint(mut self, hint: String) -> Self {
277        self.hint = Some(hint);
278        self
279    }
280
281    /// Attach the message's substitution facts. See [`Self::args`].
282    pub fn with_args(mut self, args: BTreeMap<String, serde_json::Value>) -> Self {
283        self.args = args;
284        self
285    }
286
287    /// Attach an error cause chain, walked eagerly into `source_chain`.
288    pub fn with_source(mut self, source: &(dyn std::error::Error + 'static)) -> Self {
289        let mut current: Option<&(dyn std::error::Error + 'static)> = Some(source);
290        while let Some(err) = current {
291            self.source_chain.push(err.to_string());
292            current = err.source();
293        }
294        self
295    }
296
297    pub fn fmt_pretty(&self) -> String {
298        let mut result = format!(
299            "[{}] {}",
300            match self.severity {
301                Severity::Error => "ERROR",
302                Severity::Warning => "WARN",
303            },
304            self.message
305        );
306
307        if let Some(ref code) = self.code {
308            result.push_str(&format!(" ({})", code));
309        }
310
311        if let Some(ref loc) = self.location {
312            result.push_str(&format!("\n  --> {}:{}:{}", loc.file, loc.line, loc.column));
313        }
314
315        if let Some(ref path) = self.path {
316            result.push_str(&format!("\n  at {}", path));
317        }
318
319        if let Some(ref hint) = self.hint {
320            result.push_str(&format!("\n  hint: {}", hint));
321        }
322
323        result
324    }
325
326}
327
328impl std::fmt::Display for Diagnostic {
329    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330        write!(f, "{}", self.message)
331    }
332}
333
334#[derive(thiserror::Error, Debug)]
335#[non_exhaustive]
336pub enum ParseError {
337    #[error("Input too large: {size} bytes (max: {max} bytes)")]
338    InputTooLarge { size: usize, max: usize },
339
340    #[error("Invalid YAML structure: {0}")]
341    InvalidStructure(String),
342
343    /// Markdown input was empty or whitespace-only.
344    ///
345    /// Emitted as code `parse::empty_input` so consumers can pattern-match
346    /// without inspecting the message text.
347    #[error("{0}")]
348    EmptyInput(String),
349
350    /// The document is missing its root `~~~` card-yaml block, or that block
351    /// does not declare the required `$quill` system metadata.
352    ///
353    /// Emitted as code `parse::missing_quill` so consumers can
354    /// pattern-match without inspecting the message text.
355    #[error("{0}")]
356    MissingQuill(String),
357
358    /// A `$quill` reference failed to parse as a [`crate::version::QuillReference`].
359    /// Code `parse::invalid_quill_reference`; carries
360    /// [`crate::version::quill_ref_hint`] as its diagnostic hint.
361    #[error("Invalid $quill reference '{value}': {reason}")]
362    InvalidQuillReference {
363        value: String,
364        /// The `from_str` violation.
365        reason: String,
366    },
367
368    /// A card body's markdown could not be imported into the content model:
369    /// today only when container nesting exceeds
370    /// [`MAX_NESTING_DEPTH`]. Code `parse::body_import`.
371    #[error("{0}")]
372    BodyImport(String),
373
374    #[error("YAML error at line {line}: {message}")]
375    YamlErrorWithLocation {
376        message: String,
377        /// Line number in the source document (1-indexed)
378        line: usize,
379        /// Index of the metadata block (0-indexed)
380        block_index: usize,
381        /// Optional actionable hint attached when the YAML parser's message
382        /// is too cryptic to be recoverable on its own. Derived by the
383        /// internal `document::yaml_hints` enrichment pass.
384        hint: Option<String>,
385    },
386}
387
388impl ParseError {
389    /// The facts this error's message interpolates. See [`Diagnostic::args`].
390    ///
391    /// The four `String` variants contribute no keys, for two different
392    /// reasons canon distinguishes: `EmptyInput` is one fixed sentence, while
393    /// `InvalidStructure`, `BodyImport`, and `MissingQuill` carry prose minted
394    /// per-site. `MissingQuill` looks fixed and is not: it picks one of three
395    /// sentences by re-reading the source, and no field records which.
396    pub fn args(&self) -> BTreeMap<String, serde_json::Value> {
397        match self {
398            ParseError::InputTooLarge { size, max } => diag_args! {
399                "size" => size,
400                "max" => max,
401            },
402            ParseError::InvalidStructure(_) => diag_args! {},
403            ParseError::EmptyInput(_) => diag_args! {},
404            ParseError::MissingQuill(_) => diag_args! {},
405            ParseError::BodyImport(_) => diag_args! {},
406            // `reason` is the `from_str` violation in English and stays in
407            // `message`; `value` alone carries the consumer's sentence.
408            ParseError::InvalidQuillReference { value, reason: _ } => diag_args! {
409                "value" => value,
410            },
411            // This diagnostic sets no `location`, so `args` is the only
412            // structured route to the coordinates the message names. The
413            // message is the YAML engine's own prose and keeps no key.
414            ParseError::YamlErrorWithLocation {
415                message: _,
416                line,
417                block_index,
418                hint: _,
419            } => diag_args! {
420                "line" => line,
421                "blockIndex" => block_index,
422            },
423        }
424    }
425
426    pub fn to_diagnostic(&self) -> Diagnostic {
427        let diag = match self {
428            ParseError::InputTooLarge { size, max } => Diagnostic::new(
429                Severity::Error,
430                format!("Input too large: {} bytes (max: {} bytes)", size, max),
431            )
432            .with_code("parse::input_too_large".to_string()),
433            ParseError::InvalidStructure(msg) => Diagnostic::new(Severity::Error, msg.clone())
434                .with_code("parse::invalid_structure".to_string()),
435            ParseError::EmptyInput(msg) => Diagnostic::new(Severity::Error, msg.clone())
436                .with_code("parse::empty_input".to_string()),
437            ParseError::MissingQuill(msg) => Diagnostic::new(Severity::Error, msg.clone())
438                .with_code("parse::missing_quill".to_string()),
439            ParseError::BodyImport(msg) => Diagnostic::new(Severity::Error, msg.clone())
440                .with_code("parse::body_import".to_string()),
441            ParseError::InvalidQuillReference { value, reason } => Diagnostic::new(
442                Severity::Error,
443                format!("Invalid $quill reference '{}': {}", value, reason),
444            )
445            .with_code("parse::invalid_quill_reference".to_string())
446            .with_hint(crate::version::quill_ref_hint().to_string()),
447            ParseError::YamlErrorWithLocation {
448                message,
449                line,
450                block_index,
451                hint,
452            } => {
453                let mut d = Diagnostic::new(
454                    Severity::Error,
455                    format!(
456                        "YAML error at line {} (block {}): {}",
457                        line, block_index, message
458                    ),
459                )
460                .with_code("parse::yaml_error_with_location".to_string());
461                if let Some(h) = hint {
462                    d = d.with_hint(h.clone());
463                }
464                d
465            }
466        };
467        diag.with_args(self.args())
468    }
469}
470
471/// Main error type for rendering operations: a non-empty collection of
472/// [`Diagnostic`]s.
473///
474/// There is no failure taxonomy beyond the diagnostics themselves: the
475/// machine-routable identity of a failure is each diagnostic's namespaced
476/// `code` (`parse::*`, `validation::*`, `quill::*`, `typst::*`, `backend::*`,
477/// `engine::*`). Every consumer, and every language binding, handles all
478/// rendering errors through this single shape; route on
479/// `diagnostics()[..].code`, not on a type.
480#[derive(Debug)]
481pub struct RenderError {
482    /// Always non-empty; held by the constructors.
483    diags: Vec<Diagnostic>,
484}
485
486impl RenderError {
487    /// Wrap `diags` as a failure. `diags` should be non-empty; the invariant is
488    /// enforced only by `debug_assert!`, so a release build can construct an
489    /// empty `RenderError`. That is deliberately non-fatal: the `Display` impl
490    /// carries an `[]` fallback branch rather than promising the invariant is
491    /// load-bearing. Every internal caller passes a non-empty vec.
492    pub fn new(diags: Vec<Diagnostic>) -> Self {
493        debug_assert!(
494            !diags.is_empty(),
495            "RenderError requires at least one diagnostic"
496        );
497        Self { diags }
498    }
499
500    /// Wrap a single diagnostic as a failure.
501    pub fn from_diag(diag: Diagnostic) -> Self {
502        Self { diags: vec![diag] }
503    }
504
505    /// Returns all diagnostics for this error. Non-empty by construction (see
506    /// [`RenderError::new`]'s debug-asserted invariant).
507    pub fn diagnostics(&self) -> &[Diagnostic] {
508        &self.diags
509    }
510
511    /// Consume the error and return its diagnostics.
512    pub fn into_diagnostics(self) -> Vec<Diagnostic> {
513        self.diags
514    }
515
516    /// The count-based summary line shared by `Display` and every binding's
517    /// exception message: the sole diagnostic's `message` for one, an
518    /// `"<N> error(s): <first message>"` aggregate for more. The single source
519    /// of truth for this rule: bindings delegate here rather than re-deriving
520    /// it. An empty slice yields `"render error"` defensively (see
521    /// [`RenderError::new`]'s debug-only non-empty invariant).
522    pub fn summary_message(diags: &[Diagnostic]) -> String {
523        match diags {
524            [d] => d.message.clone(),
525            [first, ..] => format!("{} error(s): {}", diags.len(), first.message),
526            [] => "render error".to_string(),
527        }
528    }
529}
530
531/// The primary message for a single diagnostic; an
532/// `"<N> error(s): <first message>"` aggregate for more: the same rule the
533/// WASM binding applies to thrown `Error.message`.
534impl std::fmt::Display for RenderError {
535    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
536        write!(f, "{}", Self::summary_message(&self.diags))
537    }
538}
539
540impl std::error::Error for RenderError {}
541
542impl From<ParseError> for RenderError {
543    fn from(err: ParseError) -> Self {
544        RenderError::from_diag(err.to_diagnostic())
545    }
546}
547
548#[derive(Debug)]
549#[non_exhaustive]
550pub struct RenderResult {
551    pub artifacts: Vec<crate::Artifact>,
552    pub warnings: Vec<Diagnostic>,
553    pub output_format: OutputFormat,
554    /// Schema-field geometry sidecar, populated only when
555    /// [`RenderOptions::regions`](crate::RenderOptions) is set (empty
556    /// otherwise). The same entries [`LiveSession::regions`](crate::LiveSession::regions)
557    /// serves, for consumers without a live session. Whole-document geometry:
558    /// page indices are document-space even under a `pages` subset render.
559    pub regions: Vec<crate::RenderedRegion>,
560}
561
562impl RenderResult {
563    pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
564        Self {
565            artifacts,
566            warnings: Vec::new(),
567            output_format,
568            regions: Vec::new(),
569        }
570    }
571}
572
573pub fn print_errors(err: &RenderError) {
574    for d in err.diagnostics() {
575        eprintln!("{}", d.fmt_pretty());
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn test_diagnostic_with_source_chain() {
585        let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
586        let diag =
587            Diagnostic::new(Severity::Error, "Rendering failed".to_string()).with_source(&root_err);
588
589        assert_eq!(diag.source_chain.len(), 1);
590        assert!(diag.source_chain[0].contains("File not found"));
591    }
592
593    #[test]
594    fn test_diagnostic_serialization() {
595        let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
596            .with_code("E001".to_string())
597            .with_location(Location {
598                file: "test.typ".to_string(),
599                line: 10,
600                column: 5,
601            });
602
603        let json = serde_json::to_string(&diag).unwrap();
604        assert!(json.contains("Test error"));
605        assert!(json.contains("E001"));
606        assert!(json.contains("\"severity\":\"error\""));
607        assert!(json.contains("\"column\":5"));
608    }
609
610    #[test]
611    fn test_render_error_single_diagnostic_shape() {
612        let err = RenderError::from_diag(Diagnostic::new(
613            Severity::Error,
614            "no such backend".to_string(),
615        ));
616        assert_eq!(err.diagnostics().len(), 1);
617        assert_eq!(err.to_string(), "no such backend");
618
619        let owned = err.into_diagnostics();
620        assert_eq!(owned.len(), 1);
621        assert_eq!(owned[0].message, "no such backend");
622    }
623
624    #[test]
625    fn test_render_error_display_aggregates_multi_diagnostic() {
626        let err = RenderError::new(vec![
627            Diagnostic::new(Severity::Error, "a".to_string()),
628            Diagnostic::new(Severity::Error, "b".to_string()),
629        ]);
630        assert_eq!(err.to_string(), "2 error(s): a");
631    }
632
633    #[test]
634    fn test_diagnostic_fmt_pretty() {
635        let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
636            .with_code("W001".to_string())
637            .with_location(Location {
638                file: "input.md".to_string(),
639                line: 5,
640                column: 10,
641            })
642            .with_hint("Use the new field name instead".to_string());
643
644        let output = diag.fmt_pretty();
645        assert!(output.contains("[WARN]"));
646        assert!(output.contains("Deprecated field used"));
647        assert!(output.contains("W001"));
648        assert!(output.contains("input.md:5:10"));
649        assert!(output.contains("hint:"));
650    }
651
652    #[test]
653    fn test_diagnostic_with_path() {
654        let diag = Diagnostic::new(Severity::Error, "Type mismatch".to_string())
655            .with_code("validation::type_mismatch".to_string())
656            .with_path("cards.indorsement[0].signature_block".to_string());
657
658        assert_eq!(
659            diag.path.as_deref(),
660            Some("cards.indorsement[0].signature_block")
661        );
662
663        let json = serde_json::to_string(&diag).unwrap();
664        assert!(json.contains("\"path\":\"cards.indorsement[0].signature_block\""));
665
666        let pretty = diag.fmt_pretty();
667        assert!(pretty.contains("at cards.indorsement[0].signature_block"));
668    }
669
670}
671
672/// The canon table in `prose/canon/ERROR.md` § "Diagnostic args" is the contract
673/// a consumer writes its string table against, so it is tested like one rather
674/// than maintained by hand beside the code.
675#[cfg(test)]
676mod args_canon {
677    use std::collections::BTreeMap;
678
679    use super::ParseError;
680    use crate::document::EditError;
681    use crate::quill::{CoercionError, ValidationError};
682
683    /// `code` → its arg keys, sorted. Every variant of every enum on the
684    /// structured surface appears once.
685    fn minted() -> BTreeMap<String, Vec<String>> {
686        let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
687        let mut add = |code: &str, args: BTreeMap<String, serde_json::Value>| {
688            let keys: Vec<String> = args.keys().cloned().collect();
689            assert!(
690                out.insert(code.to_string(), keys).is_none(),
691                "two samples for `{code}`: one code carries one payload"
692            );
693        };
694
695        for e in [
696            ValidationError::TypeMismatch {
697                path: "main.n".into(),
698                expected: "string".into(),
699                actual: "integer".into(),
700                source_token: "42".into(),
701                default: Some("\"x\"".into()),
702            },
703            ValidationError::EnumViolation {
704                path: "main.tone".into(),
705                value: "loud".into(),
706                allowed: vec!["quiet".into()],
707            },
708            ValidationError::FormatViolation {
709                path: "main.when".into(),
710                format: "date".into(),
711            },
712            ValidationError::UnknownCard {
713                path: "cards[0]".into(),
714                card: "ghost".into(),
715            },
716            ValidationError::BodyDisabled {
717                path: "cards.sig[0].body".into(),
718                card: "sig".into(),
719            },
720            ValidationError::NotInline {
721                path: "main.title".into(),
722            },
723            ValidationError::NotPlain {
724                path: "main.title".into(),
725            },
726        ] {
727            add(e.code(), e.args());
728        }
729
730        for e in [
731            EditError::InvalidFieldName("9bad".into()),
732            EditError::UnknownField("nope".into()),
733            EditError::InvalidKindName("Bad".into()),
734            EditError::ReservedKind,
735            EditError::IndexOutOfRange { index: 3, len: 1 },
736            EditError::ValueTooDeep { max: 8 },
737            EditError::Import(quillmark_content::import::ImportError::NestingTooDeep {
738                depth: 9,
739                max: 8,
740            }),
741            EditError::FieldDecode {
742                field: "body".into(),
743                codec: crate::document::edit::CODEC_RICHTEXT.into(),
744                message: "x".into(),
745            },
746            EditError::FieldNotContent {
747                field: "qty".into(),
748                declared: "integer".into(),
749            },
750            EditError::FieldNotInline {
751                field: "body".into(),
752                codec: crate::document::edit::CODEC_PLAINTEXT.into(),
753            },
754            EditError::FieldCoercionFailed {
755                field: "n".into(),
756                target: "integer".into(),
757                message: "x".into(),
758            },
759            EditError::ContentApply(quillmark_content::ApplyError::LineOutOfRange {
760                line: 3,
761                lines: 1,
762            }),
763        ] {
764            add(e.code(), e.args());
765        }
766
767        // The `conform::*` family: the strict write's refusals, re-namespaced by
768        // `conform_diagnostic`. Minted through that function rather than
769        // re-derived, so the table cannot drift from the code that stamps it.
770        for e in [
771            EditError::InvalidFieldName("9bad".into()),
772            EditError::ValueTooDeep { max: 8 },
773            EditError::FieldNotInline {
774                field: "body".into(),
775                codec: crate::document::edit::CODEC_RICHTEXT.into(),
776            },
777            EditError::FieldDecode {
778                field: "body".into(),
779                codec: crate::document::edit::CODEC_PLAINTEXT.into(),
780                message: "x".into(),
781            },
782            EditError::FieldCoercionFailed {
783                field: "n".into(),
784                target: "integer".into(),
785                message: "x".into(),
786            },
787        ] {
788            let diag = crate::quill::conform::conform_diagnostic(&e, &crate::DocPath::main());
789            add(
790                diag.code.as_deref().expect("conform diagnostics carry a code"),
791                diag.args,
792            );
793        }
794
795        for e in [
796            ParseError::InputTooLarge { size: 2, max: 1 },
797            ParseError::InvalidStructure("x".into()),
798            ParseError::EmptyInput("x".into()),
799            ParseError::MissingQuill("x".into()),
800            ParseError::BodyImport("x".into()),
801            ParseError::InvalidQuillReference {
802                value: "a@b".into(),
803                reason: "x".into(),
804            },
805            ParseError::YamlErrorWithLocation {
806                message: "x".into(),
807                line: 3,
808                block_index: 1,
809                hint: None,
810            },
811        ] {
812            let diag = e.to_diagnostic();
813            add(diag.code.as_deref().expect("parse errors carry a code"), diag.args);
814        }
815
816        // Two codes are minted beside their error rather than from a variant:
817        // `compose::coercion_error` wraps the whole `CoercionError`, and
818        // `compose::fill_warning` has no error type at all.
819        add(
820            "validation::coercion_failed",
821            CoercionError::Uncoercible {
822                path: "card_kinds.sig.n".into(),
823                value: "\"x\"".into(),
824                target: "integer".into(),
825                reason: "string is not a valid integer".into(),
826            }
827            .args(),
828        );
829        add("validation::must_fill", BTreeMap::new());
830
831        out
832    }
833
834    /// The `| code | args | outcome |` rows of the canon table, keyed the same
835    /// way. `—` is no keys; a trailing `?` marks a conditional key, which the
836    /// sample above supplies.
837    fn declared() -> BTreeMap<String, Vec<String>> {
838        let canon = include_str!("../../../prose/canon/ERROR.md");
839        let mut rows = canon
840            .lines()
841            .skip_while(|l| !l.starts_with("| Code | Args | Outcome |"))
842            .skip(2)
843            .take_while(|l| l.starts_with('|'));
844
845        let mut out = BTreeMap::new();
846        for row in &mut rows {
847            let cells: Vec<&str> = row.trim_matches('|').split('|').map(str::trim).collect();
848            assert_eq!(cells.len(), 3, "malformed canon row: {row}");
849            let code = cells[0].trim_matches('`').to_string();
850            let keys = if cells[1] == "—" {
851                Vec::new()
852            } else {
853                let mut keys: Vec<String> = cells[1]
854                    .split(',')
855                    .map(|k| k.trim().trim_end_matches('?').trim_matches('`').to_string())
856                    .collect();
857                keys.sort();
858                keys
859            };
860            assert!(out.insert(code, keys).is_none(), "duplicate canon row: {row}");
861        }
862        assert!(!out.is_empty(), "canon args table not found in ERROR.md");
863        out
864    }
865
866    /// The other direction: a code off the table carries no args, so a
867    /// consumer's template falls back rather than half-filling. `quill::*` is
868    /// the largest such family and the one with `format!`-built codes.
869    #[test]
870    fn out_of_scope_codes_carry_no_args() {
871        let diags = crate::quill::QuillConfig::from_yaml_with_warnings(
872            r#"
873Quill:
874  name: t
875  version: "1.0"
876  backend: typst
877  description: A slot whose literal contradicts its declared type
878
879main:
880  fields:
881    title:
882      type: string
883      default: 42
884"#,
885        )
886        .expect_err("a default that contradicts its type fails config validation");
887
888        assert!(
889            diags.iter().any(|d| d
890                .code
891                .as_deref()
892                .is_some_and(|c| c.starts_with("quill::"))),
893            "expected a quill:: diagnostic, got {:?}",
894            diags.iter().map(|d| &d.code).collect::<Vec<_>>()
895        );
896        for d in &diags {
897            assert!(
898                d.args.is_empty(),
899                "`{:?}` is off the canon table and must carry no args",
900                d.code
901            );
902        }
903    }
904
905    #[test]
906    fn diagnostic_args_match_canon() {
907        assert_eq!(
908            declared(),
909            minted(),
910            "`ERROR.md` § \"Diagnostic args\" and the minted args disagree"
911        );
912    }
913}