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-z_][a-z0-9_]*       // same charset enforced for fields/kinds
20//! index       := [0-9]+
21//! ```
22//!
23//! Because field names and card kinds are validated to that charset (no `.`,
24//! `[`, `]`, or 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)
48pub const MAX_NESTING_DEPTH: usize = 100;
49
50/// Re-exported from [`crate::document::limits::MAX_YAML_DEPTH`].
51pub use crate::document::limits::MAX_YAML_DEPTH;
52
53/// Maximum number of card blocks allowed per document
54pub const MAX_CARD_COUNT: usize = 1000;
55
56/// Maximum number of fields allowed per document
57pub const MAX_FIELD_COUNT: usize = 1000;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
60#[serde(rename_all = "lowercase")]
61pub enum Severity {
62    /// Fatal error that prevents completion
63    Error,
64    /// Non-fatal issue that may need attention
65    Warning,
66    /// Informational message
67    Note,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct Location {
73    /// Source file name (e.g., "plate.typ", "template.typ", "input.md")
74    pub file: String,
75    /// Line number (1-indexed)
76    pub line: u32,
77    /// Column number (1-indexed)
78    pub column: u32,
79}
80
81/// Structured diagnostic information.
82///
83/// `source_chain` is a flat list of error messages from any attached
84/// `std::error::Error` cause chain, eagerly walked at construction time so
85/// the diagnostic remains trivially `Clone` and fully serializable across
86/// every binding boundary.
87#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct Diagnostic {
90    pub severity: Severity,
91    /// Optional error code (e.g., "E001", "typst::syntax")
92    #[serde(skip_serializing_if = "Option::is_none", default)]
93    pub code: Option<String>,
94    pub message: String,
95    /// Primary source location (text anchor: file/line/column).
96    ///
97    /// Set by parsers and backend compilers. May co-exist with [`Self::path`]
98    /// — the two anchors are independent.
99    #[serde(skip_serializing_if = "Option::is_none", default)]
100    pub location: Option<Location>,
101    /// Document-model anchor — a dotted/bracketed path into the typed
102    /// [`crate::document::Document`].
103    ///
104    /// Set by schema validation and coercion. See the module-level docs for
105    /// the path grammar and conventions. May co-exist with [`Self::location`].
106    #[serde(skip_serializing_if = "Option::is_none", default)]
107    pub path: Option<String>,
108    #[serde(skip_serializing_if = "Option::is_none", default)]
109    pub hint: Option<String>,
110    /// Flattened cause chain (outermost first).
111    #[serde(skip_serializing_if = "Vec::is_empty", default)]
112    pub source_chain: Vec<String>,
113}
114
115impl Diagnostic {
116    pub fn new(severity: Severity, message: String) -> Self {
117        Self {
118            severity,
119            code: None,
120            message,
121            location: None,
122            path: None,
123            hint: None,
124            source_chain: Vec::new(),
125        }
126    }
127
128    pub fn with_code(mut self, code: String) -> Self {
129        self.code = Some(code);
130        self
131    }
132
133    pub fn with_location(mut self, location: Location) -> Self {
134        self.location = Some(location);
135        self
136    }
137
138    /// Set the document-model path anchor.
139    ///
140    /// See the module-level docs for the path grammar and conventions.
141    pub fn with_path(mut self, path: String) -> Self {
142        self.path = Some(path);
143        self
144    }
145
146    pub fn with_hint(mut self, hint: String) -> Self {
147        self.hint = Some(hint);
148        self
149    }
150
151    /// Attach an error cause chain, walked eagerly into `source_chain`.
152    pub fn with_source(mut self, source: &(dyn std::error::Error + 'static)) -> Self {
153        let mut current: Option<&(dyn std::error::Error + 'static)> = Some(source);
154        while let Some(err) = current {
155            self.source_chain.push(err.to_string());
156            current = err.source();
157        }
158        self
159    }
160
161    pub fn fmt_pretty(&self) -> String {
162        let mut result = format!(
163            "[{}] {}",
164            match self.severity {
165                Severity::Error => "ERROR",
166                Severity::Warning => "WARN",
167                Severity::Note => "NOTE",
168            },
169            self.message
170        );
171
172        if let Some(ref code) = self.code {
173            result.push_str(&format!(" ({})", code));
174        }
175
176        if let Some(ref loc) = self.location {
177            result.push_str(&format!("\n  --> {}:{}:{}", loc.file, loc.line, loc.column));
178        }
179
180        if let Some(ref path) = self.path {
181            result.push_str(&format!("\n  at {}", path));
182        }
183
184        if let Some(ref hint) = self.hint {
185            result.push_str(&format!("\n  hint: {}", hint));
186        }
187
188        result
189    }
190
191    /// Format diagnostic with source chain for debugging.
192    pub fn fmt_pretty_with_source(&self) -> String {
193        let mut result = self.fmt_pretty();
194
195        for (i, cause) in self.source_chain.iter().enumerate() {
196            result.push_str(&format!("\n  cause {}: {}", i + 1, cause));
197        }
198
199        result
200    }
201}
202
203impl std::fmt::Display for Diagnostic {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        write!(f, "{}", self.message)
206    }
207}
208
209#[derive(thiserror::Error, Debug)]
210pub enum ParseError {
211    #[error("Input too large: {size} bytes (max: {max} bytes)")]
212    InputTooLarge { size: usize, max: usize },
213
214    #[error("Invalid YAML structure: {0}")]
215    InvalidStructure(String),
216
217    /// Markdown input was empty or whitespace-only.
218    ///
219    /// Emitted as code `parse::empty_input` so consumers can pattern-match
220    /// without inspecting the message text.
221    #[error("{0}")]
222    EmptyInput(String),
223
224    /// The document is missing its root `~~~` card-yaml block, or that block
225    /// does not declare the required `$quill` system metadata.
226    ///
227    /// Emitted as code `parse::missing_quill` so consumers can
228    /// pattern-match without inspecting the message text.
229    #[error("{0}")]
230    MissingQuill(String),
231
232    /// A `$quill` reference failed to parse as a [`crate::version::QuillReference`].
233    /// Code `parse::invalid_quill_reference`; carries
234    /// [`crate::version::quill_ref_hint`] as its diagnostic hint.
235    #[error("Invalid $quill reference '{value}': {reason}")]
236    InvalidQuillReference {
237        value: String,
238        /// The `from_str` violation.
239        reason: String,
240    },
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::InvalidQuillReference { value, reason } => Diagnostic::new(
271                Severity::Error,
272                format!("Invalid $quill reference '{}': {}", value, reason),
273            )
274            .with_code("parse::invalid_quill_reference".to_string())
275            .with_hint(crate::version::quill_ref_hint().to_string()),
276            ParseError::YamlErrorWithLocation {
277                message,
278                line,
279                block_index,
280                hint,
281            } => {
282                let mut d = Diagnostic::new(
283                    Severity::Error,
284                    format!(
285                        "YAML error at line {} (block {}): {}",
286                        line, block_index, message
287                    ),
288                )
289                .with_code("parse::yaml_error_with_location".to_string());
290                if let Some(h) = hint {
291                    d = d.with_hint(h.clone());
292                }
293                d
294            }
295        }
296    }
297}
298
299/// Main error type for rendering operations.
300///
301/// Every variant carries a non-empty `diags: Vec<Diagnostic>`. Variants whose
302/// failure is inherently a single diagnostic still carry a one-element vector
303/// — the uniform shape lets every consumer, and every language binding,
304/// handle all rendering errors through a single code path. See
305/// [`RenderError::diagnostics`] and [`RenderError::into_diagnostics`]. The
306/// variant itself records the *kind* of failure (which bindings map to typed
307/// exceptions); the payload is always just the diagnostics.
308#[derive(Debug)]
309pub enum RenderError {
310    /// Failed to create rendering engine.
311    EngineCreation {
312        /// Diagnostics describing the failure. Always non-empty.
313        diags: Vec<Diagnostic>,
314    },
315
316    /// Invalid YAML in a card-yaml block.
317    InvalidPayload {
318        /// Diagnostics describing the failure. Always non-empty.
319        diags: Vec<Diagnostic>,
320    },
321
322    /// Backend compilation failed with one or more errors.
323    CompilationFailed {
324        /// All compilation diagnostics. Always non-empty.
325        diags: Vec<Diagnostic>,
326    },
327
328    /// Requested output format not supported by backend.
329    FormatNotSupported {
330        /// Diagnostics describing the failure. Always non-empty.
331        diags: Vec<Diagnostic>,
332    },
333
334    /// Backend not registered with engine.
335    UnsupportedBackend {
336        /// Diagnostics describing the failure. Always non-empty.
337        diags: Vec<Diagnostic>,
338    },
339
340    /// Validation failed for parsed document — may carry multiple diagnostics
341    /// when several problems are detected during a single validation pass
342    /// (e.g. multiple missing required fields). Each diagnostic should set
343    /// `path` to anchor the error at a specific location in the document model.
344    ValidationFailed {
345        /// All validation diagnostics. Always non-empty.
346        diags: Vec<Diagnostic>,
347    },
348
349    /// Quill configuration error — may carry multiple diagnostics when several
350    /// problems are detected during parsing (e.g. several unknown keys at once).
351    QuillConfig {
352        /// All configuration diagnostics. Always non-empty.
353        diags: Vec<Diagnostic>,
354    },
355}
356
357impl RenderError {
358    /// Returns all diagnostics for this error. Always non-empty.
359    pub fn diagnostics(&self) -> &[Diagnostic] {
360        match self {
361            RenderError::EngineCreation { diags }
362            | RenderError::InvalidPayload { diags }
363            | RenderError::CompilationFailed { diags }
364            | RenderError::FormatNotSupported { diags }
365            | RenderError::UnsupportedBackend { diags }
366            | RenderError::ValidationFailed { diags }
367            | RenderError::QuillConfig { diags } => diags,
368        }
369    }
370
371    /// Consume the error and return its diagnostics.
372    pub fn into_diagnostics(self) -> Vec<Diagnostic> {
373        match self {
374            RenderError::EngineCreation { diags }
375            | RenderError::InvalidPayload { diags }
376            | RenderError::CompilationFailed { diags }
377            | RenderError::FormatNotSupported { diags }
378            | RenderError::UnsupportedBackend { diags }
379            | RenderError::ValidationFailed { diags }
380            | RenderError::QuillConfig { diags } => diags,
381        }
382    }
383}
384
385impl std::fmt::Display for RenderError {
386    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387        match self {
388            RenderError::CompilationFailed { diags } => {
389                write!(
390                    f,
391                    "Backend compilation failed with {} error(s)",
392                    diags.len()
393                )
394            }
395            RenderError::ValidationFailed { diags } => {
396                write!(f, "Validation failed with {} error(s)", diags.len())
397            }
398            RenderError::QuillConfig { diags } => {
399                write!(
400                    f,
401                    "Quill configuration failed with {} error(s)",
402                    diags.len()
403                )
404            }
405            RenderError::EngineCreation { .. }
406            | RenderError::InvalidPayload { .. }
407            | RenderError::FormatNotSupported { .. }
408            | RenderError::UnsupportedBackend { .. } => match self.diagnostics().first() {
409                Some(d) => write!(f, "{}", d.message),
410                None => write!(f, "render error"),
411            },
412        }
413    }
414}
415
416impl std::error::Error for RenderError {}
417
418impl From<ParseError> for RenderError {
419    fn from(err: ParseError) -> Self {
420        RenderError::InvalidPayload {
421            diags: vec![Diagnostic::new(Severity::Error, err.to_string())
422                .with_code("parse::error".to_string())],
423        }
424    }
425}
426
427#[derive(Debug)]
428pub struct RenderResult {
429    pub artifacts: Vec<crate::Artifact>,
430    pub warnings: Vec<Diagnostic>,
431    pub output_format: OutputFormat,
432}
433
434impl RenderResult {
435    pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
436        Self {
437            artifacts,
438            warnings: Vec::new(),
439            output_format,
440        }
441    }
442
443    pub fn with_warning(mut self, warning: Diagnostic) -> Self {
444        self.warnings.push(warning);
445        self
446    }
447}
448
449pub fn print_errors(err: &RenderError) {
450    for d in err.diagnostics() {
451        eprintln!("{}", d.fmt_pretty());
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn test_diagnostic_with_source_chain() {
461        let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
462        let diag =
463            Diagnostic::new(Severity::Error, "Rendering failed".to_string()).with_source(&root_err);
464
465        assert_eq!(diag.source_chain.len(), 1);
466        assert!(diag.source_chain[0].contains("File not found"));
467    }
468
469    #[test]
470    fn test_diagnostic_serialization() {
471        let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
472            .with_code("E001".to_string())
473            .with_location(Location {
474                file: "test.typ".to_string(),
475                line: 10,
476                column: 5,
477            });
478
479        let json = serde_json::to_string(&diag).unwrap();
480        assert!(json.contains("Test error"));
481        assert!(json.contains("E001"));
482        assert!(json.contains("\"severity\":\"error\""));
483        assert!(json.contains("\"column\":5"));
484    }
485
486    #[test]
487    fn test_render_error_diagnostics_extraction() {
488        let diag1 = Diagnostic::new(Severity::Error, "Error 1".to_string());
489        let diag2 = Diagnostic::new(Severity::Error, "Error 2".to_string());
490
491        let err = RenderError::CompilationFailed {
492            diags: vec![diag1, diag2],
493        };
494
495        let diags = err.diagnostics();
496        assert_eq!(diags.len(), 2);
497    }
498
499    #[test]
500    fn test_render_error_uniform_single_diagnostic_shape() {
501        // Single-diagnostic kinds carry a one-element vector and expose it
502        // through the same accessors as the multi-diagnostic kinds.
503        let err = RenderError::UnsupportedBackend {
504            diags: vec![Diagnostic::new(
505                Severity::Error,
506                "no such backend".to_string(),
507            )],
508        };
509        assert_eq!(err.diagnostics().len(), 1);
510        assert_eq!(err.to_string(), "no such backend");
511
512        let owned = err.into_diagnostics();
513        assert_eq!(owned.len(), 1);
514        assert_eq!(owned[0].message, "no such backend");
515    }
516
517    #[test]
518    fn test_render_error_display_aggregates_multi_diagnostic() {
519        let err = RenderError::ValidationFailed {
520            diags: vec![
521                Diagnostic::new(Severity::Error, "a".to_string()),
522                Diagnostic::new(Severity::Error, "b".to_string()),
523            ],
524        };
525        assert_eq!(err.to_string(), "Validation failed with 2 error(s)");
526    }
527
528    #[test]
529    fn test_diagnostic_fmt_pretty() {
530        let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
531            .with_code("W001".to_string())
532            .with_location(Location {
533                file: "input.md".to_string(),
534                line: 5,
535                column: 10,
536            })
537            .with_hint("Use the new field name instead".to_string());
538
539        let output = diag.fmt_pretty();
540        assert!(output.contains("[WARN]"));
541        assert!(output.contains("Deprecated field used"));
542        assert!(output.contains("W001"));
543        assert!(output.contains("input.md:5:10"));
544        assert!(output.contains("hint:"));
545    }
546
547    #[test]
548    fn test_diagnostic_with_path() {
549        let diag = Diagnostic::new(Severity::Error, "Missing field".to_string())
550            .with_code("validation::field_absent".to_string())
551            .with_path("cards.indorsement[0].signature_block".to_string());
552
553        assert_eq!(
554            diag.path.as_deref(),
555            Some("cards.indorsement[0].signature_block")
556        );
557
558        let json = serde_json::to_string(&diag).unwrap();
559        assert!(json.contains("\"path\":\"cards.indorsement[0].signature_block\""));
560
561        let pretty = diag.fmt_pretty();
562        assert!(pretty.contains("at cards.indorsement[0].signature_block"));
563    }
564
565    #[test]
566    fn test_diagnostic_path_omitted_when_none() {
567        let diag = Diagnostic::new(Severity::Error, "No path".to_string());
568        let json = serde_json::to_string(&diag).unwrap();
569        assert!(!json.contains("\"path\""));
570    }
571
572    #[test]
573    fn test_diagnostic_fmt_pretty_with_source() {
574        let root_err = std::io::Error::other("Underlying error");
575        let diag = Diagnostic::new(Severity::Error, "Top-level error".to_string())
576            .with_code("E002".to_string())
577            .with_source(&root_err);
578
579        let output = diag.fmt_pretty_with_source();
580        assert!(output.contains("[ERROR]"));
581        assert!(output.contains("Top-level error"));
582        assert!(output.contains("cause 1:"));
583        assert!(output.contains("Underlying error"));
584    }
585
586    #[test]
587    fn test_render_result_with_warnings() {
588        let artifacts = vec![];
589        let warning = Diagnostic::new(Severity::Warning, "Test warning".to_string());
590
591        let result = RenderResult::new(artifacts, OutputFormat::Pdf).with_warning(warning);
592
593        assert_eq!(result.warnings.len(), 1);
594        assert_eq!(result.warnings[0].message, "Test warning");
595    }
596}