Skip to content

Commit cb759b6

Browse files
authored
Merge pull request #308 from epage/editor
feat(hyperlink): Add editor-specific links
2 parents c11f10a + 768df75 commit cb759b6

2 files changed

Lines changed: 240 additions & 19 deletions

File tree

crates/anstyle-hyperlink/src/file.rs

Lines changed: 236 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,30 +20,12 @@ pub fn path_to_url(path: &std::path::Path) -> Option<String> {
2020
/// the computer you've SSH'ed into
2121
/// ([reference](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda#file-uris-and-the-hostname))
2222
pub fn file_to_url(hostname: Option<&str>, path: &std::path::Path) -> Option<String> {
23-
if !path.is_absolute() {
24-
return None;
25-
}
26-
2723
let mut url = "file://".to_owned();
2824
if let Some(hostname) = hostname {
2925
url.push_str(hostname);
3026
}
3127

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-
}
28+
encode_path(path, &mut url);
4729

4830
Some(url)
4931
}
@@ -79,3 +61,238 @@ const PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH.add(b'/').add(b'%');
7961
// The backslash (\) character is treated as a path separator in special URLs
8062
// so it needs to be additionally escaped in that case.
8163
const SPECIAL_PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH_SEGMENT.add(b'\\');
64+
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+
176+
fn encode_path(path: &std::path::Path, url: &mut String) {
177+
let mut is_path_empty = true;
178+
179+
for component in path.components() {
180+
is_path_empty = false;
181+
match component {
182+
std::path::Component::Prefix(prefix) => {
183+
url.push_str(URL_PATH_SEP);
184+
let component = prefix.as_os_str().to_string_lossy();
185+
url.push_str(&component);
186+
}
187+
std::path::Component::RootDir => {}
188+
std::path::Component::CurDir => {}
189+
std::path::Component::ParentDir => {
190+
url.push_str(URL_PATH_SEP);
191+
url.push_str("..");
192+
}
193+
std::path::Component::Normal(part) => {
194+
url.push_str(URL_PATH_SEP);
195+
let component = part.to_string_lossy();
196+
url.extend(percent_encoding::percent_encode(
197+
component.as_bytes(),
198+
SPECIAL_PATH_SEGMENT,
199+
));
200+
}
201+
}
202+
}
203+
if is_path_empty {
204+
// An URL's path must not be empty
205+
url.push_str(URL_PATH_SEP);
206+
}
207+
}
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)