use {
percent_encoding::AsciiSet,
serde::{
Deserialize,
Serialize,
de::Error,
},
std::{
borrow::Cow,
hash::Hash,
ops::{
Deref,
DerefMut,
},
path::{
Path,
PathBuf,
},
str::FromStr,
},
};
#[derive(Clone)]
pub struct Uri {
inner: fluent_uri::Uri<String>,
relative_start: Option<u16>,
}
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(|inner| {
Uri {
inner,
relative_start: None,
}
})
.map_err(|err| Error::custom(err.to_string()))
}
}
impl std::fmt::Display for Uri {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.as_str().fmt(f)
}
}
impl std::fmt::Debug for Uri {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.as_str().fmt(f)
}
}
impl From<fluent_uri::Uri<String>> for Uri {
fn from(uri: fluent_uri::Uri<String>) -> Self {
Self {
inner: uri,
relative_start: None,
}
}
}
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::error::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
fluent_uri::Uri::parse(s).map(|uri| {
Self {
inner: uri.to_owned(),
relative_start: None,
}
})
}
}
impl Deref for Uri {
type Target = fluent_uri::Uri<String>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl DerefMut for Uri {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
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();
if let Some(first_char) = chars.next() {
let drive_letter = first_char.to_ascii_uppercase();
let rest: String = chars.collect();
format!("{}{}", drive_letter, rest)
} else {
path.to_string()
}
} else {
path.to_string()
}
}
const ASCII_SET: AsciiSet =
percent_encoding::NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~')
.remove(b'/');
impl Uri {
pub fn parse(s: &str) -> Result<Self, fluent_uri::error::ParseError> {
Self::from_str(s)
}
pub fn relative_path(&self) -> Result<&str, &'static str> {
match self.relative_start {
| Some(start) => Ok(&self.as_str()[start as usize..]),
| None => Err("No root path has been set for this URI"),
}
}
pub fn set_root_path(&mut self, root: &str) -> bool {
let uri_str = self.as_str();
if let Some(offset) = uri_str.find(root) {
let mut relative_offset = offset + root.len();
if uri_str.as_bytes().get(relative_offset) == Some(&b'/') {
relative_offset += 1;
}
if relative_offset <= u16::MAX as usize {
self.relative_start = Some(relative_offset as u16);
return true;
}
}
false
}
pub fn join(&self, reference: &str) -> Option<Self> {
let reference_uri = fluent_uri::UriRef::parse(reference).ok()?;
let resolved = reference_uri.resolve_against(&self.inner).ok()?;
Some(Self {
inner: resolved,
relative_start: None,
})
}
pub fn join_result(&self, reference: &str) -> Result<Self, String> {
self
.join(reference)
.ok_or_else(|| format!("Failed to join URI reference: {}", reference))
}
#[must_use]
pub fn as_path(&self) -> &Path {
Path::new(self.path().as_str())
}
#[must_use]
pub fn path_str(&self) -> &str {
self.path().as_str()
}
pub fn path_segments(&self) -> Option<impl DoubleEndedIterator<Item = &str>> {
let path = self.path().as_str();
if path.is_empty() || !path.starts_with('/') {
return None;
}
Some(path.split('/').filter(|s| !s.is_empty()))
}
pub fn set_path(&mut self, path: &str) {
let new_uri_str = format!(
"{}://{}{}{}{}",
self.scheme().as_str(),
self.authority().map(|a| a.as_str()).unwrap_or(""),
path,
self
.query()
.map(|q| format!("?{}", q.as_str()))
.unwrap_or_default(),
self
.fragment()
.map(|f| format!("#{}", f.as_str()))
.unwrap_or_default()
);
if let Ok(new_uri) = Self::from_str(&new_uri_str) {
*self = new_uri;
}
}
#[must_use]
pub fn parent_folder(&self) -> Option<Self> {
let path = self.path().as_str();
let path_trimmed = path.trim_end_matches('/');
let last_slash = path_trimmed.rfind('/')?;
if last_slash == 0 {
return None;
}
let parent_path = &path_trimmed[..last_slash];
let scheme = self.scheme();
let authority = self.authority().map(|a| a.as_str());
let uri_str = match authority {
| Some(auth) => format!("{scheme}://{auth}{parent_path}"),
| None => format!("{scheme}:{parent_path}"),
};
Self::parse(&uri_str).ok()
}
pub fn make_relative(&self, other: &Uri) -> Option<String> {
if self.scheme() != other.scheme() {
return None;
}
let self_auth = self.authority().map(|a| a.as_str());
let other_auth = other.authority().map(|a| a.as_str());
if self_auth != other_auth {
return None;
}
let base_path = other.path().as_str();
let self_path = self.path().as_str();
if !self_path.starts_with(base_path) {
return None;
}
let relative = &self_path[base_path.len()..];
Some(relative.trim_start_matches('/').to_string())
}
#[must_use]
pub fn to_file_path(&self) -> Option<Cow<'_, Path>> {
let path_str = self.path().decode().into_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 file(&self) -> &str {
self.path().as_str().rsplit('/').next().unwrap_or("")
}
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::*,
fluent_uri::encoding::EStr,
std::{
path::{
Path,
PathBuf,
},
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());
}
#[test]
fn test_join() {
let base = Uri::from_str("file:///root/").unwrap();
let joined = base.join("subdir/file.txt").unwrap();
assert_eq!(joined.as_str(), "file:///root/subdir/file.txt");
let base2 = Uri::from_str("file:///root").unwrap();
let joined2 = base2.join("file.txt").unwrap();
assert_eq!(joined2.as_str(), "file:///file.txt");
let base3 = Uri::from_str("https://example.com/path/").unwrap();
let joined3 = base3.join("../other.html").unwrap();
assert_eq!(joined3.as_str(), "https://example.com/other.html");
}
#[test]
#[cfg(unix)]
fn test_parent_folder_unix() {
let uri = Uri::from_str("file:///home/user/project/file.txt").unwrap();
let parent = uri.parent_folder().unwrap();
assert_eq!(parent.as_str(), "file:///home/user/project");
let grandparent = parent.parent_folder().unwrap();
assert_eq!(grandparent.as_str(), "file:///home/user");
let uri_spaces =
Uri::from_str("file:///home/user/my%20project/file.txt").unwrap();
let parent_spaces = uri_spaces.parent_folder().unwrap();
assert_eq!(parent_spaces.as_str(), "file:///home/user/my%20project");
let uri_unicode =
Uri::from_str("file:///home/user/%E6%96%87%E4%BB%B6/test.txt").unwrap();
let parent_unicode = uri_unicode.parent_folder().unwrap();
assert_eq!(
parent_unicode.as_str(),
"file:///home/user/%E6%96%87%E4%BB%B6"
);
let uri_root = Uri::from_str("file:///file.txt").unwrap();
let parent_root = uri_root.parent_folder();
assert!(
parent_root.is_none() || parent_root.unwrap().as_str() == "file:///"
);
}
#[test]
#[cfg(windows)]
fn test_parent_folder_windows() {
let uri =
Uri::from_str("file:///C%3A/Users/user/project/file.txt").unwrap();
let parent = uri.parent_folder().unwrap();
assert_eq!(parent.as_str(), "file:///C%3A/Users/user/project");
let grandparent = parent.parent_folder().unwrap();
assert_eq!(grandparent.as_str(), "file:///C%3A/Users/user");
let uri_spaces =
Uri::from_str("file:///C%3A/Users/user/my%20project/file.txt").unwrap();
let parent_spaces = uri_spaces.parent_folder().unwrap();
assert_eq!(
parent_spaces.as_str(),
"file:///C%3A/Users/user/my%20project"
);
}
#[test]
fn test_parent_folder_sibling_comparison() {
let file1 = Uri::from_str("file:///project/src/main.rs").unwrap();
let file2 = Uri::from_str("file:///project/src/lib.rs").unwrap();
let parent1 = file1.parent_folder().unwrap();
let parent2 = file2.parent_folder().unwrap();
assert_eq!(parent1, parent2);
let file3 = Uri::from_str("file:///project/tests/test.rs").unwrap();
let parent3 = file3.parent_folder().unwrap();
assert_ne!(parent1, parent3);
}
#[test]
fn test_file_returns_filename() {
let uri =
Uri::from_str("file:///home/user/project/gold.bld").unwrap();
assert_eq!(uri.file(), "gold.bld");
}
#[test]
fn test_file_workspace_manifest() {
let uri =
Uri::from_str("file:///home/user/project/workspace.bld").unwrap();
assert_eq!(uri.file(), "workspace.bld");
}
#[test]
fn test_file_nested_path() {
let uri =
Uri::from_str("file:///a/b/c/d/file.txt").unwrap();
assert_eq!(uri.file(), "file.txt");
}
#[test]
fn test_file_root_file() {
let uri = Uri::from_str("file:///file.txt").unwrap();
assert_eq!(uri.file(), "file.txt");
}
#[test]
fn test_file_trailing_slash() {
let uri =
Uri::from_str("file:///home/user/project/").unwrap();
assert_eq!(uri.file(), "");
}
#[test]
fn test_file_no_path() {
let uri = Uri::from_str("file://").unwrap();
assert_eq!(uri.file(), "");
}
#[test]
fn test_file_percent_encoded() {
let uri =
Uri::from_str("file:///home/user/my%20file.txt").unwrap();
assert_eq!(uri.file(), "my%20file.txt");
}
}