Skip to content

Commit a712dba

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 a712dba

File tree

2 files changed

+240
-15
lines changed

2 files changed

+240
-15
lines changed

crates/anstyle-hyperlink/src/file.rs

Lines changed: 236 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,238 @@ 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+
std::path::Component::RootDir => {}
193+
std::path::Component::CurDir => {}
194+
std::path::Component::ParentDir => {
195+
url.push_str("..");
196+
}
197+
std::path::Component::Normal(part) => {
198+
url.push_str(URL_PATH_SEP);
199+
let component = part.to_string_lossy();
200+
url.extend(percent_encoding::percent_encode(
201+
component.as_bytes(),
202+
SPECIAL_PATH_SEGMENT,
203+
));
204+
}
205+
}
206+
}
207+
if is_path_empty {
208+
// An URL's path must not be empty
209+
url.push_str(URL_PATH_SEP);
210+
}
211+
}
212+
213+
#[cfg(test)]
214+
mod tests {
215+
use super::*;
216+
217+
#[test]
218+
fn funky_file_path() {
219+
let editor_urls = Editor::all()
220+
.map(|editor| editor.to_url(None, "/tmp/a b#c".as_ref(), 1, 1))
221+
.map(|editor| editor.unwrap_or_else(|| "-".to_owned()))
222+
.collect::<Vec<_>>()
223+
.join("\n");
224+
225+
snapbox::assert_data_eq!(
226+
editor_urls,
227+
snapbox::str![[r#"
228+
cursor://file/tmp/a%20b%23c:1:1
229+
grep+:///tmp/a%20b%23c:1
230+
file:///tmp/a%20b%23c#1
231+
mvim://open?url=file:///tmp/a%20b%23c&line=1&column=1
232+
txmt://open?url=file:///tmp/a%20b%23c&line=1&column=1
233+
vscode://file/tmp/a%20b%23c:1:1
234+
vscode-insiders://file/tmp/a%20b%23c:1:1
235+
vscodium://file/tmp/a%20b%23c:1:1
236+
"#]]
237+
);
238+
}
239+
240+
#[test]
241+
fn with_hostname() {
242+
let editor_urls = Editor::all()
243+
.map(|editor| editor.to_url(Some("localhost"), "/home/foo/file.txt".as_ref(), 1, 1))
244+
.map(|editor| editor.unwrap_or_else(|| "-".to_owned()))
245+
.collect::<Vec<_>>()
246+
.join("\n");
247+
248+
snapbox::assert_data_eq!(
249+
editor_urls,
250+
snapbox::str![[r#"
251+
cursor://file/home/foo/file.txt:1:1
252+
grep+:///home/foo/file.txt:1
253+
file://localhost/home/foo/file.txt#1
254+
mvim://open?url=file:///home/foo/file.txt&line=1&column=1
255+
txmt://open?url=file:///home/foo/file.txt&line=1&column=1
256+
vscode://file/home/foo/file.txt:1:1
257+
vscode-insiders://file/home/foo/file.txt:1:1
258+
vscodium://file/home/foo/file.txt:1:1
259+
"#]]
260+
);
261+
}
262+
263+
#[test]
264+
#[cfg(windows)]
265+
fn windows_file_path() {
266+
let editor_urls = Editor::all()
267+
.map(|editor| editor.to_url(None, "C:\\Users\\foo\\help.txt".as_ref(), 1, 1))
268+
.map(|editor| editor.unwrap_or_else(|| "-".to_owned()))
269+
.collect::<Vec<_>>()
270+
.join("\n");
271+
272+
snapbox::assert_data_eq!(
273+
editor_urls,
274+
snapbox::str![[r#"
275+
cursor://file/:1:1
276+
grep+:///:1
277+
file:///#1
278+
mvim://open?url=file:///&line=1&column=1
279+
txmt://open?url=file:///&line=1&column=1
280+
vscode://file/:1:1
281+
vscode-insiders://file/:1:1
282+
vscodium://file/:1:1
283+
"#]]
284+
);
285+
}
286+
287+
#[test]
288+
fn editor_strings_round_trip() {
289+
let editors = Editor::all().collect::<Vec<_>>();
290+
let parsed = editors
291+
.iter()
292+
.map(|editor| editor.to_string().parse())
293+
.collect::<Result<Vec<_>, _>>();
294+
295+
assert_eq!(parsed, Ok(editors));
296+
}
297+
298+
#[test]
299+
fn invalid_editor_string_errors() {
300+
assert_eq!("code".parse::<Editor>(), Err(ParseEditorError));
301+
}
302+
}

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)