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
250/// A minimal multi-source [`ariadne::Cache`] over in-memory files, keyed
251/// by display path. The single-file `(id, source)` tuple used previously
252/// cannot serve labels rooted in another `Concat` piece, which need one
253/// source section per file.
254#[cfg(feature = "ariadne")]
255struct ContextSourceCache {
256    files: Vec<(String, ariadne::Source<String>)>,
257}
258
259#[cfg(feature = "ariadne")]
260impl ariadne::Cache<String> for ContextSourceCache {
261    type Storage = String;
262
263    fn fetch(&mut self, id: &String) -> Result<&ariadne::Source<String>, impl std::fmt::Debug> {
264        self.files
265            .iter()
266            .find(|(path, _)| path == id)
267            .map(|(_, source)| source)
268            .ok_or(MissingSource)
269    }
270
271    fn display<'a>(&self, id: &'a String) -> Option<impl std::fmt::Display + 'a> {
272        Some(id)
273    }
274}
275
276/// Fetch-error type for [`ContextSourceCache`]; ariadne only formats it
277/// into an eprintln for sources no label references.
278#[cfg(feature = "ariadne")]
279struct MissingSource;
280
281#[cfg(feature = "ariadne")]
282impl std::fmt::Debug for MissingSource {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        f.write_str("source not registered in the diagnostic's source context")
285    }
286}
287
288impl DiagnosticMessage {
289    /// Access the diagnostic message builder API.
290    ///
291    /// This is the recommended way to create diagnostic messages, as the builder API
292    /// encodes tidyverse-style guidelines and makes it easy to construct well-structured
293    /// error messages.
294    ///
295    /// # Example
296    ///
297    /// ```
298    /// use quarto_error_reporting::{DiagnosticMessage, DiagnosticMessageBuilder};
299    ///
300    /// let error = DiagnosticMessageBuilder::error("Incompatible types")
301    ///     .with_code("Q-1-2") // quarto-error-code-audit-ignore
302    ///     .problem("Cannot combine date and datetime types")
303    ///     .add_detail("`x` has type `date`")
304    ///     .add_detail("`y` has type `datetime`")
305    ///     .add_hint("Convert both to the same type?")
306    ///     .build();
307    /// ```
308    pub fn builder() -> crate::builder::DiagnosticMessageBuilder {
309        // This is just a convenience for accessing the builder type
310        // Users should call DiagnosticMessageBuilder::error() etc directly
311        crate::builder::DiagnosticMessageBuilder::error("")
312    }
313
314    /// Create a new diagnostic message with just a title and kind.
315    ///
316    /// Note: Consider using `DiagnosticMessage::builder()` instead for better structure.
317    pub fn new(kind: DiagnosticKind, title: impl Into<String>) -> Self {
318        Self {
319            code: None,
320            title: title.into(),
321            kind,
322            problem: None,
323            details: Vec::new(),
324            hints: Vec::new(),
325            location: None,
326        }
327    }
328
329    /// Create an error diagnostic.
330    ///
331    /// Note: Consider using `DiagnosticMessage::builder().error()` instead for better structure.
332    pub fn error(title: impl Into<String>) -> Self {
333        Self::new(DiagnosticKind::Error, title)
334    }
335
336    /// Create a warning diagnostic.
337    ///
338    /// Note: Consider using `DiagnosticMessage::builder().warning()` instead for better structure.
339    pub fn warning(title: impl Into<String>) -> Self {
340        Self::new(DiagnosticKind::Warning, title)
341    }
342
343    /// Create an info diagnostic.
344    ///
345    /// Note: Consider using `DiagnosticMessage::builder().info()` instead for better structure.
346    pub fn info(title: impl Into<String>) -> Self {
347        Self::new(DiagnosticKind::Info, title)
348    }
349
350    /// Set the error code.
351    ///
352    /// Error codes follow the format `Q-<subsystem>-<number>` (e.g., "Q-1-1").
353    ///
354    /// # Example
355    ///
356    /// ```
357    /// use quarto_error_reporting::DiagnosticMessage;
358    ///
359    /// let msg = DiagnosticMessage::error("YAML Syntax Error")
360    ///     .with_code("Q-1-1");
361    /// ```
362    pub fn with_code(mut self, code: impl Into<String>) -> Self {
363        self.code = Some(code.into());
364        self
365    }
366
367    /// Get the documentation URL for this error, if it has an error code.
368    ///
369    /// # Example
370    ///
371    /// Resolves the code against the installed [`CatalogProvider`]
372    /// (`crate::catalog`); returns `None` when no catalog is installed, the
373    /// code is unknown, or the entry has no docs URL.
374    ///
375    /// ```
376    /// use quarto_error_reporting::DiagnosticMessage;
377    ///
378    /// let msg = DiagnosticMessage::error("Internal Error")
379    ///     .with_code("Q-0-1");
380    ///
381    /// // `Some(url)` iff a catalog mapping "Q-0-1" (with a docs URL) is installed.
382    /// let _ = msg.docs_url();
383    /// ```
384    pub fn docs_url(&self) -> Option<&str> {
385        self.code
386            .as_ref()
387            .and_then(|code| crate::catalog::get_docs_url(code))
388    }
389
390    /// Render this diagnostic message as text following tidyverse style.
391    ///
392    /// This is a convenience method that uses default rendering options.
393    /// For more control over rendering, use [`Self::to_text_with_options`].
394    ///
395    /// # Example
396    ///
397    /// ```
398    /// use quarto_error_reporting::DiagnosticMessageBuilder;
399    ///
400    /// let msg = DiagnosticMessageBuilder::error("Invalid input")
401    ///     .problem("Values must be numeric")
402    ///     .add_detail("Found text in column 3")
403    ///     .add_hint("Convert to numbers first?")
404    ///     .build();
405    /// let text = msg.to_text(None);
406    /// assert!(text.contains("Error: Invalid input"));
407    /// assert!(text.contains("Values must be numeric"));
408    /// ```
409    pub fn to_text(&self, ctx: Option<&quarto_source_map::SourceContext>) -> String {
410        self.to_text_with_options(ctx, &TextRenderOptions::default())
411    }
412
413    /// Render this diagnostic message as text following tidyverse style with custom options.
414    ///
415    /// Format:
416    /// ```text
417    /// Error: title
418    /// Problem statement here
419    /// ✖ Error detail 1
420    /// ✖ Error detail 2
421    /// ℹ Info detail
422    /// • Note detail
423    /// ? Hint 1
424    /// ? Hint 2
425    /// ```
426    ///
427    /// # Example
428    ///
429    /// ```
430    /// use quarto_error_reporting::{DiagnosticMessageBuilder, TextRenderOptions};
431    ///
432    /// let msg = DiagnosticMessageBuilder::error("Invalid input")
433    ///     .problem("Values must be numeric")
434    ///     .add_detail("Found text in column 3")
435    ///     .add_hint("Convert to numbers first?")
436    ///     .build();
437    ///
438    /// // Disable hyperlinks for snapshot testing
439    /// let options = TextRenderOptions { enable_hyperlinks: false };
440    /// let text = msg.to_text_with_options(None, &options);
441    /// assert!(text.contains("Error: Invalid input"));
442    /// ```
443    pub fn to_text_with_options(
444        &self,
445        ctx: Option<&quarto_source_map::SourceContext>,
446        options: &TextRenderOptions,
447    ) -> String {
448        self.to_text_with_renderer(ctx, options, None)
449    }
450
451    /// Like [`Self::to_text_with_options`], but explicitly selects which
452    /// source-context snippet renderer draws the visual code excerpt.
453    ///
454    /// Pass `Some(SourceRenderer::Ariadne)` or
455    /// `Some(SourceRenderer::AnnotateSnippets)` to force a specific
456    /// renderer (the corresponding feature must be enabled), or `None`
457    /// to use [`SourceRenderer::default_for_features`]. This is the seam
458    /// for experimenting with diagnostic rendering styles without
459    /// changing the rest of the API: only the source-excerpt block
460    /// differs between renderers; the surrounding structured text
461    /// (unlocated details, hints) is identical.
462    ///
463    /// When no renderer feature is enabled — or the diagnostic has no
464    /// location / source context — this falls back to the structured
465    /// tidyverse-style text block, exactly as [`Self::to_text_with_options`].
466    ///
467    /// # Example
468    ///
469    /// ```
470    /// use quarto_error_reporting::{DiagnosticMessageBuilder, TextRenderOptions};
471    ///
472    /// let msg = DiagnosticMessageBuilder::error("Invalid input")
473    ///     .problem("Values must be numeric")
474    ///     .build();
475    ///
476    /// // `None` picks the default renderer for the enabled features.
477    /// let text = msg.to_text_with_renderer(None, &TextRenderOptions::default(), None);
478    /// assert!(text.contains("Invalid input"));
479    /// ```
480    pub fn to_text_with_renderer(
481        &self,
482        ctx: Option<&quarto_source_map::SourceContext>,
483        options: &TextRenderOptions,
484        renderer: Option<SourceRenderer>,
485    ) -> String {
486        use std::fmt::Write;
487
488        let mut result = String::new();
489
490        // Check if we have any location info that could be displayed in a
491        // source excerpt. This includes the main diagnostic location OR
492        // any detail with a location.
493        let has_any_location =
494            self.location.is_some() || self.details.iter().any(|d| d.location.is_some());
495
496        // If we have location info and source context, render the source
497        // excerpt with the selected (or default) renderer.
498        let has_source_render = if let (true, Some(ctx_val)) = (has_any_location, ctx) {
499            // Use main location if available, otherwise use first detail location
500            let location = self
501                .location
502                .as_ref()
503                .or_else(|| self.details.iter().find_map(|d| d.location.as_ref()));
504
505            if let Some(loc) = location {
506                if let Some(snippet_output) =
507                    self.render_source_context(loc, ctx_val, options.enable_hyperlinks, renderer)
508                {
509                    result.push_str(&snippet_output);
510                    true
511                } else {
512                    false
513                }
514            } else {
515                false
516            }
517        } else {
518            false
519        };
520
521        // If we don't have a source excerpt, show full tidyverse-style content.
522        // If we do, only show details without locations and hints
523        // (the renderer already shows: title, code, problem, and located details)
524        if !has_source_render {
525            // No source excerpt - show everything in tidyverse style
526
527            // Title with kind prefix and error code (e.g., "Error [Q-1-1]: Invalid input")
528            let kind_str = match self.kind {
529                DiagnosticKind::Error => "Error",
530                DiagnosticKind::Warning => "Warning",
531                DiagnosticKind::Info => "Info",
532                DiagnosticKind::Note => "Note",
533            };
534            if let Some(code) = &self.code {
535                writeln!(result, "{} [{}]: {}", kind_str, code, self.title).unwrap();
536            } else {
537                writeln!(result, "{}: {}", kind_str, self.title).unwrap();
538            }
539
540            // Show location info if available (but no ariadne rendering)
541            if let Some(loc) = &self.location {
542                // Try to map with context if available
543                if let Some(ctx) = ctx {
544                    if let Some(mapped) = loc.map_offset(loc.start_offset(), ctx)
545                        && let Some(file) = ctx.get_file(mapped.file_id)
546                    {
547                        writeln!(
548                            result,
549                            "  at {}:{}:{}",
550                            file.path,
551                            mapped.location.row + 1,
552                            mapped.location.column + 1
553                        )
554                        .unwrap();
555                    }
556                } else {
557                    // No context: show immediate location (1-indexed for display)
558                    // Note: Without context, we can't get row/column from offsets
559                    // We could map_offset with ctx to get Location, but ctx is None here
560                    writeln!(result, "  at offset {}", loc.start_offset()).unwrap();
561                }
562            }
563
564            // Problem statement (optional additional context)
565            if let Some(problem) = &self.problem {
566                writeln!(result, "{}", problem.as_str()).unwrap();
567            }
568
569            // All details with appropriate bullets
570            for detail in &self.details {
571                let bullet = match detail.kind {
572                    DetailKind::Error => "✖",
573                    DetailKind::Info => "ℹ",
574                    DetailKind::Note | DetailKind::Faded => "•",
575                };
576                writeln!(result, "{} {}", bullet, detail.content.as_str()).unwrap();
577            }
578
579            // All hints
580            for hint in &self.hints {
581                writeln!(result, "ℹ {}", hint.as_str()).unwrap();
582            }
583        } else {
584            // Have a source excerpt - only show details without locations and hints
585            // (the renderer shows title, code, problem, and located details)
586
587            // Details without locations (the source excerpt can't show these)
588            for detail in &self.details {
589                if detail.location.is_none() {
590                    let bullet = match detail.kind {
591                        DetailKind::Error => "✖",
592                        DetailKind::Info => "ℹ",
593                        DetailKind::Note | DetailKind::Faded => "•",
594                    };
595                    writeln!(result, "{} {}", bullet, detail.content.as_str()).unwrap();
596                }
597            }
598
599            // All hints (ariadne doesn't show hints)
600            for hint in &self.hints {
601                writeln!(result, "ℹ {}", hint.as_str()).unwrap();
602            }
603        }
604
605        result
606    }
607
608    /// Render this diagnostic message as a JSON value.
609    ///
610    /// Returns a structured JSON object with all fields:
611    /// ```json
612    /// {
613    ///   "kind": "error",
614    ///   "title": "Invalid input",
615    ///   "code": "Q-1-2", // quarto-error-code-audit-ignore
616    ///   "problem": "Values must be numeric",
617    ///   "details": [{"kind": "error", "content": "Found text in column 3"}],
618    ///   "hints": ["Convert to numbers first?"]
619    /// }
620    /// ```
621    ///
622    /// # Example
623    ///
624    /// ```
625    /// use quarto_error_reporting::DiagnosticMessage;
626    ///
627    /// let msg = DiagnosticMessage::error("Something went wrong");
628    /// let json = msg.to_json();
629    /// assert_eq!(json["kind"], "error");
630    /// assert_eq!(json["title"], "Something went wrong");
631    /// ```
632    pub fn to_json(&self) -> serde_json::Value {
633        use serde_json::json;
634
635        let kind_str = match self.kind {
636            DiagnosticKind::Error => "error",
637            DiagnosticKind::Warning => "warning",
638            DiagnosticKind::Info => "info",
639            DiagnosticKind::Note => "note",
640        };
641
642        let mut obj = json!({
643            "kind": kind_str,
644            "title": self.title,
645        });
646
647        // Add optional fields
648        if let Some(code) = &self.code {
649            obj["code"] = json!(code);
650        }
651
652        if let Some(problem) = &self.problem {
653            obj["problem"] = problem.to_json();
654        }
655
656        if !self.details.is_empty() {
657            let details: Vec<_> = self
658                .details
659                .iter()
660                .map(|d| {
661                    let detail_kind = match d.kind {
662                        DetailKind::Error => "error",
663                        DetailKind::Info => "info",
664                        DetailKind::Note => "note",
665                        DetailKind::Faded => "faded",
666                    };
667                    let mut detail_obj = json!({
668                        "kind": detail_kind,
669                        "content": d.content.to_json()
670                    });
671                    if let Some(location) = &d.location {
672                        detail_obj["location"] = json!(location);
673                    }
674                    detail_obj
675                })
676                .collect();
677            obj["details"] = json!(details);
678        }
679
680        if !self.hints.is_empty() {
681            let hints: Vec<_> = self.hints.iter().map(|h| h.to_json()).collect();
682            obj["hints"] = json!(hints);
683        }
684
685        if let Some(location) = &self.location {
686            obj["location"] = json!(location); // quarto-source-map::SourceInfo is Serialize
687        }
688
689        obj
690    }
691
692    /// Snap a mapped byte range onto UTF-8 character boundaries within
693    /// `content`, clamping it into the file and keeping `start <= end`.
694    ///
695    /// # Why the renderers need this
696    ///
697    /// Both source-context renderers slice the source by byte offset, and
698    /// both **panic** — they do not merely mis-render — on an offset that
699    /// falls inside a multi-byte character. Measured 2026-08-23 against the
700    /// versions this crate's lockfile resolves (ariadne 0.6.0,
701    /// annotate-snippets 0.12.16; the manifest declares only `0.6` and
702    /// `0.12`), rendering `"text: <span>Ask AI \u{2728}</span>"` in which
703    /// `\u{2728}` occupies bytes 19..22 and the offset each row names is
704    /// placed at 20 or 21 — both interior to that character:
705    ///
706    /// | offset placed mid-character | ariadne | annotate-snippets |
707    /// |---|---|---|
708    /// | label start   | panics, `write.rs:84`  | panics, `renderer/source_map.rs:71` |
709    /// | label end     | panics, `write.rs:102` | panics, `renderer/source_map.rs:98` |
710    /// | report anchor | panics, `write.rs:267` | n/a — this crate passes no separate anchor |
711    ///
712    /// The clamping half guards two further aborts that boundary-snapping
713    /// alone would not catch, measured the same way:
714    ///
715    /// | malformed range | ariadne | annotate-snippets |
716    /// |---|---|---|
717    /// | end past EOF          | tolerates (degraded excerpt) | panics, `renderer/source_map.rs:158` |
718    /// | inverted, `end < start` | panics, `lib.rs:145` (a plain `assert!`, so also in release) | `renderer/render.rs:1394` subtracts unchecked: panics only where overflow checks are on (debug/test), wraps silently in a default release build |
719    ///
720    /// Printing a diagnostic must never be able to kill a render, so we
721    /// normalize here rather than trusting the input.
722    ///
723    /// # What is actually load-bearing, and when
724    ///
725    /// **How much the snapping half is doing depends on what
726    /// `quarto-source-map` resolves to.** Since 0.1.2,
727    /// `FileInformation::offset_to_location` returns the *floored* offset
728    /// (`src/file_info.rs:116-125`:
729    /// `safe_offset` walks left onto a character boundary and is returned as
730    /// `Location.offset`; 0.1.0 and 0.1.1 computed `safe_offset` but returned
731    /// the raw `offset`). **This crate's declared floor is still
732    /// `quarto-source-map = "0.1.0"` (`Cargo.toml:28`)**, so a consumer that
733    /// resolves 0.1.0 or 0.1.1 — an existing lock, another graph member
734    /// pinning `=0.1.1`, `-Z minimal-versions` — gets no upstream floor at
735    /// all, and for that build the snap below is the only guard rather than
736    /// a backstop. A published library's lockfile does not constrain its
737    /// consumers; downstream, q2 had to raise its own floor, because this
738    /// manifest does not force it.
739    ///
740    /// On a resolution that *does* have the floor, it covers the mapping
741    /// path broadly: every value-producing path of `SourceInfo::map_offset`
742    /// runs through `offset_to_location` (`src/mapping.rs:38`; `Generated`
743    /// yields `None` instead), and all three call sites here are fed
744    /// `map_offset` results — the span shared by the report anchor and the
745    /// main label, and the detail-label spans, both in
746    /// `render_ariadne_source_context`; and the `clamp` closure in
747    /// `render_annotate_snippets_source_context`.
748    ///
749    /// So a mid-character offset can no longer reach a renderer through the
750    /// mapping path **when the span resolves within a single file**. The
751    /// cross-file `Concat` shape described below is an exception for the
752    /// snapping half as well as the clamping half: `map_offset` floors an
753    /// offset against the piece's *own* file content (`src/mapping.rs:25-38`),
754    /// which says nothing about where character boundaries fall in
755    /// `content`.
756    ///
757    /// The snap is therefore kept deliberately. It is the only guard on a
758    /// pre-0.1.2 resolution, a live guard on the cross-file shape, and the
759    /// backstop if the upstream floor regresses or a future caller hands us
760    /// raw, unmapped offsets.
761    ///
762    /// **The clamping half is still live.** `end_mapped` is not always the
763    /// mapped image of this span's end: when `map_offset(length())` fails,
764    /// both renderer paths substitute `map_offset(length() - 1)` and then
765    /// fall back to `start_mapped` (the `length() - 1` fallback at
766    /// `:842-852` in `render_ariadne_source_context` and `:1038-1047` in
767    /// `render_annotate_snippets_source_context`, line numbers as of 0.2.2).
768    /// And `content` belongs to `root_file_id()`, which for a `Concat` is
769    /// the file of the *first* piece that resolves to one
770    /// (`quarto-source-map`'s `src/source_info.rs:549-560`), while
771    /// `map_offset` resolves into whichever piece contains the offset.
772    /// Structurally, then — a `Concat` spanning two files — the two ends
773    /// can resolve into different files, whose offsets are neither ordered
774    /// with respect to each other nor bounded by `content.len()`. (That
775    /// shape is permitted by the types; unlike the panics tabled above it
776    /// has not been exercised here.) `start.min(len)`, `end.min(len)` and
777    /// `.max(s)` reduce any of that to an in-range, non-inverted span.
778    ///
779    /// # Behaviour
780    ///
781    /// The range is widened, not truncated: `start` floors to the start of
782    /// the character containing it and `end` ceils to the end of the
783    /// character containing it, so the highlight covers whole characters
784    /// and can never invert. This differs from the upstream floor, which
785    /// walks an end offset *left* rather than widening it; the two agree
786    /// whenever only the start is misaligned.
787    ///
788    /// # Coverage
789    ///
790    /// The direct unit coverage is `snap_span_widens_to_whole_characters`.
791    /// The two `..._renders_diagnostic_with_originally_mid_character_span`
792    /// tests are end-to-end smoke checks only, and do not bind to this
793    /// helper: commit `5e48166`, *"Re-anchor the mid-character-span crash
794    /// tests after the 0.1.3 floor"*, re-anchored them and records why. (It
795    /// is a commit of PR #5, which was squash-merged as `87f1d38`, so it is
796    /// reachable through that PR rather than from `main`'s history.)
797    ///
798    /// Downstream, the quarto-dev/q2 repository is adding an end-to-end pin
799    /// for the founding crash, under `crates/quarto/tests/integration/`: it
800    /// drives the real `q2` binary over a website project whose
801    /// `_quarto.yml` navbar entry embeds `\u{2728}` **mid-string**, as
802    /// `text: '<span id="x">Ask AI \u{2728}</span>'`, and asserts a clean
803    /// exit alongside the expected caret columns. The trailing `</span>` is
804    /// load-bearing — one asserted caret falls past it — so it is the same
805    /// shape as this crate's own tests, where `\u{2728}` sits at bytes
806    /// 19..22 and `</span>` at 22..29. That pin will redden only if q2's
807    /// mapping regresses *and* both this snap and the upstream floor are
808    /// gone: it guards the combination, not this helper on its own.
809    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
810    fn snap_span_to_char_boundaries(
811        content: &str,
812        start: usize,
813        end: usize,
814    ) -> std::ops::Range<usize> {
815        let len = content.len();
816        let mut s = start.min(len);
817        let mut e = end.min(len).max(s);
818        while s > 0 && !content.is_char_boundary(s) {
819            s -= 1;
820        }
821        while e < len && !content.is_char_boundary(e) {
822            e += 1;
823        }
824        s..e
825    }
826
827    /// Dispatch to the selected source-context renderer.
828    ///
829    /// `renderer` of `None` resolves to [`SourceRenderer::default_for_features`].
830    /// Returns `None` when no renderer is available (no renderer feature
831    /// enabled) or the chosen renderer could not draw the excerpt (e.g.
832    /// the file content is unavailable — common in WASM), in which case
833    /// the caller falls back to the structured text block.
834    #[cfg_attr(
835        not(any(feature = "ariadne", feature = "annotate-snippets")),
836        allow(unused_variables)
837    )]
838    fn render_source_context(
839        &self,
840        main_location: &quarto_source_map::SourceInfo,
841        ctx: &quarto_source_map::SourceContext,
842        enable_hyperlinks: bool,
843        renderer: Option<SourceRenderer>,
844    ) -> Option<String> {
845        let renderer = renderer.or_else(SourceRenderer::default_for_features)?;
846        match renderer {
847            #[cfg(feature = "ariadne")]
848            SourceRenderer::Ariadne => {
849                self.render_ariadne_source_context(main_location, ctx, enable_hyperlinks)
850            }
851            #[cfg(feature = "annotate-snippets")]
852            SourceRenderer::AnnotateSnippets => {
853                self.render_annotate_snippets_source_context(main_location, ctx, enable_hyperlinks)
854            }
855        }
856    }
857
858    /// Wrap a file path with OSC 8 ANSI hyperlink codes for clickable terminal links.
859    ///
860    /// OSC 8 is a terminal escape sequence that creates clickable hyperlinks:
861    /// `\x1b]8;;URI\x1b\\TEXT\x1b\\`
862    ///
863    /// `path` is always the *displayed* text (for a virtual file, the
864    /// cell-qualified label). `link_target` is the disk path the hyperlink
865    /// opens — normally `path` itself when it exists on disk, or the owning
866    /// notebook for a virtual file that carries a `FileOrigin`. `None`
867    /// disables the link.
868    ///
869    /// A link is emitted only if:
870    /// - Hyperlinks are enabled via the `enable_hyperlinks` parameter
871    /// - `link_target` is `Some` and can be canonicalized
872    ///
873    /// The `url` crate handles:
874    /// - Platform differences (Windows drive letters vs Unix paths)
875    /// - Percent-encoding of special characters
876    /// - Proper file:// URL construction
877    ///
878    /// Line and column numbers are added to the URL as a fragment identifier
879    /// (e.g., `file:///path#line:column`), which is supported by iTerm2 3.4+
880    /// and other terminal emulators for opening files at specific positions.
881    /// The fragment is emitted only when the position belongs to the linked
882    /// file itself (`link_target == path`); an origin link opens a different
883    /// file whose coordinates we cannot speak to.
884    ///
885    /// Returns the wrapped path if conditions are met, otherwise returns the original path.
886    ///
887    /// Only used by the ariadne renderer (annotate-snippets has no OSC 8 support).
888    #[cfg(all(feature = "ariadne", not(target_family = "wasm")))]
889    fn wrap_path_with_hyperlink(
890        path: &str,
891        link_target: Option<&str>,
892        line: Option<usize>,
893        column: Option<usize>,
894        enable_hyperlinks: bool,
895    ) -> String {
896        // Don't add hyperlinks if disabled (e.g., for snapshot testing)
897        if !enable_hyperlinks {
898            return path.to_string();
899        }
900
901        let Some(target) = link_target else {
902            return path.to_string();
903        };
904
905        // Canonicalize to absolute path
906        let abs_path = match std::fs::canonicalize(target) {
907            Ok(p) => p,
908            Err(_) => return path.to_string(), // Can't canonicalize, skip hyperlink
909        };
910
911        // Convert to file:// URL (handles Windows/Unix + percent-encoding)
912        let mut file_url = match url::Url::from_file_path(Self::plain_absolute_path(abs_path)) {
913            Ok(url) => url.as_str().to_string(),
914            Err(_) => return path.to_string(), // Conversion failed, skip hyperlink
915        };
916
917        // Add line and column as fragment identifier (e.g., #line:column)
918        // This format is supported by iTerm2 3.4+ semantic history — but
919        // only when the position belongs to the linked file itself. An
920        // origin link opens a *different* file (the owning notebook), and
921        // the diagnostic's coordinates are relative to the virtual file.
922        if target == path
923            && let Some(line_num) = line
924        {
925            match column {
926                Some(col_num) => file_url.push_str(&format!("#{}:{}", line_num, col_num)),
927                None => file_url.push_str(&format!("#{}", line_num)),
928            }
929        }
930
931        // Wrap with OSC 8 codes: \x1b]8;;URI\x1b\\TEXT\x1b]8;;\x1b\\
932        format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", file_url, path)
933    }
934
935    /// The absolute path to hand to `url::Url::from_file_path`.
936    ///
937    /// Gated with `wrap_path_with_hyperlink` (its only production caller);
938    /// unit tests call it so the expected URL is computed the same way the
939    /// production renderer does. Windows: `fs::canonicalize` returns
940    /// verbatim (`\\?\C:\…`) paths, which the url crate renders as
941    /// `file://?/C:/…` — no terminal can open that. Strip the verbatim
942    /// prefix (`\\?\UNC\` → `\\`) to get the plain absolute form.
943    #[cfg(all(feature = "ariadne", not(target_family = "wasm")))]
944    fn plain_absolute_path(p: std::path::PathBuf) -> std::path::PathBuf {
945        #[cfg(windows)]
946        {
947            let s = p.as_os_str().to_string_lossy();
948            if let Some(rest) = s.strip_prefix(r#"\\?\UNC\"#) {
949                return std::path::PathBuf::from(format!(r"\\{rest}"));
950            }
951            if let Some(rest) = s.strip_prefix(r#"\\?\"#) {
952                return std::path::PathBuf::from(rest);
953            }
954        }
955        p
956    }
957
958    /// The disk path a file's label should hyperlink to: the owning
959    /// notebook for a virtual file with a `FileOrigin`, the file itself
960    /// when it exists on disk, `None` otherwise (ephemeral or missing).
961    #[cfg(feature = "ariadne")]
962    fn hyperlink_target(file: &quarto_source_map::SourceFile) -> Option<&str> {
963        match file.metadata.origin.as_ref() {
964            Some(quarto_source_map::FileOrigin::NotebookCell { notebook_path, .. }) => {
965                Some(notebook_path)
966            }
967            None => std::path::Path::new(&file.path)
968                .exists()
969                .then_some(file.path.as_str()),
970        }
971    }
972
973    /// WASM version: hyperlinks don't make sense in WASM environments (no file system).
974    /// Just return the path unmodified.
975    #[cfg(all(feature = "ariadne", target_family = "wasm"))]
976    fn wrap_path_with_hyperlink(
977        path: &str,
978        _link_target: Option<&str>,
979        _line: Option<usize>,
980        _column: Option<usize>,
981        _enable_hyperlinks: bool,
982    ) -> String {
983        path.to_string()
984    }
985
986    /// One-line textual location for a span whose two ends resolve into
987    /// different `Concat` pieces, rendered in place of a snippet. A
988    /// cross-piece span cannot be drawn as one excerpt, and clamping it
989    /// into either piece's content would point at the wrong file.
990    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
991    fn format_cross_piece_location(
992        start: &quarto_source_map::MappedLocation,
993        end: &quarto_source_map::MappedLocation,
994        ctx: &quarto_source_map::SourceContext,
995    ) -> String {
996        let name = |file_id| match ctx.get_file(file_id) {
997            Some(file) => file.path.clone(),
998            None => "<unknown file>".to_string(),
999        };
1000        // Line and column numbers are 1-indexed for display (Location uses 0-indexed)
1001        format!(
1002            "  --> {}:{}:{} (spans through {}:{}:{})\n",
1003            name(start.file_id),
1004            start.location.row + 1,
1005            start.location.column + 1,
1006            name(end.file_id),
1007            end.location.row + 1,
1008            end.location.column + 1,
1009        )
1010    }
1011
1012    /// Render source context using ariadne (private helper for to_text).
1013    ///
1014    /// This produces the visual source code snippet with highlighting.
1015    /// The tidyverse-style problem/details/hints are added separately by to_text().
1016    #[cfg(feature = "ariadne")]
1017    fn render_ariadne_source_context(
1018        &self,
1019        main_location: &quarto_source_map::SourceInfo,
1020        ctx: &quarto_source_map::SourceContext,
1021        enable_hyperlinks: bool,
1022    ) -> Option<String> {
1023        use ariadne::{Color, Config, IndexType, Label, Report, ReportKind, Source};
1024
1025        // Mirror of ariadne's private `Config::unimportant_color()` from
1026        // ariadne 0.6.0 (`src/lib.rs:543`). We use this for `DetailKind::Faded`
1027        // labels so they blend visually with characters that fall outside any
1028        // label. Bump this constant if the ariadne dependency upgrades and
1029        // changes the colour.
1030        const ARIADNE_UNIMPORTANT_COLOR: Color = Color::Fixed(249);
1031
1032        // The report's file is the one the span *starts* in. For a
1033        // multi-piece `Concat` (q2's per-cell ipynb virtual files),
1034        // `root_file_id()` is the first piece's file — wrong for a
1035        // diagnostic rooted in a later piece — while `map_offset`
1036        // resolves piece-aware. `start_mapped.file_id` is correct for
1037        // both shapes (and identical to `root_file_id()` on a
1038        // single-file source).
1039        let start_mapped = main_location.map_offset(0, ctx)?;
1040        let file_id = start_mapped.file_id;
1041
1042        let file = ctx.get_file(file_id)?;
1043
1044        // Get file content: use stored content for ephemeral files, or read from disk.
1045        // In WASM (and any host with no real filesystem) the disk read fails with
1046        // "operation not supported on this platform"; the only graceful response is
1047        // to drop the source-context snippet. The diagnostic's code, message, and
1048        // hints still surface — only the Ariadne visual is unavailable.
1049        let content = match &file.content {
1050            Some(c) => c.clone(),
1051            None => match std::fs::read_to_string(&file.path) {
1052                Ok(s) => s,
1053                Err(_) => return None,
1054            },
1055        };
1056
1057        // For end offset, try the full length first. If that fails (e.g., when the span
1058        // extends past EOF), clamp to the last valid position. This handles edge cases
1059        // like errors pointing to EOF or diagnostics with off-by-one end offsets.
1060        let end_mapped = main_location
1061            .map_offset(main_location.length(), ctx)
1062            .or_else(|| {
1063                // Clamp: if length() fails, try length()-1, which should be the last valid byte
1064                if main_location.length() > 0 {
1065                    main_location.map_offset(main_location.length() - 1, ctx)
1066                } else {
1067                    None
1068                }
1069            })
1070            .unwrap_or_else(|| start_mapped.clone());
1071
1072        // A span whose two ends resolve into different pieces cannot be
1073        // drawn as one snippet — rendering it against either piece's
1074        // content would clamp it silently into the wrong file. Label both
1075        // ends textually instead.
1076        if end_mapped.file_id != start_mapped.file_id {
1077            return Some(Self::format_cross_piece_location(
1078                &start_mapped,
1079                &end_mapped,
1080                ctx,
1081            ));
1082        }
1083
1084        // Create display path with OSC 8 hyperlink for clickable file paths.
1085        // A virtual cell file (FileOrigin) links its owning notebook; a real
1086        // file links itself; an ephemeral file with no origin stays unlinked.
1087        let link_target = Self::hyperlink_target(file);
1088        // Line and column numbers are 1-indexed for display (start_mapped.location uses 0-indexed)
1089        let line = Some(start_mapped.location.row + 1);
1090        let column = Some(start_mapped.location.column + 1);
1091        let display_path = Self::wrap_path_with_hyperlink(
1092            &file.path,
1093            link_target,
1094            line,
1095            column,
1096            enable_hyperlinks,
1097        );
1098
1099        // Determine report kind and color
1100        let (report_kind, main_color) = match self.kind {
1101            DiagnosticKind::Error => (ReportKind::Error, Color::Red),
1102            DiagnosticKind::Warning => (ReportKind::Warning, Color::Yellow),
1103            DiagnosticKind::Info => (ReportKind::Advice, Color::Cyan),
1104            DiagnosticKind::Note => (ReportKind::Advice, Color::Blue),
1105        };
1106
1107        // Snap once, up front: every offset handed to ariadne below (the
1108        // report anchor and the main label) must be char-boundary safe.
1109        let main_span = Self::snap_span_to_char_boundaries(
1110            &content,
1111            start_mapped.location.offset,
1112            end_mapped.location.offset,
1113        );
1114
1115        // Build the report using the mapped offset for proper line:column display
1116        // IMPORTANT: Use IndexType::Byte because our offsets are byte offsets, not character offsets
1117        let mut report = Report::build(
1118            report_kind,
1119            (display_path.clone(), main_span.start..main_span.start),
1120        )
1121        .with_config(Config::default().with_index_type(IndexType::Byte));
1122
1123        // Add title with error code
1124        if let Some(code) = &self.code {
1125            report = report.with_message(format!("[{}] {}", code, self.title));
1126        } else {
1127            report = report.with_message(&self.title);
1128        }
1129
1130        // Add main location label using the snapped span computed above.
1131        let main_message = if let Some(problem) = &self.problem {
1132            problem.as_str()
1133        } else {
1134            &self.title
1135        };
1136
1137        // Set `with_order` on every label using its end offset. Ariadne
1138        // groups labels by source and starts a new group whenever a label's
1139        // end line is *before* the previous label's end line. Without an
1140        // explicit order, multi-line main labels and per-line "padding"
1141        // detail labels (used to defeat Ariadne's middle-line elision) end
1142        // up in separate groups, producing a duplicated snippet block.
1143        // Sorting by end offset puts the smaller-line labels first so the
1144        // grouping algorithm extends rather than splits.
1145        report = report.with_label(
1146            Label::new((display_path.clone(), main_span.clone()))
1147                .with_message(main_message)
1148                .with_color(main_color)
1149                .with_order(main_span.end as i32),
1150        );
1151
1152        // Add detail locations as additional labels (only those with locations).
1153        // Details rooted (start and end) in the report's file stay inline
1154        // labels; details rooted wholly in another piece are collected and
1155        // appended after the loop as labels in their own file's source
1156        // section. A detail whose own span straddles pieces is not
1157        // representable as one inline label and is skipped (the main-span
1158        // cross-piece label above covers the common cross-cell shape).
1159        let mut cache_files = vec![(display_path.clone(), Source::from(content.clone()))];
1160        let mut foreign_labels: Vec<(String, std::ops::Range<usize>, Option<String>, Color)> =
1161            Vec::new();
1162        for detail in &self.details {
1163            if let Some(detail_loc) = &detail.location {
1164                // Map detail offsets to original file positions
1165                // map_offset expects relative offsets (0 = start of SourceInfo's range)
1166                let (Some(detail_start), Some(detail_end)) = (
1167                    detail_loc.map_offset(0, ctx),
1168                    detail_loc.map_offset(detail_loc.length(), ctx),
1169                ) else {
1170                    continue;
1171                };
1172                let detail_color = match detail.kind {
1173                    DetailKind::Error => Color::Red,
1174                    DetailKind::Info => Color::Cyan,
1175                    DetailKind::Note => Color::Blue,
1176                    // Match Ariadne's unimportant colour so faded
1177                    // labels visually disappear into the surrounding
1178                    // unlabelled text.
1179                    DetailKind::Faded => ARIADNE_UNIMPORTANT_COLOR,
1180                };
1181
1182                if detail_start.file_id == detail_end.file_id && detail_start.file_id != file_id {
1183                    // Wholly inside another piece: render it there as its
1184                    // own source section instead of silently dropping it.
1185                    let Some(detail_file) = ctx.get_file(detail_start.file_id) else {
1186                        continue;
1187                    };
1188                    let Some(detail_content) = detail_file
1189                        .content
1190                        .clone()
1191                        .or_else(|| std::fs::read_to_string(&detail_file.path).ok())
1192                    else {
1193                        continue;
1194                    };
1195                    // File-level id (no per-line hyperlink fragment) so
1196                    // several details in one foreign file share a source
1197                    // section; ariadne derives the header line:column from
1198                    // the label span itself.
1199                    let foreign_display = Self::wrap_path_with_hyperlink(
1200                        &detail_file.path,
1201                        Self::hyperlink_target(detail_file),
1202                        None,
1203                        None,
1204                        enable_hyperlinks,
1205                    );
1206                    let detail_span = Self::snap_span_to_char_boundaries(
1207                        &detail_content,
1208                        detail_start.location.offset,
1209                        detail_end.location.offset,
1210                    );
1211                    if !cache_files.iter().any(|(path, _)| *path == foreign_display) {
1212                        cache_files.push((foreign_display.clone(), Source::from(detail_content)));
1213                    }
1214                    let message = (!detail.content.as_str().is_empty())
1215                        .then(|| detail.content.as_str().to_string());
1216                    foreign_labels.push((foreign_display, detail_span, message, detail_color));
1217                    continue;
1218                }
1219
1220                if detail_start.file_id == file_id && detail_end.file_id == file_id {
1221                    let detail_span = Self::snap_span_to_char_boundaries(
1222                        &content,
1223                        detail_start.location.offset,
1224                        detail_end.location.offset,
1225                    );
1226                    // Empty-content details exist purely to force Ariadne
1227                    // to display a line that would otherwise be elided
1228                    // inside a multi-line span. Leaving the label's
1229                    // message at None makes Ariadne skip drawing the
1230                    // `╰── ...` arrow row underneath, so the source line
1231                    // appears clean.
1232                    let mut label = Label::new((display_path.clone(), detail_span.clone()))
1233                        .with_color(detail_color)
1234                        .with_order(detail_span.end as i32);
1235                    if !detail.content.as_str().is_empty() {
1236                        label = label.with_message(detail.content.as_str());
1237                    }
1238                    report = report.with_label(label);
1239                }
1240            }
1241        }
1242
1243        // Foreign-piece labels sort after every same-file label so
1244        // ariadne's order-keyed grouping draws the report's file first,
1245        // intact; no realistic same-file span end reaches this base.
1246        for (foreign_order, (path, span, message, color)) in (1_000_000i32..).zip(foreign_labels) {
1247            let mut label = Label::new((path, span))
1248                .with_color(color)
1249                .with_order(foreign_order);
1250            if let Some(message) = message {
1251                label = label.with_message(message);
1252            }
1253            report = report.with_label(label);
1254        }
1255
1256        // Render to string
1257        let report = report.finish();
1258        let mut output = Vec::new();
1259        report
1260            .write(ContextSourceCache { files: cache_files }, &mut output)
1261            .ok()?;
1262
1263        let output_str = String::from_utf8(output).ok()?;
1264
1265        // Post-process to extend hyperlinks to include line:column numbers
1266        // Ariadne adds :line:column after our hyperlinked path, so we need to
1267        // move the hyperlink end marker to include those numbers. Only for
1268        // self-links: an origin link opens a different file, and the
1269        // appended coordinates are relative to the virtual file — they
1270        // must not leak into the notebook URL.
1271        if enable_hyperlinks && link_target == Some(file.path.as_str()) {
1272            Some(Self::extend_hyperlink_to_include_line_column(
1273                &output_str,
1274                &file.path,
1275            ))
1276        } else {
1277            Some(output_str)
1278        }
1279    }
1280
1281    /// Render source context using [`annotate-snippets`](https://crates.io/crates/annotate-snippets),
1282    /// the rust-lang toolchain's diagnostic style (private helper for to_text).
1283    ///
1284    /// Mirrors [`Self::render_ariadne_source_context`]'s offset-mapping
1285    /// logic but emits the `error[CODE]: …` / `-->` / gutter-bar look.
1286    /// Differences from the ariadne path, by design:
1287    ///
1288    /// - The error code is rendered natively via `Title::id` (e.g.
1289    ///   `error[Q-2-5]: …`) rather than prefixed into the message.
1290    /// - There are **no terminal hyperlinks** — annotate-snippets has no
1291    ///   OSC 8 support, so `_enable_hyperlinks` is ignored.
1292    /// - Detail labels are all rendered as `Context` annotations
1293    ///   (annotate-snippets has no per-label color), so the `DetailKind`
1294    ///   color distinction and the `Faded` blend are not reproduced.
1295    /// - Empty-content "padding" details (an ariadne workaround for
1296    ///   mid-span line elision) are skipped: annotate-snippets folds
1297    ///   unannotated lines natively, which is the look we want here.
1298    #[cfg(feature = "annotate-snippets")]
1299    fn render_annotate_snippets_source_context(
1300        &self,
1301        main_location: &quarto_source_map::SourceInfo,
1302        ctx: &quarto_source_map::SourceContext,
1303        _enable_hyperlinks: bool,
1304    ) -> Option<String> {
1305        use annotate_snippets::{AnnotationKind, Level, Renderer, Snippet};
1306
1307        // The report's file is the one the span *starts* in — same
1308        // contract as the ariadne path: `root_file_id()` is the first
1309        // `Concat` piece's file, wrong for a diagnostic rooted in a
1310        // later piece, while `map_offset` resolves piece-aware.
1311        let start_mapped = main_location.map_offset(0, ctx)?;
1312        let file_id = start_mapped.file_id;
1313
1314        // A span whose two ends resolve into different pieces cannot be
1315        // drawn as one snippet; label both ends textually rather than
1316        // silently clamping into one piece's content.
1317        let end_mapped = main_location
1318            .map_offset(main_location.length(), ctx)
1319            .or_else(|| {
1320                if main_location.length() > 0 {
1321                    main_location.map_offset(main_location.length() - 1, ctx)
1322                } else {
1323                    None
1324                }
1325            })
1326            .unwrap_or_else(|| start_mapped.clone());
1327        if end_mapped.file_id != start_mapped.file_id {
1328            return Some(Self::format_cross_piece_location(
1329                &start_mapped,
1330                &end_mapped,
1331                ctx,
1332            ));
1333        }
1334
1335        let file = ctx.get_file(file_id)?;
1336        let content = match &file.content {
1337            Some(c) => c.clone(),
1338            None => std::fs::read_to_string(&file.path).ok()?,
1339        };
1340        // Clamp a mapped byte range into the source, keeping start <= end
1341        // and both ends on UTF-8 character boundaries (annotate-snippets
1342        // panics on a mid-character offset just as ariadne does).
1343        let clamp = |start: usize, end: usize| -> std::ops::Range<usize> {
1344            Self::snap_span_to_char_boundaries(&content, start, end)
1345        };
1346
1347        let main_span = clamp(start_mapped.location.offset, end_mapped.location.offset);
1348
1349        let level = match self.kind {
1350            DiagnosticKind::Error => Level::ERROR,
1351            DiagnosticKind::Warning => Level::WARNING,
1352            DiagnosticKind::Info => Level::INFO,
1353            DiagnosticKind::Note => Level::NOTE,
1354        };
1355
1356        // Primary label message: the problem statement, else the title.
1357        let main_message = match &self.problem {
1358            Some(problem) => problem.as_str(),
1359            None => self.title.as_str(),
1360        };
1361
1362        let mut snippet = Snippet::source(content.as_str())
1363            .path(file.path.as_str())
1364            .line_start(1)
1365            .annotation(AnnotationKind::Primary.span(main_span).label(main_message));
1366
1367        // Details rooted (start and end) in the report's file become
1368        // Context annotations; details rooted wholly in another piece
1369        // render as their own snippet element below the main one instead
1370        // of being silently dropped. A detail whose own span straddles
1371        // pieces is not representable as one annotation and is skipped
1372        // (the main-span cross-piece label covers the common shape).
1373        let mut foreign: Vec<(String, String, std::ops::Range<usize>, &str)> = Vec::new();
1374        for detail in &self.details {
1375            // Skip empty-content padding details (see the doc comment).
1376            if detail.content.as_str().is_empty() {
1377                continue;
1378            }
1379            let Some(detail_loc) = &detail.location else {
1380                continue;
1381            };
1382            let (Some(detail_start), Some(detail_end)) = (
1383                detail_loc.map_offset(0, ctx),
1384                detail_loc.map_offset(detail_loc.length(), ctx),
1385            ) else {
1386                continue;
1387            };
1388            if detail_start.file_id == detail_end.file_id && detail_start.file_id != file_id {
1389                let Some(detail_file) = ctx.get_file(detail_start.file_id) else {
1390                    continue;
1391                };
1392                let Some(detail_content) = detail_file
1393                    .content
1394                    .clone()
1395                    .or_else(|| std::fs::read_to_string(&detail_file.path).ok())
1396                else {
1397                    continue;
1398                };
1399                let detail_span = Self::snap_span_to_char_boundaries(
1400                    &detail_content,
1401                    detail_start.location.offset,
1402                    detail_end.location.offset,
1403                );
1404                foreign.push((
1405                    detail_file.path.clone(),
1406                    detail_content,
1407                    detail_span,
1408                    detail.content.as_str(),
1409                ));
1410                continue;
1411            }
1412            if detail_start.file_id == file_id && detail_end.file_id == file_id {
1413                let detail_span = clamp(detail_start.location.offset, detail_end.location.offset);
1414                snippet = snippet.annotation(
1415                    AnnotationKind::Context
1416                        .span(detail_span)
1417                        .label(detail.content.as_str()),
1418                );
1419            }
1420        }
1421
1422        // Build the titled group; render the error code natively via `id`,
1423        // then append each foreign-piece detail as its own snippet element
1424        // (annotate-snippets draws one excerpt per element, each with its
1425        // own `--> path` header).
1426        let mut title = level.primary_title(self.title.as_str());
1427        if let Some(code) = &self.code {
1428            title = title.id(code.as_str());
1429        }
1430        let mut group = title.element(snippet);
1431        // Iterate by reference: the built `Snippet`s borrow `path`/`content`,
1432        // so the owned strings must stay alive in `foreign` until `render`.
1433        for (path, content, span, message) in &foreign {
1434            let extra = Snippet::source(content.as_str())
1435                .path(path)
1436                .line_start(1)
1437                .annotation(AnnotationKind::Context.span(span.clone()).label(*message));
1438            group = group.element(extra);
1439        }
1440
1441        // `Renderer::render` returns text with no trailing newline, but
1442        // `to_text` appends unlocated details and hints directly after the
1443        // excerpt with `writeln!`. Match the ariadne path (which ends in a
1444        // newline) so those lines don't glue onto the last source row.
1445        let mut rendered = Renderer::styled().render(&[group]);
1446        if !rendered.ends_with('\n') {
1447            rendered.push('\n');
1448        }
1449        Some(rendered)
1450    }
1451
1452    /// Extend OSC 8 hyperlinks to include the :line:column suffix that ariadne adds.
1453    ///
1454    /// Ariadne formats file references as `path:line:column`, but since we wrap the path
1455    /// with OSC 8 codes, the structure becomes: `[hyperlink:path]:line:column`
1456    /// We want: `[hyperlink:path:line:column]`
1457    ///
1458    /// This function finds patterns like `path]8;;\:line:column` and moves the hyperlink
1459    /// end marker to after the line:column part.
1460    #[cfg(feature = "ariadne")]
1461    fn extend_hyperlink_to_include_line_column(output: &str, original_path: &str) -> String {
1462        // Pattern: original_path followed by ]8;;\ then :numbers:numbers
1463        // We want to move the ]8;;\ to after the :numbers:numbers part
1464        let end_marker = "\x1b]8;;\x1b\\";
1465        let search_pattern = format!("{}{}", original_path, end_marker);
1466
1467        let mut result = output.to_string();
1468        while let Some(pos) = result.find(&search_pattern) {
1469            let after_marker = pos + search_pattern.len();
1470            // Check if what follows is :line:column pattern
1471            if let Some(rest) = result.get(after_marker..) {
1472                // Match :digits:digits pattern
1473                if let Some(colon_end) = Self::find_line_column_end(rest) {
1474                    // Move the end marker to after the :line:column
1475                    let before = &result[..pos + original_path.len()];
1476                    let line_col = &rest[..colon_end];
1477                    let after = &rest[colon_end..];
1478                    result = format!("{}{}{}{}", before, line_col, end_marker, after);
1479                    continue;
1480                }
1481            }
1482            break;
1483        }
1484        result
1485    }
1486
1487    /// Find the end position of a :line:column pattern at the start of the string.
1488    /// Returns None if the pattern doesn't match.
1489    #[cfg(feature = "ariadne")]
1490    fn find_line_column_end(s: &str) -> Option<usize> {
1491        let bytes = s.as_bytes();
1492        if bytes.is_empty() || bytes[0] != b':' {
1493            return None;
1494        }
1495
1496        let mut pos = 1;
1497        // Read digits for line number
1498        while pos < bytes.len() && bytes[pos].is_ascii_digit() {
1499            pos += 1;
1500        }
1501        if pos == 1 || pos >= bytes.len() || bytes[pos] != b':' {
1502            return None; // No digits or no second colon
1503        }
1504
1505        pos += 1; // Skip second colon
1506        let col_start = pos;
1507        // Read digits for column number
1508        while pos < bytes.len() && bytes[pos].is_ascii_digit() {
1509            pos += 1;
1510        }
1511        if pos == col_start {
1512            return None; // No digits for column
1513        }
1514
1515        Some(pos)
1516    }
1517}
1518
1519#[cfg(test)]
1520mod tests {
1521    use super::*;
1522
1523    #[test]
1524    fn test_diagnostic_kind() {
1525        assert_eq!(DiagnosticKind::Error, DiagnosticKind::Error);
1526        assert_ne!(DiagnosticKind::Error, DiagnosticKind::Warning);
1527    }
1528
1529    #[test]
1530    fn test_message_content_from_str() {
1531        let content: MessageContent = "test".into();
1532        assert_eq!(content.as_str(), "test");
1533    }
1534
1535    #[test]
1536    fn test_diagnostic_message_new() {
1537        let msg = DiagnosticMessage::new(DiagnosticKind::Error, "Test error");
1538        assert_eq!(msg.title, "Test error");
1539        assert_eq!(msg.kind, DiagnosticKind::Error);
1540        assert!(msg.code.is_none());
1541        assert!(msg.problem.is_none());
1542        assert!(msg.details.is_empty());
1543        assert!(msg.hints.is_empty());
1544    }
1545
1546    #[test]
1547    fn test_diagnostic_message_constructors() {
1548        let error = DiagnosticMessage::error("Error");
1549        assert_eq!(error.kind, DiagnosticKind::Error);
1550        assert!(error.code.is_none());
1551
1552        let warning = DiagnosticMessage::warning("Warning");
1553        assert_eq!(warning.kind, DiagnosticKind::Warning);
1554
1555        let info = DiagnosticMessage::info("Info");
1556        assert_eq!(info.kind, DiagnosticKind::Info);
1557    }
1558
1559    #[test]
1560    fn test_with_code() {
1561        let msg = DiagnosticMessage::error("Test error").with_code("Q-1-1");
1562        assert_eq!(msg.code, Some("Q-1-1".to_string()));
1563    }
1564
1565    // The positive case — `docs_url()` for a real code resolves to the
1566    // quarto.org URL — moved to `quarto-error-catalog`'s integration tests,
1567    // where the `Q-*` catalog is installed. Here we only cover the
1568    // catalog-free cases (no code / unknown code → `None`), which hold
1569    // regardless of whether a catalog is installed.
1570
1571    #[test]
1572    fn test_docs_url_without_code() {
1573        let msg = DiagnosticMessage::error("Test error");
1574        assert!(msg.docs_url().is_none());
1575    }
1576
1577    #[test]
1578    fn test_docs_url_invalid_code() {
1579        let msg = DiagnosticMessage::error("Test error").with_code("Q-999-999"); // quarto-error-code-audit-ignore
1580        assert!(msg.docs_url().is_none());
1581    }
1582
1583    #[test]
1584    fn test_to_text_simple_error() {
1585        let msg = DiagnosticMessage::error("Something went wrong");
1586        assert_eq!(msg.to_text(None), "Error: Something went wrong\n");
1587    }
1588
1589    #[test]
1590    fn test_to_text_with_code() {
1591        let msg = DiagnosticMessage::error("Something went wrong").with_code("Q-1-1");
1592        assert_eq!(msg.to_text(None), "Error [Q-1-1]: Something went wrong\n");
1593    }
1594
1595    #[test]
1596    fn test_to_text_full_message() {
1597        use crate::builder::DiagnosticMessageBuilder;
1598
1599        let msg = DiagnosticMessageBuilder::error("Invalid input")
1600            .problem("Values must be numeric")
1601            .add_detail("Found text in column 3")
1602            .add_info("Columns should contain only numbers")
1603            .add_hint("Convert to numbers first?")
1604            .build();
1605
1606        let text = msg.to_text(None);
1607        assert!(text.contains("Error: Invalid input"));
1608        assert!(text.contains("Values must be numeric"));
1609        assert!(text.contains("✖ Found text in column 3"));
1610        assert!(text.contains("ℹ Columns should contain only numbers"));
1611        assert!(text.contains("ℹ Convert to numbers first?"));
1612    }
1613
1614    #[test]
1615    fn test_to_json_simple() {
1616        let msg = DiagnosticMessage::error("Something went wrong");
1617        let json = msg.to_json();
1618
1619        assert_eq!(json["kind"], "error");
1620        assert_eq!(json["title"], "Something went wrong");
1621        assert!(json.get("code").is_none());
1622        assert!(json.get("problem").is_none());
1623    }
1624
1625    #[test]
1626    fn test_to_json_with_code() {
1627        let msg = DiagnosticMessage::error("Something went wrong").with_code("Q-1-1");
1628        let json = msg.to_json();
1629
1630        assert_eq!(json["kind"], "error");
1631        assert_eq!(json["title"], "Something went wrong");
1632        assert_eq!(json["code"], "Q-1-1");
1633    }
1634
1635    #[test]
1636    fn test_to_json_full_message() {
1637        use crate::builder::DiagnosticMessageBuilder;
1638
1639        let msg = DiagnosticMessageBuilder::error("Invalid input")
1640            .with_code("Q-1-2") // quarto-error-code-audit-ignore
1641            .problem("Values must be numeric")
1642            .add_detail("Found text in column 3")
1643            .add_info("Expected numbers")
1644            .add_hint("Convert to numbers first?")
1645            .build();
1646
1647        let json = msg.to_json();
1648        assert_eq!(json["kind"], "error");
1649        assert_eq!(json["title"], "Invalid input");
1650        assert_eq!(json["code"], "Q-1-2"); // quarto-error-code-audit-ignore
1651        assert_eq!(json["problem"]["type"], "markdown");
1652        assert_eq!(json["problem"]["content"], "Values must be numeric");
1653        assert_eq!(json["details"][0]["kind"], "error");
1654        assert_eq!(json["details"][0]["content"]["type"], "markdown");
1655        assert_eq!(
1656            json["details"][0]["content"]["content"],
1657            "Found text in column 3"
1658        );
1659        assert_eq!(json["details"][1]["kind"], "info");
1660        assert_eq!(json["details"][1]["content"]["type"], "markdown");
1661        assert_eq!(json["details"][1]["content"]["content"], "Expected numbers");
1662        assert_eq!(json["hints"][0]["type"], "markdown");
1663        assert_eq!(json["hints"][0]["content"], "Convert to numbers first?");
1664    }
1665
1666    #[test]
1667    fn test_to_json_warning() {
1668        let msg = DiagnosticMessage::warning("Be careful");
1669        let json = msg.to_json();
1670
1671        assert_eq!(json["kind"], "warning");
1672        assert_eq!(json["title"], "Be careful");
1673    }
1674
1675    #[test]
1676    fn test_location_in_to_text_without_context() {
1677        use crate::builder::DiagnosticMessageBuilder;
1678
1679        // Create a location at offsets 100-110
1680        let location =
1681            quarto_source_map::SourceInfo::original(quarto_source_map::FileId(0), 100, 110);
1682
1683        let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1684            .with_location(location)
1685            .build();
1686
1687        let text = msg.to_text(None);
1688
1689        // Without context, should show offset (we can't get row/column without context)
1690        assert!(text.contains("Invalid syntax"));
1691        assert!(text.contains("at offset 100"));
1692    }
1693
1694    #[test]
1695    fn test_location_in_to_text_with_context() {
1696        use crate::builder::DiagnosticMessageBuilder;
1697
1698        // Create a source context with a file
1699        let mut ctx = quarto_source_map::SourceContext::new();
1700        let file_id = ctx.add_file(
1701            "test.qmd".to_string(),
1702            Some("line 1\nline 2\nline 3\nline 4".to_string()),
1703        );
1704
1705        // Create a location in that file (offset 7 is start of "line 2")
1706        let location = quarto_source_map::SourceInfo::original(
1707            file_id, 7,  // Start of "line 2"
1708            13, // End of "line 2"
1709        );
1710
1711        let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1712            .with_location(location)
1713            .build();
1714
1715        let text = msg.to_text(Some(&ctx));
1716
1717        // With context, should show file path and 1-indexed location
1718        assert!(text.contains("Invalid syntax"));
1719        assert!(text.contains("test.qmd"));
1720        assert!(text.contains("2:1")); // row 1 + 1, column 0 + 1
1721    }
1722
1723    #[test]
1724    fn test_location_in_to_json() {
1725        use crate::builder::DiagnosticMessageBuilder;
1726
1727        let location =
1728            quarto_source_map::SourceInfo::original(quarto_source_map::FileId(0), 100, 110);
1729
1730        let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1731            .with_location(location)
1732            .build();
1733
1734        let json = msg.to_json();
1735
1736        // Should have location field with Original variant
1737        assert!(json.get("location").is_some());
1738        let loc = &json["location"];
1739
1740        // Verify the SourceInfo is serialized correctly (as Original enum variant)
1741        assert!(loc.get("Original").is_some());
1742        let original = &loc["Original"];
1743        assert_eq!(original["file_id"], 0);
1744        assert_eq!(original["start_offset"], 100);
1745        assert_eq!(original["end_offset"], 110);
1746    }
1747
1748    #[test]
1749    fn test_location_optional_in_to_json() {
1750        let msg = DiagnosticMessage::error("No location");
1751        let json = msg.to_json();
1752
1753        // Should not have location field when not provided
1754        assert!(json.get("location").is_none());
1755    }
1756
1757    #[test]
1758    fn test_text_render_options_disable_hyperlinks() {
1759        use crate::builder::DiagnosticMessageBuilder;
1760
1761        let mut ctx = quarto_source_map::SourceContext::new();
1762        let file_id = ctx.add_file("test.qmd".to_string(), Some("test content".to_string()));
1763
1764        let location = quarto_source_map::SourceInfo::original(file_id, 0, 4);
1765
1766        let msg = DiagnosticMessageBuilder::error("Test error")
1767            .with_location(location)
1768            .build();
1769
1770        // With hyperlinks enabled (default)
1771        let with_hyperlinks = msg.to_text(Some(&ctx));
1772
1773        // With hyperlinks disabled
1774        let options = TextRenderOptions {
1775            enable_hyperlinks: false,
1776        };
1777        let without_hyperlinks = msg.to_text_with_options(Some(&ctx), &options);
1778
1779        // When hyperlinks are disabled, output should be different
1780        // (specifically, no OSC 8 escape sequences)
1781        if with_hyperlinks.contains("\x1b]8;") {
1782            assert!(
1783                !without_hyperlinks.contains("\x1b]8;"),
1784                "Disabled hyperlinks should not contain OSC 8 codes"
1785            );
1786        }
1787    }
1788
1789    #[test]
1790    fn test_text_render_options_default() {
1791        let options = TextRenderOptions::default();
1792        assert!(
1793            options.enable_hyperlinks,
1794            "Default should enable hyperlinks"
1795        );
1796    }
1797
1798    #[test]
1799    fn test_render_with_custom_options() {
1800        use crate::builder::DiagnosticMessageBuilder;
1801
1802        let msg = DiagnosticMessageBuilder::error("Test")
1803            .problem("Something went wrong")
1804            .add_detail("Detail 1")
1805            .add_hint("Try this")
1806            .build();
1807
1808        let options = TextRenderOptions {
1809            enable_hyperlinks: false,
1810        };
1811
1812        let text = msg.to_text_with_options(None, &options);
1813
1814        // Should still render properly without hyperlinks
1815        assert!(text.contains("Error: Test"));
1816        assert!(text.contains("Something went wrong"));
1817        assert!(text.contains("Detail 1"));
1818        assert!(text.contains("Try this"));
1819    }
1820
1821    /// Strip CSI SGR color sequences (`ESC [ … m`). The annotate-snippets
1822    /// path emits no OSC 8 hyperlinks, so color is all we need to remove
1823    /// to make substring assertions robust to styling.
1824    #[cfg(feature = "annotate-snippets")]
1825    fn strip_ansi(s: &str) -> String {
1826        let mut out = String::new();
1827        let mut chars = s.chars().peekable();
1828        while let Some(c) = chars.next() {
1829            if c == '\u{1b}' {
1830                for n in chars.by_ref() {
1831                    if n == 'm' {
1832                        break;
1833                    }
1834                }
1835            } else {
1836                out.push(c);
1837            }
1838        }
1839        out
1840    }
1841
1842    /// The annotate-snippets renderer emits the rust-lang toolchain look:
1843    /// an `error[CODE]: …` header, a `-->` origin line, and `^` underlines
1844    /// — not ariadne's enclosing box.
1845    #[cfg(feature = "annotate-snippets")]
1846    #[test]
1847    fn annotate_snippets_renderer_produces_rust_style_output() {
1848        use crate::builder::DiagnosticMessageBuilder;
1849
1850        let mut ctx = quarto_source_map::SourceContext::new();
1851        let file_id = ctx.add_file(
1852            "test.qmd".to_string(),
1853            Some("line 1\nline 2\nline 3".to_string()),
1854        );
1855        // Offsets 7..13 cover "line 2" on row 2.
1856        let location = quarto_source_map::SourceInfo::original(file_id, 7, 13);
1857        let msg = DiagnosticMessageBuilder::error("Bad thing")
1858            .with_code("Q-9-9")
1859            .with_location(location)
1860            .problem("this is wrong")
1861            .build();
1862
1863        let opts = TextRenderOptions {
1864            enable_hyperlinks: false,
1865        };
1866        let raw =
1867            msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
1868        let text = strip_ansi(&raw);
1869
1870        assert!(
1871            text.contains("error[Q-9-9]"),
1872            "expected rust-style code header; got: {text:?}"
1873        );
1874        assert!(
1875            text.contains("-->"),
1876            "expected rust-style origin arrow; got: {text:?}"
1877        );
1878        assert!(
1879            text.contains("test.qmd:2:1"),
1880            "expected mapped location; got: {text:?}"
1881        );
1882        assert!(
1883            !text.contains('\u{256D}'),
1884            "annotate-snippets must not draw ariadne's box corner; got: {text:?}"
1885        );
1886        // No OSC 8 hyperlinks from annotate-snippets.
1887        assert!(
1888            !raw.contains("\u{1b}]8;"),
1889            "annotate-snippets emits no OSC 8 hyperlinks; got: {raw:?}"
1890        );
1891    }
1892
1893    /// Direct coverage of the snapping helper's contract: clamp into the
1894    /// file, widen to whole characters, never invert.
1895    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
1896    #[test]
1897    fn snap_span_widens_to_whole_characters() {
1898        // `\u{2728}` occupies bytes 3..6.
1899        let content = "abc\u{2728}def";
1900        assert_eq!(content.len(), 9);
1901
1902        let snap = |s, e| DiagnosticMessage::snap_span_to_char_boundaries(content, s, e);
1903
1904        // Already aligned: unchanged.
1905        assert_eq!(snap(0, 3), 0..3);
1906        assert_eq!(snap(3, 6), 3..6);
1907
1908        // Start inside the char floors to its first byte; end inside it ceils
1909        // to its last, so the highlight covers the whole character.
1910        assert_eq!(snap(4, 9), 3..9);
1911        assert_eq!(snap(5, 9), 3..9);
1912        assert_eq!(snap(0, 4), 0..6);
1913        assert_eq!(snap(0, 5), 0..6);
1914        assert_eq!(snap(4, 5), 3..6);
1915
1916        // Past EOF clamps to the file length.
1917        assert_eq!(snap(3, 999), 3..9);
1918        assert_eq!(snap(999, 999), 9..9);
1919
1920        // Inverted input collapses to an empty range rather than inverting.
1921        assert_eq!(snap(6, 3), 6..6);
1922
1923        // An empty range inside a character still snaps to a boundary.
1924        let r = snap(4, 4);
1925        assert!(content.is_char_boundary(r.start) && content.is_char_boundary(r.end));
1926        assert!(r.start <= r.end);
1927
1928        // Second content block: the exact offset pair the two
1929        // `..._does_not_panic` integration tests below use, now that the
1930        // `quarto-source-map` 0.1.2+ floor makes 21 unreachable at their
1931        // level (see those tests' doc comments). This is where that
1932        // coverage now lives.
1933        //
1934        // Layout (byte offsets):
1935        //   `text: <span>Ask AI ` = 0..19, `\u{2728}` = 19..22, `</span>` = 22..29
1936        let content2 = "text: <span>Ask AI \u{2728}</span>";
1937        assert!(!content2.is_char_boundary(21), "test fixture precondition");
1938        let snap2 = |s, e| DiagnosticMessage::snap_span_to_char_boundaries(content2, s, e);
1939
1940        // 21 is mid-`\u{2728}` (bytes 19..22) and floors to 19; 28 is
1941        // already on a boundary (inside the trailing ASCII `</span>`) and
1942        // is left unchanged.
1943        assert_eq!(snap2(21, 28), 19..28);
1944    }
1945
1946    /// A diagnostic whose `SourceInfo` span originally lands mid-character
1947    /// still renders end to end under the ariadne renderer.
1948    ///
1949    /// This test used to be the integration-level proof that
1950    /// `snap_span_to_char_boundaries` prevents ariadne's mid-character
1951    /// panic. It no longer is: `quarto-source-map` 0.1.2+ floors
1952    /// `offset_to_location`'s returned offset to a UTF-8 character
1953    /// boundary, so by the time `map_offset` hands this test's span
1954    /// (21..28, with 21 mid-`\u{2728}`) to the renderer it has already
1955    /// become 19..28 — the snap in this crate is never exercised against a
1956    /// mid-character offset at this level, because one can no longer be
1957    /// constructed here. The snap's actual coverage moved to
1958    /// `snap_span_widens_to_whole_characters`'s second content block, which
1959    /// calls it directly with these same offsets.
1960    ///
1961    /// **Accepted, not an oversight:** after the upstream floor, no revert
1962    /// of this crate's own code (the snap helper or its three call sites)
1963    /// can turn this test red — it is unbound with respect to this crate's
1964    /// diff. That is known and accepted; the test stays as an end-to-end
1965    /// smoke check that a span-carrying diagnostic renders successfully
1966    /// under ariadne, not as a snap regression test.
1967    ///
1968    /// Layout of the source below (byte offsets):
1969    ///   `text: <span>Ask AI ` = 0..19, `\u{2728}` = 19..22, `</span>` = 22..29
1970    /// so 21 is two bytes into the three-byte char — exactly the observed
1971    /// off-by-one-left onto a multi-byte boundary.
1972    #[cfg(feature = "ariadne")]
1973    #[test]
1974    fn ariadne_renders_diagnostic_with_originally_mid_character_span() {
1975        use crate::builder::DiagnosticMessageBuilder;
1976
1977        let content = "text: <span>Ask AI \u{2728}</span>".to_string();
1978        assert!(!content.is_char_boundary(21), "test fixture precondition");
1979
1980        let mut ctx = quarto_source_map::SourceContext::new();
1981        let file_id = ctx.add_file("_quarto.yml".to_string(), Some(content.clone()));
1982        // 21..28 — start is mid-`\u{2728}`, mirroring the config-path shift.
1983        let location = quarto_source_map::SourceInfo::original(file_id, 21, 28);
1984        let msg = DiagnosticMessageBuilder::warning("HTML element converted to raw HTML")
1985            .with_code("Q-2-9")
1986            .with_location(location)
1987            .build();
1988
1989        let opts = TextRenderOptions {
1990            enable_hyperlinks: false,
1991        };
1992        let text = msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::Ariadne));
1993
1994        assert!(
1995            text.contains("HTML element converted to raw HTML"),
1996            "diagnostic must still render; got: {text:?}"
1997        );
1998        assert!(
1999            text.contains("_quarto.yml"),
2000            "source context must still render; got: {text:?}"
2001        );
2002    }
2003
2004    /// The same end-to-end smoke check as
2005    /// `ariadne_renders_diagnostic_with_originally_mid_character_span`, for
2006    /// the annotate-snippets renderer.
2007    ///
2008    /// It no longer exercises the mid-character path either, for the same
2009    /// reason: `quarto-source-map` 0.1.2+'s floor in `offset_to_location`
2010    /// means `map_offset` has already snapped this test's 21..28 span to
2011    /// 19..28 before it reaches annotate-snippets' `clamp` closure, so the
2012    /// closure never sees a mid-character offset from this call path. The
2013    /// snap's real coverage lives in `snap_span_widens_to_whole_characters`'s
2014    /// second content block (same 21..28 offsets, exercised directly).
2015    ///
2016    /// **Accepted, not an oversight:** as with the ariadne test above, no
2017    /// revert of this crate's own snap logic can turn this test red after
2018    /// the upstream floor — that unbinding is known and accepted.
2019    #[cfg(feature = "annotate-snippets")]
2020    #[test]
2021    fn annotate_snippets_renders_diagnostic_with_originally_mid_character_span() {
2022        use crate::builder::DiagnosticMessageBuilder;
2023
2024        let content = "text: <span>Ask AI \u{2728}</span>".to_string();
2025        assert!(!content.is_char_boundary(21), "test fixture precondition");
2026
2027        let mut ctx = quarto_source_map::SourceContext::new();
2028        let file_id = ctx.add_file("_quarto.yml".to_string(), Some(content.clone()));
2029        let location = quarto_source_map::SourceInfo::original(file_id, 21, 28);
2030        let msg = DiagnosticMessageBuilder::warning("HTML element converted to raw HTML")
2031            .with_code("Q-2-9")
2032            .with_location(location)
2033            .build();
2034
2035        let opts = TextRenderOptions {
2036            enable_hyperlinks: false,
2037        };
2038        let text =
2039            msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
2040
2041        assert!(
2042            text.contains("HTML element converted to raw HTML"),
2043            "diagnostic must still render; got: {text:?}"
2044        );
2045    }
2046
2047    /// Strip CSI SGR color sequences, for tests gated under a single
2048    /// renderer feature that can't rely on `strip_ansi` above (which is
2049    /// gated on `annotate-snippets` only — ariadne colorizes its source
2050    /// line and marker row too, so an ariadne-only test needs the same
2051    /// stripping without pulling in that feature). Same logic as
2052    /// `strip_ansi`, duplicated rather than re-gated so as not to touch
2053    /// the existing helper.
2054    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
2055    fn strip_ansi_colors(s: &str) -> String {
2056        let mut out = String::new();
2057        let mut chars = s.chars().peekable();
2058        while let Some(c) = chars.next() {
2059            if c == '\u{1b}' {
2060                for n in chars.by_ref() {
2061                    if n == 'm' {
2062                        break;
2063                    }
2064                }
2065            } else {
2066                out.push(c);
2067            }
2068        }
2069        out
2070    }
2071
2072    /// Measures the *rendered* width of a label whose mapped span is
2073    /// genuinely zero-width, under the ariadne renderer.
2074    ///
2075    /// `\u{2728}` occupies bytes 6..9 of the content below; the input span
2076    /// `SourceInfo::original(fid, 7, 8)` has **both ends** strictly inside
2077    /// that character (unlike the `..._does_not_panic` tests above, whose
2078    /// span only *starts* mid-character). Before the `quarto-source-map`
2079    /// 0.1.2+ floor, `map_offset` passed the raw offsets 7 and 8 through
2080    /// unchanged and this crate's own snap widened them to the whole
2081    /// character (6..9). After the floor, `offset_to_location` already
2082    /// floors both 7 and 8 down to 6 before this crate ever sees them, so
2083    /// both mapped offsets are 6 — the snap runs on `6..6`, which is
2084    /// already boundary-aligned, and has nothing left to widen. The
2085    /// highlight that reaches the renderer is therefore zero-width, not
2086    /// the whole character.
2087    ///
2088    /// ariadne's `Report::build` anchor is already `start..start`, so "a
2089    /// zero-width label probably renders fine" was a reasonable guess
2090    /// before this test — turning that guess into a measurement is the
2091    /// point here. The assertion is keyed on the renderer's own
2092    /// zero-width-vs-one-character marker *shape* (a bare `│` vs. `┬─`),
2093    /// not merely on the message text appearing, so it fails if the
2094    /// highlight ever widens back to covering the whole character.
2095    #[cfg(feature = "ariadne")]
2096    #[test]
2097    fn ariadne_zero_width_label_renders_a_bare_marker() {
2098        use crate::builder::DiagnosticMessageBuilder;
2099
2100        let content = "x = 'A\u{2728}B'".to_string();
2101        let mut ctx = quarto_source_map::SourceContext::new();
2102        let file_id = ctx.add_file("scratch.qmd".to_string(), Some(content.clone()));
2103        let location = quarto_source_map::SourceInfo::original(file_id, 7, 8);
2104        let msg = DiagnosticMessageBuilder::warning("scratch")
2105            .with_code("Q-2-9")
2106            .with_location(location)
2107            .build();
2108        let opts = TextRenderOptions {
2109            enable_hyperlinks: false,
2110        };
2111        let text = msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::Ariadne));
2112        let stripped = strip_ansi_colors(&text);
2113
2114        let lines: Vec<&str> = stripped.lines().collect();
2115        let source_idx = lines
2116            .iter()
2117            .position(|l| l.contains("x = 'A\u{2728}B'"))
2118            .unwrap_or_else(|| panic!("source line must render; got: {stripped:?}"));
2119        let marker_line = lines[source_idx + 1];
2120        // Skip past the gutter's own `│` (present on every row, e.g.
2121        // `   │       │  `) to isolate the marker glyphs themselves.
2122        let gutter_end = marker_line
2123            .find('│')
2124            .map(|i| i + '│'.len_utf8())
2125            .unwrap_or_else(|| panic!("marker row must have a gutter `│`; got: {marker_line:?}"));
2126        let marker = marker_line[gutter_end..].trim();
2127
2128        assert_eq!(
2129            marker, "│",
2130            "expected the zero-width `│` marker (a whole-character label \
2131             would instead draw `┬─`); got {marker:?} in:\n{stripped}"
2132        );
2133    }
2134
2135    /// The same measurement as `ariadne_zero_width_label_renders_a_bare_marker`,
2136    /// for the annotate-snippets renderer. See that test's doc comment for
2137    /// why both ends of `SourceInfo::original(fid, 7, 8)` land on the same
2138    /// mapped offset (6) after the `quarto-source-map` 0.1.2+ floor.
2139    ///
2140    /// annotate-snippets underlines a span with one `^` per byte of width,
2141    /// so the discriminating measurement here is even more direct than
2142    /// ariadne's marker shape: a zero-width label draws exactly one `^`,
2143    /// a whole-character label draws two (`^^`).
2144    #[cfg(feature = "annotate-snippets")]
2145    #[test]
2146    fn annotate_snippets_zero_width_label_renders_a_single_caret() {
2147        use crate::builder::DiagnosticMessageBuilder;
2148
2149        let content = "x = 'A\u{2728}B'".to_string();
2150        let mut ctx = quarto_source_map::SourceContext::new();
2151        let file_id = ctx.add_file("scratch.qmd".to_string(), Some(content.clone()));
2152        let location = quarto_source_map::SourceInfo::original(file_id, 7, 8);
2153        let msg = DiagnosticMessageBuilder::warning("scratch")
2154            .with_code("Q-2-9")
2155            .with_location(location)
2156            .build();
2157        let opts = TextRenderOptions {
2158            enable_hyperlinks: false,
2159        };
2160        let text =
2161            msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
2162        let stripped = strip_ansi_colors(&text);
2163
2164        let lines: Vec<&str> = stripped.lines().collect();
2165        let source_idx = lines
2166            .iter()
2167            .position(|l| l.contains("x = 'A\u{2728}B'"))
2168            .unwrap_or_else(|| panic!("source line must render; got: {stripped:?}"));
2169        let marker_line = lines[source_idx + 1];
2170        let caret_run: String = marker_line.chars().filter(|&c| c == '^').collect();
2171
2172        assert_eq!(
2173            caret_run, "^",
2174            "expected a single `^` caret marking a zero-width label (a \
2175             whole-character label would instead draw `^^`); got \
2176             {caret_run:?} in line: {marker_line:?}"
2177        );
2178    }
2179
2180    /// Forcing a specific renderer is honored: ariadne draws its boxed
2181    /// excerpt (the U+256D corner) while annotate-snippets does not.
2182    #[cfg(all(feature = "ariadne", feature = "annotate-snippets"))]
2183    #[test]
2184    fn renderer_selection_switches_styles() {
2185        use crate::builder::DiagnosticMessageBuilder;
2186
2187        let mut ctx = quarto_source_map::SourceContext::new();
2188        let file_id = ctx.add_file("a.qmd".to_string(), Some("alpha\nbeta\ngamma".to_string()));
2189        let location = quarto_source_map::SourceInfo::original(file_id, 6, 10); // "beta"
2190        let msg = DiagnosticMessageBuilder::error("Pick a style")
2191            .with_location(location)
2192            .build();
2193        let opts = TextRenderOptions {
2194            enable_hyperlinks: false,
2195        };
2196
2197        let ariadne = msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::Ariadne));
2198        let snippets =
2199            msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
2200
2201        assert!(ariadne.contains('\u{256D}'), "ariadne draws a box corner");
2202        assert!(
2203            !strip_ansi(&snippets).contains('\u{256D}'),
2204            "annotate-snippets does not"
2205        );
2206        assert!(strip_ansi(&snippets).contains("-->"));
2207    }
2208
2209    // ==================== Concat / cross-piece rendering ====================
2210    //
2211    // A diagnostic whose location resolves through a multi-piece `Concat`
2212    // (the shape q2's ipynb processor produces: one virtual file per
2213    // notebook cell) must render against the piece the span actually
2214    // resolves into — not `root_file_id()`, which is the *first* rooted
2215    // piece. These tests pin the plan-7c contract: report file from
2216    // `start_mapped.file_id`; a span straddling pieces renders a
2217    // cross-file location label with no snippet; a detail rooted in
2218    // another piece renders in its own file's source block.
2219
2220    /// Two per-cell virtual files joined by a `Concat`, exactly as the
2221    /// ipynb converter registers them. Returns the context, the concat,
2222    /// and piece 1's length (the concat offset where piece 2 begins).
2223    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
2224    fn concat_fixture() -> (
2225        quarto_source_map::SourceContext,
2226        quarto_source_map::SourceInfo,
2227        usize,
2228    ) {
2229        let piece1 = "first cell text\n";
2230        let piece2 = "second cell text\n";
2231        let mut ctx = quarto_source_map::SourceContext::new();
2232        let f1 = ctx.add_file(
2233            "notebook.ipynb[cell 1, markdown]".to_string(),
2234            Some(piece1.to_string()),
2235        );
2236        let f2 = ctx.add_file(
2237            "notebook.ipynb[cell 2, markdown]".to_string(),
2238            Some(piece2.to_string()),
2239        );
2240        let concat = quarto_source_map::SourceInfo::concat(vec![
2241            (
2242                quarto_source_map::SourceInfo::original(f1, 0, piece1.len()),
2243                piece1.len(),
2244            ),
2245            (
2246                quarto_source_map::SourceInfo::original(f2, 0, piece2.len()),
2247                piece2.len(),
2248            ),
2249        ]);
2250        (ctx, concat, piece1.len())
2251    }
2252
2253    /// The percent/spin shape: a `Concat` whose pieces all root to ONE
2254    /// file. Must render exactly as a plain single-file diagnostic — the
2255    /// fix must not disturb it.
2256    #[cfg(feature = "ariadne")]
2257    #[test]
2258    fn ariadne_concat_single_file_pieces_passthrough() {
2259        use crate::builder::DiagnosticMessageBuilder;
2260
2261        let content = "alpha\nbeta\ngamma\n";
2262        let mut ctx = quarto_source_map::SourceContext::new();
2263        let f1 = ctx.add_file("script.qmd".to_string(), Some(content.to_string()));
2264        let concat = quarto_source_map::SourceInfo::concat(vec![
2265            (quarto_source_map::SourceInfo::original(f1, 0, 6), 6),
2266            (
2267                quarto_source_map::SourceInfo::original(f1, 6, content.len()),
2268                content.len() - 6,
2269            ),
2270        ]);
2271        let location = quarto_source_map::SourceInfo::substring(concat, 6, 10); // "beta"
2272        let msg = DiagnosticMessageBuilder::error("Bad chunk")
2273            .with_location(location)
2274            .build();
2275        let opts = TextRenderOptions {
2276            enable_hyperlinks: false,
2277        };
2278        let text = strip_ansi_colors(&msg.to_text_with_renderer(
2279            Some(&ctx),
2280            &opts,
2281            Some(SourceRenderer::Ariadne),
2282        ));
2283
2284        assert!(
2285            text.contains("script.qmd"),
2286            "single-file passthrough must keep the file label; got:\n{text}"
2287        );
2288        assert!(
2289            text.contains("beta"),
2290            "single-file passthrough must render the snippet; got:\n{text}"
2291        );
2292    }
2293
2294    /// A main span rooted wholly in the *first* piece already renders
2295    /// correctly (`root_file_id()` happens to agree with
2296    /// `start_mapped.file_id`); must stay correct after the fix.
2297    #[cfg(feature = "ariadne")]
2298    #[test]
2299    fn ariadne_concat_main_span_in_first_piece_renders_own_file() {
2300        use crate::builder::DiagnosticMessageBuilder;
2301
2302        let (ctx, concat, _) = concat_fixture();
2303        let location = quarto_source_map::SourceInfo::substring(concat, 0, 5); // "first"
2304        let msg = DiagnosticMessageBuilder::error("Bad cell")
2305            .with_location(location)
2306            .build();
2307        let opts = TextRenderOptions {
2308            enable_hyperlinks: false,
2309        };
2310        let text = strip_ansi_colors(&msg.to_text_with_renderer(
2311            Some(&ctx),
2312            &opts,
2313            Some(SourceRenderer::Ariadne),
2314        ));
2315
2316        assert!(
2317            text.contains("notebook.ipynb[cell 1, markdown]"),
2318            "first-piece span must label piece 1; got:\n{text}"
2319        );
2320        assert!(
2321            text.contains("first cell text"),
2322            "first-piece span must show piece 1's snippet; got:\n{text}"
2323        );
2324        assert!(
2325            !text.contains("notebook.ipynb[cell 2, markdown]"),
2326            "piece 2 must not appear; got:\n{text}"
2327        );
2328    }
2329
2330    /// THE cross-piece case: a diagnostic rooted wholly in a later piece
2331    /// must be labeled and snippeted from *that* piece — today it gets
2332    /// piece 1's label and piece 1's content at piece-2 offsets.
2333    #[cfg(feature = "ariadne")]
2334    #[test]
2335    fn ariadne_concat_main_span_in_later_piece_renders_own_file() {
2336        use crate::builder::DiagnosticMessageBuilder;
2337
2338        let (ctx, concat, l1) = concat_fixture();
2339        let location = quarto_source_map::SourceInfo::substring(concat, l1, l1 + 6); // "second"
2340        let msg = DiagnosticMessageBuilder::error("Bad cell")
2341            .with_location(location)
2342            .build();
2343        let opts = TextRenderOptions {
2344            enable_hyperlinks: false,
2345        };
2346        let text = strip_ansi_colors(&msg.to_text_with_renderer(
2347            Some(&ctx),
2348            &opts,
2349            Some(SourceRenderer::Ariadne),
2350        ));
2351
2352        assert!(
2353            text.contains("notebook.ipynb[cell 2, markdown]"),
2354            "later-piece span must label the owning piece; got:\n{text}"
2355        );
2356        assert!(
2357            text.contains("second cell text"),
2358            "later-piece span must snippet the owning piece; got:\n{text}"
2359        );
2360        assert!(
2361            !text.contains("first cell text"),
2362            "the wrong piece must not render; got:\n{text}"
2363        );
2364    }
2365
2366    /// A span straddling two pieces cannot be one snippet; it must render
2367    /// an explicit cross-file label naming both pieces and no snippet —
2368    /// today it is silently clamped into piece 1.
2369    #[cfg(feature = "ariadne")]
2370    #[test]
2371    fn ariadne_concat_straddling_span_labels_both_pieces_without_snippet() {
2372        use crate::builder::DiagnosticMessageBuilder;
2373
2374        let (ctx, concat, l1) = concat_fixture();
2375        // Starts mid-piece-1 ("cell text…"), ends mid-piece-2 ("second").
2376        let location = quarto_source_map::SourceInfo::substring(concat, 6, l1 + 6);
2377        let msg = DiagnosticMessageBuilder::error("Cross-cell markdown")
2378            .with_location(location)
2379            .build();
2380        let opts = TextRenderOptions {
2381            enable_hyperlinks: false,
2382        };
2383        let text = strip_ansi_colors(&msg.to_text_with_renderer(
2384            Some(&ctx),
2385            &opts,
2386            Some(SourceRenderer::Ariadne),
2387        ));
2388
2389        assert!(
2390            text.contains("--> notebook.ipynb[cell 1, markdown]:1:7"),
2391            "straddling span must name its start piece and position; got:\n{text}"
2392        );
2393        assert!(
2394            text.contains("(spans through notebook.ipynb[cell 2, markdown]:1:7)"),
2395            "straddling span must name its end piece and position; got:\n{text}"
2396        );
2397        assert!(
2398            !text.contains("first cell text") && !text.contains("second cell text"),
2399            "a cross-piece span must render no snippet; got:\n{text}"
2400        );
2401    }
2402
2403    /// A detail rooted wholly in another piece must render — in its own
2404    /// file's source block — not be silently dropped.
2405    #[cfg(feature = "ariadne")]
2406    #[test]
2407    fn ariadne_concat_detail_in_another_piece_renders_own_file() {
2408        use crate::builder::DiagnosticMessageBuilder;
2409
2410        let (ctx, concat, l1) = concat_fixture();
2411        let main = quarto_source_map::SourceInfo::substring(concat.clone(), 0, 5);
2412        let detail = quarto_source_map::SourceInfo::substring(concat, l1, l1 + 6);
2413        let msg = DiagnosticMessageBuilder::error("Mismatch")
2414            .with_location(main)
2415            .add_detail_at("related token in cell 2", detail)
2416            .build();
2417        let opts = TextRenderOptions {
2418            enable_hyperlinks: false,
2419        };
2420        let text = strip_ansi_colors(&msg.to_text_with_renderer(
2421            Some(&ctx),
2422            &opts,
2423            Some(SourceRenderer::Ariadne),
2424        ));
2425
2426        assert!(
2427            text.contains("notebook.ipynb[cell 1, markdown]") && text.contains("first cell text"),
2428            "main span must still render; got:\n{text}"
2429        );
2430        assert!(
2431            text.contains("notebook.ipynb[cell 2, markdown]"),
2432            "foreign-piece detail must render its own block; got:\n{text}"
2433        );
2434        assert!(
2435            text.contains("second cell text"),
2436            "foreign-piece detail must snippet its own file; got:\n{text}"
2437        );
2438        assert!(
2439            text.contains("related token in cell 2"),
2440            "foreign-piece detail must keep its message; got:\n{text}"
2441        );
2442    }
2443
2444    /// Passthrough for the annotate-snippets renderer (see the ariadne
2445    /// twin for the contract).
2446    #[cfg(feature = "annotate-snippets")]
2447    #[test]
2448    fn annotate_snippets_concat_single_file_pieces_passthrough() {
2449        use crate::builder::DiagnosticMessageBuilder;
2450
2451        let content = "alpha\nbeta\ngamma\n";
2452        let mut ctx = quarto_source_map::SourceContext::new();
2453        let f1 = ctx.add_file("script.qmd".to_string(), Some(content.to_string()));
2454        let concat = quarto_source_map::SourceInfo::concat(vec![
2455            (quarto_source_map::SourceInfo::original(f1, 0, 6), 6),
2456            (
2457                quarto_source_map::SourceInfo::original(f1, 6, content.len()),
2458                content.len() - 6,
2459            ),
2460        ]);
2461        let location = quarto_source_map::SourceInfo::substring(concat, 6, 10); // "beta"
2462        let msg = DiagnosticMessageBuilder::error("Bad chunk")
2463            .with_location(location)
2464            .build();
2465        let opts = TextRenderOptions {
2466            enable_hyperlinks: false,
2467        };
2468        let text = strip_ansi(&msg.to_text_with_renderer(
2469            Some(&ctx),
2470            &opts,
2471            Some(SourceRenderer::AnnotateSnippets),
2472        ));
2473
2474        assert!(
2475            text.contains("script.qmd") && text.contains("beta"),
2476            "single-file passthrough must render unchanged; got:\n{text}"
2477        );
2478    }
2479
2480    /// First-piece span under annotate-snippets (see the ariadne twin).
2481    #[cfg(feature = "annotate-snippets")]
2482    #[test]
2483    fn annotate_snippets_concat_main_span_in_first_piece_renders_own_file() {
2484        use crate::builder::DiagnosticMessageBuilder;
2485
2486        let (ctx, concat, _) = concat_fixture();
2487        let location = quarto_source_map::SourceInfo::substring(concat, 0, 5); // "first"
2488        let msg = DiagnosticMessageBuilder::error("Bad cell")
2489            .with_location(location)
2490            .build();
2491        let opts = TextRenderOptions {
2492            enable_hyperlinks: false,
2493        };
2494        let text = strip_ansi(&msg.to_text_with_renderer(
2495            Some(&ctx),
2496            &opts,
2497            Some(SourceRenderer::AnnotateSnippets),
2498        ));
2499
2500        assert!(
2501            text.contains("notebook.ipynb[cell 1, markdown]") && text.contains("first cell text"),
2502            "first-piece span must render piece 1; got:\n{text}"
2503        );
2504        assert!(
2505            !text.contains("notebook.ipynb[cell 2, markdown]"),
2506            "piece 2 must not appear; got:\n{text}"
2507        );
2508    }
2509
2510    /// Later-piece span under annotate-snippets (see the ariadne twin).
2511    #[cfg(feature = "annotate-snippets")]
2512    #[test]
2513    fn annotate_snippets_concat_main_span_in_later_piece_renders_own_file() {
2514        use crate::builder::DiagnosticMessageBuilder;
2515
2516        let (ctx, concat, l1) = concat_fixture();
2517        let location = quarto_source_map::SourceInfo::substring(concat, l1, l1 + 6); // "second"
2518        let msg = DiagnosticMessageBuilder::error("Bad cell")
2519            .with_location(location)
2520            .build();
2521        let opts = TextRenderOptions {
2522            enable_hyperlinks: false,
2523        };
2524        let text = strip_ansi(&msg.to_text_with_renderer(
2525            Some(&ctx),
2526            &opts,
2527            Some(SourceRenderer::AnnotateSnippets),
2528        ));
2529
2530        assert!(
2531            text.contains("notebook.ipynb[cell 2, markdown]"),
2532            "later-piece span must label the owning piece; got:\n{text}"
2533        );
2534        assert!(
2535            text.contains("second cell text"),
2536            "later-piece span must snippet the owning piece; got:\n{text}"
2537        );
2538        assert!(
2539            !text.contains("first cell text"),
2540            "the wrong piece must not render; got:\n{text}"
2541        );
2542    }
2543
2544    /// Straddling span under annotate-snippets (see the ariadne twin).
2545    #[cfg(feature = "annotate-snippets")]
2546    #[test]
2547    fn annotate_snippets_concat_straddling_span_labels_both_pieces_without_snippet() {
2548        use crate::builder::DiagnosticMessageBuilder;
2549
2550        let (ctx, concat, l1) = concat_fixture();
2551        let location = quarto_source_map::SourceInfo::substring(concat, 6, l1 + 6);
2552        let msg = DiagnosticMessageBuilder::error("Cross-cell markdown")
2553            .with_location(location)
2554            .build();
2555        let opts = TextRenderOptions {
2556            enable_hyperlinks: false,
2557        };
2558        let text = strip_ansi(&msg.to_text_with_renderer(
2559            Some(&ctx),
2560            &opts,
2561            Some(SourceRenderer::AnnotateSnippets),
2562        ));
2563
2564        assert!(
2565            text.contains("--> notebook.ipynb[cell 1, markdown]:1:7"),
2566            "straddling span must name its start piece and position; got:\n{text}"
2567        );
2568        assert!(
2569            text.contains("(spans through notebook.ipynb[cell 2, markdown]:1:7)"),
2570            "straddling span must name its end piece and position; got:\n{text}"
2571        );
2572        assert!(
2573            !text.contains("first cell text") && !text.contains("second cell text"),
2574            "a cross-piece span must render no snippet; got:\n{text}"
2575        );
2576    }
2577
2578    /// Foreign-piece detail under annotate-snippets (see the ariadne
2579    /// twin).
2580    #[cfg(feature = "annotate-snippets")]
2581    #[test]
2582    fn annotate_snippets_concat_detail_in_another_piece_renders_own_file() {
2583        use crate::builder::DiagnosticMessageBuilder;
2584
2585        let (ctx, concat, l1) = concat_fixture();
2586        let main = quarto_source_map::SourceInfo::substring(concat.clone(), 0, 5);
2587        let detail = quarto_source_map::SourceInfo::substring(concat, l1, l1 + 6);
2588        let msg = DiagnosticMessageBuilder::error("Mismatch")
2589            .with_location(main)
2590            .add_detail_at("related token in cell 2", detail)
2591            .build();
2592        let opts = TextRenderOptions {
2593            enable_hyperlinks: false,
2594        };
2595        let text = strip_ansi(&msg.to_text_with_renderer(
2596            Some(&ctx),
2597            &opts,
2598            Some(SourceRenderer::AnnotateSnippets),
2599        ));
2600
2601        assert!(
2602            text.contains("notebook.ipynb[cell 1, markdown]") && text.contains("first cell text"),
2603            "main span must still render; got:\n{text}"
2604        );
2605        assert!(
2606            text.contains("notebook.ipynb[cell 2, markdown]")
2607                && text.contains("second cell text")
2608                && text.contains("related token in cell 2"),
2609            "foreign-piece detail must render its own block with its message; got:\n{text}"
2610        );
2611    }
2612
2613    // ==================== Origin-aware hyperlinks (plan 7c) ====================
2614    //
2615    // A virtual file carrying `FileMetadata::origin` (a notebook cell) is
2616    // hyperlinked to the *owning notebook on disk*, and the `#line:column`
2617    // fragment must be suppressed: origin coordinates are cell-relative and
2618    // would be wrong in the notebook's URL. Without origin, a virtual file
2619    // gets no hyperlink at all (its pseudo-path does not exist on disk).
2620
2621    /// Extract every OSC 8 link URL from raw terminal output.
2622    #[cfg(feature = "ariadne")]
2623    fn osc8_urls(text: &str) -> Vec<String> {
2624        let mut urls = Vec::new();
2625        let mut rest = text;
2626        while let Some(start) = rest.find("\x1b]8;;") {
2627            let after = &rest[start + 5..];
2628            match after.find("\x1b\\") {
2629                Some(end) => {
2630                    urls.push(after[..end].to_string());
2631                    rest = &after[end..];
2632                }
2633                None => break,
2634            }
2635        }
2636        urls
2637    }
2638
2639    /// A real notebook on disk plus two per-cell virtual files joined by a
2640    /// `Concat`, with `origin` attached to both cells. `notebook_path` is the
2641    /// absolute disk path — what q2 registers after resolving the document.
2642    #[cfg(feature = "ariadne")]
2643    fn origin_fixture() -> (
2644        quarto_source_map::SourceContext,
2645        quarto_source_map::SourceInfo,
2646        tempfile::TempDir,
2647        std::path::PathBuf,
2648        usize,
2649    ) {
2650        let piece1 = "first cell text\n";
2651        let piece2 = "second cell text\n";
2652        let dir = tempfile::TempDir::new().unwrap();
2653        let notebook = dir.path().join("notebook.ipynb");
2654        std::fs::write(&notebook, "{}").unwrap();
2655        let origin_for = |index: usize| {
2656            Some(quarto_source_map::FileOrigin::NotebookCell {
2657                notebook_path: notebook.display().to_string(),
2658                cell_index: index,
2659                cell_id: None,
2660                cell_type: "markdown".to_string(),
2661            })
2662        };
2663        let mut ctx = quarto_source_map::SourceContext::new();
2664        let f1 = ctx.add_file(
2665            "notebook.ipynb[cell 1, markdown]".to_string(),
2666            Some(piece1.to_string()),
2667        );
2668        let f2 = ctx.add_file(
2669            "notebook.ipynb[cell 2, markdown]".to_string(),
2670            Some(piece2.to_string()),
2671        );
2672        ctx.get_file_mut(f1).unwrap().metadata.origin = origin_for(1);
2673        ctx.get_file_mut(f2).unwrap().metadata.origin = origin_for(2);
2674        let concat = quarto_source_map::SourceInfo::concat(vec![
2675            (
2676                quarto_source_map::SourceInfo::original(f1, 0, piece1.len()),
2677                piece1.len(),
2678            ),
2679            (
2680                quarto_source_map::SourceInfo::original(f2, 0, piece2.len()),
2681                piece2.len(),
2682            ),
2683        ]);
2684        (ctx, concat, dir, notebook, piece1.len())
2685    }
2686
2687    #[cfg(feature = "ariadne")]
2688    #[test]
2689    fn ariadne_origin_cell_hyperlinks_the_owning_notebook() {
2690        use crate::builder::DiagnosticMessageBuilder;
2691
2692        let (ctx, concat, _dir, notebook, l1) = origin_fixture();
2693        let location = quarto_source_map::SourceInfo::substring(concat, l1, l1 + 6); // "second"
2694        let msg = DiagnosticMessageBuilder::error("Bad cell")
2695            .with_location(location)
2696            .build();
2697        let opts = TextRenderOptions {
2698            enable_hyperlinks: true,
2699        };
2700        let text = msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::Ariadne));
2701
2702        // Compute the expected URL exactly the way the renderer does, so
2703        // platform-specific path forms (Windows verbatim `\\?\…`) can't
2704        // make the test diverge from production.
2705        let canonical = std::fs::canonicalize(&notebook).unwrap();
2706        let expected_prefix =
2707            url::Url::from_file_path(super::DiagnosticMessage::plain_absolute_path(canonical))
2708                .unwrap()
2709                .as_str()
2710                .to_string();
2711        let urls = osc8_urls(&text);
2712        let cell_link = urls
2713            .iter()
2714            .find(|u| u.starts_with(&expected_prefix))
2715            .expect("cell label must hyperlink to the owning notebook");
2716
2717        // The fragment must be absent: origin coordinates are cell-relative
2718        // and would be wrong as a notebook line/column.
2719        assert!(
2720            !cell_link.contains('#'),
2721            "cell hyperlink must not carry a #line:col fragment; got {cell_link:?}"
2722        );
2723        // The human-readable label is still the cell-qualified pseudo-path.
2724        assert!(
2725            text.contains("notebook.ipynb[cell 2, markdown]"),
2726            "label must stay the pseudo-path; got:\n{text}"
2727        );
2728    }
2729
2730    #[cfg(feature = "ariadne")]
2731    #[test]
2732    fn ariadne_virtual_file_without_origin_gets_no_hyperlink() {
2733        use crate::builder::DiagnosticMessageBuilder;
2734
2735        let (ctx, concat, _l1) = concat_fixture();
2736        let location = quarto_source_map::SourceInfo::substring(concat, 0, 5);
2737        let msg = DiagnosticMessageBuilder::error("Bad cell")
2738            .with_location(location)
2739            .build();
2740        let opts = TextRenderOptions {
2741            enable_hyperlinks: true,
2742        };
2743        let text = msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::Ariadne));
2744
2745        assert!(
2746            osc8_urls(&text).is_empty(),
2747            "a virtual file whose path does not exist on disk must not be hyperlinked; got:\n{text}"
2748        );
2749    }
2750}