pure-editor-stdx 0.1.0

Standard library extensions
Documentation
//! 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<Url, ParseError> {
        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<P: AsRef<Path>>(path: P) -> Result<Url, ()> {
        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<P: AsRef<Path>>(path: P) -> Result<Url, ()> {
        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<PathBuf, ()> {
        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::<Vec<u8>>();
            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<Self, Self::Err> {
        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<str> for Url {
    fn as_ref(&self) -> &str {
        &self.0
    }
}

impl Serialize for Url {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        serializer.serialize_str(&self.0)
    }
}

impl<'de> Deserialize<'de> for Url {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        // 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);
    }
}