1use std::collections::BTreeMap;
20
21use crate::OutputFormat;
22
23macro_rules! diag_args {
27 ($($key:literal => $value:expr),* $(,)?) => {{
28 #[allow(unused_mut)]
29 let mut map = ::std::collections::BTreeMap::<String, ::serde_json::Value>::new();
30 $(map.insert($key.to_string(), ::serde_json::json!($value));)*
31 map
32 }};
33}
34
35pub(crate) use diag_args;
36
37pub const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024;
39
40pub const MAX_YAML_SIZE: usize = 1024 * 1024;
42
43pub use quillmark_content::MAX_NESTING_DEPTH;
48
49pub use crate::document::limits::MAX_YAML_DEPTH;
51
52pub const MAX_CARD_COUNT: usize = 1000;
54
55pub const MAX_FIELD_COUNT: usize = 1000;
57
58#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct YamlError {
70 message: String,
71 hint: Option<String>,
72 line: Option<u32>,
73 column: Option<u32>,
74}
75
76impl YamlError {
77 pub fn message(&self) -> &str {
79 &self.message
80 }
81
82 pub fn hint(&self) -> Option<&str> {
84 self.hint.as_deref()
85 }
86
87 pub fn line(&self) -> Option<u32> {
89 self.line
90 }
91
92 pub fn column(&self) -> Option<u32> {
94 self.column
95 }
96
97 pub fn to_diagnostic(&self, code: &str, file: &str) -> Diagnostic {
100 let mut diag = Diagnostic::new(Severity::Error, self.message.clone())
101 .with_code(code.to_string());
102 if let (Some(line), Some(column)) = (self.line, self.column) {
103 diag = diag.with_location(Location::new(file.to_string(), line, column));
104 }
105 match &self.hint {
106 Some(h) => diag.with_hint(h.clone()),
107 None => diag,
108 }
109 }
110
111 pub(crate) fn from_de(err: serde_saphyr::Error, yaml: &str) -> Self {
114 let enriched = crate::document::yaml_hints::enrich_yaml_error(&err.to_string(), yaml);
119 let loc = err.location();
122 Self {
123 message: enriched.message,
124 hint: enriched.hint,
125 line: loc.and_then(|l| u32::try_from(l.line()).ok()),
126 column: loc.and_then(|l| u32::try_from(l.column()).ok()),
127 }
128 }
129
130 pub(crate) fn from_ser(err: serde_saphyr::ser::Error) -> Self {
132 Self {
133 message: err.to_string(),
134 hint: None,
135 line: None,
136 column: None,
137 }
138 }
139}
140
141impl std::fmt::Display for YamlError {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 f.write_str(&self.message)
147 }
148}
149
150impl std::error::Error for YamlError {}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
164#[serde(rename_all = "lowercase")]
165#[non_exhaustive]
166pub enum Severity {
167 Error,
169 Warning,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
174#[serde(rename_all = "camelCase")]
175#[non_exhaustive]
176pub struct Location {
177 pub file: String,
179 pub line: u32,
181 pub column: u32,
183}
184
185impl Location {
186 pub fn new(file: String, line: u32, column: u32) -> Self {
189 Self { file, line, column }
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
200#[serde(rename_all = "camelCase")]
201#[non_exhaustive]
202pub struct Diagnostic {
203 pub severity: Severity,
204 #[serde(skip_serializing_if = "Option::is_none", default)]
206 pub code: Option<String>,
207 pub message: String,
208 #[serde(skip_serializing_if = "Option::is_none", default)]
213 pub location: Option<Location>,
214 #[serde(skip_serializing_if = "Option::is_none", default)]
220 pub path: Option<String>,
221 #[serde(skip_serializing_if = "Option::is_none", default)]
222 pub hint: Option<String>,
223 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
237 pub args: BTreeMap<String, serde_json::Value>,
238 #[serde(skip_serializing_if = "Vec::is_empty", default)]
241 pub source_chain: Vec<String>,
242}
243
244impl Diagnostic {
245 pub fn new(severity: Severity, message: String) -> Self {
246 Self {
247 severity,
248 code: None,
249 message,
250 location: None,
251 path: None,
252 hint: None,
253 args: BTreeMap::new(),
254 source_chain: Vec::new(),
255 }
256 }
257
258 pub fn with_code(mut self, code: String) -> Self {
259 self.code = Some(code);
260 self
261 }
262
263 pub fn with_location(mut self, location: Location) -> Self {
264 self.location = Some(location);
265 self
266 }
267
268 pub fn with_path(mut self, path: String) -> Self {
272 self.path = Some(path);
273 self
274 }
275
276 pub fn with_hint(mut self, hint: String) -> Self {
277 self.hint = Some(hint);
278 self
279 }
280
281 pub fn with_args(mut self, args: BTreeMap<String, serde_json::Value>) -> Self {
283 self.args = args;
284 self
285 }
286
287 pub fn with_source(mut self, source: &(dyn std::error::Error + 'static)) -> Self {
289 let mut current: Option<&(dyn std::error::Error + 'static)> = Some(source);
290 while let Some(err) = current {
291 self.source_chain.push(err.to_string());
292 current = err.source();
293 }
294 self
295 }
296
297 pub fn fmt_pretty(&self) -> String {
298 let mut result = format!(
299 "[{}] {}",
300 match self.severity {
301 Severity::Error => "ERROR",
302 Severity::Warning => "WARN",
303 },
304 self.message
305 );
306
307 if let Some(ref code) = self.code {
308 result.push_str(&format!(" ({})", code));
309 }
310
311 if let Some(ref loc) = self.location {
312 result.push_str(&format!("\n --> {}:{}:{}", loc.file, loc.line, loc.column));
313 }
314
315 if let Some(ref path) = self.path {
316 result.push_str(&format!("\n at {}", path));
317 }
318
319 if let Some(ref hint) = self.hint {
320 result.push_str(&format!("\n hint: {}", hint));
321 }
322
323 result
324 }
325
326}
327
328impl std::fmt::Display for Diagnostic {
329 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330 write!(f, "{}", self.message)
331 }
332}
333
334#[derive(thiserror::Error, Debug)]
335#[non_exhaustive]
336pub enum ParseError {
337 #[error("Input too large: {size} bytes (max: {max} bytes)")]
338 InputTooLarge { size: usize, max: usize },
339
340 #[error("Invalid YAML structure: {0}")]
341 InvalidStructure(String),
342
343 #[error("{0}")]
348 EmptyInput(String),
349
350 #[error("{0}")]
356 MissingQuill(String),
357
358 #[error("Invalid $quill reference '{value}': {reason}")]
362 InvalidQuillReference {
363 value: String,
364 reason: String,
366 },
367
368 #[error("{0}")]
372 BodyImport(String),
373
374 #[error("YAML error at line {line}: {message}")]
375 YamlErrorWithLocation {
376 message: String,
377 line: usize,
379 block_index: usize,
381 hint: Option<String>,
385 },
386}
387
388impl ParseError {
389 pub fn args(&self) -> BTreeMap<String, serde_json::Value> {
397 match self {
398 ParseError::InputTooLarge { size, max } => diag_args! {
399 "size" => size,
400 "max" => max,
401 },
402 ParseError::InvalidStructure(_) => diag_args! {},
403 ParseError::EmptyInput(_) => diag_args! {},
404 ParseError::MissingQuill(_) => diag_args! {},
405 ParseError::BodyImport(_) => diag_args! {},
406 ParseError::InvalidQuillReference { value, reason: _ } => diag_args! {
409 "value" => value,
410 },
411 ParseError::YamlErrorWithLocation {
415 message: _,
416 line,
417 block_index,
418 hint: _,
419 } => diag_args! {
420 "line" => line,
421 "blockIndex" => block_index,
422 },
423 }
424 }
425
426 pub fn to_diagnostic(&self) -> Diagnostic {
427 let diag = match self {
428 ParseError::InputTooLarge { size, max } => Diagnostic::new(
429 Severity::Error,
430 format!("Input too large: {} bytes (max: {} bytes)", size, max),
431 )
432 .with_code("parse::input_too_large".to_string()),
433 ParseError::InvalidStructure(msg) => Diagnostic::new(Severity::Error, msg.clone())
434 .with_code("parse::invalid_structure".to_string()),
435 ParseError::EmptyInput(msg) => Diagnostic::new(Severity::Error, msg.clone())
436 .with_code("parse::empty_input".to_string()),
437 ParseError::MissingQuill(msg) => Diagnostic::new(Severity::Error, msg.clone())
438 .with_code("parse::missing_quill".to_string()),
439 ParseError::BodyImport(msg) => Diagnostic::new(Severity::Error, msg.clone())
440 .with_code("parse::body_import".to_string()),
441 ParseError::InvalidQuillReference { value, reason } => Diagnostic::new(
442 Severity::Error,
443 format!("Invalid $quill reference '{}': {}", value, reason),
444 )
445 .with_code("parse::invalid_quill_reference".to_string())
446 .with_hint(crate::version::quill_ref_hint().to_string()),
447 ParseError::YamlErrorWithLocation {
448 message,
449 line,
450 block_index,
451 hint,
452 } => {
453 let mut d = Diagnostic::new(
454 Severity::Error,
455 format!(
456 "YAML error at line {} (block {}): {}",
457 line, block_index, message
458 ),
459 )
460 .with_code("parse::yaml_error_with_location".to_string());
461 if let Some(h) = hint {
462 d = d.with_hint(h.clone());
463 }
464 d
465 }
466 };
467 diag.with_args(self.args())
468 }
469}
470
471#[derive(Debug)]
481pub struct RenderError {
482 diags: Vec<Diagnostic>,
484}
485
486impl RenderError {
487 pub fn new(diags: Vec<Diagnostic>) -> Self {
493 debug_assert!(
494 !diags.is_empty(),
495 "RenderError requires at least one diagnostic"
496 );
497 Self { diags }
498 }
499
500 pub fn from_diag(diag: Diagnostic) -> Self {
502 Self { diags: vec![diag] }
503 }
504
505 pub fn diagnostics(&self) -> &[Diagnostic] {
508 &self.diags
509 }
510
511 pub fn into_diagnostics(self) -> Vec<Diagnostic> {
513 self.diags
514 }
515
516 pub fn summary_message(diags: &[Diagnostic]) -> String {
523 match diags {
524 [d] => d.message.clone(),
525 [first, ..] => format!("{} error(s): {}", diags.len(), first.message),
526 [] => "render error".to_string(),
527 }
528 }
529}
530
531impl std::fmt::Display for RenderError {
535 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
536 write!(f, "{}", Self::summary_message(&self.diags))
537 }
538}
539
540impl std::error::Error for RenderError {}
541
542impl From<ParseError> for RenderError {
543 fn from(err: ParseError) -> Self {
544 RenderError::from_diag(err.to_diagnostic())
545 }
546}
547
548#[derive(Debug)]
549#[non_exhaustive]
550pub struct RenderResult {
551 pub artifacts: Vec<crate::Artifact>,
552 pub warnings: Vec<Diagnostic>,
553 pub output_format: OutputFormat,
554 pub regions: Vec<crate::RenderedRegion>,
560}
561
562impl RenderResult {
563 pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
564 Self {
565 artifacts,
566 warnings: Vec::new(),
567 output_format,
568 regions: Vec::new(),
569 }
570 }
571}
572
573pub fn print_errors(err: &RenderError) {
574 for d in err.diagnostics() {
575 eprintln!("{}", d.fmt_pretty());
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
584 fn test_diagnostic_with_source_chain() {
585 let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
586 let diag =
587 Diagnostic::new(Severity::Error, "Rendering failed".to_string()).with_source(&root_err);
588
589 assert_eq!(diag.source_chain.len(), 1);
590 assert!(diag.source_chain[0].contains("File not found"));
591 }
592
593 #[test]
594 fn test_diagnostic_serialization() {
595 let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
596 .with_code("E001".to_string())
597 .with_location(Location {
598 file: "test.typ".to_string(),
599 line: 10,
600 column: 5,
601 });
602
603 let json = serde_json::to_string(&diag).unwrap();
604 assert!(json.contains("Test error"));
605 assert!(json.contains("E001"));
606 assert!(json.contains("\"severity\":\"error\""));
607 assert!(json.contains("\"column\":5"));
608 }
609
610 #[test]
611 fn test_render_error_single_diagnostic_shape() {
612 let err = RenderError::from_diag(Diagnostic::new(
613 Severity::Error,
614 "no such backend".to_string(),
615 ));
616 assert_eq!(err.diagnostics().len(), 1);
617 assert_eq!(err.to_string(), "no such backend");
618
619 let owned = err.into_diagnostics();
620 assert_eq!(owned.len(), 1);
621 assert_eq!(owned[0].message, "no such backend");
622 }
623
624 #[test]
625 fn test_render_error_display_aggregates_multi_diagnostic() {
626 let err = RenderError::new(vec![
627 Diagnostic::new(Severity::Error, "a".to_string()),
628 Diagnostic::new(Severity::Error, "b".to_string()),
629 ]);
630 assert_eq!(err.to_string(), "2 error(s): a");
631 }
632
633 #[test]
634 fn test_diagnostic_fmt_pretty() {
635 let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
636 .with_code("W001".to_string())
637 .with_location(Location {
638 file: "input.md".to_string(),
639 line: 5,
640 column: 10,
641 })
642 .with_hint("Use the new field name instead".to_string());
643
644 let output = diag.fmt_pretty();
645 assert!(output.contains("[WARN]"));
646 assert!(output.contains("Deprecated field used"));
647 assert!(output.contains("W001"));
648 assert!(output.contains("input.md:5:10"));
649 assert!(output.contains("hint:"));
650 }
651
652 #[test]
653 fn test_diagnostic_with_path() {
654 let diag = Diagnostic::new(Severity::Error, "Type mismatch".to_string())
655 .with_code("validation::type_mismatch".to_string())
656 .with_path("cards.indorsement[0].signature_block".to_string());
657
658 assert_eq!(
659 diag.path.as_deref(),
660 Some("cards.indorsement[0].signature_block")
661 );
662
663 let json = serde_json::to_string(&diag).unwrap();
664 assert!(json.contains("\"path\":\"cards.indorsement[0].signature_block\""));
665
666 let pretty = diag.fmt_pretty();
667 assert!(pretty.contains("at cards.indorsement[0].signature_block"));
668 }
669
670}
671
672#[cfg(test)]
676mod args_canon {
677 use std::collections::BTreeMap;
678
679 use super::ParseError;
680 use crate::document::EditError;
681 use crate::quill::{CoercionError, ValidationError};
682
683 fn minted() -> BTreeMap<String, Vec<String>> {
686 let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
687 let mut add = |code: &str, args: BTreeMap<String, serde_json::Value>| {
688 let keys: Vec<String> = args.keys().cloned().collect();
689 assert!(
690 out.insert(code.to_string(), keys).is_none(),
691 "two samples for `{code}`: one code carries one payload"
692 );
693 };
694
695 for e in [
696 ValidationError::TypeMismatch {
697 path: "main.n".into(),
698 expected: "string".into(),
699 actual: "integer".into(),
700 source_token: "42".into(),
701 default: Some("\"x\"".into()),
702 },
703 ValidationError::EnumViolation {
704 path: "main.tone".into(),
705 value: "loud".into(),
706 allowed: vec!["quiet".into()],
707 },
708 ValidationError::FormatViolation {
709 path: "main.when".into(),
710 format: "date".into(),
711 },
712 ValidationError::UnknownCard {
713 path: "cards[0]".into(),
714 card: "ghost".into(),
715 },
716 ValidationError::BodyDisabled {
717 path: "cards.sig[0].body".into(),
718 card: "sig".into(),
719 },
720 ValidationError::NotInline {
721 path: "main.title".into(),
722 },
723 ValidationError::NotPlain {
724 path: "main.title".into(),
725 },
726 ] {
727 add(e.code(), e.args());
728 }
729
730 for e in [
731 EditError::InvalidFieldName("9bad".into()),
732 EditError::UnknownField("nope".into()),
733 EditError::InvalidKindName("Bad".into()),
734 EditError::ReservedKind,
735 EditError::IndexOutOfRange { index: 3, len: 1 },
736 EditError::ValueTooDeep { max: 8 },
737 EditError::Import(quillmark_content::import::ImportError::NestingTooDeep {
738 depth: 9,
739 max: 8,
740 }),
741 EditError::FieldDecode {
742 field: "body".into(),
743 codec: crate::document::edit::CODEC_RICHTEXT.into(),
744 message: "x".into(),
745 },
746 EditError::FieldNotContent {
747 field: "qty".into(),
748 declared: "integer".into(),
749 },
750 EditError::FieldNotInline {
751 field: "body".into(),
752 codec: crate::document::edit::CODEC_PLAINTEXT.into(),
753 },
754 EditError::FieldCoercionFailed {
755 field: "n".into(),
756 target: "integer".into(),
757 message: "x".into(),
758 },
759 EditError::ContentApply(quillmark_content::ApplyError::LineOutOfRange {
760 line: 3,
761 lines: 1,
762 }),
763 ] {
764 add(e.code(), e.args());
765 }
766
767 for e in [
771 EditError::InvalidFieldName("9bad".into()),
772 EditError::ValueTooDeep { max: 8 },
773 EditError::FieldNotInline {
774 field: "body".into(),
775 codec: crate::document::edit::CODEC_RICHTEXT.into(),
776 },
777 EditError::FieldDecode {
778 field: "body".into(),
779 codec: crate::document::edit::CODEC_PLAINTEXT.into(),
780 message: "x".into(),
781 },
782 EditError::FieldCoercionFailed {
783 field: "n".into(),
784 target: "integer".into(),
785 message: "x".into(),
786 },
787 ] {
788 let diag = crate::quill::conform::conform_diagnostic(&e, &crate::DocPath::main());
789 add(
790 diag.code.as_deref().expect("conform diagnostics carry a code"),
791 diag.args,
792 );
793 }
794
795 for e in [
796 ParseError::InputTooLarge { size: 2, max: 1 },
797 ParseError::InvalidStructure("x".into()),
798 ParseError::EmptyInput("x".into()),
799 ParseError::MissingQuill("x".into()),
800 ParseError::BodyImport("x".into()),
801 ParseError::InvalidQuillReference {
802 value: "a@b".into(),
803 reason: "x".into(),
804 },
805 ParseError::YamlErrorWithLocation {
806 message: "x".into(),
807 line: 3,
808 block_index: 1,
809 hint: None,
810 },
811 ] {
812 let diag = e.to_diagnostic();
813 add(diag.code.as_deref().expect("parse errors carry a code"), diag.args);
814 }
815
816 add(
820 "validation::coercion_failed",
821 CoercionError::Uncoercible {
822 path: "card_kinds.sig.n".into(),
823 value: "\"x\"".into(),
824 target: "integer".into(),
825 reason: "string is not a valid integer".into(),
826 }
827 .args(),
828 );
829 add("validation::must_fill", BTreeMap::new());
830
831 out
832 }
833
834 fn declared() -> BTreeMap<String, Vec<String>> {
838 let canon = include_str!("../../../prose/canon/ERROR.md");
839 let mut rows = canon
840 .lines()
841 .skip_while(|l| !l.starts_with("| Code | Args | Outcome |"))
842 .skip(2)
843 .take_while(|l| l.starts_with('|'));
844
845 let mut out = BTreeMap::new();
846 for row in &mut rows {
847 let cells: Vec<&str> = row.trim_matches('|').split('|').map(str::trim).collect();
848 assert_eq!(cells.len(), 3, "malformed canon row: {row}");
849 let code = cells[0].trim_matches('`').to_string();
850 let keys = if cells[1] == "—" {
851 Vec::new()
852 } else {
853 let mut keys: Vec<String> = cells[1]
854 .split(',')
855 .map(|k| k.trim().trim_end_matches('?').trim_matches('`').to_string())
856 .collect();
857 keys.sort();
858 keys
859 };
860 assert!(out.insert(code, keys).is_none(), "duplicate canon row: {row}");
861 }
862 assert!(!out.is_empty(), "canon args table not found in ERROR.md");
863 out
864 }
865
866 #[test]
870 fn out_of_scope_codes_carry_no_args() {
871 let diags = crate::quill::QuillConfig::from_yaml_with_warnings(
872 r#"
873Quill:
874 name: t
875 version: "1.0"
876 backend: typst
877 description: A slot whose literal contradicts its declared type
878
879main:
880 fields:
881 title:
882 type: string
883 default: 42
884"#,
885 )
886 .expect_err("a default that contradicts its type fails config validation");
887
888 assert!(
889 diags.iter().any(|d| d
890 .code
891 .as_deref()
892 .is_some_and(|c| c.starts_with("quill::"))),
893 "expected a quill:: diagnostic, got {:?}",
894 diags.iter().map(|d| &d.code).collect::<Vec<_>>()
895 );
896 for d in &diags {
897 assert!(
898 d.args.is_empty(),
899 "`{:?}` is off the canon table and must carry no args",
900 d.code
901 );
902 }
903 }
904
905 #[test]
906 fn diagnostic_args_match_canon() {
907 assert_eq!(
908 declared(),
909 minted(),
910 "`ERROR.md` § \"Diagnostic args\" and the minted args disagree"
911 );
912 }
913}