use std::collections::BTreeMap;
use crate::OutputFormat;
macro_rules! diag_args {
($($key:literal => $value:expr),* $(,)?) => {{
#[allow(unused_mut)]
let mut map = ::std::collections::BTreeMap::<String, ::serde_json::Value>::new();
$(map.insert($key.to_string(), ::serde_json::json!($value));)*
map
}};
}
pub(crate) use diag_args;
pub const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024;
pub const MAX_YAML_SIZE: usize = 1024 * 1024;
pub use quillmark_content::MAX_NESTING_DEPTH;
pub use crate::document::limits::MAX_YAML_DEPTH;
pub const MAX_CARD_COUNT: usize = 1000;
pub const MAX_FIELD_COUNT: usize = 1000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct YamlError {
message: String,
hint: Option<String>,
line: Option<u32>,
column: Option<u32>,
}
impl YamlError {
pub fn message(&self) -> &str {
&self.message
}
pub fn hint(&self) -> Option<&str> {
self.hint.as_deref()
}
pub fn line(&self) -> Option<u32> {
self.line
}
pub fn column(&self) -> Option<u32> {
self.column
}
pub fn to_diagnostic(&self, code: &str, file: &str) -> Diagnostic {
let mut diag = Diagnostic::new(Severity::Error, self.message.clone())
.with_code(code.to_string());
if let (Some(line), Some(column)) = (self.line, self.column) {
diag = diag.with_location(Location::new(file.to_string(), line, column));
}
match &self.hint {
Some(h) => diag.with_hint(h.clone()),
None => diag,
}
}
pub(crate) fn from_de(err: serde_saphyr::Error, yaml: &str) -> Self {
let enriched = crate::document::yaml_hints::enrich_yaml_error(&err.to_string(), yaml);
let loc = err.location();
Self {
message: enriched.message,
hint: enriched.hint,
line: loc.and_then(|l| u32::try_from(l.line()).ok()),
column: loc.and_then(|l| u32::try_from(l.column()).ok()),
}
}
pub(crate) fn from_ser(err: serde_saphyr::ser::Error) -> Self {
Self {
message: err.to_string(),
hint: None,
line: None,
column: None,
}
}
}
impl std::fmt::Display for YamlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for YamlError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Severity {
Error,
Warning,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Location {
pub file: String,
pub line: u32,
pub column: u32,
}
impl Location {
pub fn new(file: String, line: u32, column: u32) -> Self {
Self { file, line, column }
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Diagnostic {
pub severity: Severity,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub code: Option<String>,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub location: Option<Location>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub hint: Option<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
pub args: BTreeMap<String, serde_json::Value>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub source_chain: Vec<String>,
}
impl Diagnostic {
pub fn new(severity: Severity, message: String) -> Self {
Self {
severity,
code: None,
message,
location: None,
path: None,
hint: None,
args: BTreeMap::new(),
source_chain: Vec::new(),
}
}
pub fn with_code(mut self, code: String) -> Self {
self.code = Some(code);
self
}
pub fn with_location(mut self, location: Location) -> Self {
self.location = Some(location);
self
}
pub fn with_path(mut self, path: String) -> Self {
self.path = Some(path);
self
}
pub fn with_hint(mut self, hint: String) -> Self {
self.hint = Some(hint);
self
}
pub fn with_args(mut self, args: BTreeMap<String, serde_json::Value>) -> Self {
self.args = args;
self
}
pub fn with_source(mut self, source: &(dyn std::error::Error + 'static)) -> Self {
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(source);
while let Some(err) = current {
self.source_chain.push(err.to_string());
current = err.source();
}
self
}
pub fn fmt_pretty(&self) -> String {
let mut result = format!(
"[{}] {}",
match self.severity {
Severity::Error => "ERROR",
Severity::Warning => "WARN",
},
self.message
);
if let Some(ref code) = self.code {
result.push_str(&format!(" ({})", code));
}
if let Some(ref loc) = self.location {
result.push_str(&format!("\n --> {}:{}:{}", loc.file, loc.line, loc.column));
}
if let Some(ref path) = self.path {
result.push_str(&format!("\n at {}", path));
}
if let Some(ref hint) = self.hint {
result.push_str(&format!("\n hint: {}", hint));
}
result
}
}
impl std::fmt::Display for Diagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum ParseError {
#[error("Input too large: {size} bytes (max: {max} bytes)")]
InputTooLarge { size: usize, max: usize },
#[error("Invalid YAML structure: {0}")]
InvalidStructure(String),
#[error("{0}")]
EmptyInput(String),
#[error("{0}")]
MissingQuill(String),
#[error("Invalid $quill reference '{value}': {reason}")]
InvalidQuillReference {
value: String,
reason: String,
},
#[error("{0}")]
BodyImport(String),
#[error("YAML error at line {line}: {message}")]
YamlErrorWithLocation {
message: String,
line: usize,
block_index: usize,
hint: Option<String>,
},
}
impl ParseError {
pub fn args(&self) -> BTreeMap<String, serde_json::Value> {
match self {
ParseError::InputTooLarge { size, max } => diag_args! {
"size" => size,
"max" => max,
},
ParseError::InvalidStructure(_) => diag_args! {},
ParseError::EmptyInput(_) => diag_args! {},
ParseError::MissingQuill(_) => diag_args! {},
ParseError::BodyImport(_) => diag_args! {},
ParseError::InvalidQuillReference { value, reason: _ } => diag_args! {
"value" => value,
},
ParseError::YamlErrorWithLocation {
message: _,
line,
block_index,
hint: _,
} => diag_args! {
"line" => line,
"blockIndex" => block_index,
},
}
}
pub fn to_diagnostic(&self) -> Diagnostic {
let diag = match self {
ParseError::InputTooLarge { size, max } => Diagnostic::new(
Severity::Error,
format!("Input too large: {} bytes (max: {} bytes)", size, max),
)
.with_code("parse::input_too_large".to_string()),
ParseError::InvalidStructure(msg) => Diagnostic::new(Severity::Error, msg.clone())
.with_code("parse::invalid_structure".to_string()),
ParseError::EmptyInput(msg) => Diagnostic::new(Severity::Error, msg.clone())
.with_code("parse::empty_input".to_string()),
ParseError::MissingQuill(msg) => Diagnostic::new(Severity::Error, msg.clone())
.with_code("parse::missing_quill".to_string()),
ParseError::BodyImport(msg) => Diagnostic::new(Severity::Error, msg.clone())
.with_code("parse::body_import".to_string()),
ParseError::InvalidQuillReference { value, reason } => Diagnostic::new(
Severity::Error,
format!("Invalid $quill reference '{}': {}", value, reason),
)
.with_code("parse::invalid_quill_reference".to_string())
.with_hint(crate::version::quill_ref_hint().to_string()),
ParseError::YamlErrorWithLocation {
message,
line,
block_index,
hint,
} => {
let mut d = Diagnostic::new(
Severity::Error,
format!(
"YAML error at line {} (block {}): {}",
line, block_index, message
),
)
.with_code("parse::yaml_error_with_location".to_string());
if let Some(h) = hint {
d = d.with_hint(h.clone());
}
d
}
};
diag.with_args(self.args())
}
}
#[derive(Debug)]
pub struct RenderError {
diags: Vec<Diagnostic>,
}
impl RenderError {
pub fn new(diags: Vec<Diagnostic>) -> Self {
debug_assert!(
!diags.is_empty(),
"RenderError requires at least one diagnostic"
);
Self { diags }
}
pub fn from_diag(diag: Diagnostic) -> Self {
Self { diags: vec![diag] }
}
pub fn diagnostics(&self) -> &[Diagnostic] {
&self.diags
}
pub fn into_diagnostics(self) -> Vec<Diagnostic> {
self.diags
}
pub fn summary_message(diags: &[Diagnostic]) -> String {
match diags {
[d] => d.message.clone(),
[first, ..] => format!("{} error(s): {}", diags.len(), first.message),
[] => "render error".to_string(),
}
}
}
impl std::fmt::Display for RenderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", Self::summary_message(&self.diags))
}
}
impl std::error::Error for RenderError {}
impl From<ParseError> for RenderError {
fn from(err: ParseError) -> Self {
RenderError::from_diag(err.to_diagnostic())
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct RenderResult {
pub artifacts: Vec<crate::Artifact>,
pub warnings: Vec<Diagnostic>,
pub output_format: OutputFormat,
pub regions: Vec<crate::RenderedRegion>,
}
impl RenderResult {
pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
Self {
artifacts,
warnings: Vec::new(),
output_format,
regions: Vec::new(),
}
}
}
pub fn print_errors(err: &RenderError) {
for d in err.diagnostics() {
eprintln!("{}", d.fmt_pretty());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_diagnostic_with_source_chain() {
let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
let diag =
Diagnostic::new(Severity::Error, "Rendering failed".to_string()).with_source(&root_err);
assert_eq!(diag.source_chain.len(), 1);
assert!(diag.source_chain[0].contains("File not found"));
}
#[test]
fn test_diagnostic_serialization() {
let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
.with_code("E001".to_string())
.with_location(Location {
file: "test.typ".to_string(),
line: 10,
column: 5,
});
let json = serde_json::to_string(&diag).unwrap();
assert!(json.contains("Test error"));
assert!(json.contains("E001"));
assert!(json.contains("\"severity\":\"error\""));
assert!(json.contains("\"column\":5"));
}
#[test]
fn test_render_error_single_diagnostic_shape() {
let err = RenderError::from_diag(Diagnostic::new(
Severity::Error,
"no such backend".to_string(),
));
assert_eq!(err.diagnostics().len(), 1);
assert_eq!(err.to_string(), "no such backend");
let owned = err.into_diagnostics();
assert_eq!(owned.len(), 1);
assert_eq!(owned[0].message, "no such backend");
}
#[test]
fn test_render_error_display_aggregates_multi_diagnostic() {
let err = RenderError::new(vec![
Diagnostic::new(Severity::Error, "a".to_string()),
Diagnostic::new(Severity::Error, "b".to_string()),
]);
assert_eq!(err.to_string(), "2 error(s): a");
}
#[test]
fn test_diagnostic_fmt_pretty() {
let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
.with_code("W001".to_string())
.with_location(Location {
file: "input.md".to_string(),
line: 5,
column: 10,
})
.with_hint("Use the new field name instead".to_string());
let output = diag.fmt_pretty();
assert!(output.contains("[WARN]"));
assert!(output.contains("Deprecated field used"));
assert!(output.contains("W001"));
assert!(output.contains("input.md:5:10"));
assert!(output.contains("hint:"));
}
#[test]
fn test_diagnostic_with_path() {
let diag = Diagnostic::new(Severity::Error, "Type mismatch".to_string())
.with_code("validation::type_mismatch".to_string())
.with_path("cards.indorsement[0].signature_block".to_string());
assert_eq!(
diag.path.as_deref(),
Some("cards.indorsement[0].signature_block")
);
let json = serde_json::to_string(&diag).unwrap();
assert!(json.contains("\"path\":\"cards.indorsement[0].signature_block\""));
let pretty = diag.fmt_pretty();
assert!(pretty.contains("at cards.indorsement[0].signature_block"));
}
}
#[cfg(test)]
mod args_canon {
use std::collections::BTreeMap;
use super::ParseError;
use crate::document::EditError;
use crate::quill::{CoercionError, ValidationError};
fn minted() -> BTreeMap<String, Vec<String>> {
let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut add = |code: &str, args: BTreeMap<String, serde_json::Value>| {
let keys: Vec<String> = args.keys().cloned().collect();
assert!(
out.insert(code.to_string(), keys).is_none(),
"two samples for `{code}`: one code carries one payload"
);
};
for e in [
ValidationError::TypeMismatch {
path: "main.n".into(),
expected: "string".into(),
actual: "integer".into(),
source_token: "42".into(),
default: Some("\"x\"".into()),
},
ValidationError::EnumViolation {
path: "main.tone".into(),
value: "loud".into(),
allowed: vec!["quiet".into()],
},
ValidationError::FormatViolation {
path: "main.when".into(),
format: "date".into(),
},
ValidationError::UnknownCard {
path: "cards[0]".into(),
card: "ghost".into(),
},
ValidationError::BodyDisabled {
path: "cards.sig[0].body".into(),
card: "sig".into(),
},
ValidationError::NotInline {
path: "main.title".into(),
},
ValidationError::NotPlain {
path: "main.title".into(),
},
] {
add(e.code(), e.args());
}
for e in [
EditError::InvalidFieldName("9bad".into()),
EditError::UnknownField("nope".into()),
EditError::InvalidKindName("Bad".into()),
EditError::ReservedKind,
EditError::IndexOutOfRange { index: 3, len: 1 },
EditError::ValueTooDeep { max: 8 },
EditError::Import(quillmark_content::import::ImportError::NestingTooDeep {
depth: 9,
max: 8,
}),
EditError::FieldRichtextDecode {
field: "body".into(),
message: "x".into(),
},
EditError::FieldNotContent {
field: "qty".into(),
declared: "integer".into(),
},
EditError::FieldRichtextNotInline("body".into()),
EditError::FieldConform {
field: "n".into(),
target: "integer".into(),
message: "x".into(),
},
EditError::ContentApply(quillmark_content::ApplyError::LineOutOfRange {
line: 3,
lines: 1,
}),
] {
add(e.code(), e.args());
}
for e in [
EditError::InvalidFieldName("9bad".into()),
EditError::ValueTooDeep { max: 8 },
EditError::FieldRichtextNotInline("body".into()),
EditError::FieldRichtextDecode {
field: "body".into(),
message: "x".into(),
},
EditError::FieldConform {
field: "n".into(),
target: "integer".into(),
message: "x".into(),
},
] {
let diag = crate::quill::conform::conform_diagnostic(&e, &crate::DocPath::main());
add(
diag.code.as_deref().expect("conform diagnostics carry a code"),
diag.args,
);
}
for e in [
ParseError::InputTooLarge { size: 2, max: 1 },
ParseError::InvalidStructure("x".into()),
ParseError::EmptyInput("x".into()),
ParseError::MissingQuill("x".into()),
ParseError::BodyImport("x".into()),
ParseError::InvalidQuillReference {
value: "a@b".into(),
reason: "x".into(),
},
ParseError::YamlErrorWithLocation {
message: "x".into(),
line: 3,
block_index: 1,
hint: None,
},
] {
let diag = e.to_diagnostic();
add(diag.code.as_deref().expect("parse errors carry a code"), diag.args);
}
add(
"validation::coercion_failed",
CoercionError::Uncoercible {
path: "card_kinds.sig.n".into(),
value: "\"x\"".into(),
target: "integer".into(),
reason: "string is not a valid integer".into(),
}
.args(),
);
add("validation::must_fill", BTreeMap::new());
out
}
fn declared() -> BTreeMap<String, Vec<String>> {
let canon = include_str!("../../../prose/canon/ERROR.md");
let mut rows = canon
.lines()
.skip_while(|l| !l.starts_with("| Code | Args | Outcome |"))
.skip(2)
.take_while(|l| l.starts_with('|'));
let mut out = BTreeMap::new();
for row in &mut rows {
let cells: Vec<&str> = row.trim_matches('|').split('|').map(str::trim).collect();
assert_eq!(cells.len(), 3, "malformed canon row: {row}");
let code = cells[0].trim_matches('`').to_string();
let keys = if cells[1] == "—" {
Vec::new()
} else {
let mut keys: Vec<String> = cells[1]
.split(',')
.map(|k| k.trim().trim_end_matches('?').trim_matches('`').to_string())
.collect();
keys.sort();
keys
};
assert!(out.insert(code, keys).is_none(), "duplicate canon row: {row}");
}
assert!(!out.is_empty(), "canon args table not found in ERROR.md");
out
}
#[test]
fn out_of_scope_codes_carry_no_args() {
let diags = crate::quill::QuillConfig::from_yaml_with_warnings(
r#"
Quill:
name: t
version: "1.0"
backend: typst
description: A slot whose literal contradicts its declared type
main:
fields:
title:
type: string
default: 42
"#,
)
.expect_err("a default that contradicts its type fails config validation");
assert!(
diags.iter().any(|d| d
.code
.as_deref()
.is_some_and(|c| c.starts_with("quill::"))),
"expected a quill:: diagnostic, got {:?}",
diags.iter().map(|d| &d.code).collect::<Vec<_>>()
);
for d in &diags {
assert!(
d.args.is_empty(),
"`{:?}` is off the canon table and must carry no args",
d.code
);
}
}
#[test]
fn diagnostic_args_match_canon() {
assert_eq!(
declared(),
minted(),
"`ERROR.md` § \"Diagnostic args\" and the minted args disagree"
);
}
}