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