@@ -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.
8167const 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+ }
0 commit comments