Skip to content

Commit 988677a

Browse files
authored
fix a variety of bugs, including #92 (#93)
* fix a variety of bugs, including #92
1 parent f369f55 commit 988677a

File tree

28 files changed

+109299
-107292
lines changed

28 files changed

+109299
-107292
lines changed

crates/quarto-error-reporting/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,8 @@ once_cell = { workspace = true }
2020
serde = { workspace = true }
2121
serde_json = { workspace = true }
2222

23+
# URL handling for file:// hyperlinks
24+
url = "2.5"
25+
2326
[dev-dependencies]
2427
# No dev dependencies yet

crates/quarto-error-reporting/error_catalog.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,13 @@
125125
"docs_url": "https://quarto.org/docs/errors/Q-2-6",
126126
"since_version": "99.9.9"
127127
},
128+
"Q-2-7": {
129+
"subsystem": "markdown",
130+
"title": "Unclosed Single Quote",
131+
"message_template": "I reached the end of the block before finding a closing \"'\" for the quote.",
132+
"docs_url": "https://quarto.org/docs/errors/Q-2-7",
133+
"since_version": "99.9.9"
134+
},
128135
"Q-3-1": {
129136
"subsystem": "writer",
130137
"title": "IO Error During Write",

crates/quarto-error-reporting/src/diagnostic.rs

Lines changed: 214 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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

crates/quarto-error-reporting/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,4 @@ pub mod macros;
6464
// Re-export main types for convenience
6565
pub use builder::DiagnosticMessageBuilder;
6666
pub use catalog::{ERROR_CATALOG, ErrorCodeInfo, get_docs_url, get_error_info, get_subsystem};
67-
pub use diagnostic::{DetailItem, DetailKind, DiagnosticKind, DiagnosticMessage, MessageContent};
67+
pub use diagnostic::{DetailItem, DetailKind, DiagnosticKind, DiagnosticMessage, MessageContent, TextRenderOptions};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"code": "Q-2-7",
3+
"title": "Unclosed Single Quote",
4+
"message": "I reached the end of the block before finding a closing \"'\" for the quote.",
5+
"captures": [
6+
{
7+
"label": "quote-start",
8+
"row": 0,
9+
"column": 0,
10+
"size": 1
11+
}
12+
],
13+
"notes": [
14+
{
15+
"message": "This is the opening quote. If you need an apostrophe, escape it with a backslash.",
16+
"label": "quote-start",
17+
"noteType": "simple",
18+
"trimLeadingSpace": true
19+
}
20+
]
21+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
'Tis the season to make apostrophe mistakes.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"code": "Q-2-5",
3+
"title": "Unclosed emphasis",
4+
"message": "I reached the end of the block before finding a closing _ for the emphasis.",
5+
"captures": [
6+
{
7+
"label": "emph-start",
8+
"row": 0,
9+
"column": 4,
10+
"size": 1
11+
}
12+
],
13+
"notes": [
14+
{
15+
"message": "(Emphasis start) If you meant an underscore, escape it with a backslash.",
16+
"label": "emph-start",
17+
"noteType": "simple",
18+
"trimLeadingSpace": true
19+
}
20+
]
21+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[pdf_document](a)

0 commit comments

Comments
 (0)