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