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