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    #[error("YAML error at line {line}: {message}")]
233    YamlErrorWithLocation {
234        message: String,
235        /// Line number in the source document (1-indexed)
236        line: usize,
237        /// Index of the metadata block (0-indexed)
238        block_index: usize,
239        /// Optional actionable hint attached when the YAML parser's message
240        /// is too cryptic to be recoverable on its own. Derived by the
241        /// internal `document::yaml_hints` enrichment pass.
242        hint: Option<String>,
243    },
244}
245
246impl ParseError {
247    pub fn to_diagnostic(&self) -> Diagnostic {
248        match self {
249            ParseError::InputTooLarge { size, max } => Diagnostic::new(
250                Severity::Error,
251                format!("Input too large: {} bytes (max: {} bytes)", size, max),
252            )
253            .with_code("parse::input_too_large".to_string()),
254            ParseError::InvalidStructure(msg) => Diagnostic::new(Severity::Error, msg.clone())
255                .with_code("parse::invalid_structure".to_string()),
256            ParseError::EmptyInput(msg) => Diagnostic::new(Severity::Error, msg.clone())
257                .with_code("parse::empty_input".to_string()),
258            ParseError::MissingQuill(msg) => Diagnostic::new(Severity::Error, msg.clone())
259                .with_code("parse::missing_quill".to_string()),
260            ParseError::YamlErrorWithLocation {
261                message,
262                line,
263                block_index,
264                hint,
265            } => {
266                let mut d = Diagnostic::new(
267                    Severity::Error,
268                    format!(
269                        "YAML error at line {} (block {}): {}",
270                        line, block_index, message
271                    ),
272                )
273                .with_code("parse::yaml_error_with_location".to_string());
274                if let Some(h) = hint {
275                    d = d.with_hint(h.clone());
276                }
277                d
278            }
279        }
280    }
281}
282
283/// Main error type for rendering operations.
284///
285/// Every variant carries a non-empty `diags: Vec<Diagnostic>`. Variants whose
286/// failure is inherently a single diagnostic still carry a one-element vector
287/// — the uniform shape lets every consumer, and every language binding,
288/// handle all rendering errors through a single code path. See
289/// [`RenderError::diagnostics`] and [`RenderError::into_diagnostics`]. The
290/// variant itself records the *kind* of failure (which bindings map to typed
291/// exceptions); the payload is always just the diagnostics.
292#[derive(Debug)]
293pub enum RenderError {
294    /// Failed to create rendering engine.
295    EngineCreation {
296        /// Diagnostics describing the failure. Always non-empty.
297        diags: Vec<Diagnostic>,
298    },
299
300    /// Invalid YAML in a card-yaml block.
301    InvalidPayload {
302        /// Diagnostics describing the failure. Always non-empty.
303        diags: Vec<Diagnostic>,
304    },
305
306    /// Backend compilation failed with one or more errors.
307    CompilationFailed {
308        /// All compilation diagnostics. Always non-empty.
309        diags: Vec<Diagnostic>,
310    },
311
312    /// Requested output format not supported by backend.
313    FormatNotSupported {
314        /// Diagnostics describing the failure. Always non-empty.
315        diags: Vec<Diagnostic>,
316    },
317
318    /// Backend not registered with engine.
319    UnsupportedBackend {
320        /// Diagnostics describing the failure. Always non-empty.
321        diags: Vec<Diagnostic>,
322    },
323
324    /// Validation failed for parsed document — may carry multiple diagnostics
325    /// when several problems are detected during a single validation pass
326    /// (e.g. multiple missing required fields). Each diagnostic should set
327    /// `path` to anchor the error at a specific location in the document model.
328    ValidationFailed {
329        /// All validation diagnostics. Always non-empty.
330        diags: Vec<Diagnostic>,
331    },
332
333    /// Quill configuration error — may carry multiple diagnostics when several
334    /// problems are detected during parsing (e.g. several unknown keys at once).
335    QuillConfig {
336        /// All configuration diagnostics. Always non-empty.
337        diags: Vec<Diagnostic>,
338    },
339}
340
341impl RenderError {
342    /// Returns all diagnostics for this error. Always non-empty.
343    pub fn diagnostics(&self) -> &[Diagnostic] {
344        match self {
345            RenderError::EngineCreation { diags }
346            | RenderError::InvalidPayload { diags }
347            | RenderError::CompilationFailed { diags }
348            | RenderError::FormatNotSupported { diags }
349            | RenderError::UnsupportedBackend { diags }
350            | RenderError::ValidationFailed { diags }
351            | RenderError::QuillConfig { diags } => diags,
352        }
353    }
354
355    /// Consume the error and return its diagnostics.
356    pub fn into_diagnostics(self) -> Vec<Diagnostic> {
357        match self {
358            RenderError::EngineCreation { diags }
359            | RenderError::InvalidPayload { diags }
360            | RenderError::CompilationFailed { diags }
361            | RenderError::FormatNotSupported { diags }
362            | RenderError::UnsupportedBackend { diags }
363            | RenderError::ValidationFailed { diags }
364            | RenderError::QuillConfig { diags } => diags,
365        }
366    }
367}
368
369impl std::fmt::Display for RenderError {
370    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371        match self {
372            RenderError::CompilationFailed { diags } => {
373                write!(
374                    f,
375                    "Backend compilation failed with {} error(s)",
376                    diags.len()
377                )
378            }
379            RenderError::ValidationFailed { diags } => {
380                write!(f, "Validation failed with {} error(s)", diags.len())
381            }
382            RenderError::QuillConfig { diags } => {
383                write!(
384                    f,
385                    "Quill configuration failed with {} error(s)",
386                    diags.len()
387                )
388            }
389            RenderError::EngineCreation { .. }
390            | RenderError::InvalidPayload { .. }
391            | RenderError::FormatNotSupported { .. }
392            | RenderError::UnsupportedBackend { .. } => match self.diagnostics().first() {
393                Some(d) => write!(f, "{}", d.message),
394                None => write!(f, "render error"),
395            },
396        }
397    }
398}
399
400impl std::error::Error for RenderError {}
401
402impl From<ParseError> for RenderError {
403    fn from(err: ParseError) -> Self {
404        RenderError::InvalidPayload {
405            diags: vec![Diagnostic::new(Severity::Error, err.to_string())
406                .with_code("parse::error".to_string())],
407        }
408    }
409}
410
411#[derive(Debug)]
412pub struct RenderResult {
413    pub artifacts: Vec<crate::Artifact>,
414    pub warnings: Vec<Diagnostic>,
415    pub output_format: OutputFormat,
416}
417
418impl RenderResult {
419    pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
420        Self {
421            artifacts,
422            warnings: Vec::new(),
423            output_format,
424        }
425    }
426
427    pub fn with_warning(mut self, warning: Diagnostic) -> Self {
428        self.warnings.push(warning);
429        self
430    }
431}
432
433pub fn print_errors(err: &RenderError) {
434    for d in err.diagnostics() {
435        eprintln!("{}", d.fmt_pretty());
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn test_diagnostic_with_source_chain() {
445        let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
446        let diag =
447            Diagnostic::new(Severity::Error, "Rendering failed".to_string()).with_source(&root_err);
448
449        assert_eq!(diag.source_chain.len(), 1);
450        assert!(diag.source_chain[0].contains("File not found"));
451    }
452
453    #[test]
454    fn test_diagnostic_serialization() {
455        let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
456            .with_code("E001".to_string())
457            .with_location(Location {
458                file: "test.typ".to_string(),
459                line: 10,
460                column: 5,
461            });
462
463        let json = serde_json::to_string(&diag).unwrap();
464        assert!(json.contains("Test error"));
465        assert!(json.contains("E001"));
466        assert!(json.contains("\"severity\":\"error\""));
467        assert!(json.contains("\"column\":5"));
468    }
469
470    #[test]
471    fn test_render_error_diagnostics_extraction() {
472        let diag1 = Diagnostic::new(Severity::Error, "Error 1".to_string());
473        let diag2 = Diagnostic::new(Severity::Error, "Error 2".to_string());
474
475        let err = RenderError::CompilationFailed {
476            diags: vec![diag1, diag2],
477        };
478
479        let diags = err.diagnostics();
480        assert_eq!(diags.len(), 2);
481    }
482
483    #[test]
484    fn test_render_error_uniform_single_diagnostic_shape() {
485        // Single-diagnostic kinds carry a one-element vector and expose it
486        // through the same accessors as the multi-diagnostic kinds.
487        let err = RenderError::UnsupportedBackend {
488            diags: vec![Diagnostic::new(
489                Severity::Error,
490                "no such backend".to_string(),
491            )],
492        };
493        assert_eq!(err.diagnostics().len(), 1);
494        assert_eq!(err.to_string(), "no such backend");
495
496        let owned = err.into_diagnostics();
497        assert_eq!(owned.len(), 1);
498        assert_eq!(owned[0].message, "no such backend");
499    }
500
501    #[test]
502    fn test_render_error_display_aggregates_multi_diagnostic() {
503        let err = RenderError::ValidationFailed {
504            diags: vec![
505                Diagnostic::new(Severity::Error, "a".to_string()),
506                Diagnostic::new(Severity::Error, "b".to_string()),
507            ],
508        };
509        assert_eq!(err.to_string(), "Validation failed with 2 error(s)");
510    }
511
512    #[test]
513    fn test_diagnostic_fmt_pretty() {
514        let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
515            .with_code("W001".to_string())
516            .with_location(Location {
517                file: "input.md".to_string(),
518                line: 5,
519                column: 10,
520            })
521            .with_hint("Use the new field name instead".to_string());
522
523        let output = diag.fmt_pretty();
524        assert!(output.contains("[WARN]"));
525        assert!(output.contains("Deprecated field used"));
526        assert!(output.contains("W001"));
527        assert!(output.contains("input.md:5:10"));
528        assert!(output.contains("hint:"));
529    }
530
531    #[test]
532    fn test_diagnostic_with_path() {
533        let diag = Diagnostic::new(Severity::Error, "Missing field".to_string())
534            .with_code("validation::must_fill_absent".to_string())
535            .with_path("cards.indorsement[0].signature_block".to_string());
536
537        assert_eq!(
538            diag.path.as_deref(),
539            Some("cards.indorsement[0].signature_block")
540        );
541
542        let json = serde_json::to_string(&diag).unwrap();
543        assert!(json.contains("\"path\":\"cards.indorsement[0].signature_block\""));
544
545        let pretty = diag.fmt_pretty();
546        assert!(pretty.contains("at cards.indorsement[0].signature_block"));
547    }
548
549    #[test]
550    fn test_diagnostic_path_omitted_when_none() {
551        let diag = Diagnostic::new(Severity::Error, "No path".to_string());
552        let json = serde_json::to_string(&diag).unwrap();
553        assert!(!json.contains("\"path\""));
554    }
555
556    #[test]
557    fn test_diagnostic_fmt_pretty_with_source() {
558        let root_err = std::io::Error::other("Underlying error");
559        let diag = Diagnostic::new(Severity::Error, "Top-level error".to_string())
560            .with_code("E002".to_string())
561            .with_source(&root_err);
562
563        let output = diag.fmt_pretty_with_source();
564        assert!(output.contains("[ERROR]"));
565        assert!(output.contains("Top-level error"));
566        assert!(output.contains("cause 1:"));
567        assert!(output.contains("Underlying error"));
568    }
569
570    #[test]
571    fn test_render_result_with_warnings() {
572        let artifacts = vec![];
573        let warning = Diagnostic::new(Severity::Warning, "Test warning".to_string());
574
575        let result = RenderResult::new(artifacts, OutputFormat::Pdf).with_warning(warning);
576
577        assert_eq!(result.warnings.len(), 1);
578        assert_eq!(result.warnings[0].message, "Test warning");
579    }
580}