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