Skip to content

feat: Add support for hyperlinks in origin#386

Open
scrabsha wants to merge 5 commits intorust-lang:mainfrom
scrabsha:hyperlinks
Open

feat: Add support for hyperlinks in origin#386
scrabsha wants to merge 5 commits intorust-lang:mainfrom
scrabsha:hyperlinks

Conversation

@scrabsha
Copy link
Copy Markdown
Contributor

@scrabsha scrabsha commented Mar 7, 2026

fixes #366

over:

should be reviewable commit by commit.

@scrabsha scrabsha force-pushed the hyperlinks branch 3 times, most recently from 96c9b6b to 90d3121 Compare March 7, 2026 16:03
Comment thread src/snippet.rs Outdated
Comment thread tests/hyperlink.rs Outdated
Comment thread src/snippet.rs
Comment thread src/renderer/render.rs Outdated
Comment thread src/renderer/render.rs
);
}

let str = match (&origin.line, &origin.char_column) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Muscraft can speak better to the renderer but my instinct is str should have the url in it. You can do that by having a

let (url_open, url_close) = if let Some(url) = ... {
    ...
} else {
    (Default::default(), Default::default())
};

Copy link
Copy Markdown
Contributor Author

@scrabsha scrabsha Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for what it's worth, i tried to mimic what we already do for error code URL

if let Some(url) = url.as_ref() {
buffer.append(
buffer_msg_line_offset,
&format!("\x1B]8;;{url}\x1B\\"),
label_style,
);
}
buffer.append(buffer_msg_line_offset, id, label_style);
if url.is_some() {
buffer.append(buffer_msg_line_offset, "\x1B]8;;\x1B\\", label_style);
}

happy to change my code and/or the error code logic though!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would agree with @epage here, in that str should probably have the URL in it. Most of my reasoning comes from the fact that I am not sure whether putting the URL over the whole path:line:col or just the path is the correct course of action.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intent with this is to allow liking to the location with special editor links.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[...] I am not sure whether putting the URL over the whole path:line:col or just the path is the correct course of action.

for file:// URLs, some setups allow embedding both a line and a column, some just a line, some don't allow anything other than the path to the file. we can't always assume that clicking on a link opens the file at the right line, or line/column. that is the cruel reality we have to deal with :/

the current design of this API (aka "let the caller tell us which URL to use") is great because it allows to embed URLs that are not file:// URLs, and these other URLs very much could point to a specific line/line:column (for instance, the github permalink to a file) and it'd make perfect sense for the hyperlink to span on path:line:col in that case.

one could argue that the hyperlink should span over path when it merely links to a path, to path:line when it includes a line number and to path:line:col when it embeds a column as well. i think it'd greatly hurt muscle memory, as the chunk of clickable text would constantly change. in addition to this, path, line and col are currently displayed with the exact same style and inserting a hyperlink to only some of this "block" would look counter intuitive, in my opinion.

for all these reasons, i would clearly lean towards spanning the hyperlink over path:line:col entirely rather than anything else. happy to hear other arguments though.


anyways i'm going to change the code as suggested by @epage and add another commit on top that does the same thing for the error ID hyperlink logic.

@scrabsha scrabsha force-pushed the hyperlinks branch 4 times, most recently from 9d4c4e3 to 0f52d34 Compare March 10, 2026 19:59
Comment thread src/renderer/render.rs Outdated
Comment thread src/renderer/render.rs Outdated
@scrabsha
Copy link
Copy Markdown
Contributor Author

scrabsha commented Mar 15, 2026

quick summary of what i am about to push:

@scrabsha scrabsha force-pushed the hyperlinks branch 2 times, most recently from 03ba113 to 63dd827 Compare March 15, 2026 21:21
@scrabsha scrabsha changed the title feat: Add support for hyperlinks in snippet origin feat: Add support for hyperlinks in origin Mar 15, 2026
Comment thread src/renderer/render.rs
Comment thread src/snippet.rs
///
/// [OSC-8]: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
/// [OSC-8-file-URIs]: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda#file-uris-and-the-hostname
pub fn path_url(mut self, url: impl Into<Cow<'a, str>>) -> Self {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be consistent in handling of path urls?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Origins don't have a primary span, so there's not really a line/byte column to call the formatter with. here's an example

let report = &[Level::ERROR
    .primary_title("Linker exited with error status code")
    .element(Origin::path("input.rs"))];

let render = Renderer::plain().render(report);

(i think this is how rustc renders linker-related error messages, by the way)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Origin does have a line/column, see https://docs.rs/annotate-snippets/latest/annotate_snippets/struct.Origin.html#method.line

I see that works in char_column. We likely should be consistent on how column count.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

then the second someone needs the byte column (ie: not byte column) they are stuck. it looks like a great way to paint ourselves in a corner.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this makes me realize the formatter should be Fn(usize, usize, usize) -> String so that users are free to use either the char or byte column.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When using editor URLs that take a column, how do they measure columns?

I don't think we need to be worried about a perfectly flexible solution for anything anyone might do. If there are standard conventions, we can focus on that. Generally, column likely won't make much of a difference anyways.

Comment thread src/snippet.rs
}
}

pub trait PathUrlFormatter {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a doc comment

Comment thread src/snippet.rs
}

pub trait PathUrlFormatter {
fn format_url(&self, line: usize, byte_col: usize) -> String;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still think we should pass in the path

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if the user does not call Snippet::path, or passes None to it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we even provide a path url when there is no path?

Copy link
Copy Markdown
Contributor Author

@scrabsha scrabsha Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aren't we using "<input>" (or something else, i don't remember) as a fallback when no path is specified? if so, i want the user to be able to stick a link there too.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When no path is specified, it looks like we don't print an Origin, so this should be fine

Comment thread src/snippet.rs
Comment on lines +723 to +724
// Allows to provide a consistent `Debug` implementation for `String`,
// `&str` and `Cow<str>`. Not exposed to the user.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make this a doc comment and separate out the one line summary from additional details

Comment thread src/snippet.rs

// Wrapper over `Rc<dyn PathUrlFormatter>` that implements both `Clone` and `Debug`.
#[derive(Clone)]
pub(crate) struct PathUrlFormatterWrapper<'a>(pub(crate) Rc<dyn PathUrlFormatter + 'a>);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use of Rc is a breaking change. We'd need to use Arc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is causing CI issues on thumbv6m, which makes CI complain. apparently there is no Arc on this platform. not sure how we could work around that. i'll revert my change until a solution is found.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the only solution i see would be to use Arc on platforms that support it and to fallback to Rc, but i am not thrilled by that at all. suggestions are welcome.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using the approach described in the dyn-clone trait could help, though i am not sure we are ok with (a) adding unsafe code in this codebase or (b) adding a third party dependency to annotate_snippets just for hyperlink handling is worth it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add a clone method to our trait and we only support closures that are cloneable. I think closures get that implicitly where supported.

Comment thread src/snippet.rs

// Wrapper over `Rc<dyn PathUrlFormatter>` that implements both `Clone` and `Debug`.
#[derive(Clone)]
pub(crate) struct PathUrlFormatterWrapper<'a>(pub(crate) Rc<dyn PathUrlFormatter + 'a>);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're boxing for the Rc, do we still need to allow lifetimes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dyn Trait has an implicit + 'static when no + 'lifetime is specified. this results in the following code not compiling:

use std::sync::Arc;

struct Snippet<'a> {
    source: &'a str,
    url: Option<WithoutLifetime>,
}

impl<'a> Snippet<'a> {
    fn source(source: &'a str) -> Self {
        Self {
            source,
            url: None,
        }
    }
    
    fn url(mut self, url: impl PathUrlFormatter) -> Self {
        self.url = Some(WithoutLifetime(Arc::new(url)));
        self
    }
}

struct WithoutLifetime(Arc<dyn PathUrlFormatter>);

trait PathUrlFormatter {}

The exact error message is:

error[E0310]: the parameter type `impl PathUrlFormatter` may not live long enough
  --> src/lib.rs:17:41
   |
17 |         self.url = Some(WithoutLifetime(Arc::new(url)));
   |                                         ^^^^^^^^^^^^^
   |                                         |
   |                                         the parameter type `impl PathUrlFormatter` must be valid for the static lifetime...
   |                                         ...so that the type `impl PathUrlFormatter` will meet its required lifetime bounds
   |
help: consider adding an explicit lifetime bound
   |
16 |     fn url(mut self, url: impl PathUrlFormatter + 'static) -> Self {
   |                                                 +++++++++

For more information about this error, try `rustc --explain E0310`.

forcing the formatter to be 'static would prevent formatters from borrowing anything for less than 'static, for instance:

let hostname = std::env::var("HOSTNAME").unwrap();
    
let snippet = Snippet::source("hello, world")
    .url(|line, _col| format!("file://{hostname}/path/to/file:line"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't that be fixed by

let hostname = std::env::var("HOSTNAME").unwrap();
    
let snippet = Snippet::source("hello, world")
    .url(move |line, _col| format!("file://{hostname}/path/to/file:line"));

This is an interesting trade off of lifetime complexity vs api flexibility.

Comment thread src/snippet.rs Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hyperlink support

3 participants