Skip to main content

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}