Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 236 additions & 19 deletions crates/anstyle-hyperlink/src/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,12 @@ pub fn path_to_url(path: &std::path::Path) -> Option<String> {
/// 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<String> {
if !path.is_absolute() {
return None;
}

let mut url = "file://".to_owned();
if let Some(hostname) = hostname {
url.push_str(hostname);
}

// skip the root component
let mut is_path_empty = true;
for component in path.components().skip(1) {
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,
));
}
if is_path_empty {
// An URL's path must not be empty
url.push_str(URL_PATH_SEP);
}
encode_path(path, &mut url);

Some(url)
}
Expand Down Expand Up @@ -79,3 +61,238 @@ const PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH.add(b'/').add(b'%');
// The backslash (\) character is treated as a path separator in special URLs
// 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<Item = Self> {
[
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<String> {
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<Self, Self::Err> {
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;

for component in path.components() {
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) => {
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
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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>();
let parsed = editors
.iter()
.map(|editor| editor.to_string().parse())
.collect::<Result<Vec<_>, _>>();

assert_eq!(parsed, Ok(editors));
}

#[test]
fn invalid_editor_string_errors() {
assert_eq!("code".parse::<Editor>(), Err(ParseEditorError));
}
}
4 changes: 4 additions & 0 deletions crates/anstyle-hyperlink/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading