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 crate::OutputFormat;
20
21/// Maximum input size for markdown (10 MiB)
22pub const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024;
23
24/// Maximum YAML size (1 MiB)
25pub const MAX_YAML_SIZE: usize = 1024 * 1024;
26
27/// Maximum nesting depth for markdown structures (100 levels). Owned by the
28/// markdown codecs in `quillmark-content` (the import guard) and re-exported
29/// here so the typst backend's markup converter shares one limit: a document
30/// that imports also renders, and vice versa.
31pub use quillmark_content::MAX_NESTING_DEPTH;
32
33/// Re-exported from [`crate::document::limits::MAX_YAML_DEPTH`].
34pub use crate::document::limits::MAX_YAML_DEPTH;
35
36/// Maximum number of card blocks allowed per document
37pub const MAX_CARD_COUNT: usize = 1000;
38
39/// Maximum number of fields allowed per document
40pub const MAX_FIELD_COUNT: usize = 1000;
41
42/// A YAML parse or emit failure, owned by this crate.
43///
44/// The YAML engine is `serde-saphyr`, whose version series is `0.0.x`: every
45/// release of it is a semver break under Cargo's rules. Returning its error
46/// types from a public signature would chain this crate's major version to
47/// that cadence, so the boundary converts to this type instead and no public
48/// signature names the engine. The engine is an implementation detail; this is
49/// what the contract says it is.
50///
51/// `line`/`column` are 1-indexed and present only when the engine located the
52/// failure: always absent on the emit side, which has no input to point at.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct YamlError {
55    message: String,
56    hint: Option<String>,
57    line: Option<u32>,
58    column: Option<u32>,
59}
60
61impl YamlError {
62    /// What went wrong, in YAML terms.
63    pub fn message(&self) -> &str {
64        &self.message
65    }
66
67    /// The concrete textual fix, when the failure is one this crate recognizes.
68    pub fn hint(&self) -> Option<&str> {
69        self.hint.as_deref()
70    }
71
72    /// 1-indexed line of the failure, when the engine located one.
73    pub fn line(&self) -> Option<u32> {
74        self.line
75    }
76
77    /// 1-indexed column of the failure, paired with [`Self::line`].
78    pub fn column(&self) -> Option<u32> {
79        self.column
80    }
81
82    /// A diagnostic under `code`, carrying the hint and (when the engine
83    /// located the failure) a [`Location`] against `file`.
84    pub fn to_diagnostic(&self, code: &str, file: &str) -> Diagnostic {
85        let mut diag = Diagnostic::new(Severity::Error, self.message.clone())
86            .with_code(code.to_string());
87        if let (Some(line), Some(column)) = (self.line, self.column) {
88            diag = diag.with_location(Location::new(file.to_string(), line, column));
89        }
90        match &self.hint {
91            Some(h) => diag.with_hint(h.clone()),
92            None => diag,
93        }
94    }
95
96    /// `yaml` is the text that failed to parse: the hint derivation inspects
97    /// it to name the offending construct.
98    pub(crate) fn from_de(err: serde_saphyr::Error, yaml: &str) -> Self {
99        // The engine appends its own Rust API names to some messages
100        // (`from_multiple`, `DuplicateKeyPolicy`); the enricher strips them, so
101        // "no public signature names the engine" holds for the message too, not
102        // just the type.
103        let enriched = crate::document::yaml_hints::enrich_yaml_error(&err.to_string(), yaml);
104        // `Location`'s accessors widen to u64; the fields behind them are u32,
105        // so the narrowing is lossless.
106        let loc = err.location();
107        Self {
108            message: enriched.message,
109            hint: enriched.hint,
110            line: loc.and_then(|l| u32::try_from(l.line()).ok()),
111            column: loc.and_then(|l| u32::try_from(l.column()).ok()),
112        }
113    }
114
115    /// Emission has no input to point at, so no position and no hint.
116    pub(crate) fn from_ser(err: serde_saphyr::ser::Error) -> Self {
117        Self {
118            message: err.to_string(),
119            hint: None,
120            line: None,
121            column: None,
122        }
123    }
124}
125
126impl std::fmt::Display for YamlError {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        // The message already opens with the position and carries the engine's
129        // caret diagram; [`Self::line`]/[`Self::column`] are the structured
130        // reading of the same fact, not a second one to append.
131        f.write_str(&self.message)
132    }
133}
134
135impl std::error::Error for YamlError {}
136
137/// Fatality is this two-value ladder and nothing else: `Error` blocks the
138/// stage that emits it, `Warning` never does. There is no lint-level
139/// configuration and no warning-to-error promotion; an informational aside is
140/// a [`Diagnostic::hint`], not a severity.
141///
142/// A `_` arm over this enum has a safe direction: escalate to
143/// [`Severity::Error`]. Treating an unrecognized level as fatal over-reports;
144/// treating it as a warning could hide one. Nothing here fails silently, so the
145/// enum is open ([`COMPATIBILITY`]).
146///
147/// [`COMPATIBILITY`]: https://github.com/borb-sh/quillmark/blob/main/prose/canon/COMPATIBILITY.md
148#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
149#[serde(rename_all = "lowercase")]
150#[non_exhaustive]
151pub enum Severity {
152    /// Fatal error that prevents completion
153    Error,
154    /// Non-fatal issue that may need attention
155    Warning,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
159#[serde(rename_all = "camelCase")]
160#[non_exhaustive]
161pub struct Location {
162    /// Source file name (e.g., "plate.typ", "template.typ", "input.md")
163    pub file: String,
164    /// Line number (1-indexed)
165    pub line: u32,
166    /// Column number (1-indexed)
167    pub column: u32,
168}
169
170impl Location {
171    /// The three coordinates a text anchor always carries. `line` and `column`
172    /// are 1-indexed.
173    pub fn new(file: String, line: u32, column: u32) -> Self {
174        Self { file, line, column }
175    }
176}
177
178/// Structured diagnostic information.
179///
180/// `source_chain` is a flat list of error messages from any attached
181/// `std::error::Error` cause chain, eagerly walked at construction time so
182/// the diagnostic remains trivially `Clone` and fully serializable across
183/// every binding boundary.
184#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
185#[serde(rename_all = "camelCase")]
186#[non_exhaustive]
187pub struct Diagnostic {
188    pub severity: Severity,
189    /// Optional error code (e.g., "E001", "typst::syntax")
190    #[serde(skip_serializing_if = "Option::is_none", default)]
191    pub code: Option<String>,
192    pub message: String,
193    /// Primary source location (text anchor: file/line/column).
194    ///
195    /// Set by parsers and backend compilers. May co-exist with [`Self::path`]:
196    /// the two anchors are independent.
197    #[serde(skip_serializing_if = "Option::is_none", default)]
198    pub location: Option<Location>,
199    /// Document-model anchor: a dotted/bracketed path into the typed
200    /// [`crate::document::Document`].
201    ///
202    /// Set by schema validation and coercion. See the module-level docs for
203    /// the path grammar and conventions. May co-exist with [`Self::location`].
204    #[serde(skip_serializing_if = "Option::is_none", default)]
205    pub path: Option<String>,
206    #[serde(skip_serializing_if = "Option::is_none", default)]
207    pub hint: Option<String>,
208    /// Flattened cause chain (outermost first).
209    #[serde(skip_serializing_if = "Vec::is_empty", default)]
210    pub source_chain: Vec<String>,
211}
212
213impl Diagnostic {
214    pub fn new(severity: Severity, message: String) -> Self {
215        Self {
216            severity,
217            code: None,
218            message,
219            location: None,
220            path: None,
221            hint: None,
222            source_chain: Vec::new(),
223        }
224    }
225
226    pub fn with_code(mut self, code: String) -> Self {
227        self.code = Some(code);
228        self
229    }
230
231    pub fn with_location(mut self, location: Location) -> Self {
232        self.location = Some(location);
233        self
234    }
235
236    /// Set the document-model path anchor.
237    ///
238    /// See the module-level docs for the path grammar and conventions.
239    pub fn with_path(mut self, path: String) -> Self {
240        self.path = Some(path);
241        self
242    }
243
244    pub fn with_hint(mut self, hint: String) -> Self {
245        self.hint = Some(hint);
246        self
247    }
248
249    /// Attach an error cause chain, walked eagerly into `source_chain`.
250    pub fn with_source(mut self, source: &(dyn std::error::Error + 'static)) -> Self {
251        let mut current: Option<&(dyn std::error::Error + 'static)> = Some(source);
252        while let Some(err) = current {
253            self.source_chain.push(err.to_string());
254            current = err.source();
255        }
256        self
257    }
258
259    pub fn fmt_pretty(&self) -> String {
260        let mut result = format!(
261            "[{}] {}",
262            match self.severity {
263                Severity::Error => "ERROR",
264                Severity::Warning => "WARN",
265            },
266            self.message
267        );
268
269        if let Some(ref code) = self.code {
270            result.push_str(&format!(" ({})", code));
271        }
272
273        if let Some(ref loc) = self.location {
274            result.push_str(&format!("\n  --> {}:{}:{}", loc.file, loc.line, loc.column));
275        }
276
277        if let Some(ref path) = self.path {
278            result.push_str(&format!("\n  at {}", path));
279        }
280
281        if let Some(ref hint) = self.hint {
282            result.push_str(&format!("\n  hint: {}", hint));
283        }
284
285        result
286    }
287
288}
289
290impl std::fmt::Display for Diagnostic {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        write!(f, "{}", self.message)
293    }
294}
295
296#[derive(thiserror::Error, Debug)]
297#[non_exhaustive]
298pub enum ParseError {
299    #[error("Input too large: {size} bytes (max: {max} bytes)")]
300    InputTooLarge { size: usize, max: usize },
301
302    #[error("Invalid YAML structure: {0}")]
303    InvalidStructure(String),
304
305    /// Markdown input was empty or whitespace-only.
306    ///
307    /// Emitted as code `parse::empty_input` so consumers can pattern-match
308    /// without inspecting the message text.
309    #[error("{0}")]
310    EmptyInput(String),
311
312    /// The document is missing its root `~~~` card-yaml block, or that block
313    /// does not declare the required `$quill` system metadata.
314    ///
315    /// Emitted as code `parse::missing_quill` so consumers can
316    /// pattern-match without inspecting the message text.
317    #[error("{0}")]
318    MissingQuill(String),
319
320    /// A `$quill` reference failed to parse as a [`crate::version::QuillReference`].
321    /// Code `parse::invalid_quill_reference`; carries
322    /// [`crate::version::quill_ref_hint`] as its diagnostic hint.
323    #[error("Invalid $quill reference '{value}': {reason}")]
324    InvalidQuillReference {
325        value: String,
326        /// The `from_str` violation.
327        reason: String,
328    },
329
330    /// A card body's markdown could not be imported into the content model:
331    /// today only when container nesting exceeds
332    /// [`MAX_NESTING_DEPTH`]. Code `parse::body_import`.
333    #[error("{0}")]
334    BodyImport(String),
335
336    #[error("YAML error at line {line}: {message}")]
337    YamlErrorWithLocation {
338        message: String,
339        /// Line number in the source document (1-indexed)
340        line: usize,
341        /// Index of the metadata block (0-indexed)
342        block_index: usize,
343        /// Optional actionable hint attached when the YAML parser's message
344        /// is too cryptic to be recoverable on its own. Derived by the
345        /// internal `document::yaml_hints` enrichment pass.
346        hint: Option<String>,
347    },
348}
349
350impl ParseError {
351    pub fn to_diagnostic(&self) -> Diagnostic {
352        match self {
353            ParseError::InputTooLarge { size, max } => Diagnostic::new(
354                Severity::Error,
355                format!("Input too large: {} bytes (max: {} bytes)", size, max),
356            )
357            .with_code("parse::input_too_large".to_string()),
358            ParseError::InvalidStructure(msg) => Diagnostic::new(Severity::Error, msg.clone())
359                .with_code("parse::invalid_structure".to_string()),
360            ParseError::EmptyInput(msg) => Diagnostic::new(Severity::Error, msg.clone())
361                .with_code("parse::empty_input".to_string()),
362            ParseError::MissingQuill(msg) => Diagnostic::new(Severity::Error, msg.clone())
363                .with_code("parse::missing_quill".to_string()),
364            ParseError::BodyImport(msg) => Diagnostic::new(Severity::Error, msg.clone())
365                .with_code("parse::body_import".to_string()),
366            ParseError::InvalidQuillReference { value, reason } => Diagnostic::new(
367                Severity::Error,
368                format!("Invalid $quill reference '{}': {}", value, reason),
369            )
370            .with_code("parse::invalid_quill_reference".to_string())
371            .with_hint(crate::version::quill_ref_hint().to_string()),
372            ParseError::YamlErrorWithLocation {
373                message,
374                line,
375                block_index,
376                hint,
377            } => {
378                let mut d = Diagnostic::new(
379                    Severity::Error,
380                    format!(
381                        "YAML error at line {} (block {}): {}",
382                        line, block_index, message
383                    ),
384                )
385                .with_code("parse::yaml_error_with_location".to_string());
386                if let Some(h) = hint {
387                    d = d.with_hint(h.clone());
388                }
389                d
390            }
391        }
392    }
393}
394
395/// Main error type for rendering operations: a non-empty collection of
396/// [`Diagnostic`]s.
397///
398/// There is no failure taxonomy beyond the diagnostics themselves: the
399/// machine-routable identity of a failure is each diagnostic's namespaced
400/// `code` (`parse::*`, `validation::*`, `quill::*`, `typst::*`, `backend::*`,
401/// `engine::*`). Every consumer, and every language binding, handles all
402/// rendering errors through this single shape; route on
403/// `diagnostics()[..].code`, not on a type.
404#[derive(Debug)]
405pub struct RenderError {
406    /// Always non-empty; held by the constructors.
407    diags: Vec<Diagnostic>,
408}
409
410impl RenderError {
411    /// Wrap `diags` as a failure. `diags` should be non-empty; the invariant is
412    /// enforced only by `debug_assert!`, so a release build can construct an
413    /// empty `RenderError`. That is deliberately non-fatal: the `Display` impl
414    /// carries an `[]` fallback branch rather than promising the invariant is
415    /// load-bearing. Every internal caller passes a non-empty vec.
416    pub fn new(diags: Vec<Diagnostic>) -> Self {
417        debug_assert!(
418            !diags.is_empty(),
419            "RenderError requires at least one diagnostic"
420        );
421        Self { diags }
422    }
423
424    /// Wrap a single diagnostic as a failure.
425    pub fn from_diag(diag: Diagnostic) -> Self {
426        Self { diags: vec![diag] }
427    }
428
429    /// Returns all diagnostics for this error. Non-empty by construction (see
430    /// [`RenderError::new`]'s debug-asserted invariant).
431    pub fn diagnostics(&self) -> &[Diagnostic] {
432        &self.diags
433    }
434
435    /// Consume the error and return its diagnostics.
436    pub fn into_diagnostics(self) -> Vec<Diagnostic> {
437        self.diags
438    }
439
440    /// The count-based summary line shared by `Display` and every binding's
441    /// exception message: the sole diagnostic's `message` for one, an
442    /// `"<N> error(s): <first message>"` aggregate for more. The single source
443    /// of truth for this rule: bindings delegate here rather than re-deriving
444    /// it. An empty slice yields `"render error"` defensively (see
445    /// [`RenderError::new`]'s debug-only non-empty invariant).
446    pub fn summary_message(diags: &[Diagnostic]) -> String {
447        match diags {
448            [d] => d.message.clone(),
449            [first, ..] => format!("{} error(s): {}", diags.len(), first.message),
450            [] => "render error".to_string(),
451        }
452    }
453}
454
455/// The primary message for a single diagnostic; an
456/// `"<N> error(s): <first message>"` aggregate for more: the same rule the
457/// WASM binding applies to thrown `Error.message`.
458impl std::fmt::Display for RenderError {
459    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
460        write!(f, "{}", Self::summary_message(&self.diags))
461    }
462}
463
464impl std::error::Error for RenderError {}
465
466impl From<ParseError> for RenderError {
467    fn from(err: ParseError) -> Self {
468        RenderError::from_diag(err.to_diagnostic())
469    }
470}
471
472#[derive(Debug)]
473#[non_exhaustive]
474pub struct RenderResult {
475    pub artifacts: Vec<crate::Artifact>,
476    pub warnings: Vec<Diagnostic>,
477    pub output_format: OutputFormat,
478    /// Schema-field geometry sidecar, populated only when
479    /// [`RenderOptions::regions`](crate::RenderOptions) is set (empty
480    /// otherwise). The same entries [`LiveSession::regions`](crate::LiveSession::regions)
481    /// serves, for consumers without a live session. Whole-document geometry:
482    /// page indices are document-space even under a `pages` subset render.
483    pub regions: Vec<crate::RenderedRegion>,
484}
485
486impl RenderResult {
487    pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
488        Self {
489            artifacts,
490            warnings: Vec::new(),
491            output_format,
492            regions: Vec::new(),
493        }
494    }
495}
496
497pub fn print_errors(err: &RenderError) {
498    for d in err.diagnostics() {
499        eprintln!("{}", d.fmt_pretty());
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn test_diagnostic_with_source_chain() {
509        let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
510        let diag =
511            Diagnostic::new(Severity::Error, "Rendering failed".to_string()).with_source(&root_err);
512
513        assert_eq!(diag.source_chain.len(), 1);
514        assert!(diag.source_chain[0].contains("File not found"));
515    }
516
517    #[test]
518    fn test_diagnostic_serialization() {
519        let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
520            .with_code("E001".to_string())
521            .with_location(Location {
522                file: "test.typ".to_string(),
523                line: 10,
524                column: 5,
525            });
526
527        let json = serde_json::to_string(&diag).unwrap();
528        assert!(json.contains("Test error"));
529        assert!(json.contains("E001"));
530        assert!(json.contains("\"severity\":\"error\""));
531        assert!(json.contains("\"column\":5"));
532    }
533
534    #[test]
535    fn test_render_error_single_diagnostic_shape() {
536        let err = RenderError::from_diag(Diagnostic::new(
537            Severity::Error,
538            "no such backend".to_string(),
539        ));
540        assert_eq!(err.diagnostics().len(), 1);
541        assert_eq!(err.to_string(), "no such backend");
542
543        let owned = err.into_diagnostics();
544        assert_eq!(owned.len(), 1);
545        assert_eq!(owned[0].message, "no such backend");
546    }
547
548    #[test]
549    fn test_render_error_display_aggregates_multi_diagnostic() {
550        let err = RenderError::new(vec![
551            Diagnostic::new(Severity::Error, "a".to_string()),
552            Diagnostic::new(Severity::Error, "b".to_string()),
553        ]);
554        assert_eq!(err.to_string(), "2 error(s): a");
555    }
556
557    #[test]
558    fn test_diagnostic_fmt_pretty() {
559        let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
560            .with_code("W001".to_string())
561            .with_location(Location {
562                file: "input.md".to_string(),
563                line: 5,
564                column: 10,
565            })
566            .with_hint("Use the new field name instead".to_string());
567
568        let output = diag.fmt_pretty();
569        assert!(output.contains("[WARN]"));
570        assert!(output.contains("Deprecated field used"));
571        assert!(output.contains("W001"));
572        assert!(output.contains("input.md:5:10"));
573        assert!(output.contains("hint:"));
574    }
575
576    #[test]
577    fn test_diagnostic_with_path() {
578        let diag = Diagnostic::new(Severity::Error, "Type mismatch".to_string())
579            .with_code("validation::type_mismatch".to_string())
580            .with_path("cards.indorsement[0].signature_block".to_string());
581
582        assert_eq!(
583            diag.path.as_deref(),
584            Some("cards.indorsement[0].signature_block")
585        );
586
587        let json = serde_json::to_string(&diag).unwrap();
588        assert!(json.contains("\"path\":\"cards.indorsement[0].signature_block\""));
589
590        let pretty = diag.fmt_pretty();
591        assert!(pretty.contains("at cards.indorsement[0].signature_block"));
592    }
593
594}