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)
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    /// The document was rendered with a quill that does not satisfy its `$quill`
357    /// reference — a different *name* (`quill::name_mismatch`) or a `version`
358    /// outside the selector (`quill::version_mismatch`). Distinct from
359    /// [`ValidationFailed`](RenderError::ValidationFailed): the document is
360    /// well-formed; it was paired with the wrong quill.
361    QuillMismatch {
362        /// The mismatch diagnostic. Always non-empty.
363        diags: Vec<Diagnostic>,
364    },
365}
366
367impl RenderError {
368    /// Returns all diagnostics for this error. Always non-empty.
369    pub fn diagnostics(&self) -> &[Diagnostic] {
370        match self {
371            RenderError::EngineCreation { diags }
372            | RenderError::InvalidPayload { diags }
373            | RenderError::CompilationFailed { diags }
374            | RenderError::FormatNotSupported { diags }
375            | RenderError::UnsupportedBackend { diags }
376            | RenderError::ValidationFailed { diags }
377            | RenderError::QuillConfig { diags }
378            | RenderError::QuillMismatch { diags } => diags,
379        }
380    }
381
382    /// Consume the error and return its diagnostics.
383    pub fn into_diagnostics(self) -> Vec<Diagnostic> {
384        match self {
385            RenderError::EngineCreation { diags }
386            | RenderError::InvalidPayload { diags }
387            | RenderError::CompilationFailed { diags }
388            | RenderError::FormatNotSupported { diags }
389            | RenderError::UnsupportedBackend { diags }
390            | RenderError::ValidationFailed { diags }
391            | RenderError::QuillConfig { diags }
392            | RenderError::QuillMismatch { diags } => diags,
393        }
394    }
395}
396
397impl std::fmt::Display for RenderError {
398    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399        match self {
400            RenderError::CompilationFailed { diags } => {
401                write!(
402                    f,
403                    "Backend compilation failed with {} error(s)",
404                    diags.len()
405                )
406            }
407            RenderError::ValidationFailed { diags } => {
408                write!(f, "Validation failed with {} error(s)", diags.len())
409            }
410            RenderError::QuillConfig { diags } => {
411                write!(
412                    f,
413                    "Quill configuration failed with {} error(s)",
414                    diags.len()
415                )
416            }
417            RenderError::EngineCreation { .. }
418            | RenderError::InvalidPayload { .. }
419            | RenderError::FormatNotSupported { .. }
420            | RenderError::UnsupportedBackend { .. }
421            | RenderError::QuillMismatch { .. } => match self.diagnostics().first() {
422                Some(d) => write!(f, "{}", d.message),
423                None => write!(f, "render error"),
424            },
425        }
426    }
427}
428
429impl std::error::Error for RenderError {}
430
431impl From<ParseError> for RenderError {
432    fn from(err: ParseError) -> Self {
433        RenderError::InvalidPayload {
434            diags: vec![Diagnostic::new(Severity::Error, err.to_string())
435                .with_code("parse::error".to_string())],
436        }
437    }
438}
439
440#[derive(Debug)]
441pub struct RenderResult {
442    pub artifacts: Vec<crate::Artifact>,
443    pub warnings: Vec<Diagnostic>,
444    pub output_format: OutputFormat,
445}
446
447impl RenderResult {
448    pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
449        Self {
450            artifacts,
451            warnings: Vec::new(),
452            output_format,
453        }
454    }
455
456    pub fn with_warning(mut self, warning: Diagnostic) -> Self {
457        self.warnings.push(warning);
458        self
459    }
460}
461
462pub fn print_errors(err: &RenderError) {
463    for d in err.diagnostics() {
464        eprintln!("{}", d.fmt_pretty());
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn test_diagnostic_with_source_chain() {
474        let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
475        let diag =
476            Diagnostic::new(Severity::Error, "Rendering failed".to_string()).with_source(&root_err);
477
478        assert_eq!(diag.source_chain.len(), 1);
479        assert!(diag.source_chain[0].contains("File not found"));
480    }
481
482    #[test]
483    fn test_diagnostic_serialization() {
484        let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
485            .with_code("E001".to_string())
486            .with_location(Location {
487                file: "test.typ".to_string(),
488                line: 10,
489                column: 5,
490            });
491
492        let json = serde_json::to_string(&diag).unwrap();
493        assert!(json.contains("Test error"));
494        assert!(json.contains("E001"));
495        assert!(json.contains("\"severity\":\"error\""));
496        assert!(json.contains("\"column\":5"));
497    }
498
499    #[test]
500    fn test_render_error_diagnostics_extraction() {
501        let diag1 = Diagnostic::new(Severity::Error, "Error 1".to_string());
502        let diag2 = Diagnostic::new(Severity::Error, "Error 2".to_string());
503
504        let err = RenderError::CompilationFailed {
505            diags: vec![diag1, diag2],
506        };
507
508        let diags = err.diagnostics();
509        assert_eq!(diags.len(), 2);
510    }
511
512    #[test]
513    fn test_render_error_uniform_single_diagnostic_shape() {
514        // Single-diagnostic kinds carry a one-element vector and expose it
515        // through the same accessors as the multi-diagnostic kinds.
516        let err = RenderError::UnsupportedBackend {
517            diags: vec![Diagnostic::new(
518                Severity::Error,
519                "no such backend".to_string(),
520            )],
521        };
522        assert_eq!(err.diagnostics().len(), 1);
523        assert_eq!(err.to_string(), "no such backend");
524
525        let owned = err.into_diagnostics();
526        assert_eq!(owned.len(), 1);
527        assert_eq!(owned[0].message, "no such backend");
528    }
529
530    #[test]
531    fn test_render_error_display_aggregates_multi_diagnostic() {
532        let err = RenderError::ValidationFailed {
533            diags: vec![
534                Diagnostic::new(Severity::Error, "a".to_string()),
535                Diagnostic::new(Severity::Error, "b".to_string()),
536            ],
537        };
538        assert_eq!(err.to_string(), "Validation failed with 2 error(s)");
539    }
540
541    #[test]
542    fn test_diagnostic_fmt_pretty() {
543        let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
544            .with_code("W001".to_string())
545            .with_location(Location {
546                file: "input.md".to_string(),
547                line: 5,
548                column: 10,
549            })
550            .with_hint("Use the new field name instead".to_string());
551
552        let output = diag.fmt_pretty();
553        assert!(output.contains("[WARN]"));
554        assert!(output.contains("Deprecated field used"));
555        assert!(output.contains("W001"));
556        assert!(output.contains("input.md:5:10"));
557        assert!(output.contains("hint:"));
558    }
559
560    #[test]
561    fn test_diagnostic_with_path() {
562        let diag = Diagnostic::new(Severity::Error, "Missing field".to_string())
563            .with_code("validation::field_absent".to_string())
564            .with_path("cards.indorsement[0].signature_block".to_string());
565
566        assert_eq!(
567            diag.path.as_deref(),
568            Some("cards.indorsement[0].signature_block")
569        );
570
571        let json = serde_json::to_string(&diag).unwrap();
572        assert!(json.contains("\"path\":\"cards.indorsement[0].signature_block\""));
573
574        let pretty = diag.fmt_pretty();
575        assert!(pretty.contains("at cards.indorsement[0].signature_block"));
576    }
577
578    #[test]
579    fn test_diagnostic_path_omitted_when_none() {
580        let diag = Diagnostic::new(Severity::Error, "No path".to_string());
581        let json = serde_json::to_string(&diag).unwrap();
582        assert!(!json.contains("\"path\""));
583    }
584
585    #[test]
586    fn test_diagnostic_fmt_pretty_with_source() {
587        let root_err = std::io::Error::other("Underlying error");
588        let diag = Diagnostic::new(Severity::Error, "Top-level error".to_string())
589            .with_code("E002".to_string())
590            .with_source(&root_err);
591
592        let output = diag.fmt_pretty_with_source();
593        assert!(output.contains("[ERROR]"));
594        assert!(output.contains("Top-level error"));
595        assert!(output.contains("cause 1:"));
596        assert!(output.contains("Underlying error"));
597    }
598
599    #[test]
600    fn test_render_result_with_warnings() {
601        let artifacts = vec![];
602        let warning = Diagnostic::new(Severity::Warning, "Test warning".to_string());
603
604        let result = RenderResult::new(artifacts, OutputFormat::Pdf).with_warning(warning);
605
606        assert_eq!(result.warnings.len(), 1);
607        assert_eq!(result.warnings[0].message, "Test warning");
608    }
609}