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/// Selects which source-context snippet renderer draws the visual code
62/// excerpt in [`DiagnosticMessage::to_text_with_renderer`].
63///
64/// The available variants depend on which renderer features are enabled
65/// at compile time, so this enum is `#[non_exhaustive]`: with neither
66/// `ariadne` nor `annotate-snippets` enabled it has no variants at all,
67/// and downstream `match`es must include a wildcard arm to stay
68/// compiling across feature combinations.
69///
70/// Pass `None` to [`DiagnosticMessage::to_text_with_renderer`] (or use
71/// [`DiagnosticMessage::to_text`] / [`DiagnosticMessage::to_text_with_options`])
72/// to let the crate pick a default via [`SourceRenderer::default_for_features`].
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74#[non_exhaustive]
75pub enum SourceRenderer {
76    /// [ariadne](https://crates.io/crates/ariadne)-style rendering: a
77    /// boxed source excerpt. Available with the `ariadne` feature (on by
78    /// default).
79    #[cfg(feature = "ariadne")]
80    Ariadne,
81    /// [annotate-snippets](https://crates.io/crates/annotate-snippets)-style
82    /// rendering: the rust-lang toolchain's `-->` / gutter-bar look.
83    /// Available with the `annotate-snippets` feature.
84    #[cfg(feature = "annotate-snippets")]
85    AnnotateSnippets,
86}
87
88impl SourceRenderer {
89    /// The renderer used when the caller does not specify one.
90    ///
91    /// Prefers [`SourceRenderer::Ariadne`] when the `ariadne` feature is
92    /// enabled (preserving historical behavior), then falls back to
93    /// [`SourceRenderer::AnnotateSnippets`]. Returns `None` when no
94    /// renderer feature is enabled, in which case `to_text` drops the
95    /// source-context snippet and prints the structured text block.
96    pub fn default_for_features() -> Option<Self> {
97        // Exactly one of these `#[cfg]` blocks survives in any feature
98        // configuration, so the surviving block is the function's tail
99        // expression — no `return` and no unreachable code.
100        #[cfg(feature = "ariadne")]
101        {
102            Some(Self::Ariadne)
103        }
104        #[cfg(all(not(feature = "ariadne"), feature = "annotate-snippets"))]
105        {
106            Some(Self::AnnotateSnippets)
107        }
108        #[cfg(all(not(feature = "ariadne"), not(feature = "annotate-snippets")))]
109        {
110            None
111        }
112    }
113}
114
115/// The content of a message or detail item.
116///
117/// This will eventually support Pandoc AST for rich formatting, but starts
118/// with simpler string-based content.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub enum MessageContent {
121    /// Plain text content
122    Plain(String),
123    /// Markdown content (will be parsed to Pandoc AST in later phases)
124    Markdown(String),
125    // Future: PandocAst(Box<Inlines>)
126}
127
128impl MessageContent {
129    /// Get the raw string content for display
130    pub fn as_str(&self) -> &str {
131        match self {
132            MessageContent::Plain(s) => s,
133            MessageContent::Markdown(s) => s,
134        }
135    }
136
137    /// Convert to JSON value with type information
138    pub fn to_json(&self) -> serde_json::Value {
139        use serde_json::json;
140        match self {
141            MessageContent::Plain(s) => json!({
142                "type": "plain",
143                "content": s
144            }),
145            MessageContent::Markdown(s) => json!({
146                "type": "markdown",
147                "content": s
148            }),
149        }
150    }
151}
152
153impl From<String> for MessageContent {
154    fn from(s: String) -> Self {
155        MessageContent::Markdown(s)
156    }
157}
158
159impl From<&str> for MessageContent {
160    fn from(s: &str) -> Self {
161        MessageContent::Markdown(s.to_string())
162    }
163}
164
165/// A detail item in a diagnostic message.
166///
167/// Following tidyverse guidelines, details provide specific information about
168/// the error (what went wrong, where, with what values).
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170pub struct DetailItem {
171    /// The kind of detail (error, info, note)
172    pub kind: DetailKind,
173    /// The content of the detail
174    pub content: MessageContent,
175    /// Optional source location for this detail
176    ///
177    /// When present, this identifies where in the source code this detail applies.
178    /// This allows error messages to highlight multiple related locations.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub location: Option<quarto_source_map::SourceInfo>,
181}
182
183/// A diagnostic message following tidyverse-style structure.
184///
185/// Structure:
186/// 1. **Code**: Optional error code (e.g., "Q-1-1") for searchability
187/// 2. **Title**: Brief error message
188/// 3. **Kind**: Error, Warning, Info
189/// 4. **Problem**: What went wrong (the "must" or "can't" statement)
190/// 5. **Details**: Specific information (bulleted, max 5 per tidyverse)
191/// 6. **Hints**: Optional guidance for fixing (ends with ?)
192///
193/// # Example
194///
195/// ```ignore
196/// let msg = DiagnosticMessage {
197///     code: Some("Q-1-2".to_string()), // quarto-error-code-audit-ignore
198///     title: "Incompatible types".to_string(),
199///     kind: DiagnosticKind::Error,
200///     problem: Some("Cannot combine date and datetime types".into()),
201///     details: vec![
202///         DetailItem {
203///             kind: DetailKind::Error,
204///             content: "`x`{.arg} has type `date`{.type}".into(),
205///         },
206///         DetailItem {
207///             kind: DetailKind::Error,
208///             content: "`y`{.arg} has type `datetime`{.type}".into(),
209///         },
210///     ],
211///     hints: vec!["Convert both to the same type?".into()],
212///     source_spans: vec![],
213/// };
214/// ```
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216pub struct DiagnosticMessage {
217    /// Optional error code (e.g., "Q-1-1")
218    ///
219    /// Error codes are optional but encouraged. They provide:
220    /// - Searchability (users can Google "Q-1-1")
221    /// - Stability (codes don't change even if message wording improves)
222    /// - Documentation (each code maps to a detailed explanation)
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub code: Option<String>,
225
226    /// Brief title for the error
227    pub title: String,
228
229    /// The kind of diagnostic (Error, Warning, Info)
230    pub kind: DiagnosticKind,
231
232    /// The problem statement (the "what" - using "must" or "can't")
233    pub problem: Option<MessageContent>,
234
235    /// Specific error details (the "where/why" - max 5 per tidyverse)
236    pub details: Vec<DetailItem>,
237
238    /// Optional hints for fixing (ends with ?)
239    pub hints: Vec<MessageContent>,
240
241    /// Source location for this diagnostic
242    ///
243    /// When present, this identifies where in the source code the issue occurred.
244    /// The location may track transformation history, allowing the error to be
245    /// mapped back through multiple processing steps to the original source file.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub location: Option<quarto_source_map::SourceInfo>,
248}
249
250impl DiagnosticMessage {
251    /// Access the diagnostic message builder API.
252    ///
253    /// This is the recommended way to create diagnostic messages, as the builder API
254    /// encodes tidyverse-style guidelines and makes it easy to construct well-structured
255    /// error messages.
256    ///
257    /// # Example
258    ///
259    /// ```
260    /// use quarto_error_reporting::{DiagnosticMessage, DiagnosticMessageBuilder};
261    ///
262    /// let error = DiagnosticMessageBuilder::error("Incompatible types")
263    ///     .with_code("Q-1-2") // quarto-error-code-audit-ignore
264    ///     .problem("Cannot combine date and datetime types")
265    ///     .add_detail("`x` has type `date`")
266    ///     .add_detail("`y` has type `datetime`")
267    ///     .add_hint("Convert both to the same type?")
268    ///     .build();
269    /// ```
270    pub fn builder() -> crate::builder::DiagnosticMessageBuilder {
271        // This is just a convenience for accessing the builder type
272        // Users should call DiagnosticMessageBuilder::error() etc directly
273        crate::builder::DiagnosticMessageBuilder::error("")
274    }
275
276    /// Create a new diagnostic message with just a title and kind.
277    ///
278    /// Note: Consider using `DiagnosticMessage::builder()` instead for better structure.
279    pub fn new(kind: DiagnosticKind, title: impl Into<String>) -> Self {
280        Self {
281            code: None,
282            title: title.into(),
283            kind,
284            problem: None,
285            details: Vec::new(),
286            hints: Vec::new(),
287            location: None,
288        }
289    }
290
291    /// Create an error diagnostic.
292    ///
293    /// Note: Consider using `DiagnosticMessage::builder().error()` instead for better structure.
294    pub fn error(title: impl Into<String>) -> Self {
295        Self::new(DiagnosticKind::Error, title)
296    }
297
298    /// Create a warning diagnostic.
299    ///
300    /// Note: Consider using `DiagnosticMessage::builder().warning()` instead for better structure.
301    pub fn warning(title: impl Into<String>) -> Self {
302        Self::new(DiagnosticKind::Warning, title)
303    }
304
305    /// Create an info diagnostic.
306    ///
307    /// Note: Consider using `DiagnosticMessage::builder().info()` instead for better structure.
308    pub fn info(title: impl Into<String>) -> Self {
309        Self::new(DiagnosticKind::Info, title)
310    }
311
312    /// Set the error code.
313    ///
314    /// Error codes follow the format `Q-<subsystem>-<number>` (e.g., "Q-1-1").
315    ///
316    /// # Example
317    ///
318    /// ```
319    /// use quarto_error_reporting::DiagnosticMessage;
320    ///
321    /// let msg = DiagnosticMessage::error("YAML Syntax Error")
322    ///     .with_code("Q-1-1");
323    /// ```
324    pub fn with_code(mut self, code: impl Into<String>) -> Self {
325        self.code = Some(code.into());
326        self
327    }
328
329    /// Get the documentation URL for this error, if it has an error code.
330    ///
331    /// # Example
332    ///
333    /// Resolves the code against the installed [`CatalogProvider`]
334    /// (`crate::catalog`); returns `None` when no catalog is installed, the
335    /// code is unknown, or the entry has no docs URL.
336    ///
337    /// ```
338    /// use quarto_error_reporting::DiagnosticMessage;
339    ///
340    /// let msg = DiagnosticMessage::error("Internal Error")
341    ///     .with_code("Q-0-1");
342    ///
343    /// // `Some(url)` iff a catalog mapping "Q-0-1" (with a docs URL) is installed.
344    /// let _ = msg.docs_url();
345    /// ```
346    pub fn docs_url(&self) -> Option<&str> {
347        self.code
348            .as_ref()
349            .and_then(|code| crate::catalog::get_docs_url(code))
350    }
351
352    /// Render this diagnostic message as text following tidyverse style.
353    ///
354    /// This is a convenience method that uses default rendering options.
355    /// For more control over rendering, use [`Self::to_text_with_options`].
356    ///
357    /// # Example
358    ///
359    /// ```
360    /// use quarto_error_reporting::DiagnosticMessageBuilder;
361    ///
362    /// let msg = DiagnosticMessageBuilder::error("Invalid input")
363    ///     .problem("Values must be numeric")
364    ///     .add_detail("Found text in column 3")
365    ///     .add_hint("Convert to numbers first?")
366    ///     .build();
367    /// let text = msg.to_text(None);
368    /// assert!(text.contains("Error: Invalid input"));
369    /// assert!(text.contains("Values must be numeric"));
370    /// ```
371    pub fn to_text(&self, ctx: Option<&quarto_source_map::SourceContext>) -> String {
372        self.to_text_with_options(ctx, &TextRenderOptions::default())
373    }
374
375    /// Render this diagnostic message as text following tidyverse style with custom options.
376    ///
377    /// Format:
378    /// ```text
379    /// Error: title
380    /// Problem statement here
381    /// ✖ Error detail 1
382    /// ✖ Error detail 2
383    /// ℹ Info detail
384    /// • Note detail
385    /// ? Hint 1
386    /// ? Hint 2
387    /// ```
388    ///
389    /// # Example
390    ///
391    /// ```
392    /// use quarto_error_reporting::{DiagnosticMessageBuilder, TextRenderOptions};
393    ///
394    /// let msg = DiagnosticMessageBuilder::error("Invalid input")
395    ///     .problem("Values must be numeric")
396    ///     .add_detail("Found text in column 3")
397    ///     .add_hint("Convert to numbers first?")
398    ///     .build();
399    ///
400    /// // Disable hyperlinks for snapshot testing
401    /// let options = TextRenderOptions { enable_hyperlinks: false };
402    /// let text = msg.to_text_with_options(None, &options);
403    /// assert!(text.contains("Error: Invalid input"));
404    /// ```
405    pub fn to_text_with_options(
406        &self,
407        ctx: Option<&quarto_source_map::SourceContext>,
408        options: &TextRenderOptions,
409    ) -> String {
410        self.to_text_with_renderer(ctx, options, None)
411    }
412
413    /// Like [`Self::to_text_with_options`], but explicitly selects which
414    /// source-context snippet renderer draws the visual code excerpt.
415    ///
416    /// Pass `Some(SourceRenderer::Ariadne)` or
417    /// `Some(SourceRenderer::AnnotateSnippets)` to force a specific
418    /// renderer (the corresponding feature must be enabled), or `None`
419    /// to use [`SourceRenderer::default_for_features`]. This is the seam
420    /// for experimenting with diagnostic rendering styles without
421    /// changing the rest of the API: only the source-excerpt block
422    /// differs between renderers; the surrounding structured text
423    /// (unlocated details, hints) is identical.
424    ///
425    /// When no renderer feature is enabled — or the diagnostic has no
426    /// location / source context — this falls back to the structured
427    /// tidyverse-style text block, exactly as [`Self::to_text_with_options`].
428    ///
429    /// # Example
430    ///
431    /// ```
432    /// use quarto_error_reporting::{DiagnosticMessageBuilder, TextRenderOptions};
433    ///
434    /// let msg = DiagnosticMessageBuilder::error("Invalid input")
435    ///     .problem("Values must be numeric")
436    ///     .build();
437    ///
438    /// // `None` picks the default renderer for the enabled features.
439    /// let text = msg.to_text_with_renderer(None, &TextRenderOptions::default(), None);
440    /// assert!(text.contains("Invalid input"));
441    /// ```
442    pub fn to_text_with_renderer(
443        &self,
444        ctx: Option<&quarto_source_map::SourceContext>,
445        options: &TextRenderOptions,
446        renderer: Option<SourceRenderer>,
447    ) -> String {
448        use std::fmt::Write;
449
450        let mut result = String::new();
451
452        // Check if we have any location info that could be displayed in a
453        // source excerpt. This includes the main diagnostic location OR
454        // any detail with a location.
455        let has_any_location =
456            self.location.is_some() || self.details.iter().any(|d| d.location.is_some());
457
458        // If we have location info and source context, render the source
459        // excerpt with the selected (or default) renderer.
460        let has_source_render = if let (true, Some(ctx_val)) = (has_any_location, ctx) {
461            // Use main location if available, otherwise use first detail location
462            let location = self
463                .location
464                .as_ref()
465                .or_else(|| self.details.iter().find_map(|d| d.location.as_ref()));
466
467            if let Some(loc) = location {
468                if let Some(snippet_output) =
469                    self.render_source_context(loc, ctx_val, options.enable_hyperlinks, renderer)
470                {
471                    result.push_str(&snippet_output);
472                    true
473                } else {
474                    false
475                }
476            } else {
477                false
478            }
479        } else {
480            false
481        };
482
483        // If we don't have a source excerpt, show full tidyverse-style content.
484        // If we do, only show details without locations and hints
485        // (the renderer already shows: title, code, problem, and located details)
486        if !has_source_render {
487            // No source excerpt - show everything in tidyverse style
488
489            // Title with kind prefix and error code (e.g., "Error [Q-1-1]: Invalid input")
490            let kind_str = match self.kind {
491                DiagnosticKind::Error => "Error",
492                DiagnosticKind::Warning => "Warning",
493                DiagnosticKind::Info => "Info",
494                DiagnosticKind::Note => "Note",
495            };
496            if let Some(code) = &self.code {
497                writeln!(result, "{} [{}]: {}", kind_str, code, self.title).unwrap();
498            } else {
499                writeln!(result, "{}: {}", kind_str, self.title).unwrap();
500            }
501
502            // Show location info if available (but no ariadne rendering)
503            if let Some(loc) = &self.location {
504                // Try to map with context if available
505                if let Some(ctx) = ctx {
506                    if let Some(mapped) = loc.map_offset(loc.start_offset(), ctx)
507                        && let Some(file) = ctx.get_file(mapped.file_id)
508                    {
509                        writeln!(
510                            result,
511                            "  at {}:{}:{}",
512                            file.path,
513                            mapped.location.row + 1,
514                            mapped.location.column + 1
515                        )
516                        .unwrap();
517                    }
518                } else {
519                    // No context: show immediate location (1-indexed for display)
520                    // Note: Without context, we can't get row/column from offsets
521                    // We could map_offset with ctx to get Location, but ctx is None here
522                    writeln!(result, "  at offset {}", loc.start_offset()).unwrap();
523                }
524            }
525
526            // Problem statement (optional additional context)
527            if let Some(problem) = &self.problem {
528                writeln!(result, "{}", problem.as_str()).unwrap();
529            }
530
531            // All details with appropriate bullets
532            for detail in &self.details {
533                let bullet = match detail.kind {
534                    DetailKind::Error => "✖",
535                    DetailKind::Info => "ℹ",
536                    DetailKind::Note | DetailKind::Faded => "•",
537                };
538                writeln!(result, "{} {}", bullet, detail.content.as_str()).unwrap();
539            }
540
541            // All hints
542            for hint in &self.hints {
543                writeln!(result, "ℹ {}", hint.as_str()).unwrap();
544            }
545        } else {
546            // Have a source excerpt - only show details without locations and hints
547            // (the renderer shows title, code, problem, and located details)
548
549            // Details without locations (the source excerpt can't show these)
550            for detail in &self.details {
551                if detail.location.is_none() {
552                    let bullet = match detail.kind {
553                        DetailKind::Error => "✖",
554                        DetailKind::Info => "ℹ",
555                        DetailKind::Note | DetailKind::Faded => "•",
556                    };
557                    writeln!(result, "{} {}", bullet, detail.content.as_str()).unwrap();
558                }
559            }
560
561            // All hints (ariadne doesn't show hints)
562            for hint in &self.hints {
563                writeln!(result, "ℹ {}", hint.as_str()).unwrap();
564            }
565        }
566
567        result
568    }
569
570    /// Render this diagnostic message as a JSON value.
571    ///
572    /// Returns a structured JSON object with all fields:
573    /// ```json
574    /// {
575    ///   "kind": "error",
576    ///   "title": "Invalid input",
577    ///   "code": "Q-1-2", // quarto-error-code-audit-ignore
578    ///   "problem": "Values must be numeric",
579    ///   "details": [{"kind": "error", "content": "Found text in column 3"}],
580    ///   "hints": ["Convert to numbers first?"]
581    /// }
582    /// ```
583    ///
584    /// # Example
585    ///
586    /// ```
587    /// use quarto_error_reporting::DiagnosticMessage;
588    ///
589    /// let msg = DiagnosticMessage::error("Something went wrong");
590    /// let json = msg.to_json();
591    /// assert_eq!(json["kind"], "error");
592    /// assert_eq!(json["title"], "Something went wrong");
593    /// ```
594    pub fn to_json(&self) -> serde_json::Value {
595        use serde_json::json;
596
597        let kind_str = match self.kind {
598            DiagnosticKind::Error => "error",
599            DiagnosticKind::Warning => "warning",
600            DiagnosticKind::Info => "info",
601            DiagnosticKind::Note => "note",
602        };
603
604        let mut obj = json!({
605            "kind": kind_str,
606            "title": self.title,
607        });
608
609        // Add optional fields
610        if let Some(code) = &self.code {
611            obj["code"] = json!(code);
612        }
613
614        if let Some(problem) = &self.problem {
615            obj["problem"] = problem.to_json();
616        }
617
618        if !self.details.is_empty() {
619            let details: Vec<_> = self
620                .details
621                .iter()
622                .map(|d| {
623                    let detail_kind = match d.kind {
624                        DetailKind::Error => "error",
625                        DetailKind::Info => "info",
626                        DetailKind::Note => "note",
627                        DetailKind::Faded => "faded",
628                    };
629                    let mut detail_obj = json!({
630                        "kind": detail_kind,
631                        "content": d.content.to_json()
632                    });
633                    if let Some(location) = &d.location {
634                        detail_obj["location"] = json!(location);
635                    }
636                    detail_obj
637                })
638                .collect();
639            obj["details"] = json!(details);
640        }
641
642        if !self.hints.is_empty() {
643            let hints: Vec<_> = self.hints.iter().map(|h| h.to_json()).collect();
644            obj["hints"] = json!(hints);
645        }
646
647        if let Some(location) = &self.location {
648            obj["location"] = json!(location); // quarto-source-map::SourceInfo is Serialize
649        }
650
651        obj
652    }
653
654    /// Snap a mapped byte range onto UTF-8 character boundaries within
655    /// `content`, clamping it into the file and keeping `start <= end`.
656    ///
657    /// Both renderers slice the source by byte offset — ariadne in
658    /// `write.rs`, annotate-snippets in `renderer/source_map.rs` — and both
659    /// **panic** on an offset that falls inside a multi-byte character.
660    /// The offsets we hand them come from `SourceInfo` mappings, which are
661    /// not guaranteed to be boundary-aligned: a mapping that is off by a
662    /// byte is a cosmetic caret error on ASCII but a process abort next to
663    /// a multi-byte character. Printing a diagnostic must never be able to
664    /// kill a render, so we normalize here rather than trusting the input.
665    ///
666    /// The range is widened, not truncated: `start` floors to the start of
667    /// the character containing it and `end` ceils to the end of the
668    /// character containing it, so the highlight covers whole characters
669    /// and can never invert.
670    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
671    fn snap_span_to_char_boundaries(
672        content: &str,
673        start: usize,
674        end: usize,
675    ) -> std::ops::Range<usize> {
676        let len = content.len();
677        let mut s = start.min(len);
678        let mut e = end.min(len).max(s);
679        while s > 0 && !content.is_char_boundary(s) {
680            s -= 1;
681        }
682        while e < len && !content.is_char_boundary(e) {
683            e += 1;
684        }
685        s..e
686    }
687
688    /// Dispatch to the selected source-context renderer.
689    ///
690    /// `renderer` of `None` resolves to [`SourceRenderer::default_for_features`].
691    /// Returns `None` when no renderer is available (no renderer feature
692    /// enabled) or the chosen renderer could not draw the excerpt (e.g.
693    /// the file content is unavailable — common in WASM), in which case
694    /// the caller falls back to the structured text block.
695    #[cfg_attr(
696        not(any(feature = "ariadne", feature = "annotate-snippets")),
697        allow(unused_variables)
698    )]
699    fn render_source_context(
700        &self,
701        main_location: &quarto_source_map::SourceInfo,
702        ctx: &quarto_source_map::SourceContext,
703        enable_hyperlinks: bool,
704        renderer: Option<SourceRenderer>,
705    ) -> Option<String> {
706        let renderer = renderer.or_else(SourceRenderer::default_for_features)?;
707        match renderer {
708            #[cfg(feature = "ariadne")]
709            SourceRenderer::Ariadne => {
710                self.render_ariadne_source_context(main_location, ctx, enable_hyperlinks)
711            }
712            #[cfg(feature = "annotate-snippets")]
713            SourceRenderer::AnnotateSnippets => {
714                self.render_annotate_snippets_source_context(main_location, ctx, enable_hyperlinks)
715            }
716        }
717    }
718
719    /// Wrap a file path with OSC 8 ANSI hyperlink codes for clickable terminal links.
720    ///
721    /// OSC 8 is a terminal escape sequence that creates clickable hyperlinks:
722    /// `\x1b]8;;URI\x1b\\TEXT\x1b\\`
723    ///
724    /// Only adds hyperlinks if:
725    /// - Hyperlinks are enabled via the `enable_hyperlinks` parameter
726    /// - The file exists on disk (not an ephemeral in-memory file)
727    /// - The path can be converted to an absolute path
728    ///
729    /// The `url` crate handles:
730    /// - Platform differences (Windows drive letters vs Unix paths)
731    /// - Percent-encoding of special characters
732    /// - Proper file:// URL construction
733    ///
734    /// Line and column numbers are added to the URL as a fragment identifier
735    /// (e.g., `file:///path#line:column`), which is supported by iTerm2 3.4+
736    /// and other terminal emulators for opening files at specific positions.
737    ///
738    /// Returns the wrapped path if conditions are met, otherwise returns the original path.
739    ///
740    /// Only used by the ariadne renderer (annotate-snippets has no OSC 8 support).
741    #[cfg(all(feature = "ariadne", not(target_family = "wasm")))]
742    fn wrap_path_with_hyperlink(
743        path: &str,
744        has_disk_file: bool,
745        line: Option<usize>,
746        column: Option<usize>,
747        enable_hyperlinks: bool,
748    ) -> String {
749        // Don't add hyperlinks if disabled (e.g., for snapshot testing)
750        if !enable_hyperlinks {
751            return path.to_string();
752        }
753
754        // Only add hyperlinks for real files on disk (not ephemeral in-memory files)
755        if !has_disk_file {
756            return path.to_string();
757        }
758
759        // Canonicalize to absolute path
760        let abs_path = match std::fs::canonicalize(path) {
761            Ok(p) => p,
762            Err(_) => return path.to_string(), // Can't canonicalize, skip hyperlink
763        };
764
765        // Convert to file:// URL (handles Windows/Unix + percent-encoding)
766        let mut file_url = match url::Url::from_file_path(&abs_path) {
767            Ok(url) => url.as_str().to_string(),
768            Err(_) => return path.to_string(), // Conversion failed, skip hyperlink
769        };
770
771        // Add line and column as fragment identifier (e.g., #line:column)
772        // This format is supported by iTerm2 3.4+ semantic history
773        if let Some(line_num) = line {
774            if let Some(col_num) = column {
775                file_url.push_str(&format!("#{}:{}", line_num, col_num));
776            } else {
777                file_url.push_str(&format!("#{}", line_num));
778            }
779        }
780
781        // Wrap with OSC 8 codes: \x1b]8;;URI\x1b\\TEXT\x1b]8;;\x1b\\
782        format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", file_url, path)
783    }
784
785    /// WASM version: hyperlinks don't make sense in WASM environments (no file system).
786    /// Just return the path unmodified.
787    #[cfg(all(feature = "ariadne", target_family = "wasm"))]
788    fn wrap_path_with_hyperlink(
789        path: &str,
790        _has_disk_file: bool,
791        _line: Option<usize>,
792        _column: Option<usize>,
793        _enable_hyperlinks: bool,
794    ) -> String {
795        path.to_string()
796    }
797
798    /// Render source context using ariadne (private helper for to_text).
799    ///
800    /// This produces the visual source code snippet with highlighting.
801    /// The tidyverse-style problem/details/hints are added separately by to_text().
802    #[cfg(feature = "ariadne")]
803    fn render_ariadne_source_context(
804        &self,
805        main_location: &quarto_source_map::SourceInfo,
806        ctx: &quarto_source_map::SourceContext,
807        enable_hyperlinks: bool,
808    ) -> Option<String> {
809        use ariadne::{Color, Config, IndexType, Label, Report, ReportKind, Source};
810
811        // Mirror of ariadne's private `Config::unimportant_color()` from
812        // ariadne 0.6.0 (`src/lib.rs:543`). We use this for `DetailKind::Faded`
813        // labels so they blend visually with characters that fall outside any
814        // label. Bump this constant if the ariadne dependency upgrades and
815        // changes the colour.
816        const ARIADNE_UNIMPORTANT_COLOR: Color = Color::Fixed(249);
817
818        // Extract file_id from the source mapping by traversing the chain
819        let file_id = main_location.root_file_id()?;
820
821        let file = ctx.get_file(file_id)?;
822
823        // Get file content: use stored content for ephemeral files, or read from disk.
824        // In WASM (and any host with no real filesystem) the disk read fails with
825        // "operation not supported on this platform"; the only graceful response is
826        // to drop the source-context snippet. The diagnostic's code, message, and
827        // hints still surface — only the Ariadne visual is unavailable.
828        let content = match &file.content {
829            Some(c) => c.clone(),
830            None => match std::fs::read_to_string(&file.path) {
831                Ok(s) => s,
832                Err(_) => return None,
833            },
834        };
835
836        // Map the location offsets back to original file positions
837        // map_offset expects relative offsets (0 = start of this SourceInfo's range)
838        let start_mapped = main_location.map_offset(0, ctx)?;
839        // For end offset, try the full length first. If that fails (e.g., when the span
840        // extends past EOF), clamp to the last valid position. This handles edge cases
841        // like errors pointing to EOF or diagnostics with off-by-one end offsets.
842        let end_mapped = main_location
843            .map_offset(main_location.length(), ctx)
844            .or_else(|| {
845                // Clamp: if length() fails, try length()-1, which should be the last valid byte
846                if main_location.length() > 0 {
847                    main_location.map_offset(main_location.length() - 1, ctx)
848                } else {
849                    None
850                }
851            })
852            .unwrap_or_else(|| start_mapped.clone());
853
854        // Create display path with OSC 8 hyperlink for clickable file paths
855        // Check if this path refers to a real file on disk (vs an ephemeral in-memory file)
856        let is_disk_file = std::path::Path::new(&file.path).exists();
857        // Line and column numbers are 1-indexed for display (start_mapped.location uses 0-indexed)
858        let line = Some(start_mapped.location.row + 1);
859        let column = Some(start_mapped.location.column + 1);
860        let display_path = Self::wrap_path_with_hyperlink(
861            &file.path,
862            is_disk_file,
863            line,
864            column,
865            enable_hyperlinks,
866        );
867
868        // Determine report kind and color
869        let (report_kind, main_color) = match self.kind {
870            DiagnosticKind::Error => (ReportKind::Error, Color::Red),
871            DiagnosticKind::Warning => (ReportKind::Warning, Color::Yellow),
872            DiagnosticKind::Info => (ReportKind::Advice, Color::Cyan),
873            DiagnosticKind::Note => (ReportKind::Advice, Color::Blue),
874        };
875
876        // Snap once, up front: every offset handed to ariadne below (the
877        // report anchor and the main label) must be char-boundary safe.
878        let main_span = Self::snap_span_to_char_boundaries(
879            &content,
880            start_mapped.location.offset,
881            end_mapped.location.offset,
882        );
883
884        // Build the report using the mapped offset for proper line:column display
885        // IMPORTANT: Use IndexType::Byte because our offsets are byte offsets, not character offsets
886        let mut report = Report::build(
887            report_kind,
888            (display_path.clone(), main_span.start..main_span.start),
889        )
890        .with_config(Config::default().with_index_type(IndexType::Byte));
891
892        // Add title with error code
893        if let Some(code) = &self.code {
894            report = report.with_message(format!("[{}] {}", code, self.title));
895        } else {
896            report = report.with_message(&self.title);
897        }
898
899        // Add main location label using the snapped span computed above.
900        let main_message = if let Some(problem) = &self.problem {
901            problem.as_str()
902        } else {
903            &self.title
904        };
905
906        // Set `with_order` on every label using its end offset. Ariadne
907        // groups labels by source and starts a new group whenever a label's
908        // end line is *before* the previous label's end line. Without an
909        // explicit order, multi-line main labels and per-line "padding"
910        // detail labels (used to defeat Ariadne's middle-line elision) end
911        // up in separate groups, producing a duplicated snippet block.
912        // Sorting by end offset puts the smaller-line labels first so the
913        // grouping algorithm extends rather than splits.
914        report = report.with_label(
915            Label::new((display_path.clone(), main_span.clone()))
916                .with_message(main_message)
917                .with_color(main_color)
918                .with_order(main_span.end as i32),
919        );
920
921        // Add detail locations as additional labels (only those with locations)
922        for detail in &self.details {
923            if let Some(detail_loc) = &detail.location {
924                // Extract file_id from detail location
925                let detail_file_id = match detail_loc.root_file_id() {
926                    Some(fid) => fid,
927                    None => continue, // Skip if we can't extract file_id
928                };
929
930                if detail_file_id == file_id {
931                    // Map detail offsets to original file positions
932                    // map_offset expects relative offsets (0 = start of SourceInfo's range)
933                    if let (Some(detail_start), Some(detail_end)) = (
934                        detail_loc.map_offset(0, ctx),
935                        detail_loc.map_offset(detail_loc.length(), ctx),
936                    ) {
937                        let detail_span = Self::snap_span_to_char_boundaries(
938                            &content,
939                            detail_start.location.offset,
940                            detail_end.location.offset,
941                        );
942                        let detail_color = match detail.kind {
943                            DetailKind::Error => Color::Red,
944                            DetailKind::Info => Color::Cyan,
945                            DetailKind::Note => Color::Blue,
946                            // Match Ariadne's unimportant colour so faded
947                            // labels visually disappear into the surrounding
948                            // unlabelled text.
949                            DetailKind::Faded => ARIADNE_UNIMPORTANT_COLOR,
950                        };
951
952                        // Empty-content details exist purely to force Ariadne
953                        // to display a line that would otherwise be elided
954                        // inside a multi-line span. Leaving the label's
955                        // message at None makes Ariadne skip drawing the
956                        // `╰── ...` arrow row underneath, so the source line
957                        // appears clean.
958                        let mut label = Label::new((display_path.clone(), detail_span.clone()))
959                            .with_color(detail_color)
960                            .with_order(detail_span.end as i32);
961                        if !detail.content.as_str().is_empty() {
962                            label = label.with_message(detail.content.as_str());
963                        }
964                        report = report.with_label(label);
965                    }
966                }
967            }
968        }
969
970        // Render to string
971        let report = report.finish();
972        let mut output = Vec::new();
973        report
974            .write(
975                (display_path.clone(), Source::from(content.as_str())),
976                &mut output,
977            )
978            .ok()?;
979
980        let output_str = String::from_utf8(output).ok()?;
981
982        // Post-process to extend hyperlinks to include line:column numbers
983        // Ariadne adds :line:column after our hyperlinked path, so we need to
984        // move the hyperlink end marker to include those numbers
985        if is_disk_file && enable_hyperlinks {
986            Some(Self::extend_hyperlink_to_include_line_column(
987                &output_str,
988                &file.path,
989            ))
990        } else {
991            Some(output_str)
992        }
993    }
994
995    /// Render source context using [`annotate-snippets`](https://crates.io/crates/annotate-snippets),
996    /// the rust-lang toolchain's diagnostic style (private helper for to_text).
997    ///
998    /// Mirrors [`Self::render_ariadne_source_context`]'s offset-mapping
999    /// logic but emits the `error[CODE]: …` / `-->` / gutter-bar look.
1000    /// Differences from the ariadne path, by design:
1001    ///
1002    /// - The error code is rendered natively via `Title::id` (e.g.
1003    ///   `error[Q-2-5]: …`) rather than prefixed into the message.
1004    /// - There are **no terminal hyperlinks** — annotate-snippets has no
1005    ///   OSC 8 support, so `_enable_hyperlinks` is ignored.
1006    /// - Detail labels are all rendered as `Context` annotations
1007    ///   (annotate-snippets has no per-label color), so the `DetailKind`
1008    ///   color distinction and the `Faded` blend are not reproduced.
1009    /// - Empty-content "padding" details (an ariadne workaround for
1010    ///   mid-span line elision) are skipped: annotate-snippets folds
1011    ///   unannotated lines natively, which is the look we want here.
1012    #[cfg(feature = "annotate-snippets")]
1013    fn render_annotate_snippets_source_context(
1014        &self,
1015        main_location: &quarto_source_map::SourceInfo,
1016        ctx: &quarto_source_map::SourceContext,
1017        _enable_hyperlinks: bool,
1018    ) -> Option<String> {
1019        use annotate_snippets::{AnnotationKind, Level, Renderer, Snippet};
1020
1021        // Resolve the root file and its content (same as the ariadne path).
1022        let file_id = main_location.root_file_id()?;
1023        let file = ctx.get_file(file_id)?;
1024        let content = match &file.content {
1025            Some(c) => c.clone(),
1026            None => std::fs::read_to_string(&file.path).ok()?,
1027        };
1028        // Clamp a mapped byte range into the source, keeping start <= end
1029        // and both ends on UTF-8 character boundaries (annotate-snippets
1030        // panics on a mid-character offset just as ariadne does).
1031        let clamp = |start: usize, end: usize| -> std::ops::Range<usize> {
1032            Self::snap_span_to_char_boundaries(&content, start, end)
1033        };
1034
1035        // Map the main location's offsets back to original-file byte
1036        // positions, clamping the end past EOF like the ariadne path.
1037        let start_mapped = main_location.map_offset(0, ctx)?;
1038        let end_mapped = main_location
1039            .map_offset(main_location.length(), ctx)
1040            .or_else(|| {
1041                if main_location.length() > 0 {
1042                    main_location.map_offset(main_location.length() - 1, ctx)
1043                } else {
1044                    None
1045                }
1046            })
1047            .unwrap_or_else(|| start_mapped.clone());
1048        let main_span = clamp(start_mapped.location.offset, end_mapped.location.offset);
1049
1050        let level = match self.kind {
1051            DiagnosticKind::Error => Level::ERROR,
1052            DiagnosticKind::Warning => Level::WARNING,
1053            DiagnosticKind::Info => Level::INFO,
1054            DiagnosticKind::Note => Level::NOTE,
1055        };
1056
1057        // Primary label message: the problem statement, else the title.
1058        let main_message = match &self.problem {
1059            Some(problem) => problem.as_str(),
1060            None => self.title.as_str(),
1061        };
1062
1063        let mut snippet = Snippet::source(content.as_str())
1064            .path(file.path.as_str())
1065            .line_start(1)
1066            .annotation(AnnotationKind::Primary.span(main_span).label(main_message));
1067
1068        // Detail locations in the same file become Context annotations.
1069        for detail in &self.details {
1070            // Skip empty-content padding details (see the doc comment).
1071            if detail.content.as_str().is_empty() {
1072                continue;
1073            }
1074            let Some(detail_loc) = &detail.location else {
1075                continue;
1076            };
1077            if detail_loc.root_file_id() != Some(file_id) {
1078                continue;
1079            }
1080            if let (Some(detail_start), Some(detail_end)) = (
1081                detail_loc.map_offset(0, ctx),
1082                detail_loc.map_offset(detail_loc.length(), ctx),
1083            ) {
1084                let detail_span = clamp(detail_start.location.offset, detail_end.location.offset);
1085                snippet = snippet.annotation(
1086                    AnnotationKind::Context
1087                        .span(detail_span)
1088                        .label(detail.content.as_str()),
1089                );
1090            }
1091        }
1092
1093        // Build the titled group; render the error code natively via `id`.
1094        let mut title = level.primary_title(self.title.as_str());
1095        if let Some(code) = &self.code {
1096            title = title.id(code.as_str());
1097        }
1098        let group = title.element(snippet);
1099
1100        // `Renderer::render` returns text with no trailing newline, but
1101        // `to_text` appends unlocated details and hints directly after the
1102        // excerpt with `writeln!`. Match the ariadne path (which ends in a
1103        // newline) so those lines don't glue onto the last source row.
1104        let mut rendered = Renderer::styled().render(&[group]);
1105        if !rendered.ends_with('\n') {
1106            rendered.push('\n');
1107        }
1108        Some(rendered)
1109    }
1110
1111    /// Extend OSC 8 hyperlinks to include the :line:column suffix that ariadne adds.
1112    ///
1113    /// Ariadne formats file references as `path:line:column`, but since we wrap the path
1114    /// with OSC 8 codes, the structure becomes: `[hyperlink:path]:line:column`
1115    /// We want: `[hyperlink:path:line:column]`
1116    ///
1117    /// This function finds patterns like `path]8;;\:line:column` and moves the hyperlink
1118    /// end marker to after the line:column part.
1119    #[cfg(feature = "ariadne")]
1120    fn extend_hyperlink_to_include_line_column(output: &str, original_path: &str) -> String {
1121        // Pattern: original_path followed by ]8;;\ then :numbers:numbers
1122        // We want to move the ]8;;\ to after the :numbers:numbers part
1123        let end_marker = "\x1b]8;;\x1b\\";
1124        let search_pattern = format!("{}{}", original_path, end_marker);
1125
1126        let mut result = output.to_string();
1127        while let Some(pos) = result.find(&search_pattern) {
1128            let after_marker = pos + search_pattern.len();
1129            // Check if what follows is :line:column pattern
1130            if let Some(rest) = result.get(after_marker..) {
1131                // Match :digits:digits pattern
1132                if let Some(colon_end) = Self::find_line_column_end(rest) {
1133                    // Move the end marker to after the :line:column
1134                    let before = &result[..pos + original_path.len()];
1135                    let line_col = &rest[..colon_end];
1136                    let after = &rest[colon_end..];
1137                    result = format!("{}{}{}{}", before, line_col, end_marker, after);
1138                    continue;
1139                }
1140            }
1141            break;
1142        }
1143        result
1144    }
1145
1146    /// Find the end position of a :line:column pattern at the start of the string.
1147    /// Returns None if the pattern doesn't match.
1148    #[cfg(feature = "ariadne")]
1149    fn find_line_column_end(s: &str) -> Option<usize> {
1150        let bytes = s.as_bytes();
1151        if bytes.is_empty() || bytes[0] != b':' {
1152            return None;
1153        }
1154
1155        let mut pos = 1;
1156        // Read digits for line number
1157        while pos < bytes.len() && bytes[pos].is_ascii_digit() {
1158            pos += 1;
1159        }
1160        if pos == 1 || pos >= bytes.len() || bytes[pos] != b':' {
1161            return None; // No digits or no second colon
1162        }
1163
1164        pos += 1; // Skip second colon
1165        let col_start = pos;
1166        // Read digits for column number
1167        while pos < bytes.len() && bytes[pos].is_ascii_digit() {
1168            pos += 1;
1169        }
1170        if pos == col_start {
1171            return None; // No digits for column
1172        }
1173
1174        Some(pos)
1175    }
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180    use super::*;
1181
1182    #[test]
1183    fn test_diagnostic_kind() {
1184        assert_eq!(DiagnosticKind::Error, DiagnosticKind::Error);
1185        assert_ne!(DiagnosticKind::Error, DiagnosticKind::Warning);
1186    }
1187
1188    #[test]
1189    fn test_message_content_from_str() {
1190        let content: MessageContent = "test".into();
1191        assert_eq!(content.as_str(), "test");
1192    }
1193
1194    #[test]
1195    fn test_diagnostic_message_new() {
1196        let msg = DiagnosticMessage::new(DiagnosticKind::Error, "Test error");
1197        assert_eq!(msg.title, "Test error");
1198        assert_eq!(msg.kind, DiagnosticKind::Error);
1199        assert!(msg.code.is_none());
1200        assert!(msg.problem.is_none());
1201        assert!(msg.details.is_empty());
1202        assert!(msg.hints.is_empty());
1203    }
1204
1205    #[test]
1206    fn test_diagnostic_message_constructors() {
1207        let error = DiagnosticMessage::error("Error");
1208        assert_eq!(error.kind, DiagnosticKind::Error);
1209        assert!(error.code.is_none());
1210
1211        let warning = DiagnosticMessage::warning("Warning");
1212        assert_eq!(warning.kind, DiagnosticKind::Warning);
1213
1214        let info = DiagnosticMessage::info("Info");
1215        assert_eq!(info.kind, DiagnosticKind::Info);
1216    }
1217
1218    #[test]
1219    fn test_with_code() {
1220        let msg = DiagnosticMessage::error("Test error").with_code("Q-1-1");
1221        assert_eq!(msg.code, Some("Q-1-1".to_string()));
1222    }
1223
1224    // The positive case — `docs_url()` for a real code resolves to the
1225    // quarto.org URL — moved to `quarto-error-catalog`'s integration tests,
1226    // where the `Q-*` catalog is installed. Here we only cover the
1227    // catalog-free cases (no code / unknown code → `None`), which hold
1228    // regardless of whether a catalog is installed.
1229
1230    #[test]
1231    fn test_docs_url_without_code() {
1232        let msg = DiagnosticMessage::error("Test error");
1233        assert!(msg.docs_url().is_none());
1234    }
1235
1236    #[test]
1237    fn test_docs_url_invalid_code() {
1238        let msg = DiagnosticMessage::error("Test error").with_code("Q-999-999"); // quarto-error-code-audit-ignore
1239        assert!(msg.docs_url().is_none());
1240    }
1241
1242    #[test]
1243    fn test_to_text_simple_error() {
1244        let msg = DiagnosticMessage::error("Something went wrong");
1245        assert_eq!(msg.to_text(None), "Error: Something went wrong\n");
1246    }
1247
1248    #[test]
1249    fn test_to_text_with_code() {
1250        let msg = DiagnosticMessage::error("Something went wrong").with_code("Q-1-1");
1251        assert_eq!(msg.to_text(None), "Error [Q-1-1]: Something went wrong\n");
1252    }
1253
1254    #[test]
1255    fn test_to_text_full_message() {
1256        use crate::builder::DiagnosticMessageBuilder;
1257
1258        let msg = DiagnosticMessageBuilder::error("Invalid input")
1259            .problem("Values must be numeric")
1260            .add_detail("Found text in column 3")
1261            .add_info("Columns should contain only numbers")
1262            .add_hint("Convert to numbers first?")
1263            .build();
1264
1265        let text = msg.to_text(None);
1266        assert!(text.contains("Error: Invalid input"));
1267        assert!(text.contains("Values must be numeric"));
1268        assert!(text.contains("✖ Found text in column 3"));
1269        assert!(text.contains("ℹ Columns should contain only numbers"));
1270        assert!(text.contains("ℹ Convert to numbers first?"));
1271    }
1272
1273    #[test]
1274    fn test_to_json_simple() {
1275        let msg = DiagnosticMessage::error("Something went wrong");
1276        let json = msg.to_json();
1277
1278        assert_eq!(json["kind"], "error");
1279        assert_eq!(json["title"], "Something went wrong");
1280        assert!(json.get("code").is_none());
1281        assert!(json.get("problem").is_none());
1282    }
1283
1284    #[test]
1285    fn test_to_json_with_code() {
1286        let msg = DiagnosticMessage::error("Something went wrong").with_code("Q-1-1");
1287        let json = msg.to_json();
1288
1289        assert_eq!(json["kind"], "error");
1290        assert_eq!(json["title"], "Something went wrong");
1291        assert_eq!(json["code"], "Q-1-1");
1292    }
1293
1294    #[test]
1295    fn test_to_json_full_message() {
1296        use crate::builder::DiagnosticMessageBuilder;
1297
1298        let msg = DiagnosticMessageBuilder::error("Invalid input")
1299            .with_code("Q-1-2") // quarto-error-code-audit-ignore
1300            .problem("Values must be numeric")
1301            .add_detail("Found text in column 3")
1302            .add_info("Expected numbers")
1303            .add_hint("Convert to numbers first?")
1304            .build();
1305
1306        let json = msg.to_json();
1307        assert_eq!(json["kind"], "error");
1308        assert_eq!(json["title"], "Invalid input");
1309        assert_eq!(json["code"], "Q-1-2"); // quarto-error-code-audit-ignore
1310        assert_eq!(json["problem"]["type"], "markdown");
1311        assert_eq!(json["problem"]["content"], "Values must be numeric");
1312        assert_eq!(json["details"][0]["kind"], "error");
1313        assert_eq!(json["details"][0]["content"]["type"], "markdown");
1314        assert_eq!(
1315            json["details"][0]["content"]["content"],
1316            "Found text in column 3"
1317        );
1318        assert_eq!(json["details"][1]["kind"], "info");
1319        assert_eq!(json["details"][1]["content"]["type"], "markdown");
1320        assert_eq!(json["details"][1]["content"]["content"], "Expected numbers");
1321        assert_eq!(json["hints"][0]["type"], "markdown");
1322        assert_eq!(json["hints"][0]["content"], "Convert to numbers first?");
1323    }
1324
1325    #[test]
1326    fn test_to_json_warning() {
1327        let msg = DiagnosticMessage::warning("Be careful");
1328        let json = msg.to_json();
1329
1330        assert_eq!(json["kind"], "warning");
1331        assert_eq!(json["title"], "Be careful");
1332    }
1333
1334    #[test]
1335    fn test_location_in_to_text_without_context() {
1336        use crate::builder::DiagnosticMessageBuilder;
1337
1338        // Create a location at offsets 100-110
1339        let location =
1340            quarto_source_map::SourceInfo::original(quarto_source_map::FileId(0), 100, 110);
1341
1342        let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1343            .with_location(location)
1344            .build();
1345
1346        let text = msg.to_text(None);
1347
1348        // Without context, should show offset (we can't get row/column without context)
1349        assert!(text.contains("Invalid syntax"));
1350        assert!(text.contains("at offset 100"));
1351    }
1352
1353    #[test]
1354    fn test_location_in_to_text_with_context() {
1355        use crate::builder::DiagnosticMessageBuilder;
1356
1357        // Create a source context with a file
1358        let mut ctx = quarto_source_map::SourceContext::new();
1359        let file_id = ctx.add_file(
1360            "test.qmd".to_string(),
1361            Some("line 1\nline 2\nline 3\nline 4".to_string()),
1362        );
1363
1364        // Create a location in that file (offset 7 is start of "line 2")
1365        let location = quarto_source_map::SourceInfo::original(
1366            file_id, 7,  // Start of "line 2"
1367            13, // End of "line 2"
1368        );
1369
1370        let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1371            .with_location(location)
1372            .build();
1373
1374        let text = msg.to_text(Some(&ctx));
1375
1376        // With context, should show file path and 1-indexed location
1377        assert!(text.contains("Invalid syntax"));
1378        assert!(text.contains("test.qmd"));
1379        assert!(text.contains("2:1")); // row 1 + 1, column 0 + 1
1380    }
1381
1382    #[test]
1383    fn test_location_in_to_json() {
1384        use crate::builder::DiagnosticMessageBuilder;
1385
1386        let location =
1387            quarto_source_map::SourceInfo::original(quarto_source_map::FileId(0), 100, 110);
1388
1389        let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1390            .with_location(location)
1391            .build();
1392
1393        let json = msg.to_json();
1394
1395        // Should have location field with Original variant
1396        assert!(json.get("location").is_some());
1397        let loc = &json["location"];
1398
1399        // Verify the SourceInfo is serialized correctly (as Original enum variant)
1400        assert!(loc.get("Original").is_some());
1401        let original = &loc["Original"];
1402        assert_eq!(original["file_id"], 0);
1403        assert_eq!(original["start_offset"], 100);
1404        assert_eq!(original["end_offset"], 110);
1405    }
1406
1407    #[test]
1408    fn test_location_optional_in_to_json() {
1409        let msg = DiagnosticMessage::error("No location");
1410        let json = msg.to_json();
1411
1412        // Should not have location field when not provided
1413        assert!(json.get("location").is_none());
1414    }
1415
1416    #[test]
1417    fn test_text_render_options_disable_hyperlinks() {
1418        use crate::builder::DiagnosticMessageBuilder;
1419
1420        let mut ctx = quarto_source_map::SourceContext::new();
1421        let file_id = ctx.add_file("test.qmd".to_string(), Some("test content".to_string()));
1422
1423        let location = quarto_source_map::SourceInfo::original(file_id, 0, 4);
1424
1425        let msg = DiagnosticMessageBuilder::error("Test error")
1426            .with_location(location)
1427            .build();
1428
1429        // With hyperlinks enabled (default)
1430        let with_hyperlinks = msg.to_text(Some(&ctx));
1431
1432        // With hyperlinks disabled
1433        let options = TextRenderOptions {
1434            enable_hyperlinks: false,
1435        };
1436        let without_hyperlinks = msg.to_text_with_options(Some(&ctx), &options);
1437
1438        // When hyperlinks are disabled, output should be different
1439        // (specifically, no OSC 8 escape sequences)
1440        if with_hyperlinks.contains("\x1b]8;") {
1441            assert!(
1442                !without_hyperlinks.contains("\x1b]8;"),
1443                "Disabled hyperlinks should not contain OSC 8 codes"
1444            );
1445        }
1446    }
1447
1448    #[test]
1449    fn test_text_render_options_default() {
1450        let options = TextRenderOptions::default();
1451        assert!(
1452            options.enable_hyperlinks,
1453            "Default should enable hyperlinks"
1454        );
1455    }
1456
1457    #[test]
1458    fn test_render_with_custom_options() {
1459        use crate::builder::DiagnosticMessageBuilder;
1460
1461        let msg = DiagnosticMessageBuilder::error("Test")
1462            .problem("Something went wrong")
1463            .add_detail("Detail 1")
1464            .add_hint("Try this")
1465            .build();
1466
1467        let options = TextRenderOptions {
1468            enable_hyperlinks: false,
1469        };
1470
1471        let text = msg.to_text_with_options(None, &options);
1472
1473        // Should still render properly without hyperlinks
1474        assert!(text.contains("Error: Test"));
1475        assert!(text.contains("Something went wrong"));
1476        assert!(text.contains("Detail 1"));
1477        assert!(text.contains("Try this"));
1478    }
1479
1480    /// Strip CSI SGR color sequences (`ESC [ … m`). The annotate-snippets
1481    /// path emits no OSC 8 hyperlinks, so color is all we need to remove
1482    /// to make substring assertions robust to styling.
1483    #[cfg(feature = "annotate-snippets")]
1484    fn strip_ansi(s: &str) -> String {
1485        let mut out = String::new();
1486        let mut chars = s.chars().peekable();
1487        while let Some(c) = chars.next() {
1488            if c == '\u{1b}' {
1489                for n in chars.by_ref() {
1490                    if n == 'm' {
1491                        break;
1492                    }
1493                }
1494            } else {
1495                out.push(c);
1496            }
1497        }
1498        out
1499    }
1500
1501    /// The annotate-snippets renderer emits the rust-lang toolchain look:
1502    /// an `error[CODE]: …` header, a `-->` origin line, and `^` underlines
1503    /// — not ariadne's enclosing box.
1504    #[cfg(feature = "annotate-snippets")]
1505    #[test]
1506    fn annotate_snippets_renderer_produces_rust_style_output() {
1507        use crate::builder::DiagnosticMessageBuilder;
1508
1509        let mut ctx = quarto_source_map::SourceContext::new();
1510        let file_id = ctx.add_file(
1511            "test.qmd".to_string(),
1512            Some("line 1\nline 2\nline 3".to_string()),
1513        );
1514        // Offsets 7..13 cover "line 2" on row 2.
1515        let location = quarto_source_map::SourceInfo::original(file_id, 7, 13);
1516        let msg = DiagnosticMessageBuilder::error("Bad thing")
1517            .with_code("Q-9-9")
1518            .with_location(location)
1519            .problem("this is wrong")
1520            .build();
1521
1522        let opts = TextRenderOptions {
1523            enable_hyperlinks: false,
1524        };
1525        let raw =
1526            msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
1527        let text = strip_ansi(&raw);
1528
1529        assert!(
1530            text.contains("error[Q-9-9]"),
1531            "expected rust-style code header; got: {text:?}"
1532        );
1533        assert!(
1534            text.contains("-->"),
1535            "expected rust-style origin arrow; got: {text:?}"
1536        );
1537        assert!(
1538            text.contains("test.qmd:2:1"),
1539            "expected mapped location; got: {text:?}"
1540        );
1541        assert!(
1542            !text.contains('\u{256D}'),
1543            "annotate-snippets must not draw ariadne's box corner; got: {text:?}"
1544        );
1545        // No OSC 8 hyperlinks from annotate-snippets.
1546        assert!(
1547            !raw.contains("\u{1b}]8;"),
1548            "annotate-snippets emits no OSC 8 hyperlinks; got: {raw:?}"
1549        );
1550    }
1551
1552    /// Direct coverage of the snapping helper's contract: clamp into the
1553    /// file, widen to whole characters, never invert.
1554    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
1555    #[test]
1556    fn snap_span_widens_to_whole_characters() {
1557        // `\u{2728}` occupies bytes 3..6.
1558        let content = "abc\u{2728}def";
1559        assert_eq!(content.len(), 9);
1560
1561        let snap = |s, e| DiagnosticMessage::snap_span_to_char_boundaries(content, s, e);
1562
1563        // Already aligned: unchanged.
1564        assert_eq!(snap(0, 3), 0..3);
1565        assert_eq!(snap(3, 6), 3..6);
1566
1567        // Start inside the char floors to its first byte; end inside it ceils
1568        // to its last, so the highlight covers the whole character.
1569        assert_eq!(snap(4, 9), 3..9);
1570        assert_eq!(snap(5, 9), 3..9);
1571        assert_eq!(snap(0, 4), 0..6);
1572        assert_eq!(snap(0, 5), 0..6);
1573        assert_eq!(snap(4, 5), 3..6);
1574
1575        // Past EOF clamps to the file length.
1576        assert_eq!(snap(3, 999), 3..9);
1577        assert_eq!(snap(999, 999), 9..9);
1578
1579        // Inverted input collapses to an empty range rather than inverting.
1580        assert_eq!(snap(6, 3), 6..6);
1581
1582        // An empty range inside a character still snaps to a boundary.
1583        let r = snap(4, 4);
1584        assert!(content.is_char_boundary(r.start) && content.is_char_boundary(r.end));
1585        assert!(r.start <= r.end);
1586
1587        // Second content block: the exact offset pair the two
1588        // `..._does_not_panic` integration tests below use, now that the
1589        // `quarto-source-map` 0.1.2+ floor makes 21 unreachable at their
1590        // level (see those tests' doc comments). This is where that
1591        // coverage now lives.
1592        //
1593        // Layout (byte offsets):
1594        //   `text: <span>Ask AI ` = 0..19, `\u{2728}` = 19..22, `</span>` = 22..29
1595        let content2 = "text: <span>Ask AI \u{2728}</span>";
1596        assert!(!content2.is_char_boundary(21), "test fixture precondition");
1597        let snap2 = |s, e| DiagnosticMessage::snap_span_to_char_boundaries(content2, s, e);
1598
1599        // 21 is mid-`\u{2728}` (bytes 19..22) and floors to 19; 28 is
1600        // already on a boundary (inside the trailing ASCII `</span>`) and
1601        // is left unchanged.
1602        assert_eq!(snap2(21, 28), 19..28);
1603    }
1604
1605    /// A diagnostic whose `SourceInfo` span originally lands mid-character
1606    /// still renders end to end under the ariadne renderer.
1607    ///
1608    /// This test used to be the integration-level proof that
1609    /// `snap_span_to_char_boundaries` prevents ariadne's mid-character
1610    /// panic. It no longer is: `quarto-source-map` 0.1.2+ floors
1611    /// `offset_to_location`'s returned offset to a UTF-8 character
1612    /// boundary, so by the time `map_offset` hands this test's span
1613    /// (21..28, with 21 mid-`\u{2728}`) to the renderer it has already
1614    /// become 19..28 — the snap in this crate is never exercised against a
1615    /// mid-character offset at this level, because one can no longer be
1616    /// constructed here. The snap's actual coverage moved to
1617    /// `snap_span_widens_to_whole_characters`'s second content block, which
1618    /// calls it directly with these same offsets.
1619    ///
1620    /// **Accepted, not an oversight:** after the upstream floor, no revert
1621    /// of this crate's own code (the snap helper or its three call sites)
1622    /// can turn this test red — it is unbound with respect to this crate's
1623    /// diff. That is known and accepted; the test stays as an end-to-end
1624    /// smoke check that a span-carrying diagnostic renders successfully
1625    /// under ariadne, not as a snap regression test.
1626    ///
1627    /// Layout of the source below (byte offsets):
1628    ///   `text: <span>Ask AI ` = 0..19, `\u{2728}` = 19..22, `</span>` = 22..29
1629    /// so 21 is two bytes into the three-byte char — exactly the observed
1630    /// off-by-one-left onto a multi-byte boundary.
1631    #[cfg(feature = "ariadne")]
1632    #[test]
1633    fn ariadne_renders_diagnostic_with_originally_mid_character_span() {
1634        use crate::builder::DiagnosticMessageBuilder;
1635
1636        let content = "text: <span>Ask AI \u{2728}</span>".to_string();
1637        assert!(!content.is_char_boundary(21), "test fixture precondition");
1638
1639        let mut ctx = quarto_source_map::SourceContext::new();
1640        let file_id = ctx.add_file("_quarto.yml".to_string(), Some(content.clone()));
1641        // 21..28 — start is mid-`\u{2728}`, mirroring the config-path shift.
1642        let location = quarto_source_map::SourceInfo::original(file_id, 21, 28);
1643        let msg = DiagnosticMessageBuilder::warning("HTML element converted to raw HTML")
1644            .with_code("Q-2-9")
1645            .with_location(location)
1646            .build();
1647
1648        let opts = TextRenderOptions {
1649            enable_hyperlinks: false,
1650        };
1651        let text = msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::Ariadne));
1652
1653        assert!(
1654            text.contains("HTML element converted to raw HTML"),
1655            "diagnostic must still render; got: {text:?}"
1656        );
1657        assert!(
1658            text.contains("_quarto.yml"),
1659            "source context must still render; got: {text:?}"
1660        );
1661    }
1662
1663    /// The same end-to-end smoke check as
1664    /// `ariadne_renders_diagnostic_with_originally_mid_character_span`, for
1665    /// the annotate-snippets renderer.
1666    ///
1667    /// It no longer exercises the mid-character path either, for the same
1668    /// reason: `quarto-source-map` 0.1.2+'s floor in `offset_to_location`
1669    /// means `map_offset` has already snapped this test's 21..28 span to
1670    /// 19..28 before it reaches annotate-snippets' `clamp` closure, so the
1671    /// closure never sees a mid-character offset from this call path. The
1672    /// snap's real coverage lives in `snap_span_widens_to_whole_characters`'s
1673    /// second content block (same 21..28 offsets, exercised directly).
1674    ///
1675    /// **Accepted, not an oversight:** as with the ariadne test above, no
1676    /// revert of this crate's own snap logic can turn this test red after
1677    /// the upstream floor — that unbinding is known and accepted.
1678    #[cfg(feature = "annotate-snippets")]
1679    #[test]
1680    fn annotate_snippets_renders_diagnostic_with_originally_mid_character_span() {
1681        use crate::builder::DiagnosticMessageBuilder;
1682
1683        let content = "text: <span>Ask AI \u{2728}</span>".to_string();
1684        assert!(!content.is_char_boundary(21), "test fixture precondition");
1685
1686        let mut ctx = quarto_source_map::SourceContext::new();
1687        let file_id = ctx.add_file("_quarto.yml".to_string(), Some(content.clone()));
1688        let location = quarto_source_map::SourceInfo::original(file_id, 21, 28);
1689        let msg = DiagnosticMessageBuilder::warning("HTML element converted to raw HTML")
1690            .with_code("Q-2-9")
1691            .with_location(location)
1692            .build();
1693
1694        let opts = TextRenderOptions {
1695            enable_hyperlinks: false,
1696        };
1697        let text =
1698            msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
1699
1700        assert!(
1701            text.contains("HTML element converted to raw HTML"),
1702            "diagnostic must still render; got: {text:?}"
1703        );
1704    }
1705
1706    /// Strip CSI SGR color sequences, for tests gated under a single
1707    /// renderer feature that can't rely on `strip_ansi` above (which is
1708    /// gated on `annotate-snippets` only — ariadne colorizes its source
1709    /// line and marker row too, so an ariadne-only test needs the same
1710    /// stripping without pulling in that feature). Same logic as
1711    /// `strip_ansi`, duplicated rather than re-gated so as not to touch
1712    /// the existing helper.
1713    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
1714    fn strip_ansi_colors(s: &str) -> String {
1715        let mut out = String::new();
1716        let mut chars = s.chars().peekable();
1717        while let Some(c) = chars.next() {
1718            if c == '\u{1b}' {
1719                for n in chars.by_ref() {
1720                    if n == 'm' {
1721                        break;
1722                    }
1723                }
1724            } else {
1725                out.push(c);
1726            }
1727        }
1728        out
1729    }
1730
1731    /// Measures the *rendered* width of a label whose mapped span is
1732    /// genuinely zero-width, under the ariadne renderer.
1733    ///
1734    /// `\u{2728}` occupies bytes 6..9 of the content below; the input span
1735    /// `SourceInfo::original(fid, 7, 8)` has **both ends** strictly inside
1736    /// that character (unlike the `..._does_not_panic` tests above, whose
1737    /// span only *starts* mid-character). Before the `quarto-source-map`
1738    /// 0.1.2+ floor, `map_offset` passed the raw offsets 7 and 8 through
1739    /// unchanged and this crate's own snap widened them to the whole
1740    /// character (6..9). After the floor, `offset_to_location` already
1741    /// floors both 7 and 8 down to 6 before this crate ever sees them, so
1742    /// both mapped offsets are 6 — the snap runs on `6..6`, which is
1743    /// already boundary-aligned, and has nothing left to widen. The
1744    /// highlight that reaches the renderer is therefore zero-width, not
1745    /// the whole character.
1746    ///
1747    /// ariadne's `Report::build` anchor is already `start..start`, so "a
1748    /// zero-width label probably renders fine" was a reasonable guess
1749    /// before this test — turning that guess into a measurement is the
1750    /// point here. The assertion is keyed on the renderer's own
1751    /// zero-width-vs-one-character marker *shape* (a bare `│` vs. `┬─`),
1752    /// not merely on the message text appearing, so it fails if the
1753    /// highlight ever widens back to covering the whole character.
1754    #[cfg(feature = "ariadne")]
1755    #[test]
1756    fn ariadne_zero_width_label_renders_a_bare_marker() {
1757        use crate::builder::DiagnosticMessageBuilder;
1758
1759        let content = "x = 'A\u{2728}B'".to_string();
1760        let mut ctx = quarto_source_map::SourceContext::new();
1761        let file_id = ctx.add_file("scratch.qmd".to_string(), Some(content.clone()));
1762        let location = quarto_source_map::SourceInfo::original(file_id, 7, 8);
1763        let msg = DiagnosticMessageBuilder::warning("scratch")
1764            .with_code("Q-2-9")
1765            .with_location(location)
1766            .build();
1767        let opts = TextRenderOptions {
1768            enable_hyperlinks: false,
1769        };
1770        let text = msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::Ariadne));
1771        let stripped = strip_ansi_colors(&text);
1772
1773        let lines: Vec<&str> = stripped.lines().collect();
1774        let source_idx = lines
1775            .iter()
1776            .position(|l| l.contains("x = 'A\u{2728}B'"))
1777            .unwrap_or_else(|| panic!("source line must render; got: {stripped:?}"));
1778        let marker_line = lines[source_idx + 1];
1779        // Skip past the gutter's own `│` (present on every row, e.g.
1780        // `   │       │  `) to isolate the marker glyphs themselves.
1781        let gutter_end = marker_line
1782            .find('│')
1783            .map(|i| i + '│'.len_utf8())
1784            .unwrap_or_else(|| panic!("marker row must have a gutter `│`; got: {marker_line:?}"));
1785        let marker = marker_line[gutter_end..].trim();
1786
1787        assert_eq!(
1788            marker, "│",
1789            "expected the zero-width `│` marker (a whole-character label \
1790             would instead draw `┬─`); got {marker:?} in:\n{stripped}"
1791        );
1792    }
1793
1794    /// The same measurement as `ariadne_zero_width_label_renders_a_bare_marker`,
1795    /// for the annotate-snippets renderer. See that test's doc comment for
1796    /// why both ends of `SourceInfo::original(fid, 7, 8)` land on the same
1797    /// mapped offset (6) after the `quarto-source-map` 0.1.2+ floor.
1798    ///
1799    /// annotate-snippets underlines a span with one `^` per byte of width,
1800    /// so the discriminating measurement here is even more direct than
1801    /// ariadne's marker shape: a zero-width label draws exactly one `^`,
1802    /// a whole-character label draws two (`^^`).
1803    #[cfg(feature = "annotate-snippets")]
1804    #[test]
1805    fn annotate_snippets_zero_width_label_renders_a_single_caret() {
1806        use crate::builder::DiagnosticMessageBuilder;
1807
1808        let content = "x = 'A\u{2728}B'".to_string();
1809        let mut ctx = quarto_source_map::SourceContext::new();
1810        let file_id = ctx.add_file("scratch.qmd".to_string(), Some(content.clone()));
1811        let location = quarto_source_map::SourceInfo::original(file_id, 7, 8);
1812        let msg = DiagnosticMessageBuilder::warning("scratch")
1813            .with_code("Q-2-9")
1814            .with_location(location)
1815            .build();
1816        let opts = TextRenderOptions {
1817            enable_hyperlinks: false,
1818        };
1819        let text =
1820            msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
1821        let stripped = strip_ansi_colors(&text);
1822
1823        let lines: Vec<&str> = stripped.lines().collect();
1824        let source_idx = lines
1825            .iter()
1826            .position(|l| l.contains("x = 'A\u{2728}B'"))
1827            .unwrap_or_else(|| panic!("source line must render; got: {stripped:?}"));
1828        let marker_line = lines[source_idx + 1];
1829        let caret_run: String = marker_line.chars().filter(|&c| c == '^').collect();
1830
1831        assert_eq!(
1832            caret_run, "^",
1833            "expected a single `^` caret marking a zero-width label (a \
1834             whole-character label would instead draw `^^`); got \
1835             {caret_run:?} in line: {marker_line:?}"
1836        );
1837    }
1838
1839    /// Forcing a specific renderer is honored: ariadne draws its boxed
1840    /// excerpt (the U+256D corner) while annotate-snippets does not.
1841    #[cfg(all(feature = "ariadne", feature = "annotate-snippets"))]
1842    #[test]
1843    fn renderer_selection_switches_styles() {
1844        use crate::builder::DiagnosticMessageBuilder;
1845
1846        let mut ctx = quarto_source_map::SourceContext::new();
1847        let file_id = ctx.add_file("a.qmd".to_string(), Some("alpha\nbeta\ngamma".to_string()));
1848        let location = quarto_source_map::SourceInfo::original(file_id, 6, 10); // "beta"
1849        let msg = DiagnosticMessageBuilder::error("Pick a style")
1850            .with_location(location)
1851            .build();
1852        let opts = TextRenderOptions {
1853            enable_hyperlinks: false,
1854        };
1855
1856        let ariadne = msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::Ariadne));
1857        let snippets =
1858            msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
1859
1860        assert!(ariadne.contains('\u{256D}'), "ariadne draws a box corner");
1861        assert!(
1862            !strip_ansi(&snippets).contains('\u{256D}'),
1863            "annotate-snippets does not"
1864        );
1865        assert!(strip_ansi(&snippets).contains("-->"));
1866    }
1867}