//! A minimal RFC3986 URI type backed by a string. //! //! We only care to convert between `file://` URIs and `PathBuf`, so we don't need a fully //! featured URL/URI crate. Also, LSP uses RFC3986 rather than WHATWG, and the two have different //! percent encoding rules. Encoding follows RFC3986 (percent-encode everything outside `pchar`), //! not the WHATWG URL rules the `url` crate implements. Some language servers are strict about //! this (e.g. Deno): they reject unescaped `[`/`]` in paths (valid in WHATWG but not RFC3986). use std::fmt; use std::path::{Path, PathBuf}; use std::str::FromStr; use percent_encoding::{percent_decode, percent_encode, AsciiSet, NON_ALPHANUMERIC}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// The set of bytes percent-encoded in a path. RFC3986 allows `pchar` /// (`unreserved` / `sub-delims` / `:` / `@`) plus `/` as the separator /// unescaped; everything else (and every non-ASCII byte) is encoded. const PATH: &AsciiSet = &NON_ALPHANUMERIC // unreserved: ALPHA / DIGIT (already kept) / "-" / "." / "_" / "~" .remove(b'-') .remove(b'.') .remove(b'_') .remove(b'~') // sub-delims .remove(b'!') .remove(b'$') .remove(b'&') .remove(b'\'') .remove(b'(') .remove(b')') .remove(b'*') .remove(b'+') .remove(b',') .remove(b';') .remove(b'=') // pchar extras and the path separator .remove(b':') .remove(b'@') .remove(b'/'); /// An RFC3986 URI. /// /// The URI is stored verbatim and only interpreted when a `file://` path is actually needed. #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Url(String); /// Error returned when a string is not a valid absolute URI. #[derive(Debug, PartialEq, Eq)] pub struct ParseError; impl fmt::Display for ParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("invalid URI: relative URL without a base") } } impl std::error::Error for ParseError {} impl Url { /// Parse an absolute URI. Mirrors `url::Url::parse`'s rejection of relative /// references: the input must begin with a valid scheme (`ALPHA *( ALPHA / /// DIGIT / "+" / "-" / "." ) ":"`). pub fn parse(input: &str) -> Result { let colon = input.find(':').ok_or(ParseError)?; let scheme = &input[..colon]; let mut bytes = scheme.bytes(); let valid = bytes.next().is_some_and(|b| b.is_ascii_alphabetic()) && bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')); if valid { Ok(Url(input.to_string())) } else { Err(ParseError) } } /// The full URI as a string slice. pub fn as_str(&self) -> &str { &self.0 } /// The scheme (the part before the first `:`), e.g. `file`. pub fn scheme(&self) -> &str { match self.0.find(':') { Some(i) => &self.0[..i], None => "", } } /// The (still percent-encoded) path component, matching `url::Url::path`. pub fn path(&self) -> &str { let after_scheme = match self.0.find(':') { Some(i) => &self.0[i + 1..], None => self.0.as_str(), }; // Skip an `//authority` component if present. let after_authority = match after_scheme.strip_prefix("//") { Some(rest) => match rest.find('/') { Some(i) => &rest[i..], None => "", }, None => after_scheme, }; let end = after_authority .find(['?', '#']) .unwrap_or(after_authority.len()); &after_authority[..end] } /// Build a `file://` URI from an absolute filesystem path. #[allow(clippy::result_unit_err)] pub fn from_file_path>(path: P) -> Result { let path = path.as_ref(); if !path.is_absolute() { return Err(()); } let mut serialization = String::from("file://"); serialize_path(&mut serialization, path)?; Ok(Url(serialization)) } /// Like [`Url::from_file_path`], but ensures a trailing slash so the URI /// denotes a directory. #[allow(clippy::result_unit_err)] pub fn from_directory_path>(path: P) -> Result { let mut url = Url::from_file_path(path)?; if !url.0.ends_with('/') { url.0.push('/'); } Ok(url) } /// Convert a `file://` URI back to a filesystem path. #[allow(clippy::result_unit_err)] pub fn to_file_path(&self) -> Result { if self.scheme() != "file" { return Err(()); } let rest = self.0["file:".len()..].strip_prefix("//").ok_or(())?; let (authority, path) = match rest.find('/') { Some(i) => (&rest[..i], &rest[i..]), None => (rest, ""), }; let local_host = authority.is_empty() || authority.eq_ignore_ascii_case("localhost"); #[cfg(not(windows))] { use std::ffi::OsStr; use std::os::unix::ffi::OsStrExt; if !local_host { return Err(()); } let bytes = percent_decode(path.as_bytes()).collect::>(); if bytes.is_empty() { return Err(()); } Ok(PathBuf::from(OsStr::from_bytes(&bytes))) } #[cfg(windows)] { let decoded = percent_decode(path.as_bytes()) .decode_utf8() .map_err(|_| ())?; if local_host { // `/C:/dir/file` -> `C:\dir\file` let path = decoded.strip_prefix('/').unwrap_or(&decoded); if path.is_empty() { return Err(()); } Ok(PathBuf::from(path.replace('/', "\\"))) } else { // UNC: `file://server/share/...` -> `\\server\share\...` Ok(PathBuf::from(format!( "\\\\{}{}", authority, decoded.replace('/', "\\") ))) } } } } #[cfg(not(windows))] fn serialize_path(out: &mut String, path: &Path) -> Result<(), ()> { use std::os::unix::ffi::OsStrExt; // The path is absolute, so it begins with `/`; percent-encode it while // preserving the `/` separators (they are excluded from `PATH`). out.extend(percent_encode(path.as_os_str().as_bytes(), PATH)); Ok(()) } #[cfg(windows)] fn serialize_path(out: &mut String, path: &Path) -> Result<(), ()> { use std::path::{Component, Prefix}; let mut components = path.components(); match components.next() { Some(Component::Prefix(prefix)) => match prefix.kind() { Prefix::Disk(_) | Prefix::VerbatimDisk(_) => { // `C:` -> `/C:` out.push('/'); out.push_str(&prefix.as_os_str().to_string_lossy()); } Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => { // `\\server\share` -> `//server/share` (authority + first seg) out.pop(); // drop one `/` from the `file://` we were appended to out.push_str(&server.to_string_lossy()); out.push('/'); out.extend(percent_encode(share.to_string_lossy().as_bytes(), PATH)); } _ => return Err(()), }, _ => return Err(()), } for component in components { match component { Component::RootDir => {} Component::Normal(seg) => { out.push('/'); out.extend(percent_encode(seg.to_string_lossy().as_bytes(), PATH)); } Component::CurDir => out.push_str("/."), Component::ParentDir => out.push_str("/.."), Component::Prefix(_) => return Err(()), } } Ok(()) } impl FromStr for Url { type Err = ParseError; fn from_str(s: &str) -> Result { Url::parse(s) } } impl fmt::Display for Url { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } impl fmt::Debug for Url { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } impl AsRef for Url { fn as_ref(&self) -> &str { &self.0 } } impl Serialize for Url { fn serialize(&self, serializer: S) -> Result { serializer.serialize_str(&self.0) } } impl<'de> Deserialize<'de> for Url { fn deserialize>(deserializer: D) -> Result { // Store the URI as-is. Path conversion is lazy. Ok(Url(String::deserialize(deserializer)?)) } } #[cfg(test)] mod tests { use super::*; #[test] fn scheme_and_path() { let url = Url::parse("file:///home/user/main.rs").unwrap(); assert_eq!(url.scheme(), "file"); assert_eq!(url.path(), "/home/user/main.rs"); let url = Url::parse("csharp:/metadata/foo/Baz.cs").unwrap(); assert_eq!(url.scheme(), "csharp"); assert_eq!(url.path(), "/metadata/foo/Baz.cs"); // authority present let url = Url::parse("file://host/path?q#frag").unwrap(); assert_eq!(url.path(), "/path"); } #[test] fn parse_rejects_relative() { assert!(Url::parse("src/main.rs").is_err()); assert!(Url::parse("just text").is_err()); assert!(Url::parse("https://example.com").is_ok()); } #[cfg(not(windows))] #[test] fn file_path_round_trip() { for path in [ "/home/user/main.rs", "/tmp/a b.txt", // space "/tmp/[test]/x.ts", // brackets (Deno RFC3986 fix) "/tmp/c#/Program.cs", // '#' "/home/üser/café.txt", // non-ASCII "/weird/100%/qu?ery&x=1", // '%', '?', '&' ] { let url = Url::from_file_path(path).unwrap(); assert_eq!(url.to_file_path().unwrap(), PathBuf::from(path), "{}", url); } } #[cfg(not(windows))] #[test] fn rfc3986_encoding() { let url = Url::from_file_path("/tmp/[test]/a b.ts").unwrap(); // brackets and space are percent-encoded; '/' and unreserved are not assert_eq!(url.as_str(), "file:///tmp/%5Btest%5D/a%20b.ts"); } #[cfg(not(windows))] #[test] fn directory_path_has_trailing_slash() { let url = Url::from_directory_path("/home/user").unwrap(); assert_eq!(url.as_str(), "file:///home/user/"); } #[test] fn from_file_path_rejects_relative() { assert!(Url::from_file_path("relative/path").is_err()); } #[test] fn non_file_scheme_has_no_path() { assert!(Url::parse("untitled:foo").unwrap().to_file_path().is_err()); } #[test] fn serde_is_opaque() { let url = Url::parse("file:///a/b.rs").unwrap(); let json = serde_json::to_string(&url).unwrap(); assert_eq!(json, "\"file:///a/b.rs\""); let back: Url = serde_json::from_str(&json).unwrap(); assert_eq!(url, back); } }