Skip to main content

anstyle_hyperlink/
file.rs

1/// Create a URL from a given path
2pub fn path_to_url(path: &std::path::Path) -> Option<String> {
3    // Do a best-effort for getting the host in the URL
4    let hostname = if cfg!(windows) {
5        // Not supported correctly on windows
6        None
7    } else {
8        crate::hostname().ok().and_then(|os| os.into_string().ok())
9    };
10    if path.is_dir() {
11        dir_to_url(hostname.as_deref(), path)
12    } else {
13        file_to_url(hostname.as_deref(), path)
14    }
15}
16
17/// Create a URL from a given hostname and file path
18///
19/// For hyperlink escape codes, the hostname is used to avoid issues with opening a link scoped to
20/// the computer you've SSH'ed into
21/// ([reference](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda#file-uris-and-the-hostname))
22pub fn file_to_url(hostname: Option<&str>, path: &std::path::Path) -> Option<String> {
23    let mut url = "file://".to_owned();
24    if let Some(hostname) = hostname {
25        url.push_str(hostname);
26    }
27
28    encode_path(path, &mut url);
29
30    Some(url)
31}
32
33/// Create a URL from a given hostname and directory path
34///
35/// For hyperlink escape codes, the hostname is used to avoid issues with opening a link scoped to
36/// the computer you've SSH'ed into
37/// ([reference](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda#file-uris-and-the-hostname))
38pub fn dir_to_url(hostname: Option<&str>, path: &std::path::Path) -> Option<String> {
39    let mut url = file_to_url(hostname, path)?;
40    if !url.ends_with(URL_PATH_SEP) {
41        url.push_str(URL_PATH_SEP);
42    }
43    Some(url)
44}
45
46const URL_PATH_SEP: &str = "/";
47
48/// <https://url.spec.whatwg.org/#fragment-percent-encode-set>
49const FRAGMENT: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
50    .add(b' ')
51    .add(b'"')
52    .add(b'<')
53    .add(b'>')
54    .add(b'`');
55
56/// <https://url.spec.whatwg.org/#path-percent-encode-set>
57const PATH: &percent_encoding::AsciiSet = &FRAGMENT.add(b'#').add(b'?').add(b'{').add(b'}');
58
59const PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH.add(b'/').add(b'%');
60
61// The backslash (\) character is treated as a path separator in special URLs
62// so it needs to be additionally escaped in that case.
63const 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]
69pub enum Editor {
70    Cursor,
71    Grepp,
72    Kitty,
73    MacVim,
74    TextMate,
75    VSCode,
76    VSCodeInsiders,
77    VSCodium,
78}
79
80impl 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
132impl 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
148impl 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)]
168pub struct ParseEditorError;
169
170impl 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
176fn 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)]
210mod 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#"
224cursor://file/tmp/a%20b%23c:1:1
225grep+:///tmp/a%20b%23c:1
226file:///tmp/a%20b%23c#1
227mvim://open?url=file:///tmp/a%20b%23c&line=1&column=1
228txmt://open?url=file:///tmp/a%20b%23c&line=1&column=1
229vscode://file/tmp/a%20b%23c:1:1
230vscode-insiders://file/tmp/a%20b%23c:1:1
231vscodium://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#"
247cursor://file/home/foo/file.txt:1:1
248grep+:///home/foo/file.txt:1
249file://localhost/home/foo/file.txt#1
250mvim://open?url=file:///home/foo/file.txt&line=1&column=1
251txmt://open?url=file:///home/foo/file.txt&line=1&column=1
252vscode://file/home/foo/file.txt:1:1
253vscode-insiders://file/home/foo/file.txt:1:1
254vscodium://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#"
271cursor://file/C:/Users/foo/help.txt:1:1
272grep+:///C:/Users/foo/help.txt:1
273file:///C:/Users/foo/help.txt#1
274mvim://open?url=file:///C:/Users/foo/help.txt&line=1&column=1
275txmt://open?url=file:///C:/Users/foo/help.txt&line=1&column=1
276vscode://file/C:/Users/foo/help.txt:1:1
277vscode-insiders://file/C:/Users/foo/help.txt:1:1
278vscodium://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}