@@ -62,6 +62,117 @@ const PATH_SEGMENT: &percent_encoding::AsciiSet = &PATH.add(b'/').add(b'%');
6262// so it needs to be additionally escaped in that case.
6363const SPECIAL_PATH_SEGMENT : & percent_encoding:: AsciiSet = & PATH_SEGMENT . add ( b'\\' ) ;
6464
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+
65176fn encode_path ( path : & std:: path:: Path , url : & mut String ) {
66177 let mut is_path_empty = true ;
67178
@@ -92,3 +203,94 @@ fn encode_path(path: &std::path::Path, url: &mut String) {
92203 url. push_str ( URL_PATH_SEP ) ;
93204 }
94205}
206+
207+ #[ cfg( test) ]
208+ mod tests {
209+ use super :: * ;
210+
211+ #[ test]
212+ fn funky_file_path ( ) {
213+ let editor_urls = Editor :: all ( )
214+ . map ( |editor| editor. to_url ( None , "/tmp/a b#c" . as_ref ( ) , 1 , 1 ) )
215+ . map ( |editor| editor. unwrap_or_else ( || "-" . to_owned ( ) ) )
216+ . collect :: < Vec < _ > > ( )
217+ . join ( "\n " ) ;
218+
219+ snapbox:: assert_data_eq!(
220+ editor_urls,
221+ snapbox:: str ![ [ r#"
222+ cursor://file/tmp/a%20b%23c:1:1
223+ grep+:///tmp/a%20b%23c:1
224+ file:///tmp/a%20b%23c#1
225+ mvim://open?url=file:///tmp/a%20b%23c&line=1&column=1
226+ txmt://open?url=file:///tmp/a%20b%23c&line=1&column=1
227+ vscode://file/tmp/a%20b%23c:1:1
228+ vscode-insiders://file/tmp/a%20b%23c:1:1
229+ vscodium://file/tmp/a%20b%23c:1:1
230+ "# ] ]
231+ ) ;
232+ }
233+
234+ #[ test]
235+ fn with_hostname ( ) {
236+ let editor_urls = Editor :: all ( )
237+ . map ( |editor| editor. to_url ( Some ( "localhost" ) , "/home/foo/file.txt" . as_ref ( ) , 1 , 1 ) )
238+ . map ( |editor| editor. unwrap_or_else ( || "-" . to_owned ( ) ) )
239+ . collect :: < Vec < _ > > ( )
240+ . join ( "\n " ) ;
241+
242+ snapbox:: assert_data_eq!(
243+ editor_urls,
244+ snapbox:: str ![ [ r#"
245+ cursor://file/home/foo/file.txt:1:1
246+ grep+:///home/foo/file.txt:1
247+ file://localhost/home/foo/file.txt#1
248+ mvim://open?url=file:///home/foo/file.txt&line=1&column=1
249+ txmt://open?url=file:///home/foo/file.txt&line=1&column=1
250+ vscode://file/home/foo/file.txt:1:1
251+ vscode-insiders://file/home/foo/file.txt:1:1
252+ vscodium://file/home/foo/file.txt:1:1
253+ "# ] ]
254+ ) ;
255+ }
256+
257+ #[ test]
258+ #[ cfg( windows) ]
259+ fn windows_file_path ( ) {
260+ let editor_urls = Editor :: all ( )
261+ . map ( |editor| editor. to_url ( None , "C:\\ Users\\ foo\\ help.txt" . as_ref ( ) , 1 , 1 ) )
262+ . map ( |editor| editor. unwrap_or_else ( || "-" . to_owned ( ) ) )
263+ . collect :: < Vec < _ > > ( )
264+ . join ( "\n " ) ;
265+
266+ snapbox:: assert_data_eq!(
267+ editor_urls,
268+ snapbox:: str ![ [ r#"
269+ cursor://file/C:/Users/foo/help.txt:1:1
270+ grep+:///C:/Users/foo/help.txt:1
271+ file:///C:/Users/foo/help.txt#1
272+ mvim://open?url=file:///C:/Users/foo/help.txt&line=1&column=1
273+ txmt://open?url=file:///C:/Users/foo/help.txt&line=1&column=1
274+ vscode://file/C:/Users/foo/help.txt:1:1
275+ vscode-insiders://file/C:/Users/foo/help.txt:1:1
276+ vscodium://file/C:/Users/foo/help.txt:1:1∅
277+ "# ] ]
278+ ) ;
279+ }
280+
281+ #[ test]
282+ fn editor_strings_round_trip ( ) {
283+ let editors = Editor :: all ( ) . collect :: < Vec < _ > > ( ) ;
284+ let parsed = editors
285+ . iter ( )
286+ . map ( |editor| editor. to_string ( ) . parse ( ) )
287+ . collect :: < Result < Vec < _ > , _ > > ( ) ;
288+
289+ assert_eq ! ( parsed, Ok ( editors) ) ;
290+ }
291+
292+ #[ test]
293+ fn invalid_editor_string_errors ( ) {
294+ assert_eq ! ( "code" . parse:: <Editor >( ) , Err ( ParseEditorError ) ) ;
295+ }
296+ }
0 commit comments