Skip to content

Commit 768df75

Browse files
committed
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.
1 parent 9f0e589 commit 768df75

File tree

2 files changed

+208
-0
lines changed

2 files changed

+208
-0
lines changed

crates/anstyle-hyperlink/src/file.rs

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,19 +62,132 @@ const PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH.add(b'/').add(b'%');
6262
// so it needs to be additionally escaped in that case.
6363
const SPECIAL_PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH_SEGMENT.add(b'\\');
6464

65+
/// Editor-specific file URLs
66+
#[allow(missing_docs)]
67+
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
68+
#[non_exhaustive]
69+
pub enum Editor {
70+
Cursor,
71+
Grepp,
72+
Kitty,
73+
MacVim,
74+
TextMate,
75+
VSCode,
76+
VSCodeInsiders,
77+
VSCodium,
78+
}
79+
80+
impl Editor {
81+
/// Iterate over all supported editors.
82+
pub fn all() -> impl Iterator<Item = Self> {
83+
[
84+
Self::Cursor,
85+
Self::Grepp,
86+
Self::Kitty,
87+
Self::MacVim,
88+
Self::TextMate,
89+
Self::VSCode,
90+
Self::VSCodeInsiders,
91+
Self::VSCodium,
92+
]
93+
.into_iter()
94+
}
95+
96+
/// Create an editor-specific file URL
97+
pub fn to_url(
98+
&self,
99+
hostname: Option<&str>,
100+
file: &std::path::Path,
101+
line: usize,
102+
col: usize,
103+
) -> Option<String> {
104+
let mut path = String::new();
105+
encode_path(file, &mut path);
106+
let url = match self {
107+
Self::Cursor => {
108+
format!("cursor://file{path}:{line}:{col}")
109+
}
110+
// https://github.com/misaki-web/grepp?tab=readme-ov-file#scheme-handler
111+
Self::Grepp => format!("grep+://{path}:{line}"),
112+
Self::Kitty => format!("file://{}{path}#{line}", hostname.unwrap_or_default()),
113+
// https://macvim.org/docs/gui_mac.txt.html#mvim%3A%2F%2F
114+
Self::MacVim => {
115+
format!("mvim://open?url=file://{path}&line={line}&column={col}")
116+
}
117+
// https://macromates.com/blog/2007/the-textmate-url-scheme/
118+
Self::TextMate => {
119+
format!("txmt://open?url=file://{path}&line={line}&column={col}")
120+
}
121+
// https://code.visualstudio.com/docs/editor/command-line#_opening-vs-code-with-urls
122+
Self::VSCode => format!("vscode://file{path}:{line}:{col}"),
123+
Self::VSCodeInsiders => {
124+
format!("vscode-insiders://file{path}:{line}:{col}")
125+
}
126+
Self::VSCodium => format!("vscodium://file{path}:{line}:{col}"),
127+
};
128+
Some(url)
129+
}
130+
}
131+
132+
impl core::fmt::Display for Editor {
133+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
134+
let name = match self {
135+
Self::Cursor => "cursor",
136+
Self::Grepp => "grepp",
137+
Self::Kitty => "kitty",
138+
Self::MacVim => "macvim",
139+
Self::TextMate => "textmate",
140+
Self::VSCode => "vscode",
141+
Self::VSCodeInsiders => "vscode-insiders",
142+
Self::VSCodium => "vscodium",
143+
};
144+
f.write_str(name)
145+
}
146+
}
147+
148+
impl core::str::FromStr for Editor {
149+
type Err = ParseEditorError;
150+
151+
fn from_str(s: &str) -> Result<Self, Self::Err> {
152+
match s {
153+
"cursor" => Ok(Self::Cursor),
154+
"grepp" => Ok(Self::Grepp),
155+
"kitty" => Ok(Self::Kitty),
156+
"macvim" => Ok(Self::MacVim),
157+
"textmate" => Ok(Self::TextMate),
158+
"vscode" => Ok(Self::VSCode),
159+
"vscode-insiders" => Ok(Self::VSCodeInsiders),
160+
"vscodium" => Ok(Self::VSCodium),
161+
_ => Err(ParseEditorError),
162+
}
163+
}
164+
}
165+
166+
/// Failed to parse an [`Editor`] from a string.
167+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
168+
pub struct ParseEditorError;
169+
170+
impl core::fmt::Display for ParseEditorError {
171+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
172+
f.write_str("unknown editor")
173+
}
174+
}
175+
65176
fn encode_path(path: &std::path::Path, url: &mut String) {
66177
let mut is_path_empty = true;
67178

68179
for component in path.components() {
69180
is_path_empty = false;
70181
match component {
71182
std::path::Component::Prefix(prefix) => {
183+
url.push_str(URL_PATH_SEP);
72184
let component = prefix.as_os_str().to_string_lossy();
73185
url.push_str(&component);
74186
}
75187
std::path::Component::RootDir => {}
76188
std::path::Component::CurDir => {}
77189
std::path::Component::ParentDir => {
190+
url.push_str(URL_PATH_SEP);
78191
url.push_str("..");
79192
}
80193
std::path::Component::Normal(part) => {
@@ -92,3 +205,94 @@ fn encode_path(path: &std::path::Path, url: &mut String) {
92205
url.push_str(URL_PATH_SEP);
93206
}
94207
}
208+
209+
#[cfg(test)]
210+
mod tests {
211+
use super::*;
212+
213+
#[test]
214+
fn funky_file_path() {
215+
let editor_urls = Editor::all()
216+
.map(|editor| editor.to_url(None, "/tmp/a b#c".as_ref(), 1, 1))
217+
.map(|editor| editor.unwrap_or_else(|| "-".to_owned()))
218+
.collect::<Vec<_>>()
219+
.join("\n");
220+
221+
snapbox::assert_data_eq!(
222+
editor_urls,
223+
snapbox::str![[r#"
224+
cursor://file/tmp/a%20b%23c:1:1
225+
grep+:///tmp/a%20b%23c:1
226+
file:///tmp/a%20b%23c#1
227+
mvim://open?url=file:///tmp/a%20b%23c&line=1&column=1
228+
txmt://open?url=file:///tmp/a%20b%23c&line=1&column=1
229+
vscode://file/tmp/a%20b%23c:1:1
230+
vscode-insiders://file/tmp/a%20b%23c:1:1
231+
vscodium://file/tmp/a%20b%23c:1:1
232+
"#]]
233+
);
234+
}
235+
236+
#[test]
237+
fn with_hostname() {
238+
let editor_urls = Editor::all()
239+
.map(|editor| editor.to_url(Some("localhost"), "/home/foo/file.txt".as_ref(), 1, 1))
240+
.map(|editor| editor.unwrap_or_else(|| "-".to_owned()))
241+
.collect::<Vec<_>>()
242+
.join("\n");
243+
244+
snapbox::assert_data_eq!(
245+
editor_urls,
246+
snapbox::str![[r#"
247+
cursor://file/home/foo/file.txt:1:1
248+
grep+:///home/foo/file.txt:1
249+
file://localhost/home/foo/file.txt#1
250+
mvim://open?url=file:///home/foo/file.txt&line=1&column=1
251+
txmt://open?url=file:///home/foo/file.txt&line=1&column=1
252+
vscode://file/home/foo/file.txt:1:1
253+
vscode-insiders://file/home/foo/file.txt:1:1
254+
vscodium://file/home/foo/file.txt:1:1
255+
"#]]
256+
);
257+
}
258+
259+
#[test]
260+
#[cfg(windows)]
261+
fn windows_file_path() {
262+
let editor_urls = Editor::all()
263+
.map(|editor| editor.to_url(None, "C:\\Users\\foo\\help.txt".as_ref(), 1, 1))
264+
.map(|editor| editor.unwrap_or_else(|| "-".to_owned()))
265+
.collect::<Vec<_>>()
266+
.join("\n");
267+
268+
snapbox::assert_data_eq!(
269+
editor_urls,
270+
snapbox::str![[r#"
271+
cursor://file/C:/Users/foo/help.txt:1:1
272+
grep+:///C:/Users/foo/help.txt:1
273+
file:///C:/Users/foo/help.txt#1
274+
mvim://open?url=file:///C:/Users/foo/help.txt&line=1&column=1
275+
txmt://open?url=file:///C:/Users/foo/help.txt&line=1&column=1
276+
vscode://file/C:/Users/foo/help.txt:1:1
277+
vscode-insiders://file/C:/Users/foo/help.txt:1:1
278+
vscodium://file/C:/Users/foo/help.txt:1:1
279+
"#]]
280+
);
281+
}
282+
283+
#[test]
284+
fn editor_strings_round_trip() {
285+
let editors = Editor::all().collect::<Vec<_>>();
286+
let parsed = editors
287+
.iter()
288+
.map(|editor| editor.to_string().parse())
289+
.collect::<Result<Vec<_>, _>>();
290+
291+
assert_eq!(parsed, Ok(editors));
292+
}
293+
294+
#[test]
295+
fn invalid_editor_string_errors() {
296+
assert_eq!("code".parse::<Editor>(), Err(ParseEditorError));
297+
}
298+
}

crates/anstyle-hyperlink/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ pub use file::file_to_url;
2626
#[cfg(feature = "file")]
2727
pub use file::path_to_url;
2828
#[cfg(feature = "file")]
29+
pub use file::Editor;
30+
#[cfg(feature = "file")]
31+
pub use file::ParseEditorError;
32+
#[cfg(feature = "file")]
2933
pub use hostname::hostname;
3034
pub use hyperlink::Hyperlink;
3135

0 commit comments

Comments
 (0)