1use 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
16const PATH: &AsciiSet = &NON_ALPHANUMERIC
20 .remove(b'-')
22 .remove(b'.')
23 .remove(b'_')
24 .remove(b'~')
25 .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 .remove(b':')
39 .remove(b'@')
40 .remove(b'/');
41
42#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub struct Url(String);
47
48#[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 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 pub fn as_str(&self) -> &str {
79 &self.0
80 }
81
82 pub fn scheme(&self) -> &str {
84 match self.0.find(':') {
85 Some(i) => &self.0[..i],
86 None => "",
87 }
88 }
89
90 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 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 #[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 #[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 #[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 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 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 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 out.push('/');
201 out.push_str(&prefix.as_os_str().to_string_lossy());
202 }
203 Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
204 out.pop(); 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 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 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", "/tmp/[test]/x.ts", "/tmp/c#/Program.cs", "/home/üser/café.txt", "/weird/100%/qu?ery&x=1", ] {
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 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}