quarto_error_reporting/diagnostic.rs
1//! Core diagnostic message types.
2//!
3//! This module defines the fundamental structures for representing diagnostic messages
4//! (errors, warnings, info) following tidyverse-style guidelines.
5
6use serde::{Deserialize, Serialize};
7
8/// The kind of diagnostic message.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum DiagnosticKind {
11 /// An error that prevents completion
12 Error,
13 /// A warning that doesn't prevent completion but indicates a problem
14 Warning,
15 /// Informational message
16 Info,
17 /// A note providing additional context
18 Note,
19}
20
21/// How detail items should be presented (tidyverse x/i bullet style).
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23pub enum DetailKind {
24 /// Error detail (✖ bullet in tidyverse style)
25 Error,
26 /// Info detail (i bullet in tidyverse style)
27 Info,
28 /// Note detail (plain bullet)
29 Note,
30 /// Faded detail — rendered in Ariadne with the same dim grey colour
31 /// Ariadne uses for source characters outside any label. Use it to
32 /// attach a high-priority label to a column range you want to
33 /// *exclude* from a wider label's highlighting (e.g. a block-quote
34 /// prefix inside a multi-line span). Treated the same as `Note` in
35 /// tidyverse-style text output.
36 Faded,
37}
38
39/// Options for rendering diagnostic messages to text.
40///
41/// This struct controls various aspects of text rendering, such as whether
42/// to include terminal hyperlinks for clickable file paths.
43#[derive(Debug, Clone)]
44pub struct TextRenderOptions {
45 /// Enable OSC 8 hyperlinks for clickable file paths in terminals.
46 ///
47 /// When enabled, file paths in error messages will include terminal
48 /// escape codes for clickable links (supported by iTerm2, VS Code, etc.).
49 /// Disable for snapshot testing to avoid absolute path differences.
50 pub enable_hyperlinks: bool,
51}
52
53impl Default for TextRenderOptions {
54 fn default() -> Self {
55 Self {
56 enable_hyperlinks: true,
57 }
58 }
59}
60
61/// The content of a message or detail item.
62///
63/// This will eventually support Pandoc AST for rich formatting, but starts
64/// with simpler string-based content.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub enum MessageContent {
67 /// Plain text content
68 Plain(String),
69 /// Markdown content (will be parsed to Pandoc AST in later phases)
70 Markdown(String),
71 // Future: PandocAst(Box<Inlines>)
72}
73
74impl MessageContent {
75 /// Get the raw string content for display
76 pub fn as_str(&self) -> &str {
77 match self {
78 MessageContent::Plain(s) => s,
79 MessageContent::Markdown(s) => s,
80 }
81 }
82
83 /// Convert to JSON value with type information
84 pub fn to_json(&self) -> serde_json::Value {
85 use serde_json::json;
86 match self {
87 MessageContent::Plain(s) => json!({
88 "type": "plain",
89 "content": s
90 }),
91 MessageContent::Markdown(s) => json!({
92 "type": "markdown",
93 "content": s
94 }),
95 }
96 }
97}
98
99impl From<String> for MessageContent {
100 fn from(s: String) -> Self {
101 MessageContent::Markdown(s)
102 }
103}
104
105impl From<&str> for MessageContent {
106 fn from(s: &str) -> Self {
107 MessageContent::Markdown(s.to_string())
108 }
109}
110
111/// A detail item in a diagnostic message.
112///
113/// Following tidyverse guidelines, details provide specific information about
114/// the error (what went wrong, where, with what values).
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct DetailItem {
117 /// The kind of detail (error, info, note)
118 pub kind: DetailKind,
119 /// The content of the detail
120 pub content: MessageContent,
121 /// Optional source location for this detail
122 ///
123 /// When present, this identifies where in the source code this detail applies.
124 /// This allows error messages to highlight multiple related locations.
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub location: Option<quarto_source_map::SourceInfo>,
127}
128
129/// A diagnostic message following tidyverse-style structure.
130///
131/// Structure:
132/// 1. **Code**: Optional error code (e.g., "Q-1-1") for searchability
133/// 2. **Title**: Brief error message
134/// 3. **Kind**: Error, Warning, Info
135/// 4. **Problem**: What went wrong (the "must" or "can't" statement)
136/// 5. **Details**: Specific information (bulleted, max 5 per tidyverse)
137/// 6. **Hints**: Optional guidance for fixing (ends with ?)
138///
139/// # Example
140///
141/// ```ignore
142/// let msg = DiagnosticMessage {
143/// code: Some("Q-1-2".to_string()), // quarto-error-code-audit-ignore
144/// title: "Incompatible types".to_string(),
145/// kind: DiagnosticKind::Error,
146/// problem: Some("Cannot combine date and datetime types".into()),
147/// details: vec![
148/// DetailItem {
149/// kind: DetailKind::Error,
150/// content: "`x`{.arg} has type `date`{.type}".into(),
151/// },
152/// DetailItem {
153/// kind: DetailKind::Error,
154/// content: "`y`{.arg} has type `datetime`{.type}".into(),
155/// },
156/// ],
157/// hints: vec!["Convert both to the same type?".into()],
158/// source_spans: vec![],
159/// };
160/// ```
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162pub struct DiagnosticMessage {
163 /// Optional error code (e.g., "Q-1-1")
164 ///
165 /// Error codes are optional but encouraged. They provide:
166 /// - Searchability (users can Google "Q-1-1")
167 /// - Stability (codes don't change even if message wording improves)
168 /// - Documentation (each code maps to a detailed explanation)
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub code: Option<String>,
171
172 /// Brief title for the error
173 pub title: String,
174
175 /// The kind of diagnostic (Error, Warning, Info)
176 pub kind: DiagnosticKind,
177
178 /// The problem statement (the "what" - using "must" or "can't")
179 pub problem: Option<MessageContent>,
180
181 /// Specific error details (the "where/why" - max 5 per tidyverse)
182 pub details: Vec<DetailItem>,
183
184 /// Optional hints for fixing (ends with ?)
185 pub hints: Vec<MessageContent>,
186
187 /// Source location for this diagnostic
188 ///
189 /// When present, this identifies where in the source code the issue occurred.
190 /// The location may track transformation history, allowing the error to be
191 /// mapped back through multiple processing steps to the original source file.
192 #[serde(skip_serializing_if = "Option::is_none")]
193 pub location: Option<quarto_source_map::SourceInfo>,
194}
195
196impl DiagnosticMessage {
197 /// Access the diagnostic message builder API.
198 ///
199 /// This is the recommended way to create diagnostic messages, as the builder API
200 /// encodes tidyverse-style guidelines and makes it easy to construct well-structured
201 /// error messages.
202 ///
203 /// # Example
204 ///
205 /// ```
206 /// use quarto_error_reporting::{DiagnosticMessage, DiagnosticMessageBuilder};
207 ///
208 /// let error = DiagnosticMessageBuilder::error("Incompatible types")
209 /// .with_code("Q-1-2") // quarto-error-code-audit-ignore
210 /// .problem("Cannot combine date and datetime types")
211 /// .add_detail("`x` has type `date`")
212 /// .add_detail("`y` has type `datetime`")
213 /// .add_hint("Convert both to the same type?")
214 /// .build();
215 /// ```
216 pub fn builder() -> crate::builder::DiagnosticMessageBuilder {
217 // This is just a convenience for accessing the builder type
218 // Users should call DiagnosticMessageBuilder::error() etc directly
219 crate::builder::DiagnosticMessageBuilder::error("")
220 }
221
222 /// Create a new diagnostic message with just a title and kind.
223 ///
224 /// Note: Consider using `DiagnosticMessage::builder()` instead for better structure.
225 pub fn new(kind: DiagnosticKind, title: impl Into<String>) -> Self {
226 Self {
227 code: None,
228 title: title.into(),
229 kind,
230 problem: None,
231 details: Vec::new(),
232 hints: Vec::new(),
233 location: None,
234 }
235 }
236
237 /// Create an error diagnostic.
238 ///
239 /// Note: Consider using `DiagnosticMessage::builder().error()` instead for better structure.
240 pub fn error(title: impl Into<String>) -> Self {
241 Self::new(DiagnosticKind::Error, title)
242 }
243
244 /// Create a warning diagnostic.
245 ///
246 /// Note: Consider using `DiagnosticMessage::builder().warning()` instead for better structure.
247 pub fn warning(title: impl Into<String>) -> Self {
248 Self::new(DiagnosticKind::Warning, title)
249 }
250
251 /// Create an info diagnostic.
252 ///
253 /// Note: Consider using `DiagnosticMessage::builder().info()` instead for better structure.
254 pub fn info(title: impl Into<String>) -> Self {
255 Self::new(DiagnosticKind::Info, title)
256 }
257
258 /// Set the error code.
259 ///
260 /// Error codes follow the format `Q-<subsystem>-<number>` (e.g., "Q-1-1").
261 ///
262 /// # Example
263 ///
264 /// ```
265 /// use quarto_error_reporting::DiagnosticMessage;
266 ///
267 /// let msg = DiagnosticMessage::error("YAML Syntax Error")
268 /// .with_code("Q-1-1");
269 /// ```
270 pub fn with_code(mut self, code: impl Into<String>) -> Self {
271 self.code = Some(code.into());
272 self
273 }
274
275 /// Get the documentation URL for this error, if it has an error code.
276 ///
277 /// # Example
278 ///
279 /// Resolves the code against the installed [`CatalogProvider`]
280 /// (`crate::catalog`); returns `None` when no catalog is installed, the
281 /// code is unknown, or the entry has no docs URL.
282 ///
283 /// ```
284 /// use quarto_error_reporting::DiagnosticMessage;
285 ///
286 /// let msg = DiagnosticMessage::error("Internal Error")
287 /// .with_code("Q-0-1");
288 ///
289 /// // `Some(url)` iff a catalog mapping "Q-0-1" (with a docs URL) is installed.
290 /// let _ = msg.docs_url();
291 /// ```
292 pub fn docs_url(&self) -> Option<&str> {
293 self.code
294 .as_ref()
295 .and_then(|code| crate::catalog::get_docs_url(code))
296 }
297
298 /// Render this diagnostic message as text following tidyverse style.
299 ///
300 /// This is a convenience method that uses default rendering options.
301 /// For more control over rendering, use [`Self::to_text_with_options`].
302 ///
303 /// # Example
304 ///
305 /// ```
306 /// use quarto_error_reporting::DiagnosticMessageBuilder;
307 ///
308 /// let msg = DiagnosticMessageBuilder::error("Invalid input")
309 /// .problem("Values must be numeric")
310 /// .add_detail("Found text in column 3")
311 /// .add_hint("Convert to numbers first?")
312 /// .build();
313 /// let text = msg.to_text(None);
314 /// assert!(text.contains("Error: Invalid input"));
315 /// assert!(text.contains("Values must be numeric"));
316 /// ```
317 pub fn to_text(&self, ctx: Option<&quarto_source_map::SourceContext>) -> String {
318 self.to_text_with_options(ctx, &TextRenderOptions::default())
319 }
320
321 /// Render this diagnostic message as text following tidyverse style with custom options.
322 ///
323 /// Format:
324 /// ```text
325 /// Error: title
326 /// Problem statement here
327 /// ✖ Error detail 1
328 /// ✖ Error detail 2
329 /// ℹ Info detail
330 /// • Note detail
331 /// ? Hint 1
332 /// ? Hint 2
333 /// ```
334 ///
335 /// # Example
336 ///
337 /// ```
338 /// use quarto_error_reporting::{DiagnosticMessageBuilder, TextRenderOptions};
339 ///
340 /// let msg = DiagnosticMessageBuilder::error("Invalid input")
341 /// .problem("Values must be numeric")
342 /// .add_detail("Found text in column 3")
343 /// .add_hint("Convert to numbers first?")
344 /// .build();
345 ///
346 /// // Disable hyperlinks for snapshot testing
347 /// let options = TextRenderOptions { enable_hyperlinks: false };
348 /// let text = msg.to_text_with_options(None, &options);
349 /// assert!(text.contains("Error: Invalid input"));
350 /// ```
351 pub fn to_text_with_options(
352 &self,
353 ctx: Option<&quarto_source_map::SourceContext>,
354 options: &TextRenderOptions,
355 ) -> String {
356 use std::fmt::Write;
357
358 let mut result = String::new();
359
360 // Check if we have any location info that could be displayed with ariadne
361 // This includes the main diagnostic location OR any detail with a location
362 let has_any_location =
363 self.location.is_some() || self.details.iter().any(|d| d.location.is_some());
364
365 // If we have location info and source context, render ariadne source display
366 let has_ariadne = if let (true, Some(ctx_val)) = (has_any_location, ctx) {
367 // Use main location if available, otherwise use first detail location
368 let location = self
369 .location
370 .as_ref()
371 .or_else(|| self.details.iter().find_map(|d| d.location.as_ref()));
372
373 if let Some(loc) = location {
374 if let Some(ariadne_output) =
375 self.render_ariadne_source_context(loc, ctx_val, options.enable_hyperlinks)
376 {
377 result.push_str(&ariadne_output);
378 true
379 } else {
380 false
381 }
382 } else {
383 false
384 }
385 } else {
386 false
387 };
388
389 // If we don't have ariadne output, show full tidyverse-style content
390 // If we do have ariadne, only show details without locations and hints
391 // (ariadne already shows: title, code, problem, and details with locations)
392 if !has_ariadne {
393 // No ariadne - show everything in tidyverse style
394
395 // Title with kind prefix and error code (e.g., "Error [Q-1-1]: Invalid input")
396 let kind_str = match self.kind {
397 DiagnosticKind::Error => "Error",
398 DiagnosticKind::Warning => "Warning",
399 DiagnosticKind::Info => "Info",
400 DiagnosticKind::Note => "Note",
401 };
402 if let Some(code) = &self.code {
403 writeln!(result, "{} [{}]: {}", kind_str, code, self.title).unwrap();
404 } else {
405 writeln!(result, "{}: {}", kind_str, self.title).unwrap();
406 }
407
408 // Show location info if available (but no ariadne rendering)
409 if let Some(loc) = &self.location {
410 // Try to map with context if available
411 if let Some(ctx) = ctx {
412 if let Some(mapped) = loc.map_offset(loc.start_offset(), ctx)
413 && let Some(file) = ctx.get_file(mapped.file_id)
414 {
415 writeln!(
416 result,
417 " at {}:{}:{}",
418 file.path,
419 mapped.location.row + 1,
420 mapped.location.column + 1
421 )
422 .unwrap();
423 }
424 } else {
425 // No context: show immediate location (1-indexed for display)
426 // Note: Without context, we can't get row/column from offsets
427 // We could map_offset with ctx to get Location, but ctx is None here
428 writeln!(result, " at offset {}", loc.start_offset()).unwrap();
429 }
430 }
431
432 // Problem statement (optional additional context)
433 if let Some(problem) = &self.problem {
434 writeln!(result, "{}", problem.as_str()).unwrap();
435 }
436
437 // All details with appropriate bullets
438 for detail in &self.details {
439 let bullet = match detail.kind {
440 DetailKind::Error => "✖",
441 DetailKind::Info => "ℹ",
442 DetailKind::Note | DetailKind::Faded => "•",
443 };
444 writeln!(result, "{} {}", bullet, detail.content.as_str()).unwrap();
445 }
446
447 // All hints
448 for hint in &self.hints {
449 writeln!(result, "ℹ {}", hint.as_str()).unwrap();
450 }
451 } else {
452 // Have ariadne - only show details without locations and hints
453 // (ariadne shows title, code, problem, and located details)
454
455 // Details without locations (ariadne can't show these)
456 for detail in &self.details {
457 if detail.location.is_none() {
458 let bullet = match detail.kind {
459 DetailKind::Error => "✖",
460 DetailKind::Info => "ℹ",
461 DetailKind::Note | DetailKind::Faded => "•",
462 };
463 writeln!(result, "{} {}", bullet, detail.content.as_str()).unwrap();
464 }
465 }
466
467 // All hints (ariadne doesn't show hints)
468 for hint in &self.hints {
469 writeln!(result, "ℹ {}", hint.as_str()).unwrap();
470 }
471 }
472
473 result
474 }
475
476 /// Render this diagnostic message as a JSON value.
477 ///
478 /// Returns a structured JSON object with all fields:
479 /// ```json
480 /// {
481 /// "kind": "error",
482 /// "title": "Invalid input",
483 /// "code": "Q-1-2", // quarto-error-code-audit-ignore
484 /// "problem": "Values must be numeric",
485 /// "details": [{"kind": "error", "content": "Found text in column 3"}],
486 /// "hints": ["Convert to numbers first?"]
487 /// }
488 /// ```
489 ///
490 /// # Example
491 ///
492 /// ```
493 /// use quarto_error_reporting::DiagnosticMessage;
494 ///
495 /// let msg = DiagnosticMessage::error("Something went wrong");
496 /// let json = msg.to_json();
497 /// assert_eq!(json["kind"], "error");
498 /// assert_eq!(json["title"], "Something went wrong");
499 /// ```
500 pub fn to_json(&self) -> serde_json::Value {
501 use serde_json::json;
502
503 let kind_str = match self.kind {
504 DiagnosticKind::Error => "error",
505 DiagnosticKind::Warning => "warning",
506 DiagnosticKind::Info => "info",
507 DiagnosticKind::Note => "note",
508 };
509
510 let mut obj = json!({
511 "kind": kind_str,
512 "title": self.title,
513 });
514
515 // Add optional fields
516 if let Some(code) = &self.code {
517 obj["code"] = json!(code);
518 }
519
520 if let Some(problem) = &self.problem {
521 obj["problem"] = problem.to_json();
522 }
523
524 if !self.details.is_empty() {
525 let details: Vec<_> = self
526 .details
527 .iter()
528 .map(|d| {
529 let detail_kind = match d.kind {
530 DetailKind::Error => "error",
531 DetailKind::Info => "info",
532 DetailKind::Note => "note",
533 DetailKind::Faded => "faded",
534 };
535 let mut detail_obj = json!({
536 "kind": detail_kind,
537 "content": d.content.to_json()
538 });
539 if let Some(location) = &d.location {
540 detail_obj["location"] = json!(location);
541 }
542 detail_obj
543 })
544 .collect();
545 obj["details"] = json!(details);
546 }
547
548 if !self.hints.is_empty() {
549 let hints: Vec<_> = self.hints.iter().map(|h| h.to_json()).collect();
550 obj["hints"] = json!(hints);
551 }
552
553 if let Some(location) = &self.location {
554 obj["location"] = json!(location); // quarto-source-map::SourceInfo is Serialize
555 }
556
557 obj
558 }
559
560 /// Wrap a file path with OSC 8 ANSI hyperlink codes for clickable terminal links.
561 ///
562 /// OSC 8 is a terminal escape sequence that creates clickable hyperlinks:
563 /// `\x1b]8;;URI\x1b\\TEXT\x1b\\`
564 ///
565 /// Only adds hyperlinks if:
566 /// - Hyperlinks are enabled via the `enable_hyperlinks` parameter
567 /// - The file exists on disk (not an ephemeral in-memory file)
568 /// - The path can be converted to an absolute path
569 ///
570 /// The `url` crate handles:
571 /// - Platform differences (Windows drive letters vs Unix paths)
572 /// - Percent-encoding of special characters
573 /// - Proper file:// URL construction
574 ///
575 /// Line and column numbers are added to the URL as a fragment identifier
576 /// (e.g., `file:///path#line:column`), which is supported by iTerm2 3.4+
577 /// and other terminal emulators for opening files at specific positions.
578 ///
579 /// Returns the wrapped path if conditions are met, otherwise returns the original path.
580 #[cfg(not(target_family = "wasm"))]
581 fn wrap_path_with_hyperlink(
582 path: &str,
583 has_disk_file: bool,
584 line: Option<usize>,
585 column: Option<usize>,
586 enable_hyperlinks: bool,
587 ) -> String {
588 // Don't add hyperlinks if disabled (e.g., for snapshot testing)
589 if !enable_hyperlinks {
590 return path.to_string();
591 }
592
593 // Only add hyperlinks for real files on disk (not ephemeral in-memory files)
594 if !has_disk_file {
595 return path.to_string();
596 }
597
598 // Canonicalize to absolute path
599 let abs_path = match std::fs::canonicalize(path) {
600 Ok(p) => p,
601 Err(_) => return path.to_string(), // Can't canonicalize, skip hyperlink
602 };
603
604 // Convert to file:// URL (handles Windows/Unix + percent-encoding)
605 let mut file_url = match url::Url::from_file_path(&abs_path) {
606 Ok(url) => url.as_str().to_string(),
607 Err(_) => return path.to_string(), // Conversion failed, skip hyperlink
608 };
609
610 // Add line and column as fragment identifier (e.g., #line:column)
611 // This format is supported by iTerm2 3.4+ semantic history
612 if let Some(line_num) = line {
613 if let Some(col_num) = column {
614 file_url.push_str(&format!("#{}:{}", line_num, col_num));
615 } else {
616 file_url.push_str(&format!("#{}", line_num));
617 }
618 }
619
620 // Wrap with OSC 8 codes: \x1b]8;;URI\x1b\\TEXT\x1b]8;;\x1b\\
621 format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", file_url, path)
622 }
623
624 /// WASM version: hyperlinks don't make sense in WASM environments (no file system).
625 /// Just return the path unmodified.
626 #[cfg(target_family = "wasm")]
627 fn wrap_path_with_hyperlink(
628 path: &str,
629 _has_disk_file: bool,
630 _line: Option<usize>,
631 _column: Option<usize>,
632 _enable_hyperlinks: bool,
633 ) -> String {
634 path.to_string()
635 }
636
637 /// Render source context using ariadne (private helper for to_text).
638 ///
639 /// This produces the visual source code snippet with highlighting.
640 /// The tidyverse-style problem/details/hints are added separately by to_text().
641 fn render_ariadne_source_context(
642 &self,
643 main_location: &quarto_source_map::SourceInfo,
644 ctx: &quarto_source_map::SourceContext,
645 enable_hyperlinks: bool,
646 ) -> Option<String> {
647 use ariadne::{Color, Config, IndexType, Label, Report, ReportKind, Source};
648
649 // Mirror of ariadne's private `Config::unimportant_color()` from
650 // ariadne 0.6.0 (`src/lib.rs:543`). We use this for `DetailKind::Faded`
651 // labels so they blend visually with characters that fall outside any
652 // label. Bump this constant if the ariadne dependency upgrades and
653 // changes the colour.
654 const ARIADNE_UNIMPORTANT_COLOR: Color = Color::Fixed(249);
655
656 // Extract file_id from the source mapping by traversing the chain
657 let file_id = main_location.root_file_id()?;
658
659 let file = ctx.get_file(file_id)?;
660
661 // Get file content: use stored content for ephemeral files, or read from disk.
662 // In WASM (and any host with no real filesystem) the disk read fails with
663 // "operation not supported on this platform"; the only graceful response is
664 // to drop the source-context snippet. The diagnostic's code, message, and
665 // hints still surface — only the Ariadne visual is unavailable.
666 let content = match &file.content {
667 Some(c) => c.clone(),
668 None => match std::fs::read_to_string(&file.path) {
669 Ok(s) => s,
670 Err(_) => return None,
671 },
672 };
673
674 // Map the location offsets back to original file positions
675 // map_offset expects relative offsets (0 = start of this SourceInfo's range)
676 let start_mapped = main_location.map_offset(0, ctx)?;
677 // For end offset, try the full length first. If that fails (e.g., when the span
678 // extends past EOF), clamp to the last valid position. This handles edge cases
679 // like errors pointing to EOF or diagnostics with off-by-one end offsets.
680 let end_mapped = main_location
681 .map_offset(main_location.length(), ctx)
682 .or_else(|| {
683 // Clamp: if length() fails, try length()-1, which should be the last valid byte
684 if main_location.length() > 0 {
685 main_location.map_offset(main_location.length() - 1, ctx)
686 } else {
687 None
688 }
689 })
690 .unwrap_or_else(|| start_mapped.clone());
691
692 // Create display path with OSC 8 hyperlink for clickable file paths
693 // Check if this path refers to a real file on disk (vs an ephemeral in-memory file)
694 let is_disk_file = std::path::Path::new(&file.path).exists();
695 // Line and column numbers are 1-indexed for display (start_mapped.location uses 0-indexed)
696 let line = Some(start_mapped.location.row + 1);
697 let column = Some(start_mapped.location.column + 1);
698 let display_path = Self::wrap_path_with_hyperlink(
699 &file.path,
700 is_disk_file,
701 line,
702 column,
703 enable_hyperlinks,
704 );
705
706 // Determine report kind and color
707 let (report_kind, main_color) = match self.kind {
708 DiagnosticKind::Error => (ReportKind::Error, Color::Red),
709 DiagnosticKind::Warning => (ReportKind::Warning, Color::Yellow),
710 DiagnosticKind::Info => (ReportKind::Advice, Color::Cyan),
711 DiagnosticKind::Note => (ReportKind::Advice, Color::Blue),
712 };
713
714 // Build the report using the mapped offset for proper line:column display
715 // IMPORTANT: Use IndexType::Byte because our offsets are byte offsets, not character offsets
716 let mut report = Report::build(
717 report_kind,
718 (
719 display_path.clone(),
720 start_mapped.location.offset..start_mapped.location.offset,
721 ),
722 )
723 .with_config(Config::default().with_index_type(IndexType::Byte));
724
725 // Add title with error code
726 if let Some(code) = &self.code {
727 report = report.with_message(format!("[{}] {}", code, self.title));
728 } else {
729 report = report.with_message(&self.title);
730 }
731
732 // Add main location label using mapped offsets
733 let main_span = start_mapped.location.offset..end_mapped.location.offset;
734 let main_message = if let Some(problem) = &self.problem {
735 problem.as_str()
736 } else {
737 &self.title
738 };
739
740 // Set `with_order` on every label using its end offset. Ariadne
741 // groups labels by source and starts a new group whenever a label's
742 // end line is *before* the previous label's end line. Without an
743 // explicit order, multi-line main labels and per-line "padding"
744 // detail labels (used to defeat Ariadne's middle-line elision) end
745 // up in separate groups, producing a duplicated snippet block.
746 // Sorting by end offset puts the smaller-line labels first so the
747 // grouping algorithm extends rather than splits.
748 report = report.with_label(
749 Label::new((display_path.clone(), main_span.clone()))
750 .with_message(main_message)
751 .with_color(main_color)
752 .with_order(main_span.end as i32),
753 );
754
755 // Add detail locations as additional labels (only those with locations)
756 for detail in &self.details {
757 if let Some(detail_loc) = &detail.location {
758 // Extract file_id from detail location
759 let detail_file_id = match detail_loc.root_file_id() {
760 Some(fid) => fid,
761 None => continue, // Skip if we can't extract file_id
762 };
763
764 if detail_file_id == file_id {
765 // Map detail offsets to original file positions
766 // map_offset expects relative offsets (0 = start of SourceInfo's range)
767 if let (Some(detail_start), Some(detail_end)) = (
768 detail_loc.map_offset(0, ctx),
769 detail_loc.map_offset(detail_loc.length(), ctx),
770 ) {
771 let detail_span = detail_start.location.offset..detail_end.location.offset;
772 let detail_color = match detail.kind {
773 DetailKind::Error => Color::Red,
774 DetailKind::Info => Color::Cyan,
775 DetailKind::Note => Color::Blue,
776 // Match Ariadne's unimportant colour so faded
777 // labels visually disappear into the surrounding
778 // unlabelled text.
779 DetailKind::Faded => ARIADNE_UNIMPORTANT_COLOR,
780 };
781
782 // Empty-content details exist purely to force Ariadne
783 // to display a line that would otherwise be elided
784 // inside a multi-line span. Leaving the label's
785 // message at None makes Ariadne skip drawing the
786 // `╰── ...` arrow row underneath, so the source line
787 // appears clean.
788 let mut label = Label::new((display_path.clone(), detail_span.clone()))
789 .with_color(detail_color)
790 .with_order(detail_span.end as i32);
791 if !detail.content.as_str().is_empty() {
792 label = label.with_message(detail.content.as_str());
793 }
794 report = report.with_label(label);
795 }
796 }
797 }
798 }
799
800 // Render to string
801 let report = report.finish();
802 let mut output = Vec::new();
803 report
804 .write(
805 (display_path.clone(), Source::from(content.as_str())),
806 &mut output,
807 )
808 .ok()?;
809
810 let output_str = String::from_utf8(output).ok()?;
811
812 // Post-process to extend hyperlinks to include line:column numbers
813 // Ariadne adds :line:column after our hyperlinked path, so we need to
814 // move the hyperlink end marker to include those numbers
815 if is_disk_file && enable_hyperlinks {
816 Some(Self::extend_hyperlink_to_include_line_column(
817 &output_str,
818 &file.path,
819 ))
820 } else {
821 Some(output_str)
822 }
823 }
824
825 /// Extend OSC 8 hyperlinks to include the :line:column suffix that ariadne adds.
826 ///
827 /// Ariadne formats file references as `path:line:column`, but since we wrap the path
828 /// with OSC 8 codes, the structure becomes: `[hyperlink:path]:line:column`
829 /// We want: `[hyperlink:path:line:column]`
830 ///
831 /// This function finds patterns like `path]8;;\:line:column` and moves the hyperlink
832 /// end marker to after the line:column part.
833 fn extend_hyperlink_to_include_line_column(output: &str, original_path: &str) -> String {
834 // Pattern: original_path followed by ]8;;\ then :numbers:numbers
835 // We want to move the ]8;;\ to after the :numbers:numbers part
836 let end_marker = "\x1b]8;;\x1b\\";
837 let search_pattern = format!("{}{}", original_path, end_marker);
838
839 let mut result = output.to_string();
840 while let Some(pos) = result.find(&search_pattern) {
841 let after_marker = pos + search_pattern.len();
842 // Check if what follows is :line:column pattern
843 if let Some(rest) = result.get(after_marker..) {
844 // Match :digits:digits pattern
845 if let Some(colon_end) = Self::find_line_column_end(rest) {
846 // Move the end marker to after the :line:column
847 let before = &result[..pos + original_path.len()];
848 let line_col = &rest[..colon_end];
849 let after = &rest[colon_end..];
850 result = format!("{}{}{}{}", before, line_col, end_marker, after);
851 continue;
852 }
853 }
854 break;
855 }
856 result
857 }
858
859 /// Find the end position of a :line:column pattern at the start of the string.
860 /// Returns None if the pattern doesn't match.
861 fn find_line_column_end(s: &str) -> Option<usize> {
862 let bytes = s.as_bytes();
863 if bytes.is_empty() || bytes[0] != b':' {
864 return None;
865 }
866
867 let mut pos = 1;
868 // Read digits for line number
869 while pos < bytes.len() && bytes[pos].is_ascii_digit() {
870 pos += 1;
871 }
872 if pos == 1 || pos >= bytes.len() || bytes[pos] != b':' {
873 return None; // No digits or no second colon
874 }
875
876 pos += 1; // Skip second colon
877 let col_start = pos;
878 // Read digits for column number
879 while pos < bytes.len() && bytes[pos].is_ascii_digit() {
880 pos += 1;
881 }
882 if pos == col_start {
883 return None; // No digits for column
884 }
885
886 Some(pos)
887 }
888}
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893
894 #[test]
895 fn test_diagnostic_kind() {
896 assert_eq!(DiagnosticKind::Error, DiagnosticKind::Error);
897 assert_ne!(DiagnosticKind::Error, DiagnosticKind::Warning);
898 }
899
900 #[test]
901 fn test_message_content_from_str() {
902 let content: MessageContent = "test".into();
903 assert_eq!(content.as_str(), "test");
904 }
905
906 #[test]
907 fn test_diagnostic_message_new() {
908 let msg = DiagnosticMessage::new(DiagnosticKind::Error, "Test error");
909 assert_eq!(msg.title, "Test error");
910 assert_eq!(msg.kind, DiagnosticKind::Error);
911 assert!(msg.code.is_none());
912 assert!(msg.problem.is_none());
913 assert!(msg.details.is_empty());
914 assert!(msg.hints.is_empty());
915 }
916
917 #[test]
918 fn test_diagnostic_message_constructors() {
919 let error = DiagnosticMessage::error("Error");
920 assert_eq!(error.kind, DiagnosticKind::Error);
921 assert!(error.code.is_none());
922
923 let warning = DiagnosticMessage::warning("Warning");
924 assert_eq!(warning.kind, DiagnosticKind::Warning);
925
926 let info = DiagnosticMessage::info("Info");
927 assert_eq!(info.kind, DiagnosticKind::Info);
928 }
929
930 #[test]
931 fn test_with_code() {
932 let msg = DiagnosticMessage::error("Test error").with_code("Q-1-1");
933 assert_eq!(msg.code, Some("Q-1-1".to_string()));
934 }
935
936 // The positive case — `docs_url()` for a real code resolves to the
937 // quarto.org URL — moved to `quarto-error-catalog`'s integration tests,
938 // where the `Q-*` catalog is installed. Here we only cover the
939 // catalog-free cases (no code / unknown code → `None`), which hold
940 // regardless of whether a catalog is installed.
941
942 #[test]
943 fn test_docs_url_without_code() {
944 let msg = DiagnosticMessage::error("Test error");
945 assert!(msg.docs_url().is_none());
946 }
947
948 #[test]
949 fn test_docs_url_invalid_code() {
950 let msg = DiagnosticMessage::error("Test error").with_code("Q-999-999"); // quarto-error-code-audit-ignore
951 assert!(msg.docs_url().is_none());
952 }
953
954 #[test]
955 fn test_to_text_simple_error() {
956 let msg = DiagnosticMessage::error("Something went wrong");
957 assert_eq!(msg.to_text(None), "Error: Something went wrong\n");
958 }
959
960 #[test]
961 fn test_to_text_with_code() {
962 let msg = DiagnosticMessage::error("Something went wrong").with_code("Q-1-1");
963 assert_eq!(msg.to_text(None), "Error [Q-1-1]: Something went wrong\n");
964 }
965
966 #[test]
967 fn test_to_text_full_message() {
968 use crate::builder::DiagnosticMessageBuilder;
969
970 let msg = DiagnosticMessageBuilder::error("Invalid input")
971 .problem("Values must be numeric")
972 .add_detail("Found text in column 3")
973 .add_info("Columns should contain only numbers")
974 .add_hint("Convert to numbers first?")
975 .build();
976
977 let text = msg.to_text(None);
978 assert!(text.contains("Error: Invalid input"));
979 assert!(text.contains("Values must be numeric"));
980 assert!(text.contains("✖ Found text in column 3"));
981 assert!(text.contains("ℹ Columns should contain only numbers"));
982 assert!(text.contains("ℹ Convert to numbers first?"));
983 }
984
985 #[test]
986 fn test_to_json_simple() {
987 let msg = DiagnosticMessage::error("Something went wrong");
988 let json = msg.to_json();
989
990 assert_eq!(json["kind"], "error");
991 assert_eq!(json["title"], "Something went wrong");
992 assert!(json.get("code").is_none());
993 assert!(json.get("problem").is_none());
994 }
995
996 #[test]
997 fn test_to_json_with_code() {
998 let msg = DiagnosticMessage::error("Something went wrong").with_code("Q-1-1");
999 let json = msg.to_json();
1000
1001 assert_eq!(json["kind"], "error");
1002 assert_eq!(json["title"], "Something went wrong");
1003 assert_eq!(json["code"], "Q-1-1");
1004 }
1005
1006 #[test]
1007 fn test_to_json_full_message() {
1008 use crate::builder::DiagnosticMessageBuilder;
1009
1010 let msg = DiagnosticMessageBuilder::error("Invalid input")
1011 .with_code("Q-1-2") // quarto-error-code-audit-ignore
1012 .problem("Values must be numeric")
1013 .add_detail("Found text in column 3")
1014 .add_info("Expected numbers")
1015 .add_hint("Convert to numbers first?")
1016 .build();
1017
1018 let json = msg.to_json();
1019 assert_eq!(json["kind"], "error");
1020 assert_eq!(json["title"], "Invalid input");
1021 assert_eq!(json["code"], "Q-1-2"); // quarto-error-code-audit-ignore
1022 assert_eq!(json["problem"]["type"], "markdown");
1023 assert_eq!(json["problem"]["content"], "Values must be numeric");
1024 assert_eq!(json["details"][0]["kind"], "error");
1025 assert_eq!(json["details"][0]["content"]["type"], "markdown");
1026 assert_eq!(
1027 json["details"][0]["content"]["content"],
1028 "Found text in column 3"
1029 );
1030 assert_eq!(json["details"][1]["kind"], "info");
1031 assert_eq!(json["details"][1]["content"]["type"], "markdown");
1032 assert_eq!(json["details"][1]["content"]["content"], "Expected numbers");
1033 assert_eq!(json["hints"][0]["type"], "markdown");
1034 assert_eq!(json["hints"][0]["content"], "Convert to numbers first?");
1035 }
1036
1037 #[test]
1038 fn test_to_json_warning() {
1039 let msg = DiagnosticMessage::warning("Be careful");
1040 let json = msg.to_json();
1041
1042 assert_eq!(json["kind"], "warning");
1043 assert_eq!(json["title"], "Be careful");
1044 }
1045
1046 #[test]
1047 fn test_location_in_to_text_without_context() {
1048 use crate::builder::DiagnosticMessageBuilder;
1049
1050 // Create a location at offsets 100-110
1051 let location =
1052 quarto_source_map::SourceInfo::original(quarto_source_map::FileId(0), 100, 110);
1053
1054 let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1055 .with_location(location)
1056 .build();
1057
1058 let text = msg.to_text(None);
1059
1060 // Without context, should show offset (we can't get row/column without context)
1061 assert!(text.contains("Invalid syntax"));
1062 assert!(text.contains("at offset 100"));
1063 }
1064
1065 #[test]
1066 fn test_location_in_to_text_with_context() {
1067 use crate::builder::DiagnosticMessageBuilder;
1068
1069 // Create a source context with a file
1070 let mut ctx = quarto_source_map::SourceContext::new();
1071 let file_id = ctx.add_file(
1072 "test.qmd".to_string(),
1073 Some("line 1\nline 2\nline 3\nline 4".to_string()),
1074 );
1075
1076 // Create a location in that file (offset 7 is start of "line 2")
1077 let location = quarto_source_map::SourceInfo::original(
1078 file_id, 7, // Start of "line 2"
1079 13, // End of "line 2"
1080 );
1081
1082 let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1083 .with_location(location)
1084 .build();
1085
1086 let text = msg.to_text(Some(&ctx));
1087
1088 // With context, should show file path and 1-indexed location
1089 assert!(text.contains("Invalid syntax"));
1090 assert!(text.contains("test.qmd"));
1091 assert!(text.contains("2:1")); // row 1 + 1, column 0 + 1
1092 }
1093
1094 #[test]
1095 fn test_location_in_to_json() {
1096 use crate::builder::DiagnosticMessageBuilder;
1097
1098 let location =
1099 quarto_source_map::SourceInfo::original(quarto_source_map::FileId(0), 100, 110);
1100
1101 let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1102 .with_location(location)
1103 .build();
1104
1105 let json = msg.to_json();
1106
1107 // Should have location field with Original variant
1108 assert!(json.get("location").is_some());
1109 let loc = &json["location"];
1110
1111 // Verify the SourceInfo is serialized correctly (as Original enum variant)
1112 assert!(loc.get("Original").is_some());
1113 let original = &loc["Original"];
1114 assert_eq!(original["file_id"], 0);
1115 assert_eq!(original["start_offset"], 100);
1116 assert_eq!(original["end_offset"], 110);
1117 }
1118
1119 #[test]
1120 fn test_location_optional_in_to_json() {
1121 let msg = DiagnosticMessage::error("No location");
1122 let json = msg.to_json();
1123
1124 // Should not have location field when not provided
1125 assert!(json.get("location").is_none());
1126 }
1127
1128 #[test]
1129 fn test_text_render_options_disable_hyperlinks() {
1130 use crate::builder::DiagnosticMessageBuilder;
1131
1132 let mut ctx = quarto_source_map::SourceContext::new();
1133 let file_id = ctx.add_file("test.qmd".to_string(), Some("test content".to_string()));
1134
1135 let location = quarto_source_map::SourceInfo::original(file_id, 0, 4);
1136
1137 let msg = DiagnosticMessageBuilder::error("Test error")
1138 .with_location(location)
1139 .build();
1140
1141 // With hyperlinks enabled (default)
1142 let with_hyperlinks = msg.to_text(Some(&ctx));
1143
1144 // With hyperlinks disabled
1145 let options = TextRenderOptions {
1146 enable_hyperlinks: false,
1147 };
1148 let without_hyperlinks = msg.to_text_with_options(Some(&ctx), &options);
1149
1150 // When hyperlinks are disabled, output should be different
1151 // (specifically, no OSC 8 escape sequences)
1152 if with_hyperlinks.contains("\x1b]8;") {
1153 assert!(
1154 !without_hyperlinks.contains("\x1b]8;"),
1155 "Disabled hyperlinks should not contain OSC 8 codes"
1156 );
1157 }
1158 }
1159
1160 #[test]
1161 fn test_text_render_options_default() {
1162 let options = TextRenderOptions::default();
1163 assert!(
1164 options.enable_hyperlinks,
1165 "Default should enable hyperlinks"
1166 );
1167 }
1168
1169 #[test]
1170 fn test_render_with_custom_options() {
1171 use crate::builder::DiagnosticMessageBuilder;
1172
1173 let msg = DiagnosticMessageBuilder::error("Test")
1174 .problem("Something went wrong")
1175 .add_detail("Detail 1")
1176 .add_hint("Try this")
1177 .build();
1178
1179 let options = TextRenderOptions {
1180 enable_hyperlinks: false,
1181 };
1182
1183 let text = msg.to_text_with_options(None, &options);
1184
1185 // Should still render properly without hyperlinks
1186 assert!(text.contains("Error: Test"));
1187 assert!(text.contains("Something went wrong"));
1188 assert!(text.contains("Detail 1"));
1189 assert!(text.contains("Try this"));
1190 }
1191}