@@ -10,287 +10,3 @@ mod file_path;
1010pub use file_path:: AbsPathBuf ;
1111pub use file_path:: FilePath ;
1212pub use file_path:: VirtualUri ;
13-
14- use std:: fmt;
15-
16- use stdext:: result:: ResultExt ;
17- use url:: Url ;
18-
19- /// Lexically normalised file URL identity.
20- ///
21- /// Internal identity key for files received from any source (LSP, DAP,
22- /// scanner, R runtime). Constructed via the same lexical normalisation
23- /// at every entry point so that two paths the editor considers "the
24- /// same file" produce the same [`UrlId`].
25- ///
26- /// What we normalise: drive-letter casing on Windows; percent-encoding
27- /// of `:` (decoded via `Url -> PathBuf -> Url` round-trip). No I/O:
28- /// `std::fs::canonicalize()`, no symlink resolution. The same input URI
29- /// produces the same [`UrlId`] whether or not the file exists on disk.
30- ///
31- /// # Bridging across symlinks
32- ///
33- /// R's `normalizePath()` resolves symlinks on its own. A srcref URI
34- /// from the R runtime may name `/private/tmp/foo.R` while the editor
35- /// sent us `/tmp/foo.R`. The two don't compare equal, so a `HashMap`
36- /// keyed on `UrlId` treats them as separate files. Code that needs to
37- /// match a srcref URI back to an open document or a breakpoint should
38- /// maintain a secondary index of `fs::canonicalize`d paths and fall
39- /// back to it on a primary miss.
40- /// [`crate::dap::dap_state::BreakpointMap`] in `ark` does this for
41- /// breakpoints.
42- ///
43- /// # Important: don't leak normalised URIs back out
44- ///
45- /// Even though [`UrlId`] no longer fs-canonicalises, it still
46- /// uppercases the Windows drive letter and decodes the percent-encoded
47- /// colon. When sending URIs back to the editor or to R, prefer the
48- /// original bytes the frontend sent. The frontend treats a URI as the
49- /// editor's identity for the file; a normalised form may look like a
50- /// different file to it (e.g. open a new editor pane).
51- #[ derive( Debug , Clone , PartialEq , Eq , Hash ) ]
52- pub struct UrlId ( Url ) ;
53-
54- impl UrlId {
55- /// Lexically normalise a [`Url`] into a [`UrlId`].
56- ///
57- /// Decodes encoding variants (e.g. `%3A` to `:` on Windows) and
58- /// uppercases the Windows drive letter. Does no filesystem I/O.
59- /// Non-`file:` URLs (`ark://`, `untitled:`, ...) pass through
60- /// untouched.
61- pub fn from_url ( uri : Url ) -> Self {
62- if uri. scheme ( ) != "file" {
63- return Self ( uri) ;
64- }
65-
66- // Round-trip through `PathBuf` so the URI form matches what
67- // `Url::from_file_path` produces (decoded `%3A`, etc.). Skip
68- // on error, we let pathological URIs flow through unchanged.
69- let uri = match uri. to_file_path ( ) . warn_on_err ( ) {
70- Some ( path) => Url :: from_file_path ( & path)
71- . map_err ( |( ) | anyhow:: anyhow!( "Failed to convert path to URI: {path:?}" ) )
72- . warn_on_err ( )
73- . unwrap_or ( uri) ,
74- None => uri,
75- } ;
76-
77- #[ cfg( windows) ]
78- let uri = uppercase_windows_drive_in_uri ( uri) ;
79-
80- Self ( uri)
81- }
82-
83- /// Build a [`UrlId`] from a filesystem path.
84- ///
85- /// Same lexical normalisation as [`Self::from_url`], no filesystem
86- /// I/O. Errors only if `path` can't be expressed as a URL (e.g.
87- /// not absolute on platforms that require it).
88- pub fn from_file_path ( path : impl AsRef < std:: path:: Path > ) -> anyhow:: Result < Self > {
89- let path = path. as_ref ( ) ;
90- let url = Url :: from_file_path ( path)
91- . map_err ( |( ) | anyhow:: anyhow!( "Failed to convert path to URL: {}" , path. display( ) ) ) ?;
92- Ok ( Self :: from_url ( url) )
93- }
94-
95- /// Parse a URI string into a [`UrlId`].
96- pub fn parse ( s : & str ) -> Result < Self , url:: ParseError > {
97- let url = Url :: parse ( s) ?;
98- Ok ( Self :: from_url ( url) )
99- }
100-
101- /// Access the inner [`Url`].
102- pub fn as_url ( & self ) -> & Url {
103- & self . 0
104- }
105-
106- /// Whether this URL points at a filesystem file (`file:` scheme).
107- /// Returns `false` for virtual documents like `untitled:` (unsaved
108- /// buffers) and `ark:` (synthesized sources from R). Callers that
109- /// might receive virtual URLs should gate on this before reaching for
110- /// [`Self::to_file_path`].
111- pub fn is_file ( & self ) -> bool {
112- self . 0 . scheme ( ) == "file"
113- }
114-
115- /// Filesystem path corresponding to this URL.
116- ///
117- /// Errors for non-`file:` URLs (untitled buffers, custom schemes) and
118- /// for `file:` URLs whose path can't be reconstructed (rare). Callers
119- /// that handle virtual documents should check [`Self::is_file`] first.
120- pub fn to_file_path ( & self ) -> anyhow:: Result < std:: path:: PathBuf > {
121- self . 0
122- . to_file_path ( )
123- . map_err ( |( ) | anyhow:: anyhow!( "URL has no filesystem path: {}" , self . 0 ) )
124- }
125- }
126-
127- impl fmt:: Display for UrlId {
128- fn fmt ( & self , f : & mut fmt:: Formatter < ' _ > ) -> fmt:: Result {
129- self . 0 . fmt ( f)
130- }
131- }
132-
133- /// Uppercase the drive letter in a Windows file URI for consistent hashing.
134- #[ cfg( windows) ]
135- fn uppercase_windows_drive_in_uri ( mut uri : Url ) -> Url {
136- let path = uri. path ( ) ;
137- let mut chars = path. chars ( ) ;
138-
139- // Match pattern: "/" + drive letter + ":"
140- let drive = match ( chars. next ( ) , chars. next ( ) , chars. next ( ) ) {
141- ( Some ( '/' ) , Some ( drive) , Some ( ':' ) ) if drive. is_ascii_alphabetic ( ) => drive,
142- _ => return uri,
143- } ;
144-
145- let upper = drive. to_ascii_uppercase ( ) ;
146-
147- if drive != upper {
148- let new_path = format ! ( "/{upper}:{}" , & path[ 3 ..] ) ;
149- uri. set_path ( & new_path) ;
150- }
151-
152- uri
153- }
154-
155- #[ cfg( test) ]
156- mod tests {
157- use super :: * ;
158-
159- #[ test]
160- fn test_non_file_unchanged ( ) {
161- let uri = Url :: parse ( "ark://namespace/test.R" ) . unwrap ( ) ;
162- let id = UrlId :: from_url ( uri. clone ( ) ) ;
163- assert_eq ! ( * id. as_url( ) , uri) ;
164- }
165-
166- #[ test]
167- fn test_parse_non_file ( ) {
168- let id = UrlId :: parse ( "ark://namespace/test.R" ) . unwrap ( ) ;
169- assert_eq ! ( id. as_url( ) . as_str( ) , "ark://namespace/test.R" ) ;
170- }
171-
172- #[ test]
173- fn test_equality ( ) {
174- let id1 = UrlId :: parse ( "file:///home/user/test.R" ) . unwrap ( ) ;
175- let id2 = UrlId :: parse ( "file:///home/user/test.R" ) . unwrap ( ) ;
176- assert_eq ! ( id1, id2) ;
177-
178- let id3 = UrlId :: parse ( "file:///home/user/other.R" ) . unwrap ( ) ;
179- assert_ne ! ( id1, id3) ;
180- }
181-
182- #[ test]
183- fn test_display ( ) {
184- let id = UrlId :: parse ( "file:///home/user/test.R" ) . unwrap ( ) ;
185- assert_eq ! ( format!( "{id}" ) , "file:///home/user/test.R" ) ;
186- }
187-
188- #[ test]
189- #[ cfg( not( windows) ) ]
190- fn test_nonexistent_path_unchanged ( ) {
191- // Construction is lexical-only, so nonexistent paths flow
192- // through unchanged.
193- let uri = Url :: parse ( "file:///nonexistent/path/test.R" ) . unwrap ( ) ;
194- let id = UrlId :: from_url ( uri. clone ( ) ) ;
195- assert_eq ! ( * id. as_url( ) , uri) ;
196- }
197-
198- #[ test]
199- #[ cfg( target_os = "macos" ) ]
200- fn test_does_not_resolve_symlinks ( ) {
201- // On macOS, `/tmp` is a symlink to `/private/tmp`. `UrlId` does
202- // *not* resolve it; same input bytes produce the same output
203- // regardless of the symlink graph on disk. Bridging across
204- // symlinked names is the job of secondary canonical indexes at
205- // specific seams (e.g. the DAP breakpoint store), not of
206- // construction.
207- let dir = tempfile:: tempdir_in ( "/tmp" ) . unwrap ( ) ;
208- let file = dir. path ( ) . join ( "test.R" ) ;
209- std:: fs:: write ( & file, "" ) . unwrap ( ) ;
210-
211- let original = Url :: from_file_path ( & file) . unwrap ( ) ;
212- assert ! ( original. path( ) . starts_with( "/tmp/" ) ) ;
213-
214- let id = UrlId :: from_url ( original. clone ( ) ) ;
215- assert_eq ! ( * id. as_url( ) , original) ;
216- assert ! ( id. as_url( ) . path( ) . starts_with( "/tmp/" ) ) ;
217- }
218-
219- #[ test]
220- #[ cfg( not( windows) ) ]
221- fn test_from_file_path_unix ( ) {
222- let id = UrlId :: from_file_path ( "/home/user/test.R" ) . unwrap ( ) ;
223- assert_eq ! ( id. as_url( ) . as_str( ) , "file:///home/user/test.R" ) ;
224- }
225-
226- // Windows-specific tests
227-
228- #[ test]
229- #[ cfg( windows) ]
230- fn test_decodes_percent_encoded_colon ( ) {
231- // Positron sends URIs with encoded colon
232- let uri = Url :: parse ( "file:///c%3A/Users/test/file.R" ) . unwrap ( ) ;
233- let id = UrlId :: from_url ( uri) ;
234- assert_eq ! ( id. as_url( ) . as_str( ) , "file:///C:/Users/test/file.R" ) ;
235- }
236-
237- #[ test]
238- #[ cfg( windows) ]
239- fn test_decodes_percent_encoded_colon_lowercase_hex ( ) {
240- // %3a (lowercase hex) variant
241- let uri = Url :: parse ( "file:///c%3a/Users/test/file.R" ) . unwrap ( ) ;
242- let id = UrlId :: from_url ( uri) ;
243- assert_eq ! ( id. as_url( ) . as_str( ) , "file:///C:/Users/test/file.R" ) ;
244- }
245-
246- #[ test]
247- #[ cfg( windows) ]
248- fn test_uppercases_drive_letter ( ) {
249- let uri = Url :: parse ( "file:///c:/Users/test/file.R" ) . unwrap ( ) ;
250- let id = UrlId :: from_url ( uri) ;
251- assert_eq ! ( id. as_url( ) . as_str( ) , "file:///C:/Users/test/file.R" ) ;
252- }
253-
254- #[ test]
255- #[ cfg( windows) ]
256- fn test_preserves_uppercase_drive ( ) {
257- let uri = Url :: parse ( "file:///C:/Users/test/file.R" ) . unwrap ( ) ;
258- let id = UrlId :: from_url ( uri. clone ( ) ) ;
259- assert_eq ! ( * id. as_url( ) , uri) ;
260- }
261-
262- #[ test]
263- #[ cfg( windows) ]
264- fn test_preserves_spaces_encoding ( ) {
265- // Spaces should remain percent-encoded after round-trip
266- let uri = Url :: parse ( "file:///C:/Users/test%20user/my%20file.R" ) . unwrap ( ) ;
267- let id = UrlId :: from_url ( uri) ;
268- assert_eq ! (
269- id. as_url( ) . as_str( ) ,
270- "file:///C:/Users/test%20user/my%20file.R"
271- ) ;
272- }
273-
274- #[ test]
275- #[ cfg( windows) ]
276- fn test_decodes_colon_preserves_spaces ( ) {
277- // Both encoded colon and spaces
278- let uri = Url :: parse ( "file:///c%3A/Users/test%20user/file.R" ) . unwrap ( ) ;
279- let id = UrlId :: from_url ( uri) ;
280- assert_eq ! ( id. as_url( ) . as_str( ) , "file:///C:/Users/test%20user/file.R" ) ;
281- }
282-
283- #[ test]
284- #[ cfg( windows) ]
285- fn test_parse_windows ( ) {
286- let id = UrlId :: parse ( "file:///c%3A/Users/test/file.R" ) . unwrap ( ) ;
287- assert_eq ! ( id. as_url( ) . as_str( ) , "file:///C:/Users/test/file.R" ) ;
288- }
289-
290- #[ test]
291- #[ cfg( windows) ]
292- fn test_from_file_path_windows ( ) {
293- let id = UrlId :: from_file_path ( "C:\\ Users\\ test\\ file.R" ) . unwrap ( ) ;
294- assert_eq ! ( id. as_url( ) . as_str( ) , "file:///C:/Users/test/file.R" ) ;
295- }
296- }
0 commit comments