use std::borrow::Cow;
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf, Prefix};
use std::sync::LazyLock;
use either::Either;
use path_slash::PathExt;
#[expect(clippy::print_stderr)]
pub static CWD: LazyLock<PathBuf> = LazyLock::new(|| {
std::env::current_dir().unwrap_or_else(|_e| {
eprintln!("Current directory does not exist");
std::process::exit(1);
})
});
pub trait Simplified {
fn simplified(&self) -> &Path;
fn simplified_display(&self) -> impl std::fmt::Display;
fn simple_canonicalize(&self) -> std::io::Result<PathBuf>;
fn user_display(&self) -> impl std::fmt::Display;
fn user_display_from(&self, base: impl AsRef<Path>) -> impl std::fmt::Display;
fn portable_display(&self) -> impl std::fmt::Display;
}
impl<T: AsRef<Path>> Simplified for T {
fn simplified(&self) -> &Path {
dunce::simplified(self.as_ref())
}
fn simplified_display(&self) -> impl std::fmt::Display {
dunce::simplified(self.as_ref()).display()
}
fn simple_canonicalize(&self) -> std::io::Result<PathBuf> {
dunce::canonicalize(self.as_ref())
}
fn user_display(&self) -> impl std::fmt::Display {
let path = dunce::simplified(self.as_ref());
if CWD.ancestors().nth(1).is_none() {
return path.display();
}
let path = path.strip_prefix(CWD.simplified()).unwrap_or(path);
if path.as_os_str() == "" {
return Path::new(".").display();
}
path.display()
}
fn user_display_from(&self, base: impl AsRef<Path>) -> impl std::fmt::Display {
let path = dunce::simplified(self.as_ref());
if CWD.ancestors().nth(1).is_none() {
return path.display();
}
let path = path
.strip_prefix(base.as_ref())
.unwrap_or_else(|_| path.strip_prefix(CWD.simplified()).unwrap_or(path));
if path.as_os_str() == "" {
return Path::new(".").display();
}
path.display()
}
fn portable_display(&self) -> impl std::fmt::Display {
let path = dunce::simplified(self.as_ref());
let path = path.strip_prefix(CWD.simplified()).unwrap_or(path);
path.to_slash()
.map(Either::Left)
.unwrap_or_else(|| Either::Right(path.display()))
}
}
pub trait PythonExt {
fn escape_for_python(&self) -> String;
}
impl<T: AsRef<Path>> PythonExt for T {
fn escape_for_python(&self) -> String {
self.as_ref()
.to_string_lossy()
.replace('\\', "\\\\")
.replace('"', "\\\"")
}
}
pub fn normalize_url_path(path: &str) -> Cow<'_, str> {
let path = percent_encoding::percent_decode_str(path)
.decode_utf8()
.unwrap_or(Cow::Borrowed(path));
if cfg!(windows) {
Cow::Owned(
path.strip_prefix('/')
.unwrap_or(&path)
.replace('/', std::path::MAIN_SEPARATOR_STR),
)
} else {
path
}
}
pub fn normalize_absolute_path(path: &Path) -> Result<PathBuf, std::io::Error> {
let mut components = path.components().peekable();
let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().copied() {
components.next();
PathBuf::from(c.as_os_str())
} else {
PathBuf::new()
};
for component in components {
match component {
Component::Prefix(..) => unreachable!(),
Component::RootDir => {
ret.push(component.as_os_str());
}
Component::CurDir => {}
Component::ParentDir => {
if !ret.pop() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"cannot normalize a relative path beyond the base directory: {}",
path.display()
),
));
}
}
Component::Normal(c) => {
ret.push(c);
}
}
}
Ok(ret)
}
fn path_equals_components(path: &Path) -> bool {
let mut expected_len = 0;
let mut next_needs_separator = false;
for component in path.components() {
let bytes = component.as_os_str().as_encoded_bytes();
if next_needs_separator && !matches!(component, Component::RootDir) {
expected_len += Path::new("/").as_os_str().as_encoded_bytes().len();
}
expected_len += bytes.len();
next_needs_separator = match component {
Component::RootDir => false,
Component::Prefix(_) => false,
_ => true,
};
}
expected_len == path.as_os_str().as_encoded_bytes().len()
}
pub fn normalize_path<'path>(path: impl Into<Cow<'path, Path>>) -> Cow<'path, Path> {
let path = path.into();
if path
.components()
.any(|component| matches!(component, Component::ParentDir | Component::CurDir))
{
return Cow::Owned(normalized(&path));
}
if !path_equals_components(&path) {
return Cow::Owned(normalized(&path));
}
path
}
fn normalized(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
normalized.push(component);
}
Component::ParentDir => {
match normalized.components().next_back() {
None | Some(Component::ParentDir | Component::RootDir) => {
normalized.push(component);
}
Some(Component::Normal(_) | Component::Prefix(_) | Component::CurDir) => {
normalized.pop();
}
}
}
Component::CurDir => {
}
}
}
normalized
}
pub fn relative_to(
path: impl AsRef<Path>,
base: impl AsRef<Path>,
) -> Result<PathBuf, std::io::Error> {
let path = normalize_path(path.as_ref());
let base = normalize_path(base.as_ref());
let (stripped, common_prefix) = base
.ancestors()
.find_map(|ancestor| {
dunce::simplified(&path)
.strip_prefix(dunce::simplified(ancestor))
.ok()
.map(|stripped| (stripped, ancestor))
})
.ok_or_else(|| {
std::io::Error::other(format!(
"Trivial strip failed: {} vs. {}",
path.simplified_display(),
base.simplified_display()
))
})?;
let levels_up = base.components().count() - common_prefix.components().count();
let up = std::iter::repeat_n("..", levels_up).collect::<PathBuf>();
Ok(up.join(stripped))
}
pub fn try_relative_to_if(
path: impl AsRef<Path>,
base: impl AsRef<Path>,
should_relativize: bool,
) -> Result<PathBuf, std::io::Error> {
if should_relativize {
relative_to(&path, &base).or_else(|_| std::path::absolute(path.as_ref()))
} else {
std::path::absolute(path.as_ref())
}
}
pub fn verbatim_path(path: &Path) -> Cow<'_, Path> {
if !cfg!(windows) {
return Cow::Borrowed(path);
}
let resolved_path = if path.is_relative() {
Cow::Owned(CWD.join(path))
} else {
Cow::Borrowed(path)
};
if let Some(Component::Prefix(prefix)) = resolved_path.components().next() {
match prefix.kind() {
Prefix::UNC(..) | Prefix::Disk(_) => {},
Prefix::DeviceNS(_)
| Prefix::Verbatim(_)
| Prefix::VerbatimDisk(_)
| Prefix::VerbatimUNC(..) => return Cow::Borrowed(path)
}
}
let normalized_path = normalized(&resolved_path);
let mut components = normalized_path.components();
let Some(Component::Prefix(prefix)) = components.next() else {
return Cow::Borrowed(path);
};
match prefix.kind() {
Prefix::Disk(_) => {
let mut result = OsString::from(r"\\?\");
result.push(normalized_path.as_os_str()); Cow::Owned(PathBuf::from(result))
}
Prefix::UNC(server, share) => {
let mut result = OsString::from(r"\\?\UNC\");
result.push(server);
result.push(r"\");
result.push(share);
for component in components {
match component {
Component::RootDir => {} Component::Prefix(_) => {
debug_assert!(false, "prefix already consumed");
}
Component::CurDir | Component::ParentDir => {
debug_assert!(false, "path already normalized");
}
Component::Normal(_) => {
result.push(r"\");
result.push(component.as_os_str());
}
}
}
Cow::Owned(PathBuf::from(result))
}
Prefix::DeviceNS(_)
| Prefix::Verbatim(_)
| Prefix::VerbatimDisk(_)
| Prefix::VerbatimUNC(..) => {
debug_assert!(false, "skipped via fast path");
Cow::Borrowed(path)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortablePath<'a>(&'a Path);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortablePathBuf(Box<Path>);
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for PortablePathBuf {
fn schema_name() -> Cow<'static, str> {
Cow::Borrowed("PortablePathBuf")
}
fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
PathBuf::json_schema(_gen)
}
}
impl AsRef<Path> for PortablePath<'_> {
fn as_ref(&self) -> &Path {
self.0
}
}
impl<'a, T> From<&'a T> for PortablePath<'a>
where
T: AsRef<Path> + ?Sized,
{
fn from(path: &'a T) -> Self {
PortablePath(path.as_ref())
}
}
impl std::fmt::Display for PortablePath<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let path = self.0.to_slash_lossy();
if path.is_empty() {
write!(f, ".")
} else {
write!(f, "{path}")
}
}
}
impl std::fmt::Display for PortablePathBuf {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let path = self.0.to_slash_lossy();
if path.is_empty() {
write!(f, ".")
} else {
write!(f, "{path}")
}
}
}
impl From<&str> for PortablePathBuf {
fn from(path: &str) -> Self {
if path == "." {
Self(PathBuf::new().into_boxed_path())
} else {
Self(PathBuf::from(path).into_boxed_path())
}
}
}
impl From<PortablePathBuf> for Box<Path> {
fn from(portable: PortablePathBuf) -> Self {
portable.0
}
}
impl From<Box<Path>> for PortablePathBuf {
fn from(path: Box<Path>) -> Self {
Self(path)
}
}
impl<'a> From<&'a Path> for PortablePathBuf {
fn from(path: &'a Path) -> Self {
Box::<Path>::from(path).into()
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for PortablePathBuf {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
self.to_string().serialize(serializer)
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for PortablePath<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
self.to_string().serialize(serializer)
}
}
#[cfg(feature = "serde")]
impl<'de> serde::de::Deserialize<'de> for PortablePathBuf {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
let s = <Cow<'_, str>>::deserialize(deserializer)?;
if s == "." {
Ok(Self(PathBuf::new().into_boxed_path()))
} else {
Ok(Self(PathBuf::from(s.as_ref()).into_boxed_path()))
}
}
}
impl AsRef<Path> for PortablePathBuf {
fn as_ref(&self) -> &Path {
&self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_url() {
if cfg!(windows) {
assert_eq!(
normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
"C:\\Users\\ferris\\wheel-0.42.0.tar.gz"
);
} else {
assert_eq!(
normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
"/C:/Users/ferris/wheel-0.42.0.tar.gz"
);
}
if cfg!(windows) {
assert_eq!(
normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
".\\ferris\\wheel-0.42.0.tar.gz"
);
} else {
assert_eq!(
normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
"./ferris/wheel-0.42.0.tar.gz"
);
}
if cfg!(windows) {
assert_eq!(
normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
".\\wheel cache\\wheel-0.42.0.tar.gz"
);
} else {
assert_eq!(
normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
"./wheel cache/wheel-0.42.0.tar.gz"
);
}
}
#[test]
fn test_normalize_path() {
let path = Path::new("/a/b/../c/./d");
let normalized = normalize_absolute_path(path).unwrap();
assert_eq!(normalized, Path::new("/a/c/d"));
let path = Path::new("/a/../c/./d");
let normalized = normalize_absolute_path(path).unwrap();
assert_eq!(normalized, Path::new("/c/d"));
let path = Path::new("/a/../../c/./d");
let err = normalize_absolute_path(path).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}
#[test]
fn test_relative_to() {
assert_eq!(
relative_to(
Path::new("/home/ferris/carcinization/lib/python/site-packages/foo/__init__.py"),
Path::new("/home/ferris/carcinization/lib/python/site-packages"),
)
.unwrap(),
Path::new("foo/__init__.py")
);
assert_eq!(
relative_to(
Path::new("/home/ferris/carcinization/lib/marker.txt"),
Path::new("/home/ferris/carcinization/lib/python/site-packages"),
)
.unwrap(),
Path::new("../../marker.txt")
);
assert_eq!(
relative_to(
Path::new("/home/ferris/carcinization/bin/foo_launcher"),
Path::new("/home/ferris/carcinization/lib/python/site-packages"),
)
.unwrap(),
Path::new("../../../bin/foo_launcher")
);
}
#[test]
fn test_normalize_relative() {
let cases = [
(
"../../workspace-git-path-dep-test/packages/c/../../packages/d",
"../../workspace-git-path-dep-test/packages/d",
),
(
"workspace-git-path-dep-test/packages/c/../../packages/d",
"workspace-git-path-dep-test/packages/d",
),
("./a/../../b", "../b"),
("/usr/../../foo", "/../foo"),
("foo/./bar", "foo/bar"),
("/a/./b/./c", "/a/b/c"),
("./foo/bar", "foo/bar"),
(".", ""),
("./.", ""),
("foo/.", "foo"),
("foo//bar", "foo/bar"),
("/a///b//c", "/a/b/c"),
("foo/./../bar", "bar"),
("foo/bar/./../baz", "foo/baz"),
("foo/bar", "foo/bar"),
("/a/b/c", "/a/b/c"),
("", ""),
];
for (input, expected) in cases {
assert_eq!(
normalize_path(Path::new(input)),
Path::new(expected),
"input: {input:?}"
);
}
for already_normalized in ["foo/bar", "/a/b/c", "foo", "/", ""] {
let path = Path::new(already_normalized);
assert!(
matches!(normalize_path(path), Cow::Borrowed(_)),
"expected borrowed for {already_normalized:?}"
);
}
}
#[test]
fn test_normalize_trailing_path_separator() {
let cases = [
(
"/home/ferris/projects/python/",
"/home/ferris/projects/python",
),
("python/", "python"),
("/", "/"),
("foo/bar/", "foo/bar"),
("foo//", "foo"),
];
for (input, expected) in cases {
assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
}
}
#[test]
#[cfg(windows)]
fn test_normalize_windows() {
let cases = [
(
r"C:\Users\Ferris\projects\python\",
r"C:\Users\Ferris\projects\python",
),
(r"C:\foo\.\bar", r"C:\foo\bar"),
(r"C:\foo\\bar", r"C:\foo\bar"),
(r"C:\foo\bar\..\baz", r"C:\foo\baz"),
(r"foo\.\bar", r"foo\bar"),
(r"C:foo", r"C:foo"),
(r"C:\foo", r"C:\foo"),
(r"C:\\foo", r"C:\foo"),
(r"\\?\C:foo", r"\\?\C:foo"),
(r"\\?\C:\foo", r"\\?\C:\foo"),
(r"\\?\C:\\foo", r"\\?\C:\foo"),
(r"\\server\share\foo", r"\\server\share\foo"),
];
for (input, expected) in cases {
assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
}
}
#[cfg(windows)]
#[test]
fn test_verbatim_path() {
let relative_path = format!(r"\\?\{}\path\to\logging.", CWD.simplified_display());
let relative_root = format!(
r"\\?\{}\path\to\logging.",
CWD.components()
.next()
.expect("expected a drive letter prefix")
.simplified_display()
);
let cases = [
(r"C:\path\to\logging.", r"\\?\C:\path\to\logging."),
(r"C:\path\to\.\logging.", r"\\?\C:\path\to\logging."),
(r"C:\path\to\..\to\logging.", r"\\?\C:\path\to\logging."),
(r"C:/path/to/../to/./logging.", r"\\?\C:\path\to\logging."),
(r"C:path\to\..\to\logging.", r"\\?\C:path\to\logging."), (r".\path\to\.\logging.", relative_path.as_str()),
(r"path\to\..\to\logging.", relative_path.as_str()),
(r"./path/to/logging.", relative_path.as_str()),
(r"\path\to\logging.", relative_root.as_str()),
(
r"\\127.0.0.1\c$\path\to\logging.",
r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
),
(
r"\\127.0.0.1\c$\path\to\.\logging.",
r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
),
(
r"\\127.0.0.1\c$\path\to\..\to\logging.",
r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
),
(
r"//127.0.0.1/c$/path/to/../to/./logging.",
r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
),
(r"\\?\C:\path\to\logging.", r"\\?\C:\path\to\logging."),
(
r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
),
(r"\\.\PhysicalDrive0", r"\\.\PhysicalDrive0"),
(r"\\.\NUL", r"\\.\NUL"),
];
for (input, expected) in cases {
assert_eq!(verbatim_path(Path::new(input)), Path::new(expected));
}
}
}