Skip to content

Commit 93fdfcd

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 c11f10a commit 93fdfcd

File tree

2 files changed

+243
-15
lines changed

2 files changed

+243
-15
lines changed

crates/anstyle-hyperlink/src/file.rs

Lines changed: 239 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,21 +29,7 @@ pub fn file_to_url(hostname: Option<&str>, path: &std::path::Path) -> Option<Str
2929
url.push_str(hostname);
3030
}
3131

32-
// skip the root component
33-
let mut is_path_empty = true;
34-
for component in path.components().skip(1) {
35-
is_path_empty = false;
36-
url.push_str(URL_PATH_SEP);
37-
let component = component.as_os_str().to_str()?;
38-
url.extend(percent_encoding::percent_encode(
39-
component.as_bytes(),
40-
SPECIAL_PATH_SEGMENT,
41-
));
42-
}
43-
if is_path_empty {
44-
// An URL's path must not be empty
45-
url.push_str(URL_PATH_SEP);
46-
}
32+
encode_path(path, &mut url);
4733

4834
Some(url)
4935
}
@@ -79,3 +65,241 @@ const PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH.add(b'/').add(b'%');
7965
// The backslash (\) character is treated as a path separator in special URLs
8066
// so it needs to be additionally escaped in that case.
8167
const SPECIAL_PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH_SEGMENT.add(b'\\');
68+
69+
/// Editor-specific file URLs
70+
#[allow(missing_docs)]
71+
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
72+
#[non_exhaustive]
73+
pub enum Editor {
74+
Cursor,
75+
Grepp,
76+
Kitty,
77+
MacVim,
78+
TextMate,
79+
VSCode,
80+
VSCodeInsiders,
81+
VSCodium,
82+
}
83+
84+
impl Editor {
85+
/// Iterate over all supported editors.
86+
pub fn all() -> impl Iterator<Item = Self> {
87+
[
88+
Self::Cursor,
89+
Self::Grepp,
90+
Self::Kitty,
91+
Self::MacVim,
92+
Self::TextMate,
93+
Self::VSCode,
94+
Self::VSCodeInsiders,
95+
Self::VSCodium,
96+
]
97+
.into_iter()
98+
}
99+
100+
/// Create an editor-specific file URL
101+
pub fn to_url(
102+
&self,
103+
hostname: Option<&str>,
104+
file: &std::path::Path,
105+
line: usize,
106+
col: usize,
107+
) -> Option<String> {
108+
if !file.is_absolute() {
109+
return None;
110+
}
111+
112+
let mut path = String::new();
113+
encode_path(file, &mut path);
114+
let url = match self {
115+
Self::Cursor => {
116+
format!("cursor://file{path}:{line}:{col}")
117+
}
118+
// https://github.com/misaki-web/grepp?tab=readme-ov-file#scheme-handler
119+
Self::Grepp => format!("grep+://{path}:{line}"),
120+
Self::Kitty => format!("file://{}{path}#{line}", hostname.unwrap_or_default()),
121+
// https://macvim.org/docs/gui_mac.txt.html#mvim%3A%2F%2F
122+
Self::MacVim => {
123+
format!("mvim://open?url=file://{path}&line={line}&column={col}")
124+
}
125+
// https://macromates.com/blog/2007/the-textmate-url-scheme/
126+
Self::TextMate => {
127+
format!("txmt://open?url=file://{path}&line={line}&column={col}")
128+
}
129+
// https://code.visualstudio.com/docs/editor/command-line#_opening-vs-code-with-urls
130+
Self::VSCode => format!("vscode://file{path}:{line}:{col}"),
131+
Self::VSCodeInsiders => {
132+
format!("vscode-insiders://file{path}:{line}:{col}")
133+
}
134+
Self::VSCodium => format!("vscodium://file{path}:{line}:{col}"),
135+
};
136+
Some(url)
137+
}
138+
}
139+
140+
impl core::fmt::Display for Editor {
141+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
142+
let name = match self {
143+
Self::Cursor => "cursor",
144+
Self::Grepp => "grepp",
145+
Self::Kitty => "kitty",
146+
Self::MacVim => "macvim",
147+
Self::TextMate => "textmate",
148+
Self::VSCode => "vscode",
149+
Self::VSCodeInsiders => "vscode-insiders",
150+
Self::VSCodium => "vscodium",
151+
};
152+
f.write_str(name)
153+
}
154+
}
155+
156+
impl core::str::FromStr for Editor {
157+
type Err = ParseEditorError;
158+
159+
fn from_str(s: &str) -> Result<Self, Self::Err> {
160+
match s {
161+
"cursor" => Ok(Self::Cursor),
162+
"grepp" => Ok(Self::Grepp),
163+
"kitty" => Ok(Self::Kitty),
164+
"macvim" => Ok(Self::MacVim),
165+
"textmate" => Ok(Self::TextMate),
166+
"vscode" => Ok(Self::VSCode),
167+
"vscode-insiders" => Ok(Self::VSCodeInsiders),
168+
"vscodium" => Ok(Self::VSCodium),
169+
_ => Err(ParseEditorError),
170+
}
171+
}
172+
}
173+
174+
/// Failed to parse an [`Editor`] from a string.
175+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
176+
pub struct ParseEditorError;
177+
178+
impl core::fmt::Display for ParseEditorError {
179+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
180+
f.write_str("unknown editor")
181+
}
182+
}
183+
184+
fn encode_path(path: &std::path::Path, url: &mut String) {
185+
let mut is_path_empty = true;
186+
187+
// skip the root component
188+
for component in path.components().skip(1) {
189+
is_path_empty = false;
190+
match component {
191+
std::path::Component::Prefix(prefix) => {
192+
let component = prefix.as_os_str().to_string_lossy();
193+
url.push_str(&component);
194+
}
195+
std::path::Component::RootDir => {}
196+
std::path::Component::CurDir => {}
197+
std::path::Component::ParentDir => {
198+
url.push_str("..");
199+
}
200+
std::path::Component::Normal(part) => {
201+
url.push_str(URL_PATH_SEP);
202+
let component = part.to_string_lossy();
203+
url.extend(percent_encoding::percent_encode(
204+
component.as_bytes(),
205+
SPECIAL_PATH_SEGMENT,
206+
));
207+
}
208+
}
209+
}
210+
if is_path_empty {
211+
// An URL's path must not be empty
212+
url.push_str(URL_PATH_SEP);
213+
}
214+
}
215+
216+
#[cfg(test)]
217+
mod tests {
218+
use super::*;
219+
220+
#[test]
221+
fn funky_file_path() {
222+
let editor_urls = Editor::all()
223+
.map(|editor| editor.to_url(None, "/tmp/a b#c".as_ref(), 1, 1))
224+
.map(|editor| editor.unwrap_or_else(|| "-".to_owned()))
225+
.collect::<Vec<_>>()
226+
.join("\n");
227+
228+
snapbox::assert_data_eq!(
229+
editor_urls,
230+
snapbox::str![[r#"
231+
cursor://file/tmp/a%20b%23c:1:1
232+
grep+:///tmp/a%20b%23c:1
233+
file:///tmp/a%20b%23c#1
234+
mvim://open?url=file:///tmp/a%20b%23c&line=1&column=1
235+
txmt://open?url=file:///tmp/a%20b%23c&line=1&column=1
236+
vscode://file/tmp/a%20b%23c:1:1
237+
vscode-insiders://file/tmp/a%20b%23c:1:1
238+
vscodium://file/tmp/a%20b%23c:1:1
239+
"#]]
240+
);
241+
}
242+
243+
#[test]
244+
fn with_hostname() {
245+
let editor_urls = Editor::all()
246+
.map(|editor| editor.to_url(Some("localhost"), "/home/foo/file.txt".as_ref(), 1, 1))
247+
.map(|editor| editor.unwrap_or_else(|| "-".to_owned()))
248+
.collect::<Vec<_>>()
249+
.join("\n");
250+
251+
snapbox::assert_data_eq!(
252+
editor_urls,
253+
snapbox::str![[r#"
254+
cursor://file/home/foo/file.txt:1:1
255+
grep+:///home/foo/file.txt:1
256+
file://localhost/home/foo/file.txt#1
257+
mvim://open?url=file:///home/foo/file.txt&line=1&column=1
258+
txmt://open?url=file:///home/foo/file.txt&line=1&column=1
259+
vscode://file/home/foo/file.txt:1:1
260+
vscode-insiders://file/home/foo/file.txt:1:1
261+
vscodium://file/home/foo/file.txt:1:1
262+
"#]]
263+
);
264+
}
265+
266+
#[test]
267+
#[cfg(windows)]
268+
fn windows_file_path() {
269+
let editor_urls = Editor::all()
270+
.map(|editor| editor.to_url(None, "C:\\Users\\foo\\help.txt".as_ref(), 1, 1))
271+
.map(|editor| editor.unwrap_or_else(|| "-".to_owned()))
272+
.collect::<Vec<_>>()
273+
.join("\n");
274+
275+
snapbox::assert_data_eq!(
276+
editor_urls,
277+
snapbox::str![[r#"
278+
cursor://file/C:/Users/foo/help.txt:1:1
279+
grep+:///C:/Users/foo/help.txt:1
280+
file:///C:/Users/foo/help.txt#1
281+
mvim://open?url=file:///C:/Users/foo/help.txt&line=1&column=1
282+
txmt://open?url=file:///C:/Users/foo/help.txt&line=1&column=1
283+
vscode://file/C:/Users/foo/help.txt:1:1
284+
vscode-insiders://file/C:/Users/foo/help.txt:1:1
285+
vscodium://file/C:/Users/foo/help.txt:1:1∅
286+
"#]]
287+
);
288+
}
289+
290+
#[test]
291+
fn editor_strings_round_trip() {
292+
let editors = Editor::all().collect::<Vec<_>>();
293+
let parsed = editors
294+
.iter()
295+
.map(|editor| editor.to_string().parse())
296+
.collect::<Result<Vec<_>, _>>();
297+
298+
assert_eq!(parsed, Ok(editors));
299+
}
300+
301+
#[test]
302+
fn invalid_editor_string_errors() {
303+
assert_eq!("code".parse::<Editor>(), Err(ParseEditorError));
304+
}
305+
}

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)