anstyle_hyperlink/
file.rs1pub fn path_to_url(path: &std::path::Path) -> Option<String> {
3 let hostname = if cfg!(windows) {
5 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
17pub 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
33pub 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
48const FRAGMENT: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
50 .add(b' ')
51 .add(b'"')
52 .add(b'<')
53 .add(b'>')
54 .add(b'`');
55
56const 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
61const SPECIAL_PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH_SEGMENT.add(b'\\');
64
65#[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 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 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 Self::Grepp => format!("grep+://{path}:{line}"),
112 Self::Kitty => format!("file://{}{path}#{line}", hostname.unwrap_or_default()),
113 Self::MacVim => {
115 format!("mvim://open?url=file://{path}&line={line}&column={col}")
116 }
117 Self::TextMate => {
119 format!("txmt://open?url=file://{path}&line={line}&column={col}")
120 }
121 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#[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 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}