use std::{
borrow::Cow,
hash::Hash,
ops::{Deref, DerefMut},
path::{Path, PathBuf},
str::FromStr,
};
use percent_encoding::AsciiSet;
use serde::{Deserialize, Serialize, de::Error};
#[derive(Debug, Clone)]
pub struct Uri(fluent_uri::Uri<String>);
impl Serialize for Uri {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.as_str().serialize(serializer)
}
}
impl<'de> Deserialize<'de> for Uri {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let string = String::deserialize(deserializer)?;
fluent_uri::Uri::<String>::parse(string)
.map(Uri)
.map_err(|(err, _s)| Error::custom(err.to_string()))
}
}
impl From<fluent_uri::Uri<String>> for Uri {
fn from(uri: fluent_uri::Uri<String>) -> Self {
Self(uri)
}
}
impl Ord for Uri {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.as_str().cmp(other.as_str())
}
}
impl PartialOrd for Uri {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl FromStr for Uri {
type Err = fluent_uri::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
fluent_uri::Uri::parse(s).map(|uri| Self(uri.to_owned()))
}
}
impl Deref for Uri {
type Target = fluent_uri::Uri<String>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for Uri {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl PartialEq for Uri {
fn eq(&self, other: &Self) -> bool {
self.as_str() == other.as_str()
}
}
impl Eq for Uri {}
impl Hash for Uri {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.as_str().hash(state);
}
}
#[cfg(not(windows))]
pub use std::fs::canonicalize as strict_canonicalize;
#[inline]
#[cfg(windows)]
fn strict_canonicalize<P: AsRef<Path>>(path: P) -> std::io::Result<PathBuf> {
use std::io;
fn impl_(path: PathBuf) -> std::io::Result<PathBuf> {
let head = path
.components()
.next()
.ok_or(io::Error::other("empty path"))?;
let disk_;
let head = if let std::path::Component::Prefix(prefix) = head {
if let std::path::Prefix::VerbatimDisk(disk) = prefix.kind() {
disk_ = format!("{}:", disk as char);
Path::new(&disk_)
.components()
.next()
.ok_or(io::Error::other("failed to parse disk component"))?
} else {
head
}
} else {
head
};
Ok(std::iter::once(head)
.chain(path.components().skip(1))
.collect())
}
let canon = std::fs::canonicalize(path)?;
impl_(canon)
}
#[cfg(windows)]
fn capitalize_drive_letter(path: &str) -> String {
if path.len() >= 2 && path.chars().nth(1) == Some(':') {
let mut chars = path.chars();
let drive_letter = chars.next().unwrap().to_ascii_uppercase();
let rest: String = chars.collect();
format!("{}{}", drive_letter, rest)
} else {
path.to_string()
}
}
const ASCII_SET: AsciiSet =
percent_encoding::NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~')
.remove(b'/');
impl Uri {
#[must_use]
pub fn to_file_path(&self) -> Option<Cow<'_, Path>> {
let path_str = self.path().decode().to_string_lossy();
if path_str.is_empty() {
return None;
}
let path = match path_str {
Cow::Borrowed(ref_) => Cow::Borrowed(Path::new(ref_)),
Cow::Owned(owned) => Cow::Owned(PathBuf::from(owned)),
};
if cfg!(windows) {
let auth_host = self.authority().map(|auth| auth.host()).unwrap_or_default();
if auth_host.is_empty() {
let host = path.to_string_lossy();
let host = host.get(1..)?;
return Some(Cow::Owned(PathBuf::from(host)));
}
Some(Cow::Owned(
Path::new(&format!("{auth_host}:"))
.components()
.chain(path.components())
.collect(),
))
} else {
Some(path)
}
}
pub fn from_file_path<A: AsRef<Path>>(path: A) -> Option<Self> {
let path = path.as_ref();
let fragment = if path.is_absolute() {
Cow::Borrowed(path)
} else {
match strict_canonicalize(path) {
Ok(path) => Cow::Owned(path),
Err(_) => return None,
}
};
#[cfg(windows)]
let raw_uri = {
format!(
"file:///{}",
percent_encoding::utf8_percent_encode(
&capitalize_drive_letter(&fragment.to_string_lossy().replace('\\', "/")),
&ASCII_SET
)
)
};
#[cfg(not(windows))]
let raw_uri = {
format!(
"file://{}",
percent_encoding::utf8_percent_encode(&fragment.to_string_lossy(), &ASCII_SET)
)
};
Self::from_str(&raw_uri).ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use fluent_uri::pct_enc::EStr;
use std::path::{Path, PathBuf};
use std::str::FromStr;
#[test]
fn deref_mut_fragment_add() {
let mut uri = Uri::from_str("https://www.example.com").unwrap();
uri.set_fragment(Some(EStr::new_or_panic("L11")));
assert_eq!(uri.as_str(), "https://www.example.com#L11");
}
fn with_schema(path: &str) -> String {
const EXPECTED_SCHEMA: &str = if cfg!(windows) { "file:///" } else { "file://" };
format!("{EXPECTED_SCHEMA}{path}")
}
#[test]
#[cfg(windows)]
fn test_idempotent_canonicalization() {
let lhs = strict_canonicalize(Path::new(".")).unwrap();
let rhs = strict_canonicalize(&lhs).unwrap();
assert_eq!(lhs, rhs);
}
#[test]
#[cfg(unix)]
fn test_path_roundtrip_conversion() {
let sources = [
strict_canonicalize(Path::new(".")).unwrap(),
PathBuf::from("/some/path/to/file.txt"),
PathBuf::from("/some/path/to/file with spaces.txt"),
PathBuf::from("/some/path/[[...rest]]/file.txt"),
PathBuf::from("/some/path/to/файл.txt"),
PathBuf::from("/some/path/to/文件.txt"),
];
for source in sources {
let conv = Uri::from_file_path(&source).unwrap();
let roundtrip = conv.to_file_path().unwrap();
assert_eq!(source, roundtrip, "conv={conv:?}");
}
}
#[test]
#[cfg(windows)]
fn test_path_roundtrip_conversion() {
let sources = [
strict_canonicalize(Path::new(".")).unwrap(),
PathBuf::from("C:\\some\\path\\to\\file.txt"),
PathBuf::from("C:\\some\\path\\to\\file with spaces.txt"),
PathBuf::from("C:\\some\\path\\[[...rest]]\\file.txt"),
PathBuf::from("C:\\some\\path\\to\\файл.txt"),
PathBuf::from("C:\\some\\path\\to\\文件.txt"),
];
for source in sources {
let conv = Uri::from_file_path(&source).unwrap();
let roundtrip = conv.to_file_path().unwrap();
assert_eq!(source, roundtrip, "conv={conv:?}");
}
}
#[test]
#[cfg(windows)]
fn test_windows_uri_roundtrip_conversion() {
use std::str::FromStr;
let uris = [
Uri::from_str("file:///C:/some/path/to/file.txt").unwrap(),
Uri::from_str("file:///c:/some/path/to/file.txt").unwrap(),
Uri::from_str("file:///c%3A/some/path/to/file.txt").unwrap(),
];
let final_uri = Uri::from_str("file:///C%3A/some/path/to/file.txt").unwrap();
for uri in uris {
let path = uri.to_file_path().unwrap();
assert_eq!(
&path,
Path::new("C:\\some\\path\\to\\file.txt"),
"uri={uri:?}"
);
let conv = Uri::from_file_path(&path).unwrap();
assert_eq!(
final_uri,
conv,
"path={path:?} left={} right={}",
final_uri.as_str(),
conv.as_str()
);
}
}
#[test]
#[cfg(unix)]
fn test_path_to_uri() {
let paths = [
PathBuf::from("/some/path/to/file.txt"),
PathBuf::from("/some/path/to/file with spaces.txt"),
PathBuf::from("/some/path/[[...rest]]/file.txt"),
PathBuf::from("/some/path/to/файл.txt"),
PathBuf::from("/some/path/to/文件.txt"),
];
let expected = [
with_schema("/some/path/to/file.txt"),
with_schema("/some/path/to/file%20with%20spaces.txt"),
with_schema("/some/path/%5B%5B...rest%5D%5D/file.txt"),
with_schema("/some/path/to/%D1%84%D0%B0%D0%B9%D0%BB.txt"),
with_schema("/some/path/to/%E6%96%87%E4%BB%B6.txt"),
];
for (path, expected) in paths.iter().zip(expected) {
let uri = Uri::from_file_path(path).unwrap();
assert_eq!(uri.to_string(), expected);
}
}
#[test]
#[cfg(windows)]
fn test_path_to_uri_windows() {
let paths = [
PathBuf::from("C:\\some\\path\\to\\file.txt"),
PathBuf::from("C:\\some\\path\\to\\file with spaces.txt"),
PathBuf::from("C:\\some\\path\\[[...rest]]\\file.txt"),
PathBuf::from("C:\\some\\path\\to\\файл.txt"),
PathBuf::from("C:\\some\\path\\to\\文件.txt"),
];
let expected = [
with_schema("C%3A/some/path/to/file.txt"),
with_schema("C%3A/some/path/to/file%20with%20spaces.txt"),
with_schema("C%3A/some/path/%5B%5B...rest%5D%5D/file.txt"),
with_schema("C%3A/some/path/to/%D1%84%D0%B0%D0%B9%D0%BB.txt"),
with_schema("C%3A/some/path/to/%E6%96%87%E4%BB%B6.txt"),
];
for (path, expected) in paths.iter().zip(expected) {
let uri = Uri::from_file_path(path).unwrap();
assert_eq!(uri.to_string(), expected);
}
}
#[test]
fn test_invalid_uri_on_windows() {
let uri = Uri::from_str("file://").unwrap();
let path = uri.to_file_path();
assert!(path.is_none());
}
}