@@ -62,19 +62,132 @@ 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
68179 for component in path. components ( ) {
69180 is_path_empty = false ;
70181 match component {
71182 std:: path:: Component :: Prefix ( prefix) => {
183+ url. push_str ( URL_PATH_SEP ) ;
72184 let component = prefix. as_os_str ( ) . to_string_lossy ( ) ;
73185 url. push_str ( & component) ;
74186 }
75187 std:: path:: Component :: RootDir => { }
76188 std:: path:: Component :: CurDir => { }
77189 std:: path:: Component :: ParentDir => {
190+ url. push_str ( URL_PATH_SEP ) ;
78191 url. push_str ( ".." ) ;
79192 }
80193 std:: path:: Component :: Normal ( part) => {
@@ -92,3 +205,94 @@ fn encode_path(path: &std::path::Path, url: &mut String) {
92205 url. push_str ( URL_PATH_SEP ) ;
93206 }
94207}
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+ }
0 commit comments