@@ -29,6 +29,28 @@ pub enum DetailKind {
2929 Note ,
3030}
3131
32+ /// Options for rendering diagnostic messages to text.
33+ ///
34+ /// This struct controls various aspects of text rendering, such as whether
35+ /// to include terminal hyperlinks for clickable file paths.
36+ #[ derive( Debug , Clone ) ]
37+ pub struct TextRenderOptions {
38+ /// Enable OSC 8 hyperlinks for clickable file paths in terminals.
39+ ///
40+ /// When enabled, file paths in error messages will include terminal
41+ /// escape codes for clickable links (supported by iTerm2, VS Code, etc.).
42+ /// Disable for snapshot testing to avoid absolute path differences.
43+ pub enable_hyperlinks : bool ,
44+ }
45+
46+ impl Default for TextRenderOptions {
47+ fn default ( ) -> Self {
48+ Self {
49+ enable_hyperlinks : true ,
50+ }
51+ }
52+ }
53+
3254/// The content of a message or detail item.
3355///
3456/// This will eventually support Pandoc AST for rich formatting, but starts
@@ -263,6 +285,29 @@ impl DiagnosticMessage {
263285
264286 /// Render this diagnostic message as text following tidyverse style.
265287 ///
288+ /// This is a convenience method that uses default rendering options.
289+ /// For more control over rendering, use [`to_text_with_options`].
290+ ///
291+ /// # Example
292+ ///
293+ /// ```
294+ /// use quarto_error_reporting::DiagnosticMessageBuilder;
295+ ///
296+ /// let msg = DiagnosticMessageBuilder::error("Invalid input")
297+ /// .problem("Values must be numeric")
298+ /// .add_detail("Found text in column 3")
299+ /// .add_hint("Convert to numbers first?")
300+ /// .build();
301+ /// let text = msg.to_text(None);
302+ /// assert!(text.contains("Error: Invalid input"));
303+ /// assert!(text.contains("Values must be numeric"));
304+ /// ```
305+ pub fn to_text ( & self , ctx : Option < & quarto_source_map:: SourceContext > ) -> String {
306+ self . to_text_with_options ( ctx, & TextRenderOptions :: default ( ) )
307+ }
308+
309+ /// Render this diagnostic message as text following tidyverse style with custom options.
310+ ///
266311 /// Format:
267312 /// ```text
268313 /// Error: title
@@ -278,18 +323,24 @@ impl DiagnosticMessage {
278323 /// # Example
279324 ///
280325 /// ```
281- /// use quarto_error_reporting::DiagnosticMessageBuilder;
326+ /// use quarto_error_reporting::{ DiagnosticMessageBuilder, TextRenderOptions} ;
282327 ///
283328 /// let msg = DiagnosticMessageBuilder::error("Invalid input")
284329 /// .problem("Values must be numeric")
285330 /// .add_detail("Found text in column 3")
286331 /// .add_hint("Convert to numbers first?")
287332 /// .build();
288- /// let text = msg.to_text(None);
333+ ///
334+ /// // Disable hyperlinks for snapshot testing
335+ /// let options = TextRenderOptions { enable_hyperlinks: false };
336+ /// let text = msg.to_text_with_options(None, &options);
289337 /// assert!(text.contains("Error: Invalid input"));
290- /// assert!(text.contains("Values must be numeric"));
291338 /// ```
292- pub fn to_text ( & self , ctx : Option < & quarto_source_map:: SourceContext > ) -> String {
339+ pub fn to_text_with_options (
340+ & self ,
341+ ctx : Option < & quarto_source_map:: SourceContext > ,
342+ options : & TextRenderOptions ,
343+ ) -> String {
293344 use std:: fmt:: Write ;
294345
295346 let mut result = String :: new ( ) ;
@@ -308,7 +359,7 @@ impl DiagnosticMessage {
308359 . or_else ( || self . details . iter ( ) . find_map ( |d| d. location . as_ref ( ) ) ) ;
309360
310361 if let Some ( loc) = location {
311- if let Some ( ariadne_output) = self . render_ariadne_source_context ( loc, ctx. unwrap ( ) )
362+ if let Some ( ariadne_output) = self . render_ariadne_source_context ( loc, ctx. unwrap ( ) , options . enable_hyperlinks )
312363 {
313364 result. push_str ( & ariadne_output) ;
314365 true
@@ -510,6 +561,69 @@ impl DiagnosticMessage {
510561 }
511562 }
512563
564+ /// Wrap a file path with OSC 8 ANSI hyperlink codes for clickable terminal links.
565+ ///
566+ /// OSC 8 is a terminal escape sequence that creates clickable hyperlinks:
567+ /// `\x1b]8;;URI\x1b\\TEXT\x1b\\`
568+ ///
569+ /// Only adds hyperlinks if:
570+ /// - Hyperlinks are enabled via the `enable_hyperlinks` parameter
571+ /// - The file exists on disk (not an ephemeral in-memory file)
572+ /// - The path can be converted to an absolute path
573+ ///
574+ /// The `url` crate handles:
575+ /// - Platform differences (Windows drive letters vs Unix paths)
576+ /// - Percent-encoding of special characters
577+ /// - Proper file:// URL construction
578+ ///
579+ /// Line and column numbers are added to the URL as a fragment identifier
580+ /// (e.g., `file:///path#line:column`), which is supported by iTerm2 3.4+
581+ /// and other terminal emulators for opening files at specific positions.
582+ ///
583+ /// Returns the wrapped path if conditions are met, otherwise returns the original path.
584+ fn wrap_path_with_hyperlink (
585+ path : & str ,
586+ has_disk_file : bool ,
587+ line : Option < usize > ,
588+ column : Option < usize > ,
589+ enable_hyperlinks : bool ,
590+ ) -> String {
591+ // Don't add hyperlinks if disabled (e.g., for snapshot testing)
592+ if !enable_hyperlinks {
593+ return path. to_string ( ) ;
594+ }
595+
596+ // Only add hyperlinks for real files on disk (not ephemeral in-memory files)
597+ if !has_disk_file {
598+ return path. to_string ( ) ;
599+ }
600+
601+ // Canonicalize to absolute path
602+ let abs_path = match std:: fs:: canonicalize ( path) {
603+ Ok ( p) => p,
604+ Err ( _) => return path. to_string ( ) , // Can't canonicalize, skip hyperlink
605+ } ;
606+
607+ // Convert to file:// URL (handles Windows/Unix + percent-encoding)
608+ let mut file_url = match url:: Url :: from_file_path ( & abs_path) {
609+ Ok ( url) => url. as_str ( ) . to_string ( ) ,
610+ Err ( _) => return path. to_string ( ) , // Conversion failed, skip hyperlink
611+ } ;
612+
613+ // Add line and column as fragment identifier (e.g., #line:column)
614+ // This format is supported by iTerm2 3.4+ semantic history
615+ if let Some ( line_num) = line {
616+ if let Some ( col_num) = column {
617+ file_url. push_str ( & format ! ( "#{}:{}" , line_num, col_num) ) ;
618+ } else {
619+ file_url. push_str ( & format ! ( "#{}" , line_num) ) ;
620+ }
621+ }
622+
623+ // Wrap with OSC 8 codes: \x1b]8;;URI\x1b\\TEXT\x1b]8;;\x1b\\
624+ format ! ( "\x1b ]8;;{}\x1b \\ {}\x1b ]8;;\x1b \\ " , file_url, path)
625+ }
626+
513627 /// Render source context using ariadne (private helper for to_text).
514628 ///
515629 /// This produces the visual source code snippet with highlighting.
@@ -518,6 +632,7 @@ impl DiagnosticMessage {
518632 & self ,
519633 main_location : & quarto_source_map:: SourceInfo ,
520634 ctx : & quarto_source_map:: SourceContext ,
635+ enable_hyperlinks : bool ,
521636 ) -> Option < String > {
522637 use ariadne:: { Color , Config , IndexType , Label , Report , ReportKind , Source } ;
523638
@@ -541,6 +656,14 @@ impl DiagnosticMessage {
541656 let start_mapped = main_location. map_offset ( 0 , ctx) ?;
542657 let end_mapped = main_location. map_offset ( main_location. length ( ) , ctx) ?;
543658
659+ // Create display path with OSC 8 hyperlink for clickable file paths
660+ // Check if this path refers to a real file on disk (vs an ephemeral in-memory file)
661+ let is_disk_file = std:: path:: Path :: new ( & file. path ) . exists ( ) ;
662+ // Line and column numbers are 1-indexed for display (start_mapped.location uses 0-indexed)
663+ let line = Some ( start_mapped. location . row + 1 ) ;
664+ let column = Some ( start_mapped. location . column + 1 ) ;
665+ let display_path = Self :: wrap_path_with_hyperlink ( & file. path , is_disk_file, line, column, enable_hyperlinks) ;
666+
544667 // Determine report kind and color
545668 let ( report_kind, main_color) = match self . kind {
546669 DiagnosticKind :: Error => ( ReportKind :: Error , Color :: Red ) ,
@@ -551,9 +674,12 @@ impl DiagnosticMessage {
551674
552675 // Build the report using the mapped offset for proper line:column display
553676 // IMPORTANT: Use IndexType::Byte because our offsets are byte offsets, not character offsets
554- let mut report =
555- Report :: build ( report_kind, file. path . clone ( ) , start_mapped. location . offset )
556- . with_config ( Config :: default ( ) . with_index_type ( IndexType :: Byte ) ) ;
677+ let mut report = Report :: build (
678+ report_kind,
679+ display_path. clone ( ) ,
680+ start_mapped. location . offset ,
681+ )
682+ . with_config ( Config :: default ( ) . with_index_type ( IndexType :: Byte ) ) ;
557683
558684 // Add title with error code
559685 if let Some ( code) = & self . code {
@@ -571,7 +697,7 @@ impl DiagnosticMessage {
571697 } ;
572698
573699 report = report. with_label (
574- Label :: new ( ( file . path . clone ( ) , main_span) )
700+ Label :: new ( ( display_path . clone ( ) , main_span) )
575701 . with_message ( main_message)
576702 . with_color ( main_color) ,
577703 ) ;
@@ -600,7 +726,7 @@ impl DiagnosticMessage {
600726 } ;
601727
602728 report = report. with_label (
603- Label :: new ( ( file . path . clone ( ) , detail_span) )
729+ Label :: new ( ( display_path . clone ( ) , detail_span) )
604730 . with_message ( detail. content . as_str ( ) )
605731 . with_color ( detail_color) ,
606732 ) ;
@@ -614,12 +740,88 @@ impl DiagnosticMessage {
614740 let mut output = Vec :: new ( ) ;
615741 report
616742 . write (
617- ( file . path . clone ( ) , Source :: from ( content. as_str ( ) ) ) ,
743+ ( display_path . clone ( ) , Source :: from ( content. as_str ( ) ) ) ,
618744 & mut output,
619745 )
620746 . ok ( ) ?;
621747
622- String :: from_utf8 ( output) . ok ( )
748+ let output_str = String :: from_utf8 ( output) . ok ( ) ?;
749+
750+ // Post-process to extend hyperlinks to include line:column numbers
751+ // Ariadne adds :line:column after our hyperlinked path, so we need to
752+ // move the hyperlink end marker to include those numbers
753+ if is_disk_file && enable_hyperlinks {
754+ Some ( Self :: extend_hyperlink_to_include_line_column (
755+ & output_str,
756+ & file. path ,
757+ ) )
758+ } else {
759+ Some ( output_str)
760+ }
761+ }
762+
763+ /// Extend OSC 8 hyperlinks to include the :line:column suffix that ariadne adds.
764+ ///
765+ /// Ariadne formats file references as `path:line:column`, but since we wrap the path
766+ /// with OSC 8 codes, the structure becomes: `[hyperlink:path]:line:column`
767+ /// We want: `[hyperlink:path:line:column]`
768+ ///
769+ /// This function finds patterns like `path]8;;\:line:column` and moves the hyperlink
770+ /// end marker to after the line:column part.
771+ fn extend_hyperlink_to_include_line_column ( output : & str , original_path : & str ) -> String {
772+ // Pattern: original_path followed by ]8;;\ then :numbers:numbers
773+ // We want to move the ]8;;\ to after the :numbers:numbers part
774+ let end_marker = "\x1b ]8;;\x1b \\ " ;
775+ let search_pattern = format ! ( "{}{}" , original_path, end_marker) ;
776+
777+ let mut result = output. to_string ( ) ;
778+ while let Some ( pos) = result. find ( & search_pattern) {
779+ let after_marker = pos + search_pattern. len ( ) ;
780+ // Check if what follows is :line:column pattern
781+ if let Some ( rest) = result. get ( after_marker..) {
782+ // Match :digits:digits pattern
783+ if let Some ( colon_end) = Self :: find_line_column_end ( rest) {
784+ // Move the end marker to after the :line:column
785+ let before = & result[ ..pos + original_path. len ( ) ] ;
786+ let line_col = & rest[ ..colon_end] ;
787+ let after = & rest[ colon_end..] ;
788+ result = format ! ( "{}{}{}{}" , before, line_col, end_marker, after) ;
789+ continue ;
790+ }
791+ }
792+ break ;
793+ }
794+ result
795+ }
796+
797+ /// Find the end position of a :line:column pattern at the start of the string.
798+ /// Returns None if the pattern doesn't match.
799+ fn find_line_column_end ( s : & str ) -> Option < usize > {
800+ let bytes = s. as_bytes ( ) ;
801+ if bytes. is_empty ( ) || bytes[ 0 ] != b':' {
802+ return None ;
803+ }
804+
805+ let mut pos = 1 ;
806+ // Read digits for line number
807+ while pos < bytes. len ( ) && bytes[ pos] . is_ascii_digit ( ) {
808+ pos += 1 ;
809+ }
810+ if pos == 1 || pos >= bytes. len ( ) || bytes[ pos] != b':' {
811+ return None ; // No digits or no second colon
812+ }
813+
814+ pos += 1 ; // Skip second colon
815+ let col_start = pos;
816+ // Read digits for column number
817+ while pos < bytes. len ( ) && bytes[ pos] . is_ascii_digit ( ) {
818+ pos += 1 ;
819+ }
820+ if pos == col_start {
821+ return None ; // No digits for column
822+ }
823+
824+ Some ( pos)
623825 }
624826}
625827
0 commit comments