Skip to main content

helix_stdx/
uri.rs

1//! A minimal RFC3986 URI type backed by a string.
2//!
3//! We only care to convert between `file://` URIs and `PathBuf`, so we don't need a fully
4//! featured URL/URI crate. Also, LSP uses RFC3986 rather than WHATWG, and the two have different
5//! percent encoding rules. Encoding follows RFC3986 (percent-encode everything outside `pchar`),
6//! not the WHATWG URL rules the `url` crate implements. Some language servers are strict about
7//! this (e.g. Deno): they reject unescaped `[`/`]` in paths (valid in WHATWG but not RFC3986).
8
9use std::fmt;
10use std::path::{Path, PathBuf};
11use std::str::FromStr;
12
13use percent_encoding::{percent_decode, percent_encode, AsciiSet, NON_ALPHANUMERIC};
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15
16/// The set of bytes percent-encoded in a path. RFC3986 allows `pchar`
17/// (`unreserved` / `sub-delims` / `:` / `@`) plus `/` as the separator
18/// unescaped; everything else (and every non-ASCII byte) is encoded.
19const PATH: &AsciiSet = &NON_ALPHANUMERIC
20    // unreserved: ALPHA / DIGIT (already kept) / "-" / "." / "_" / "~"
21    .remove(b'-')
22    .remove(b'.')
23    .remove(b'_')
24    .remove(b'~')
25    // sub-delims
26    .remove(b'!')
27    .remove(b'$')
28    .remove(b'&')
29    .remove(b'\'')
30    .remove(b'(')
31    .remove(b')')
32    .remove(b'*')
33    .remove(b'+')
34    .remove(b',')
35    .remove(b';')
36    .remove(b'=')
37    // pchar extras and the path separator
38    .remove(b':')
39    .remove(b'@')
40    .remove(b'/');
41
42/// An RFC3986 URI.
43///
44/// The URI is stored verbatim and only interpreted when a `file://` path is actually needed.
45#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub struct Url(String);
47
48/// Error returned when a string is not a valid absolute URI.
49#[derive(Debug, PartialEq, Eq)]
50pub struct ParseError;
51
52impl fmt::Display for ParseError {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.write_str("invalid URI: relative URL without a base")
55    }
56}
57
58impl std::error::Error for ParseError {}
59
60impl Url {
61    /// Parse an absolute URI. Mirrors `url::Url::parse`'s rejection of relative
62    /// references: the input must begin with a valid scheme (`ALPHA *( ALPHA /
63    /// DIGIT / "+" / "-" / "." ) ":"`).
64    pub fn parse(input: &str) -> Result<Url, ParseError> {
65        let colon = input.find(':').ok_or(ParseError)?;
66        let scheme = &input[..colon];
67        let mut bytes = scheme.bytes();
68        let valid = bytes.next().is_some_and(|b| b.is_ascii_alphabetic())
69            && bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'));
70        if valid {
71            Ok(Url(input.to_string()))
72        } else {
73            Err(ParseError)
74        }
75    }
76
77    /// The full URI as a string slice.
78    pub fn as_str(&self) -> &str {
79        &self.0
80    }
81
82    /// The scheme (the part before the first `:`), e.g. `file`.
83    pub fn scheme(&self) -> &str {
84        match self.0.find(':') {
85            Some(i) => &self.0[..i],
86            None => "",
87        }
88    }
89
90    /// The (still percent-encoded) path component, matching `url::Url::path`.
91    pub fn path(&self) -> &str {
92        let after_scheme = match self.0.find(':') {
93            Some(i) => &self.0[i + 1..],
94            None => self.0.as_str(),
95        };
96        // Skip an `//authority` component if present.
97        let after_authority = match after_scheme.strip_prefix("//") {
98            Some(rest) => match rest.find('/') {
99                Some(i) => &rest[i..],
100                None => "",
101            },
102            None => after_scheme,
103        };
104        let end = after_authority
105            .find(['?', '#'])
106            .unwrap_or(after_authority.len());
107        &after_authority[..end]
108    }
109
110    /// Build a `file://` URI from an absolute filesystem path.
111    #[allow(clippy::result_unit_err)]
112    pub fn from_file_path<P: AsRef<Path>>(path: P) -> Result<Url, ()> {
113        let path = path.as_ref();
114        if !path.is_absolute() {
115            return Err(());
116        }
117        let mut serialization = String::from("file://");
118        serialize_path(&mut serialization, path)?;
119        Ok(Url(serialization))
120    }
121
122    /// Like [`Url::from_file_path`], but ensures a trailing slash so the URI
123    /// denotes a directory.
124    #[allow(clippy::result_unit_err)]
125    pub fn from_directory_path<P: AsRef<Path>>(path: P) -> Result<Url, ()> {
126        let mut url = Url::from_file_path(path)?;
127        if !url.0.ends_with('/') {
128            url.0.push('/');
129        }
130        Ok(url)
131    }
132
133    /// Convert a `file://` URI back to a filesystem path.
134    #[allow(clippy::result_unit_err)]
135    pub fn to_file_path(&self) -> Result<PathBuf, ()> {
136        if self.scheme() != "file" {
137            return Err(());
138        }
139        let rest = self.0["file:".len()..].strip_prefix("//").ok_or(())?;
140        let (authority, path) = match rest.find('/') {
141            Some(i) => (&rest[..i], &rest[i..]),
142            None => (rest, ""),
143        };
144        let local_host = authority.is_empty() || authority.eq_ignore_ascii_case("localhost");
145
146        #[cfg(not(windows))]
147        {
148            use std::ffi::OsStr;
149            use std::os::unix::ffi::OsStrExt;
150            if !local_host {
151                return Err(());
152            }
153            let bytes = percent_decode(path.as_bytes()).collect::<Vec<u8>>();
154            if bytes.is_empty() {
155                return Err(());
156            }
157            Ok(PathBuf::from(OsStr::from_bytes(&bytes)))
158        }
159        #[cfg(windows)]
160        {
161            let decoded = percent_decode(path.as_bytes())
162                .decode_utf8()
163                .map_err(|_| ())?;
164            if local_host {
165                // `/C:/dir/file` -> `C:\dir\file`
166                let path = decoded.strip_prefix('/').unwrap_or(&decoded);
167                if path.is_empty() {
168                    return Err(());
169                }
170                Ok(PathBuf::from(path.replace('/', "\\")))
171            } else {
172                // UNC: `file://server/share/...` -> `\\server\share\...`
173                Ok(PathBuf::from(format!(
174                    "\\\\{}{}",
175                    authority,
176                    decoded.replace('/', "\\")
177                )))
178            }
179        }
180    }
181}
182
183#[cfg(not(windows))]
184fn serialize_path(out: &mut String, path: &Path) -> Result<(), ()> {
185    use std::os::unix::ffi::OsStrExt;
186    // The path is absolute, so it begins with `/`; percent-encode it while
187    // preserving the `/` separators (they are excluded from `PATH`).
188    out.extend(percent_encode(path.as_os_str().as_bytes(), PATH));
189    Ok(())
190}
191
192#[cfg(windows)]
193fn serialize_path(out: &mut String, path: &Path) -> Result<(), ()> {
194    use std::path::{Component, Prefix};
195    let mut components = path.components();
196    match components.next() {
197        Some(Component::Prefix(prefix)) => match prefix.kind() {
198            Prefix::Disk(_) | Prefix::VerbatimDisk(_) => {
199                // `C:` -> `/C:`
200                out.push('/');
201                out.push_str(&prefix.as_os_str().to_string_lossy());
202            }
203            Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
204                // `\\server\share` -> `//server/share` (authority + first seg)
205                out.pop(); // drop one `/` from the `file://` we were appended to
206                out.push_str(&server.to_string_lossy());
207                out.push('/');
208                out.extend(percent_encode(share.to_string_lossy().as_bytes(), PATH));
209            }
210            _ => return Err(()),
211        },
212        _ => return Err(()),
213    }
214    for component in components {
215        match component {
216            Component::RootDir => {}
217            Component::Normal(seg) => {
218                out.push('/');
219                out.extend(percent_encode(seg.to_string_lossy().as_bytes(), PATH));
220            }
221            Component::CurDir => out.push_str("/."),
222            Component::ParentDir => out.push_str("/.."),
223            Component::Prefix(_) => return Err(()),
224        }
225    }
226    Ok(())
227}
228
229impl FromStr for Url {
230    type Err = ParseError;
231
232    fn from_str(s: &str) -> Result<Self, Self::Err> {
233        Url::parse(s)
234    }
235}
236
237impl fmt::Display for Url {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        f.write_str(&self.0)
240    }
241}
242
243impl fmt::Debug for Url {
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        f.write_str(&self.0)
246    }
247}
248
249impl AsRef<str> for Url {
250    fn as_ref(&self) -> &str {
251        &self.0
252    }
253}
254
255impl Serialize for Url {
256    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
257        serializer.serialize_str(&self.0)
258    }
259}
260
261impl<'de> Deserialize<'de> for Url {
262    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
263        // Store the URI as-is. Path conversion is lazy.
264        Ok(Url(String::deserialize(deserializer)?))
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn scheme_and_path() {
274        let url = Url::parse("file:///home/user/main.rs").unwrap();
275        assert_eq!(url.scheme(), "file");
276        assert_eq!(url.path(), "/home/user/main.rs");
277
278        let url = Url::parse("csharp:/metadata/foo/Baz.cs").unwrap();
279        assert_eq!(url.scheme(), "csharp");
280        assert_eq!(url.path(), "/metadata/foo/Baz.cs");
281
282        // authority present
283        let url = Url::parse("file://host/path?q#frag").unwrap();
284        assert_eq!(url.path(), "/path");
285    }
286
287    #[test]
288    fn parse_rejects_relative() {
289        assert!(Url::parse("src/main.rs").is_err());
290        assert!(Url::parse("just text").is_err());
291        assert!(Url::parse("https://example.com").is_ok());
292    }
293
294    #[cfg(not(windows))]
295    #[test]
296    fn file_path_round_trip() {
297        for path in [
298            "/home/user/main.rs",
299            "/tmp/a b.txt",           // space
300            "/tmp/[test]/x.ts",       // brackets (Deno RFC3986 fix)
301            "/tmp/c#/Program.cs",     // '#'
302            "/home/üser/café.txt",    // non-ASCII
303            "/weird/100%/qu?ery&x=1", // '%', '?', '&'
304        ] {
305            let url = Url::from_file_path(path).unwrap();
306            assert_eq!(url.to_file_path().unwrap(), PathBuf::from(path), "{}", url);
307        }
308    }
309
310    #[cfg(not(windows))]
311    #[test]
312    fn rfc3986_encoding() {
313        let url = Url::from_file_path("/tmp/[test]/a b.ts").unwrap();
314        // brackets and space are percent-encoded; '/' and unreserved are not
315        assert_eq!(url.as_str(), "file:///tmp/%5Btest%5D/a%20b.ts");
316    }
317
318    #[cfg(not(windows))]
319    #[test]
320    fn directory_path_has_trailing_slash() {
321        let url = Url::from_directory_path("/home/user").unwrap();
322        assert_eq!(url.as_str(), "file:///home/user/");
323    }
324
325    #[test]
326    fn from_file_path_rejects_relative() {
327        assert!(Url::from_file_path("relative/path").is_err());
328    }
329
330    #[test]
331    fn non_file_scheme_has_no_path() {
332        assert!(Url::parse("untitled:foo").unwrap().to_file_path().is_err());
333    }
334
335    #[test]
336    fn serde_is_opaque() {
337        let url = Url::parse("file:///a/b.rs").unwrap();
338        let json = serde_json::to_string(&url).unwrap();
339        assert_eq!(json, "\"file:///a/b.rs\"");
340        let back: Url = serde_json::from_str(&json).unwrap();
341        assert_eq!(url, back);
342    }
343}