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