From 289ce2ac6388f7c5d121d7236ab35645c4a215aa Mon Sep 17 00:00:00 2001 From: Ed Page Date: Wed, 18 Mar 2026 09:13:02 -0500 Subject: [PATCH 1/4] fix(hyperlink): Trust user on absolute paths This makes it harder to construct URLs across windows/linux --- crates/anstyle-hyperlink/src/file.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/anstyle-hyperlink/src/file.rs b/crates/anstyle-hyperlink/src/file.rs index 624eafe..be63a05 100644 --- a/crates/anstyle-hyperlink/src/file.rs +++ b/crates/anstyle-hyperlink/src/file.rs @@ -20,10 +20,6 @@ pub fn path_to_url(path: &std::path::Path) -> Option { /// the computer you've SSH'ed into /// ([reference](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda#file-uris-and-the-hostname)) pub fn file_to_url(hostname: Option<&str>, path: &std::path::Path) -> Option { - if !path.is_absolute() { - return None; - } - let mut url = "file://".to_owned(); if let Some(hostname) = hostname { url.push_str(hostname); From 4624d60f27ca5c16ed17f9bf36e28f9cf3201ba9 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Wed, 18 Mar 2026 09:14:53 -0500 Subject: [PATCH 2/4] refactor(hyperlink): Pull out path encoding --- crates/anstyle-hyperlink/src/file.rs | 34 ++++++++++++++++------------ 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/crates/anstyle-hyperlink/src/file.rs b/crates/anstyle-hyperlink/src/file.rs index be63a05..c97ff6f 100644 --- a/crates/anstyle-hyperlink/src/file.rs +++ b/crates/anstyle-hyperlink/src/file.rs @@ -25,21 +25,7 @@ pub fn file_to_url(hostname: Option<&str>, path: &std::path::Path) -> Option Date: Wed, 18 Mar 2026 09:15:39 -0500 Subject: [PATCH 3/4] fix(hyperlink): Support windows URLs --- crates/anstyle-hyperlink/src/file.rs | 29 ++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/anstyle-hyperlink/src/file.rs b/crates/anstyle-hyperlink/src/file.rs index c97ff6f..1c62420 100644 --- a/crates/anstyle-hyperlink/src/file.rs +++ b/crates/anstyle-hyperlink/src/file.rs @@ -63,16 +63,29 @@ const PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH.add(b'/').add(b'%'); const SPECIAL_PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH_SEGMENT.add(b'\\'); fn encode_path(path: &std::path::Path, url: &mut String) { - // skip the root component let mut is_path_empty = true; - for component in path.components().skip(1) { + + for component in path.components() { is_path_empty = false; - url.push_str(URL_PATH_SEP); - let component = component.as_os_str().to_str()?; - url.extend(percent_encoding::percent_encode( - component.as_bytes(), - SPECIAL_PATH_SEGMENT, - )); + match component { + std::path::Component::Prefix(prefix) => { + let component = prefix.as_os_str().to_string_lossy(); + url.push_str(&component); + } + std::path::Component::RootDir => {} + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + url.push_str(".."); + } + std::path::Component::Normal(part) => { + url.push_str(URL_PATH_SEP); + let component = part.to_string_lossy(); + url.extend(percent_encoding::percent_encode( + component.as_bytes(), + SPECIAL_PATH_SEGMENT, + )); + } + } } if is_path_empty { // An URL's path must not be empty From 768df752c9b9c5e414bc65d7b622ef0a0e467716 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Wed, 18 Mar 2026 08:58:21 -0500 Subject: [PATCH 4/4] feat(hyperlink): Add editor-specific links This is setup to integrate with a config or CLI. If nothing else, this helps to serve as documentation. --- crates/anstyle-hyperlink/src/file.rs | 204 +++++++++++++++++++++++++++ crates/anstyle-hyperlink/src/lib.rs | 4 + 2 files changed, 208 insertions(+) diff --git a/crates/anstyle-hyperlink/src/file.rs b/crates/anstyle-hyperlink/src/file.rs index 1c62420..897c3d2 100644 --- a/crates/anstyle-hyperlink/src/file.rs +++ b/crates/anstyle-hyperlink/src/file.rs @@ -62,6 +62,117 @@ const PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH.add(b'/').add(b'%'); // so it needs to be additionally escaped in that case. const SPECIAL_PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH_SEGMENT.add(b'\\'); +/// Editor-specific file URLs +#[allow(missing_docs)] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Editor { + Cursor, + Grepp, + Kitty, + MacVim, + TextMate, + VSCode, + VSCodeInsiders, + VSCodium, +} + +impl Editor { + /// Iterate over all supported editors. + pub fn all() -> impl Iterator { + [ + Self::Cursor, + Self::Grepp, + Self::Kitty, + Self::MacVim, + Self::TextMate, + Self::VSCode, + Self::VSCodeInsiders, + Self::VSCodium, + ] + .into_iter() + } + + /// Create an editor-specific file URL + pub fn to_url( + &self, + hostname: Option<&str>, + file: &std::path::Path, + line: usize, + col: usize, + ) -> Option { + let mut path = String::new(); + encode_path(file, &mut path); + let url = match self { + Self::Cursor => { + format!("cursor://file{path}:{line}:{col}") + } + // https://github.com/misaki-web/grepp?tab=readme-ov-file#scheme-handler + Self::Grepp => format!("grep+://{path}:{line}"), + Self::Kitty => format!("file://{}{path}#{line}", hostname.unwrap_or_default()), + // https://macvim.org/docs/gui_mac.txt.html#mvim%3A%2F%2F + Self::MacVim => { + format!("mvim://open?url=file://{path}&line={line}&column={col}") + } + // https://macromates.com/blog/2007/the-textmate-url-scheme/ + Self::TextMate => { + format!("txmt://open?url=file://{path}&line={line}&column={col}") + } + // https://code.visualstudio.com/docs/editor/command-line#_opening-vs-code-with-urls + Self::VSCode => format!("vscode://file{path}:{line}:{col}"), + Self::VSCodeInsiders => { + format!("vscode-insiders://file{path}:{line}:{col}") + } + Self::VSCodium => format!("vscodium://file{path}:{line}:{col}"), + }; + Some(url) + } +} + +impl core::fmt::Display for Editor { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let name = match self { + Self::Cursor => "cursor", + Self::Grepp => "grepp", + Self::Kitty => "kitty", + Self::MacVim => "macvim", + Self::TextMate => "textmate", + Self::VSCode => "vscode", + Self::VSCodeInsiders => "vscode-insiders", + Self::VSCodium => "vscodium", + }; + f.write_str(name) + } +} + +impl core::str::FromStr for Editor { + type Err = ParseEditorError; + + fn from_str(s: &str) -> Result { + match s { + "cursor" => Ok(Self::Cursor), + "grepp" => Ok(Self::Grepp), + "kitty" => Ok(Self::Kitty), + "macvim" => Ok(Self::MacVim), + "textmate" => Ok(Self::TextMate), + "vscode" => Ok(Self::VSCode), + "vscode-insiders" => Ok(Self::VSCodeInsiders), + "vscodium" => Ok(Self::VSCodium), + _ => Err(ParseEditorError), + } + } +} + +/// Failed to parse an [`Editor`] from a string. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ParseEditorError; + +impl core::fmt::Display for ParseEditorError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("unknown editor") + } +} + fn encode_path(path: &std::path::Path, url: &mut String) { let mut is_path_empty = true; @@ -69,12 +180,14 @@ fn encode_path(path: &std::path::Path, url: &mut String) { is_path_empty = false; match component { std::path::Component::Prefix(prefix) => { + url.push_str(URL_PATH_SEP); let component = prefix.as_os_str().to_string_lossy(); url.push_str(&component); } std::path::Component::RootDir => {} std::path::Component::CurDir => {} std::path::Component::ParentDir => { + url.push_str(URL_PATH_SEP); url.push_str(".."); } std::path::Component::Normal(part) => { @@ -92,3 +205,94 @@ fn encode_path(path: &std::path::Path, url: &mut String) { url.push_str(URL_PATH_SEP); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn funky_file_path() { + let editor_urls = Editor::all() + .map(|editor| editor.to_url(None, "/tmp/a b#c".as_ref(), 1, 1)) + .map(|editor| editor.unwrap_or_else(|| "-".to_owned())) + .collect::>() + .join("\n"); + + snapbox::assert_data_eq!( + editor_urls, + snapbox::str![[r#" +cursor://file/tmp/a%20b%23c:1:1 +grep+:///tmp/a%20b%23c:1 +file:///tmp/a%20b%23c#1 +mvim://open?url=file:///tmp/a%20b%23c&line=1&column=1 +txmt://open?url=file:///tmp/a%20b%23c&line=1&column=1 +vscode://file/tmp/a%20b%23c:1:1 +vscode-insiders://file/tmp/a%20b%23c:1:1 +vscodium://file/tmp/a%20b%23c:1:1 +"#]] + ); + } + + #[test] + fn with_hostname() { + let editor_urls = Editor::all() + .map(|editor| editor.to_url(Some("localhost"), "/home/foo/file.txt".as_ref(), 1, 1)) + .map(|editor| editor.unwrap_or_else(|| "-".to_owned())) + .collect::>() + .join("\n"); + + snapbox::assert_data_eq!( + editor_urls, + snapbox::str![[r#" +cursor://file/home/foo/file.txt:1:1 +grep+:///home/foo/file.txt:1 +file://localhost/home/foo/file.txt#1 +mvim://open?url=file:///home/foo/file.txt&line=1&column=1 +txmt://open?url=file:///home/foo/file.txt&line=1&column=1 +vscode://file/home/foo/file.txt:1:1 +vscode-insiders://file/home/foo/file.txt:1:1 +vscodium://file/home/foo/file.txt:1:1 +"#]] + ); + } + + #[test] + #[cfg(windows)] + fn windows_file_path() { + let editor_urls = Editor::all() + .map(|editor| editor.to_url(None, "C:\\Users\\foo\\help.txt".as_ref(), 1, 1)) + .map(|editor| editor.unwrap_or_else(|| "-".to_owned())) + .collect::>() + .join("\n"); + + snapbox::assert_data_eq!( + editor_urls, + snapbox::str![[r#" +cursor://file/C:/Users/foo/help.txt:1:1 +grep+:///C:/Users/foo/help.txt:1 +file:///C:/Users/foo/help.txt#1 +mvim://open?url=file:///C:/Users/foo/help.txt&line=1&column=1 +txmt://open?url=file:///C:/Users/foo/help.txt&line=1&column=1 +vscode://file/C:/Users/foo/help.txt:1:1 +vscode-insiders://file/C:/Users/foo/help.txt:1:1 +vscodium://file/C:/Users/foo/help.txt:1:1 +"#]] + ); + } + + #[test] + fn editor_strings_round_trip() { + let editors = Editor::all().collect::>(); + let parsed = editors + .iter() + .map(|editor| editor.to_string().parse()) + .collect::, _>>(); + + assert_eq!(parsed, Ok(editors)); + } + + #[test] + fn invalid_editor_string_errors() { + assert_eq!("code".parse::(), Err(ParseEditorError)); + } +} diff --git a/crates/anstyle-hyperlink/src/lib.rs b/crates/anstyle-hyperlink/src/lib.rs index 2147fe0..7f66767 100644 --- a/crates/anstyle-hyperlink/src/lib.rs +++ b/crates/anstyle-hyperlink/src/lib.rs @@ -26,6 +26,10 @@ pub use file::file_to_url; #[cfg(feature = "file")] pub use file::path_to_url; #[cfg(feature = "file")] +pub use file::Editor; +#[cfg(feature = "file")] +pub use file::ParseEditorError; +#[cfg(feature = "file")] pub use hostname::hostname; pub use hyperlink::Hyperlink;