Skip to content

Commit 0e61e13

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 9a963e2 commit 0e61e13

2 files changed

Lines changed: 206 additions & 0 deletions

File tree

crates/anstyle-hyperlink/src/file.rs

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,117 @@ 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

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

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)