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 /// Dispatch to the selected source-context renderer.
655 ///
656 /// `renderer` of `None` resolves to [`SourceRenderer::default_for_features`].
657 /// Returns `None` when no renderer is available (no renderer feature
658 /// enabled) or the chosen renderer could not draw the excerpt (e.g.
659 /// the file content is unavailable — common in WASM), in which case
660 /// the caller falls back to the structured text block.
661 #[cfg_attr(
662 not(any(feature = "ariadne", feature = "annotate-snippets")),
663 allow(unused_variables)
664 )]
665 fn render_source_context(
666 &self,
667 main_location: &quarto_source_map::SourceInfo,
668 ctx: &quarto_source_map::SourceContext,
669 enable_hyperlinks: bool,
670 renderer: Option<SourceRenderer>,
671 ) -> Option<String> {
672 let renderer = renderer.or_else(SourceRenderer::default_for_features)?;
673 match renderer {
674 #[cfg(feature = "ariadne")]
675 SourceRenderer::Ariadne => {
676 self.render_ariadne_source_context(main_location, ctx, enable_hyperlinks)
677 }
678 #[cfg(feature = "annotate-snippets")]
679 SourceRenderer::AnnotateSnippets => {
680 self.render_annotate_snippets_source_context(main_location, ctx, enable_hyperlinks)
681 }
682 }
683 }
684
685 /// Wrap a file path with OSC 8 ANSI hyperlink codes for clickable terminal links.
686 ///
687 /// OSC 8 is a terminal escape sequence that creates clickable hyperlinks:
688 /// `\x1b]8;;URI\x1b\\TEXT\x1b\\`
689 ///
690 /// Only adds hyperlinks if:
691 /// - Hyperlinks are enabled via the `enable_hyperlinks` parameter
692 /// - The file exists on disk (not an ephemeral in-memory file)
693 /// - The path can be converted to an absolute path
694 ///
695 /// The `url` crate handles:
696 /// - Platform differences (Windows drive letters vs Unix paths)
697 /// - Percent-encoding of special characters
698 /// - Proper file:// URL construction
699 ///
700 /// Line and column numbers are added to the URL as a fragment identifier
701 /// (e.g., `file:///path#line:column`), which is supported by iTerm2 3.4+
702 /// and other terminal emulators for opening files at specific positions.
703 ///
704 /// Returns the wrapped path if conditions are met, otherwise returns the original path.
705 ///
706 /// Only used by the ariadne renderer (annotate-snippets has no OSC 8 support).
707 #[cfg(all(feature = "ariadne", not(target_family = "wasm")))]
708 fn wrap_path_with_hyperlink(
709 path: &str,
710 has_disk_file: bool,
711 line: Option<usize>,
712 column: Option<usize>,
713 enable_hyperlinks: bool,
714 ) -> String {
715 // Don't add hyperlinks if disabled (e.g., for snapshot testing)
716 if !enable_hyperlinks {
717 return path.to_string();
718 }
719
720 // Only add hyperlinks for real files on disk (not ephemeral in-memory files)
721 if !has_disk_file {
722 return path.to_string();
723 }
724
725 // Canonicalize to absolute path
726 let abs_path = match std::fs::canonicalize(path) {
727 Ok(p) => p,
728 Err(_) => return path.to_string(), // Can't canonicalize, skip hyperlink
729 };
730
731 // Convert to file:// URL (handles Windows/Unix + percent-encoding)
732 let mut file_url = match url::Url::from_file_path(&abs_path) {
733 Ok(url) => url.as_str().to_string(),
734 Err(_) => return path.to_string(), // Conversion failed, skip hyperlink
735 };
736
737 // Add line and column as fragment identifier (e.g., #line:column)
738 // This format is supported by iTerm2 3.4+ semantic history
739 if let Some(line_num) = line {
740 if let Some(col_num) = column {
741 file_url.push_str(&format!("#{}:{}", line_num, col_num));
742 } else {
743 file_url.push_str(&format!("#{}", line_num));
744 }
745 }
746
747 // Wrap with OSC 8 codes: \x1b]8;;URI\x1b\\TEXT\x1b]8;;\x1b\\
748 format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", file_url, path)
749 }
750
751 /// WASM version: hyperlinks don't make sense in WASM environments (no file system).
752 /// Just return the path unmodified.
753 #[cfg(all(feature = "ariadne", target_family = "wasm"))]
754 fn wrap_path_with_hyperlink(
755 path: &str,
756 _has_disk_file: bool,
757 _line: Option<usize>,
758 _column: Option<usize>,
759 _enable_hyperlinks: bool,
760 ) -> String {
761 path.to_string()
762 }
763
764 /// Render source context using ariadne (private helper for to_text).
765 ///
766 /// This produces the visual source code snippet with highlighting.
767 /// The tidyverse-style problem/details/hints are added separately by to_text().
768 #[cfg(feature = "ariadne")]
769 fn render_ariadne_source_context(
770 &self,
771 main_location: &quarto_source_map::SourceInfo,
772 ctx: &quarto_source_map::SourceContext,
773 enable_hyperlinks: bool,
774 ) -> Option<String> {
775 use ariadne::{Color, Config, IndexType, Label, Report, ReportKind, Source};
776
777 // Mirror of ariadne's private `Config::unimportant_color()` from
778 // ariadne 0.6.0 (`src/lib.rs:543`). We use this for `DetailKind::Faded`
779 // labels so they blend visually with characters that fall outside any
780 // label. Bump this constant if the ariadne dependency upgrades and
781 // changes the colour.
782 const ARIADNE_UNIMPORTANT_COLOR: Color = Color::Fixed(249);
783
784 // Extract file_id from the source mapping by traversing the chain
785 let file_id = main_location.root_file_id()?;
786
787 let file = ctx.get_file(file_id)?;
788
789 // Get file content: use stored content for ephemeral files, or read from disk.
790 // In WASM (and any host with no real filesystem) the disk read fails with
791 // "operation not supported on this platform"; the only graceful response is
792 // to drop the source-context snippet. The diagnostic's code, message, and
793 // hints still surface — only the Ariadne visual is unavailable.
794 let content = match &file.content {
795 Some(c) => c.clone(),
796 None => match std::fs::read_to_string(&file.path) {
797 Ok(s) => s,
798 Err(_) => return None,
799 },
800 };
801
802 // Map the location offsets back to original file positions
803 // map_offset expects relative offsets (0 = start of this SourceInfo's range)
804 let start_mapped = main_location.map_offset(0, ctx)?;
805 // For end offset, try the full length first. If that fails (e.g., when the span
806 // extends past EOF), clamp to the last valid position. This handles edge cases
807 // like errors pointing to EOF or diagnostics with off-by-one end offsets.
808 let end_mapped = main_location
809 .map_offset(main_location.length(), ctx)
810 .or_else(|| {
811 // Clamp: if length() fails, try length()-1, which should be the last valid byte
812 if main_location.length() > 0 {
813 main_location.map_offset(main_location.length() - 1, ctx)
814 } else {
815 None
816 }
817 })
818 .unwrap_or_else(|| start_mapped.clone());
819
820 // Create display path with OSC 8 hyperlink for clickable file paths
821 // Check if this path refers to a real file on disk (vs an ephemeral in-memory file)
822 let is_disk_file = std::path::Path::new(&file.path).exists();
823 // Line and column numbers are 1-indexed for display (start_mapped.location uses 0-indexed)
824 let line = Some(start_mapped.location.row + 1);
825 let column = Some(start_mapped.location.column + 1);
826 let display_path = Self::wrap_path_with_hyperlink(
827 &file.path,
828 is_disk_file,
829 line,
830 column,
831 enable_hyperlinks,
832 );
833
834 // Determine report kind and color
835 let (report_kind, main_color) = match self.kind {
836 DiagnosticKind::Error => (ReportKind::Error, Color::Red),
837 DiagnosticKind::Warning => (ReportKind::Warning, Color::Yellow),
838 DiagnosticKind::Info => (ReportKind::Advice, Color::Cyan),
839 DiagnosticKind::Note => (ReportKind::Advice, Color::Blue),
840 };
841
842 // Build the report using the mapped offset for proper line:column display
843 // IMPORTANT: Use IndexType::Byte because our offsets are byte offsets, not character offsets
844 let mut report = Report::build(
845 report_kind,
846 (
847 display_path.clone(),
848 start_mapped.location.offset..start_mapped.location.offset,
849 ),
850 )
851 .with_config(Config::default().with_index_type(IndexType::Byte));
852
853 // Add title with error code
854 if let Some(code) = &self.code {
855 report = report.with_message(format!("[{}] {}", code, self.title));
856 } else {
857 report = report.with_message(&self.title);
858 }
859
860 // Add main location label using mapped offsets
861 let main_span = start_mapped.location.offset..end_mapped.location.offset;
862 let main_message = if let Some(problem) = &self.problem {
863 problem.as_str()
864 } else {
865 &self.title
866 };
867
868 // Set `with_order` on every label using its end offset. Ariadne
869 // groups labels by source and starts a new group whenever a label's
870 // end line is *before* the previous label's end line. Without an
871 // explicit order, multi-line main labels and per-line "padding"
872 // detail labels (used to defeat Ariadne's middle-line elision) end
873 // up in separate groups, producing a duplicated snippet block.
874 // Sorting by end offset puts the smaller-line labels first so the
875 // grouping algorithm extends rather than splits.
876 report = report.with_label(
877 Label::new((display_path.clone(), main_span.clone()))
878 .with_message(main_message)
879 .with_color(main_color)
880 .with_order(main_span.end as i32),
881 );
882
883 // Add detail locations as additional labels (only those with locations)
884 for detail in &self.details {
885 if let Some(detail_loc) = &detail.location {
886 // Extract file_id from detail location
887 let detail_file_id = match detail_loc.root_file_id() {
888 Some(fid) => fid,
889 None => continue, // Skip if we can't extract file_id
890 };
891
892 if detail_file_id == file_id {
893 // Map detail offsets to original file positions
894 // map_offset expects relative offsets (0 = start of SourceInfo's range)
895 if let (Some(detail_start), Some(detail_end)) = (
896 detail_loc.map_offset(0, ctx),
897 detail_loc.map_offset(detail_loc.length(), ctx),
898 ) {
899 let detail_span = detail_start.location.offset..detail_end.location.offset;
900 let detail_color = match detail.kind {
901 DetailKind::Error => Color::Red,
902 DetailKind::Info => Color::Cyan,
903 DetailKind::Note => Color::Blue,
904 // Match Ariadne's unimportant colour so faded
905 // labels visually disappear into the surrounding
906 // unlabelled text.
907 DetailKind::Faded => ARIADNE_UNIMPORTANT_COLOR,
908 };
909
910 // Empty-content details exist purely to force Ariadne
911 // to display a line that would otherwise be elided
912 // inside a multi-line span. Leaving the label's
913 // message at None makes Ariadne skip drawing the
914 // `╰── ...` arrow row underneath, so the source line
915 // appears clean.
916 let mut label = Label::new((display_path.clone(), detail_span.clone()))
917 .with_color(detail_color)
918 .with_order(detail_span.end as i32);
919 if !detail.content.as_str().is_empty() {
920 label = label.with_message(detail.content.as_str());
921 }
922 report = report.with_label(label);
923 }
924 }
925 }
926 }
927
928 // Render to string
929 let report = report.finish();
930 let mut output = Vec::new();
931 report
932 .write(
933 (display_path.clone(), Source::from(content.as_str())),
934 &mut output,
935 )
936 .ok()?;
937
938 let output_str = String::from_utf8(output).ok()?;
939
940 // Post-process to extend hyperlinks to include line:column numbers
941 // Ariadne adds :line:column after our hyperlinked path, so we need to
942 // move the hyperlink end marker to include those numbers
943 if is_disk_file && enable_hyperlinks {
944 Some(Self::extend_hyperlink_to_include_line_column(
945 &output_str,
946 &file.path,
947 ))
948 } else {
949 Some(output_str)
950 }
951 }
952
953 /// Render source context using [`annotate-snippets`](https://crates.io/crates/annotate-snippets),
954 /// the rust-lang toolchain's diagnostic style (private helper for to_text).
955 ///
956 /// Mirrors [`Self::render_ariadne_source_context`]'s offset-mapping
957 /// logic but emits the `error[CODE]: …` / `-->` / gutter-bar look.
958 /// Differences from the ariadne path, by design:
959 ///
960 /// - The error code is rendered natively via `Title::id` (e.g.
961 /// `error[Q-2-5]: …`) rather than prefixed into the message.
962 /// - There are **no terminal hyperlinks** — annotate-snippets has no
963 /// OSC 8 support, so `_enable_hyperlinks` is ignored.
964 /// - Detail labels are all rendered as `Context` annotations
965 /// (annotate-snippets has no per-label color), so the `DetailKind`
966 /// color distinction and the `Faded` blend are not reproduced.
967 /// - Empty-content "padding" details (an ariadne workaround for
968 /// mid-span line elision) are skipped: annotate-snippets folds
969 /// unannotated lines natively, which is the look we want here.
970 #[cfg(feature = "annotate-snippets")]
971 fn render_annotate_snippets_source_context(
972 &self,
973 main_location: &quarto_source_map::SourceInfo,
974 ctx: &quarto_source_map::SourceContext,
975 _enable_hyperlinks: bool,
976 ) -> Option<String> {
977 use annotate_snippets::{AnnotationKind, Level, Renderer, Snippet};
978
979 // Resolve the root file and its content (same as the ariadne path).
980 let file_id = main_location.root_file_id()?;
981 let file = ctx.get_file(file_id)?;
982 let content = match &file.content {
983 Some(c) => c.clone(),
984 None => std::fs::read_to_string(&file.path).ok()?,
985 };
986 let content_len = content.len();
987
988 // Clamp a mapped byte range into the source, keeping start <= end.
989 let clamp = |start: usize, end: usize| -> std::ops::Range<usize> {
990 let s = start.min(content_len);
991 let e = end.min(content_len).max(s);
992 s..e
993 };
994
995 // Map the main location's offsets back to original-file byte
996 // positions, clamping the end past EOF like the ariadne path.
997 let start_mapped = main_location.map_offset(0, ctx)?;
998 let end_mapped = main_location
999 .map_offset(main_location.length(), ctx)
1000 .or_else(|| {
1001 if main_location.length() > 0 {
1002 main_location.map_offset(main_location.length() - 1, ctx)
1003 } else {
1004 None
1005 }
1006 })
1007 .unwrap_or_else(|| start_mapped.clone());
1008 let main_span = clamp(start_mapped.location.offset, end_mapped.location.offset);
1009
1010 let level = match self.kind {
1011 DiagnosticKind::Error => Level::ERROR,
1012 DiagnosticKind::Warning => Level::WARNING,
1013 DiagnosticKind::Info => Level::INFO,
1014 DiagnosticKind::Note => Level::NOTE,
1015 };
1016
1017 // Primary label message: the problem statement, else the title.
1018 let main_message = match &self.problem {
1019 Some(problem) => problem.as_str(),
1020 None => self.title.as_str(),
1021 };
1022
1023 let mut snippet = Snippet::source(content.as_str())
1024 .path(file.path.as_str())
1025 .line_start(1)
1026 .annotation(AnnotationKind::Primary.span(main_span).label(main_message));
1027
1028 // Detail locations in the same file become Context annotations.
1029 for detail in &self.details {
1030 // Skip empty-content padding details (see the doc comment).
1031 if detail.content.as_str().is_empty() {
1032 continue;
1033 }
1034 let Some(detail_loc) = &detail.location else {
1035 continue;
1036 };
1037 if detail_loc.root_file_id() != Some(file_id) {
1038 continue;
1039 }
1040 if let (Some(detail_start), Some(detail_end)) = (
1041 detail_loc.map_offset(0, ctx),
1042 detail_loc.map_offset(detail_loc.length(), ctx),
1043 ) {
1044 let detail_span = clamp(detail_start.location.offset, detail_end.location.offset);
1045 snippet = snippet.annotation(
1046 AnnotationKind::Context
1047 .span(detail_span)
1048 .label(detail.content.as_str()),
1049 );
1050 }
1051 }
1052
1053 // Build the titled group; render the error code natively via `id`.
1054 let mut title = level.primary_title(self.title.as_str());
1055 if let Some(code) = &self.code {
1056 title = title.id(code.as_str());
1057 }
1058 let group = title.element(snippet);
1059
1060 // `Renderer::render` returns text with no trailing newline, but
1061 // `to_text` appends unlocated details and hints directly after the
1062 // excerpt with `writeln!`. Match the ariadne path (which ends in a
1063 // newline) so those lines don't glue onto the last source row.
1064 let mut rendered = Renderer::styled().render(&[group]);
1065 if !rendered.ends_with('\n') {
1066 rendered.push('\n');
1067 }
1068 Some(rendered)
1069 }
1070
1071 /// Extend OSC 8 hyperlinks to include the :line:column suffix that ariadne adds.
1072 ///
1073 /// Ariadne formats file references as `path:line:column`, but since we wrap the path
1074 /// with OSC 8 codes, the structure becomes: `[hyperlink:path]:line:column`
1075 /// We want: `[hyperlink:path:line:column]`
1076 ///
1077 /// This function finds patterns like `path]8;;\:line:column` and moves the hyperlink
1078 /// end marker to after the line:column part.
1079 #[cfg(feature = "ariadne")]
1080 fn extend_hyperlink_to_include_line_column(output: &str, original_path: &str) -> String {
1081 // Pattern: original_path followed by ]8;;\ then :numbers:numbers
1082 // We want to move the ]8;;\ to after the :numbers:numbers part
1083 let end_marker = "\x1b]8;;\x1b\\";
1084 let search_pattern = format!("{}{}", original_path, end_marker);
1085
1086 let mut result = output.to_string();
1087 while let Some(pos) = result.find(&search_pattern) {
1088 let after_marker = pos + search_pattern.len();
1089 // Check if what follows is :line:column pattern
1090 if let Some(rest) = result.get(after_marker..) {
1091 // Match :digits:digits pattern
1092 if let Some(colon_end) = Self::find_line_column_end(rest) {
1093 // Move the end marker to after the :line:column
1094 let before = &result[..pos + original_path.len()];
1095 let line_col = &rest[..colon_end];
1096 let after = &rest[colon_end..];
1097 result = format!("{}{}{}{}", before, line_col, end_marker, after);
1098 continue;
1099 }
1100 }
1101 break;
1102 }
1103 result
1104 }
1105
1106 /// Find the end position of a :line:column pattern at the start of the string.
1107 /// Returns None if the pattern doesn't match.
1108 #[cfg(feature = "ariadne")]
1109 fn find_line_column_end(s: &str) -> Option<usize> {
1110 let bytes = s.as_bytes();
1111 if bytes.is_empty() || bytes[0] != b':' {
1112 return None;
1113 }
1114
1115 let mut pos = 1;
1116 // Read digits for line number
1117 while pos < bytes.len() && bytes[pos].is_ascii_digit() {
1118 pos += 1;
1119 }
1120 if pos == 1 || pos >= bytes.len() || bytes[pos] != b':' {
1121 return None; // No digits or no second colon
1122 }
1123
1124 pos += 1; // Skip second colon
1125 let col_start = pos;
1126 // Read digits for column number
1127 while pos < bytes.len() && bytes[pos].is_ascii_digit() {
1128 pos += 1;
1129 }
1130 if pos == col_start {
1131 return None; // No digits for column
1132 }
1133
1134 Some(pos)
1135 }
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140 use super::*;
1141
1142 #[test]
1143 fn test_diagnostic_kind() {
1144 assert_eq!(DiagnosticKind::Error, DiagnosticKind::Error);
1145 assert_ne!(DiagnosticKind::Error, DiagnosticKind::Warning);
1146 }
1147
1148 #[test]
1149 fn test_message_content_from_str() {
1150 let content: MessageContent = "test".into();
1151 assert_eq!(content.as_str(), "test");
1152 }
1153
1154 #[test]
1155 fn test_diagnostic_message_new() {
1156 let msg = DiagnosticMessage::new(DiagnosticKind::Error, "Test error");
1157 assert_eq!(msg.title, "Test error");
1158 assert_eq!(msg.kind, DiagnosticKind::Error);
1159 assert!(msg.code.is_none());
1160 assert!(msg.problem.is_none());
1161 assert!(msg.details.is_empty());
1162 assert!(msg.hints.is_empty());
1163 }
1164
1165 #[test]
1166 fn test_diagnostic_message_constructors() {
1167 let error = DiagnosticMessage::error("Error");
1168 assert_eq!(error.kind, DiagnosticKind::Error);
1169 assert!(error.code.is_none());
1170
1171 let warning = DiagnosticMessage::warning("Warning");
1172 assert_eq!(warning.kind, DiagnosticKind::Warning);
1173
1174 let info = DiagnosticMessage::info("Info");
1175 assert_eq!(info.kind, DiagnosticKind::Info);
1176 }
1177
1178 #[test]
1179 fn test_with_code() {
1180 let msg = DiagnosticMessage::error("Test error").with_code("Q-1-1");
1181 assert_eq!(msg.code, Some("Q-1-1".to_string()));
1182 }
1183
1184 // The positive case — `docs_url()` for a real code resolves to the
1185 // quarto.org URL — moved to `quarto-error-catalog`'s integration tests,
1186 // where the `Q-*` catalog is installed. Here we only cover the
1187 // catalog-free cases (no code / unknown code → `None`), which hold
1188 // regardless of whether a catalog is installed.
1189
1190 #[test]
1191 fn test_docs_url_without_code() {
1192 let msg = DiagnosticMessage::error("Test error");
1193 assert!(msg.docs_url().is_none());
1194 }
1195
1196 #[test]
1197 fn test_docs_url_invalid_code() {
1198 let msg = DiagnosticMessage::error("Test error").with_code("Q-999-999"); // quarto-error-code-audit-ignore
1199 assert!(msg.docs_url().is_none());
1200 }
1201
1202 #[test]
1203 fn test_to_text_simple_error() {
1204 let msg = DiagnosticMessage::error("Something went wrong");
1205 assert_eq!(msg.to_text(None), "Error: Something went wrong\n");
1206 }
1207
1208 #[test]
1209 fn test_to_text_with_code() {
1210 let msg = DiagnosticMessage::error("Something went wrong").with_code("Q-1-1");
1211 assert_eq!(msg.to_text(None), "Error [Q-1-1]: Something went wrong\n");
1212 }
1213
1214 #[test]
1215 fn test_to_text_full_message() {
1216 use crate::builder::DiagnosticMessageBuilder;
1217
1218 let msg = DiagnosticMessageBuilder::error("Invalid input")
1219 .problem("Values must be numeric")
1220 .add_detail("Found text in column 3")
1221 .add_info("Columns should contain only numbers")
1222 .add_hint("Convert to numbers first?")
1223 .build();
1224
1225 let text = msg.to_text(None);
1226 assert!(text.contains("Error: Invalid input"));
1227 assert!(text.contains("Values must be numeric"));
1228 assert!(text.contains("✖ Found text in column 3"));
1229 assert!(text.contains("ℹ Columns should contain only numbers"));
1230 assert!(text.contains("ℹ Convert to numbers first?"));
1231 }
1232
1233 #[test]
1234 fn test_to_json_simple() {
1235 let msg = DiagnosticMessage::error("Something went wrong");
1236 let json = msg.to_json();
1237
1238 assert_eq!(json["kind"], "error");
1239 assert_eq!(json["title"], "Something went wrong");
1240 assert!(json.get("code").is_none());
1241 assert!(json.get("problem").is_none());
1242 }
1243
1244 #[test]
1245 fn test_to_json_with_code() {
1246 let msg = DiagnosticMessage::error("Something went wrong").with_code("Q-1-1");
1247 let json = msg.to_json();
1248
1249 assert_eq!(json["kind"], "error");
1250 assert_eq!(json["title"], "Something went wrong");
1251 assert_eq!(json["code"], "Q-1-1");
1252 }
1253
1254 #[test]
1255 fn test_to_json_full_message() {
1256 use crate::builder::DiagnosticMessageBuilder;
1257
1258 let msg = DiagnosticMessageBuilder::error("Invalid input")
1259 .with_code("Q-1-2") // quarto-error-code-audit-ignore
1260 .problem("Values must be numeric")
1261 .add_detail("Found text in column 3")
1262 .add_info("Expected numbers")
1263 .add_hint("Convert to numbers first?")
1264 .build();
1265
1266 let json = msg.to_json();
1267 assert_eq!(json["kind"], "error");
1268 assert_eq!(json["title"], "Invalid input");
1269 assert_eq!(json["code"], "Q-1-2"); // quarto-error-code-audit-ignore
1270 assert_eq!(json["problem"]["type"], "markdown");
1271 assert_eq!(json["problem"]["content"], "Values must be numeric");
1272 assert_eq!(json["details"][0]["kind"], "error");
1273 assert_eq!(json["details"][0]["content"]["type"], "markdown");
1274 assert_eq!(
1275 json["details"][0]["content"]["content"],
1276 "Found text in column 3"
1277 );
1278 assert_eq!(json["details"][1]["kind"], "info");
1279 assert_eq!(json["details"][1]["content"]["type"], "markdown");
1280 assert_eq!(json["details"][1]["content"]["content"], "Expected numbers");
1281 assert_eq!(json["hints"][0]["type"], "markdown");
1282 assert_eq!(json["hints"][0]["content"], "Convert to numbers first?");
1283 }
1284
1285 #[test]
1286 fn test_to_json_warning() {
1287 let msg = DiagnosticMessage::warning("Be careful");
1288 let json = msg.to_json();
1289
1290 assert_eq!(json["kind"], "warning");
1291 assert_eq!(json["title"], "Be careful");
1292 }
1293
1294 #[test]
1295 fn test_location_in_to_text_without_context() {
1296 use crate::builder::DiagnosticMessageBuilder;
1297
1298 // Create a location at offsets 100-110
1299 let location =
1300 quarto_source_map::SourceInfo::original(quarto_source_map::FileId(0), 100, 110);
1301
1302 let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1303 .with_location(location)
1304 .build();
1305
1306 let text = msg.to_text(None);
1307
1308 // Without context, should show offset (we can't get row/column without context)
1309 assert!(text.contains("Invalid syntax"));
1310 assert!(text.contains("at offset 100"));
1311 }
1312
1313 #[test]
1314 fn test_location_in_to_text_with_context() {
1315 use crate::builder::DiagnosticMessageBuilder;
1316
1317 // Create a source context with a file
1318 let mut ctx = quarto_source_map::SourceContext::new();
1319 let file_id = ctx.add_file(
1320 "test.qmd".to_string(),
1321 Some("line 1\nline 2\nline 3\nline 4".to_string()),
1322 );
1323
1324 // Create a location in that file (offset 7 is start of "line 2")
1325 let location = quarto_source_map::SourceInfo::original(
1326 file_id, 7, // Start of "line 2"
1327 13, // End of "line 2"
1328 );
1329
1330 let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1331 .with_location(location)
1332 .build();
1333
1334 let text = msg.to_text(Some(&ctx));
1335
1336 // With context, should show file path and 1-indexed location
1337 assert!(text.contains("Invalid syntax"));
1338 assert!(text.contains("test.qmd"));
1339 assert!(text.contains("2:1")); // row 1 + 1, column 0 + 1
1340 }
1341
1342 #[test]
1343 fn test_location_in_to_json() {
1344 use crate::builder::DiagnosticMessageBuilder;
1345
1346 let location =
1347 quarto_source_map::SourceInfo::original(quarto_source_map::FileId(0), 100, 110);
1348
1349 let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1350 .with_location(location)
1351 .build();
1352
1353 let json = msg.to_json();
1354
1355 // Should have location field with Original variant
1356 assert!(json.get("location").is_some());
1357 let loc = &json["location"];
1358
1359 // Verify the SourceInfo is serialized correctly (as Original enum variant)
1360 assert!(loc.get("Original").is_some());
1361 let original = &loc["Original"];
1362 assert_eq!(original["file_id"], 0);
1363 assert_eq!(original["start_offset"], 100);
1364 assert_eq!(original["end_offset"], 110);
1365 }
1366
1367 #[test]
1368 fn test_location_optional_in_to_json() {
1369 let msg = DiagnosticMessage::error("No location");
1370 let json = msg.to_json();
1371
1372 // Should not have location field when not provided
1373 assert!(json.get("location").is_none());
1374 }
1375
1376 #[test]
1377 fn test_text_render_options_disable_hyperlinks() {
1378 use crate::builder::DiagnosticMessageBuilder;
1379
1380 let mut ctx = quarto_source_map::SourceContext::new();
1381 let file_id = ctx.add_file("test.qmd".to_string(), Some("test content".to_string()));
1382
1383 let location = quarto_source_map::SourceInfo::original(file_id, 0, 4);
1384
1385 let msg = DiagnosticMessageBuilder::error("Test error")
1386 .with_location(location)
1387 .build();
1388
1389 // With hyperlinks enabled (default)
1390 let with_hyperlinks = msg.to_text(Some(&ctx));
1391
1392 // With hyperlinks disabled
1393 let options = TextRenderOptions {
1394 enable_hyperlinks: false,
1395 };
1396 let without_hyperlinks = msg.to_text_with_options(Some(&ctx), &options);
1397
1398 // When hyperlinks are disabled, output should be different
1399 // (specifically, no OSC 8 escape sequences)
1400 if with_hyperlinks.contains("\x1b]8;") {
1401 assert!(
1402 !without_hyperlinks.contains("\x1b]8;"),
1403 "Disabled hyperlinks should not contain OSC 8 codes"
1404 );
1405 }
1406 }
1407
1408 #[test]
1409 fn test_text_render_options_default() {
1410 let options = TextRenderOptions::default();
1411 assert!(
1412 options.enable_hyperlinks,
1413 "Default should enable hyperlinks"
1414 );
1415 }
1416
1417 #[test]
1418 fn test_render_with_custom_options() {
1419 use crate::builder::DiagnosticMessageBuilder;
1420
1421 let msg = DiagnosticMessageBuilder::error("Test")
1422 .problem("Something went wrong")
1423 .add_detail("Detail 1")
1424 .add_hint("Try this")
1425 .build();
1426
1427 let options = TextRenderOptions {
1428 enable_hyperlinks: false,
1429 };
1430
1431 let text = msg.to_text_with_options(None, &options);
1432
1433 // Should still render properly without hyperlinks
1434 assert!(text.contains("Error: Test"));
1435 assert!(text.contains("Something went wrong"));
1436 assert!(text.contains("Detail 1"));
1437 assert!(text.contains("Try this"));
1438 }
1439
1440 /// Strip CSI SGR color sequences (`ESC [ … m`). The annotate-snippets
1441 /// path emits no OSC 8 hyperlinks, so color is all we need to remove
1442 /// to make substring assertions robust to styling.
1443 #[cfg(feature = "annotate-snippets")]
1444 fn strip_ansi(s: &str) -> String {
1445 let mut out = String::new();
1446 let mut chars = s.chars().peekable();
1447 while let Some(c) = chars.next() {
1448 if c == '\u{1b}' {
1449 for n in chars.by_ref() {
1450 if n == 'm' {
1451 break;
1452 }
1453 }
1454 } else {
1455 out.push(c);
1456 }
1457 }
1458 out
1459 }
1460
1461 /// The annotate-snippets renderer emits the rust-lang toolchain look:
1462 /// an `error[CODE]: …` header, a `-->` origin line, and `^` underlines
1463 /// — not ariadne's enclosing box.
1464 #[cfg(feature = "annotate-snippets")]
1465 #[test]
1466 fn annotate_snippets_renderer_produces_rust_style_output() {
1467 use crate::builder::DiagnosticMessageBuilder;
1468
1469 let mut ctx = quarto_source_map::SourceContext::new();
1470 let file_id = ctx.add_file(
1471 "test.qmd".to_string(),
1472 Some("line 1\nline 2\nline 3".to_string()),
1473 );
1474 // Offsets 7..13 cover "line 2" on row 2.
1475 let location = quarto_source_map::SourceInfo::original(file_id, 7, 13);
1476 let msg = DiagnosticMessageBuilder::error("Bad thing")
1477 .with_code("Q-9-9")
1478 .with_location(location)
1479 .problem("this is wrong")
1480 .build();
1481
1482 let opts = TextRenderOptions {
1483 enable_hyperlinks: false,
1484 };
1485 let raw =
1486 msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
1487 let text = strip_ansi(&raw);
1488
1489 assert!(
1490 text.contains("error[Q-9-9]"),
1491 "expected rust-style code header; got: {text:?}"
1492 );
1493 assert!(
1494 text.contains("-->"),
1495 "expected rust-style origin arrow; got: {text:?}"
1496 );
1497 assert!(
1498 text.contains("test.qmd:2:1"),
1499 "expected mapped location; got: {text:?}"
1500 );
1501 assert!(
1502 !text.contains('\u{256D}'),
1503 "annotate-snippets must not draw ariadne's box corner; got: {text:?}"
1504 );
1505 // No OSC 8 hyperlinks from annotate-snippets.
1506 assert!(
1507 !raw.contains("\u{1b}]8;"),
1508 "annotate-snippets emits no OSC 8 hyperlinks; got: {raw:?}"
1509 );
1510 }
1511
1512 /// Forcing a specific renderer is honored: ariadne draws its boxed
1513 /// excerpt (the U+256D corner) while annotate-snippets does not.
1514 #[cfg(all(feature = "ariadne", feature = "annotate-snippets"))]
1515 #[test]
1516 fn renderer_selection_switches_styles() {
1517 use crate::builder::DiagnosticMessageBuilder;
1518
1519 let mut ctx = quarto_source_map::SourceContext::new();
1520 let file_id = ctx.add_file("a.qmd".to_string(), Some("alpha\nbeta\ngamma".to_string()));
1521 let location = quarto_source_map::SourceInfo::original(file_id, 6, 10); // "beta"
1522 let msg = DiagnosticMessageBuilder::error("Pick a style")
1523 .with_location(location)
1524 .build();
1525 let opts = TextRenderOptions {
1526 enable_hyperlinks: false,
1527 };
1528
1529 let ariadne = msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::Ariadne));
1530 let snippets =
1531 msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
1532
1533 assert!(ariadne.contains('\u{256D}'), "ariadne draws a box corner");
1534 assert!(
1535 !strip_ansi(&snippets).contains('\u{256D}'),
1536 "annotate-snippets does not"
1537 );
1538 assert!(strip_ansi(&snippets).contains("-->"));
1539 }
1540}