Skip to content

Commit 50c6fb7

Browse files
committed
Use FilePath instead of UrlId
1 parent 4ab9303 commit 50c6fb7

36 files changed

Lines changed: 277 additions & 530 deletions

crates/aether_path/src/file_path.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,29 @@ impl FilePath {
4848
Self::Virtual(VirtualUri::new(url.clone()))
4949
}
5050

51+
/// Build a [`FilePath::File`] from a filesystem path. Errors if
52+
/// the path can't be expressed as a UTF-8 absolute path.
53+
pub fn from_file_path(path: impl AsRef<Path>) -> anyhow::Result<Self> {
54+
let path = path.as_ref();
55+
AbsPathBuf::from_path(path)
56+
.map(Self::File)
57+
.ok_or_else(|| anyhow::anyhow!("Path is not UTF-8 absolute: {}", path.display()))
58+
}
59+
60+
/// Parse a URI string into a [`FilePath`]. `file:` URIs become
61+
/// [`FilePath::File`]; everything else becomes [`FilePath::Virtual`].
62+
pub fn parse(s: &str) -> anyhow::Result<Self> {
63+
let url = Url::parse(s)?;
64+
Ok(Self::from_url(&url))
65+
}
66+
67+
/// Filesystem path buffer for the `File` arm. Returns `None` for the
68+
/// `Virtual` arm.
69+
pub fn to_path_buf(&self) -> Option<std::path::PathBuf> {
70+
self.as_file()
71+
.map(|p| p.as_path().as_std_path().to_path_buf())
72+
}
73+
5174
/// Reconstruct a [`Url`].
5275
///
5376
/// `File` arms rebuild a `file:` URL from the stored path; `Virtual`
@@ -85,6 +108,19 @@ impl FilePath {
85108
}
86109
}
87110

111+
impl std::fmt::Display for FilePath {
112+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113+
// `File` arms format as a `file:` URL so the output matches
114+
// what we'd send on the wire, not as a bare path. The path
115+
// form is reachable via `as_file().map(|p| p.as_path())` for
116+
// callers that want it.
117+
match self {
118+
Self::File(p) => p.to_url().fmt(f),
119+
Self::Virtual(u) => u.fmt(f),
120+
}
121+
}
122+
}
123+
88124
/// Lexically normalised absolute UTF-8 path. Identity for filesystem
89125
/// files inside [`FilePath::File`].
90126
///

crates/aether_path/src/lib.rs

Lines changed: 0 additions & 284 deletions
Original file line numberDiff line numberDiff line change
@@ -10,287 +10,3 @@ mod file_path;
1010
pub use file_path::AbsPathBuf;
1111
pub use file_path::FilePath;
1212
pub 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-
}

crates/ark/src/console.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ mod console_graphics;
116116
mod console_integration;
117117
mod console_repl;
118118

119-
use aether_path::UrlId;
119+
use aether_path::FilePath;
120120
use console_annotate::annotate_input;
121121
pub(crate) use console_debug::DebugCallText;
122122
pub(crate) use console_debug::DebugStoppedReason;

crates/ark/src/console/console_annotate.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
// Copyright (C) 2026 Posit Software, PBC. All rights reserved.
55
//
66

7+
use aether_path::FilePath;
78
use aether_syntax::RBracedExpressions;
89
use aether_syntax::RExpressionList;
910
use aether_syntax::RLanguage;
1011
use aether_syntax::RSyntaxElement;
1112
use aether_syntax::RSyntaxKind;
1213
use aether_syntax::RSyntaxNode;
13-
use aether_path::UrlId;
1414
use amalthea::wire::execute_request::CodeLocation;
1515
use anyhow::anyhow;
1616
use biome_line_index::LineIndex;
@@ -933,7 +933,7 @@ pub unsafe extern "C-unwind" fn ps_annotate_source(
933933
// R source references may flow back to the frontend, which expects its
934934
// own URI representation.
935935
let uri = Url::parse(&uri)?;
936-
let uri_id = UrlId::from_url(uri.clone());
936+
let uri_id = FilePath::from_url(&uri);
937937

938938
let mut dap_guard = Console::get().debug_dap.lock().unwrap();
939939

0 commit comments

Comments
 (0)