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