1use crate::OutputFormat;
40
41pub const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024;
43
44pub const MAX_YAML_SIZE: usize = 1024 * 1024;
46
47pub use quillmark_content::MAX_NESTING_DEPTH;
52
53pub use crate::document::limits::MAX_YAML_DEPTH;
55
56pub const MAX_CARD_COUNT: usize = 1000;
58
59pub const MAX_FIELD_COUNT: usize = 1000;
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
67#[serde(rename_all = "lowercase")]
68pub enum Severity {
69 Error,
71 Warning,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct Location {
78 pub file: String,
80 pub line: u32,
82 pub column: u32,
84}
85
86#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct Diagnostic {
95 pub severity: Severity,
96 #[serde(skip_serializing_if = "Option::is_none", default)]
98 pub code: Option<String>,
99 pub message: String,
100 #[serde(skip_serializing_if = "Option::is_none", default)]
105 pub location: Option<Location>,
106 #[serde(skip_serializing_if = "Option::is_none", default)]
112 pub path: Option<String>,
113 #[serde(skip_serializing_if = "Option::is_none", default)]
114 pub hint: Option<String>,
115 #[serde(skip_serializing_if = "Vec::is_empty", default)]
117 pub source_chain: Vec<String>,
118}
119
120impl Diagnostic {
121 pub fn new(severity: Severity, message: String) -> Self {
122 Self {
123 severity,
124 code: None,
125 message,
126 location: None,
127 path: None,
128 hint: None,
129 source_chain: Vec::new(),
130 }
131 }
132
133 pub fn with_code(mut self, code: String) -> Self {
134 self.code = Some(code);
135 self
136 }
137
138 pub fn with_location(mut self, location: Location) -> Self {
139 self.location = Some(location);
140 self
141 }
142
143 pub fn with_path(mut self, path: String) -> Self {
147 self.path = Some(path);
148 self
149 }
150
151 pub fn with_hint(mut self, hint: String) -> Self {
152 self.hint = Some(hint);
153 self
154 }
155
156 pub fn with_source(mut self, source: &(dyn std::error::Error + 'static)) -> Self {
158 let mut current: Option<&(dyn std::error::Error + 'static)> = Some(source);
159 while let Some(err) = current {
160 self.source_chain.push(err.to_string());
161 current = err.source();
162 }
163 self
164 }
165
166 pub fn fmt_pretty(&self) -> String {
167 let mut result = format!(
168 "[{}] {}",
169 match self.severity {
170 Severity::Error => "ERROR",
171 Severity::Warning => "WARN",
172 },
173 self.message
174 );
175
176 if let Some(ref code) = self.code {
177 result.push_str(&format!(" ({})", code));
178 }
179
180 if let Some(ref loc) = self.location {
181 result.push_str(&format!("\n --> {}:{}:{}", loc.file, loc.line, loc.column));
182 }
183
184 if let Some(ref path) = self.path {
185 result.push_str(&format!("\n at {}", path));
186 }
187
188 if let Some(ref hint) = self.hint {
189 result.push_str(&format!("\n hint: {}", hint));
190 }
191
192 result
193 }
194
195}
196
197impl std::fmt::Display for Diagnostic {
198 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199 write!(f, "{}", self.message)
200 }
201}
202
203#[derive(thiserror::Error, Debug)]
204pub enum ParseError {
205 #[error("Input too large: {size} bytes (max: {max} bytes)")]
206 InputTooLarge { size: usize, max: usize },
207
208 #[error("Invalid YAML structure: {0}")]
209 InvalidStructure(String),
210
211 #[error("{0}")]
216 EmptyInput(String),
217
218 #[error("{0}")]
224 MissingQuill(String),
225
226 #[error("Invalid $quill reference '{value}': {reason}")]
230 InvalidQuillReference {
231 value: String,
232 reason: String,
234 },
235
236 #[error("{0}")]
240 BodyImport(String),
241
242 #[error("YAML error at line {line}: {message}")]
243 YamlErrorWithLocation {
244 message: String,
245 line: usize,
247 block_index: usize,
249 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::BodyImport(msg) => Diagnostic::new(Severity::Error, msg.clone())
271 .with_code("parse::body_import".to_string()),
272 ParseError::InvalidQuillReference { value, reason } => Diagnostic::new(
273 Severity::Error,
274 format!("Invalid $quill reference '{}': {}", value, reason),
275 )
276 .with_code("parse::invalid_quill_reference".to_string())
277 .with_hint(crate::version::quill_ref_hint().to_string()),
278 ParseError::YamlErrorWithLocation {
279 message,
280 line,
281 block_index,
282 hint,
283 } => {
284 let mut d = Diagnostic::new(
285 Severity::Error,
286 format!(
287 "YAML error at line {} (block {}): {}",
288 line, block_index, message
289 ),
290 )
291 .with_code("parse::yaml_error_with_location".to_string());
292 if let Some(h) = hint {
293 d = d.with_hint(h.clone());
294 }
295 d
296 }
297 }
298 }
299}
300
301#[derive(Debug)]
311pub struct RenderError {
312 diags: Vec<Diagnostic>,
314}
315
316impl RenderError {
317 pub fn new(diags: Vec<Diagnostic>) -> Self {
323 debug_assert!(
324 !diags.is_empty(),
325 "RenderError requires at least one diagnostic"
326 );
327 Self { diags }
328 }
329
330 pub fn from_diag(diag: Diagnostic) -> Self {
332 Self { diags: vec![diag] }
333 }
334
335 pub fn diagnostics(&self) -> &[Diagnostic] {
338 &self.diags
339 }
340
341 pub fn into_diagnostics(self) -> Vec<Diagnostic> {
343 self.diags
344 }
345
346 pub fn summary_message(diags: &[Diagnostic]) -> String {
353 match diags {
354 [d] => d.message.clone(),
355 [first, ..] => format!("{} error(s): {}", diags.len(), first.message),
356 [] => "render error".to_string(),
357 }
358 }
359}
360
361impl std::fmt::Display for RenderError {
365 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
366 write!(f, "{}", Self::summary_message(&self.diags))
367 }
368}
369
370impl std::error::Error for RenderError {}
371
372impl From<ParseError> for RenderError {
373 fn from(err: ParseError) -> Self {
374 RenderError::from_diag(err.to_diagnostic())
375 }
376}
377
378#[derive(Debug)]
379pub struct RenderResult {
380 pub artifacts: Vec<crate::Artifact>,
381 pub warnings: Vec<Diagnostic>,
382 pub output_format: OutputFormat,
383 pub regions: Vec<crate::RenderedRegion>,
389}
390
391impl RenderResult {
392 pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
393 Self {
394 artifacts,
395 warnings: Vec::new(),
396 output_format,
397 regions: Vec::new(),
398 }
399 }
400}
401
402pub fn print_errors(err: &RenderError) {
403 for d in err.diagnostics() {
404 eprintln!("{}", d.fmt_pretty());
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn test_diagnostic_with_source_chain() {
414 let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
415 let diag =
416 Diagnostic::new(Severity::Error, "Rendering failed".to_string()).with_source(&root_err);
417
418 assert_eq!(diag.source_chain.len(), 1);
419 assert!(diag.source_chain[0].contains("File not found"));
420 }
421
422 #[test]
423 fn test_diagnostic_serialization() {
424 let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
425 .with_code("E001".to_string())
426 .with_location(Location {
427 file: "test.typ".to_string(),
428 line: 10,
429 column: 5,
430 });
431
432 let json = serde_json::to_string(&diag).unwrap();
433 assert!(json.contains("Test error"));
434 assert!(json.contains("E001"));
435 assert!(json.contains("\"severity\":\"error\""));
436 assert!(json.contains("\"column\":5"));
437 }
438
439 #[test]
440 fn test_render_error_single_diagnostic_shape() {
441 let err = RenderError::from_diag(Diagnostic::new(
442 Severity::Error,
443 "no such backend".to_string(),
444 ));
445 assert_eq!(err.diagnostics().len(), 1);
446 assert_eq!(err.to_string(), "no such backend");
447
448 let owned = err.into_diagnostics();
449 assert_eq!(owned.len(), 1);
450 assert_eq!(owned[0].message, "no such backend");
451 }
452
453 #[test]
454 fn test_render_error_display_aggregates_multi_diagnostic() {
455 let err = RenderError::new(vec![
456 Diagnostic::new(Severity::Error, "a".to_string()),
457 Diagnostic::new(Severity::Error, "b".to_string()),
458 ]);
459 assert_eq!(err.to_string(), "2 error(s): a");
460 }
461
462 #[test]
463 fn test_diagnostic_fmt_pretty() {
464 let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
465 .with_code("W001".to_string())
466 .with_location(Location {
467 file: "input.md".to_string(),
468 line: 5,
469 column: 10,
470 })
471 .with_hint("Use the new field name instead".to_string());
472
473 let output = diag.fmt_pretty();
474 assert!(output.contains("[WARN]"));
475 assert!(output.contains("Deprecated field used"));
476 assert!(output.contains("W001"));
477 assert!(output.contains("input.md:5:10"));
478 assert!(output.contains("hint:"));
479 }
480
481 #[test]
482 fn test_diagnostic_with_path() {
483 let diag = Diagnostic::new(Severity::Error, "Type mismatch".to_string())
484 .with_code("validation::type_mismatch".to_string())
485 .with_path("cards.indorsement[0].signature_block".to_string());
486
487 assert_eq!(
488 diag.path.as_deref(),
489 Some("cards.indorsement[0].signature_block")
490 );
491
492 let json = serde_json::to_string(&diag).unwrap();
493 assert!(json.contains("\"path\":\"cards.indorsement[0].signature_block\""));
494
495 let pretty = diag.fmt_pretty();
496 assert!(pretty.contains("at cards.indorsement[0].signature_block"));
497 }
498
499}