From 98ad490a42d951412bad58a8d03079a626f2ad8c Mon Sep 17 00:00:00 2001 From: Nicholas Yang Date: Thu, 6 Apr 2023 13:54:38 -0400 Subject: [PATCH] port(turborepo): New Turbopath (vercel/turbo#4439) ### Description This is meant as a very very simple port of turbopath to Rust. It's basically the Go code but the Rust version. The goal here is to make something that works, and that we can extend with a little ChatGPT and copilot help. ### Testing Instructions There are doc tests on relevant functions and some regular tests too. --- crates/turbopath/Cargo.toml | 13 + .../turbopath/src/absolute_system_path_buf.rs | 225 ++++++++++++++++++ .../turbopath/src/anchored_system_path_buf.rs | 51 ++++ crates/turbopath/src/lib.rs | 59 +++++ .../turbopath/src/relative_system_path_buf.rs | 101 ++++++++ .../turbopath/src/relative_unix_path_buf.rs | 130 ++++++++++ 6 files changed, 579 insertions(+) create mode 100644 crates/turbopath/Cargo.toml create mode 100644 crates/turbopath/src/absolute_system_path_buf.rs create mode 100644 crates/turbopath/src/anchored_system_path_buf.rs create mode 100644 crates/turbopath/src/lib.rs create mode 100644 crates/turbopath/src/relative_system_path_buf.rs create mode 100644 crates/turbopath/src/relative_unix_path_buf.rs diff --git a/crates/turbopath/Cargo.toml b/crates/turbopath/Cargo.toml new file mode 100644 index 0000000000000..b6fb4d764961d --- /dev/null +++ b/crates/turbopath/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "turbopath" +version = "0.1.0" +license = "MPL-2.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +path-slash = "0.2.1" +# TODO: Make this a crate feature +serde = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/turbopath/src/absolute_system_path_buf.rs b/crates/turbopath/src/absolute_system_path_buf.rs new file mode 100644 index 0000000000000..5c0194446d09b --- /dev/null +++ b/crates/turbopath/src/absolute_system_path_buf.rs @@ -0,0 +1,225 @@ +use std::{ + borrow::Cow, + ffi::OsStr, + fmt, + path::{Components, Path, PathBuf}, +}; + +use serde::Serialize; + +use crate::{AnchoredSystemPathBuf, IntoSystem, PathValidationError, RelativeSystemPathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize)] +pub struct AbsoluteSystemPathBuf(PathBuf); + +impl AbsoluteSystemPathBuf { + /// Create a new AbsoluteSystemPathBuf from `unchecked_path`. + /// Confirms that `unchecked_path` is absolute and converts it to a system + /// path. + /// + /// # Arguments + /// + /// * `unchecked_path`: The path to be validated and converted to an + /// `AbsoluteSystemPathBuf`. + /// + /// returns: Result + /// + /// # Examples + /// + /// ``` + /// use std::path::{Path, PathBuf}; + /// use turbopath::AbsoluteSystemPathBuf; + /// #[cfg(windows)] + /// let path = PathBuf::from("C:/Users/user"); + /// #[cfg(not(windows))] + /// let path = PathBuf::from("/Users/user"); + /// + /// let absolute_path = AbsoluteSystemPathBuf::new(path).unwrap(); + /// + /// #[cfg(windows)] + /// assert_eq!(absolute_path.as_path(), Path::new("C:\\Users\\user")); + /// #[cfg(not(windows))] + /// assert_eq!(absolute_path.as_path(), Path::new("/Users/user")); + /// ``` + pub fn new(unchecked_path: impl Into) -> Result { + let unchecked_path = unchecked_path.into(); + if !unchecked_path.is_absolute() { + return Err(PathValidationError::NotAbsolute(unchecked_path)); + } + + let system_path = unchecked_path.into_system()?; + Ok(AbsoluteSystemPathBuf(system_path)) + } + + /// Anchors `path` at `self`. + /// + /// # Arguments + /// + /// * `path`: The path to be anchored at `self` + /// + /// returns: Result + /// + /// # Examples + /// + /// ``` + /// use std::path::Path; + /// use turbopath::{AbsoluteSystemPathBuf, AnchoredSystemPathBuf}; + /// #[cfg(not(windows))] + /// { + /// let base = AbsoluteSystemPathBuf::new("/Users/user").unwrap(); + /// let anchored_path = AbsoluteSystemPathBuf::new("/Users/user/Documents").unwrap(); + /// let anchored_path = base.anchor(&anchored_path).unwrap(); + /// assert_eq!(anchored_path.as_path(), Path::new("Documents")); + /// } + /// + /// #[cfg(windows)] + /// { + /// let base = AbsoluteSystemPathBuf::new("C:\\Users\\user").unwrap(); + /// let anchored_path = AbsoluteSystemPathBuf::new("C:\\Users\\user\\Documents").unwrap(); + /// let anchored_path = base.anchor(&anchored_path).unwrap(); + /// assert_eq!(anchored_path.as_path(), Path::new("Documents")); + /// } + /// ``` + pub fn anchor( + &self, + path: &AbsoluteSystemPathBuf, + ) -> Result { + AnchoredSystemPathBuf::new(self, path) + } + + /// Resolves `path` with `self` as anchor. + /// + /// # Arguments + /// + /// * `path`: The path to be anchored at `self` + /// + /// returns: AbsoluteSystemPathBuf + /// + /// # Examples + /// + /// ``` + /// use std::path::Path; + /// use turbopath::{AbsoluteSystemPathBuf, AnchoredSystemPathBuf}; + /// #[cfg(not(windows))] + /// let absolute_path = AbsoluteSystemPathBuf::new("/Users/user").unwrap(); + /// #[cfg(windows)] + /// let absolute_path = AbsoluteSystemPathBuf::new("C:\\Users\\user").unwrap(); + /// + /// let anchored_path = Path::new("Documents").try_into().unwrap(); + /// let resolved_path = absolute_path.resolve(&anchored_path); + /// + /// #[cfg(not(windows))] + /// assert_eq!(resolved_path.as_path(), Path::new("/Users/user/Documents")); + /// #[cfg(windows)] + /// assert_eq!(resolved_path.as_path(), Path::new("C:\\Users\\user\\Documents")); + /// ``` + pub fn resolve(&self, path: &AnchoredSystemPathBuf) -> AbsoluteSystemPathBuf { + AbsoluteSystemPathBuf(self.0.join(path.as_path())) + } + + pub fn as_path(&self) -> &Path { + self.0.as_path() + } + + pub fn components(&self) -> Components<'_> { + self.0.components() + } + + pub fn parent(&self) -> Option { + self.0 + .parent() + .map(|p| AbsoluteSystemPathBuf(p.to_path_buf())) + } + + pub fn starts_with>(&self, base: P) -> bool { + self.0.starts_with(base.as_ref()) + } + + pub fn ends_with>(&self, child: P) -> bool { + self.0.ends_with(child.as_ref()) + } + + pub fn join_relative(&self, path: RelativeSystemPathBuf) -> AbsoluteSystemPathBuf { + AbsoluteSystemPathBuf(self.0.join(path.as_path())) + } + + pub fn to_str(&self) -> Result<&str, PathValidationError> { + self.0 + .to_str() + .ok_or_else(|| PathValidationError::InvalidUnicode(self.0.clone())) + } + + pub fn to_string_lossy(&self) -> Cow<'_, str> { + self.0.to_string_lossy() + } + + pub fn file_name(&self) -> Option<&OsStr> { + self.0.file_name() + } + + pub fn exists(&self) -> bool { + self.0.exists() + } + + pub fn extension(&self) -> Option<&OsStr> { + self.0.extension() + } +} + +impl Into for AbsoluteSystemPathBuf { + fn into(self) -> PathBuf { + self.0 + } +} + +impl fmt::Display for AbsoluteSystemPathBuf { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.display().fmt(f) + } +} + +impl AsRef for AbsoluteSystemPathBuf { + fn as_ref(&self) -> &Path { + self.0.as_path() + } +} + +#[cfg(test)] +mod tests { + use std::assert_matches::assert_matches; + + use crate::{AbsoluteSystemPathBuf, PathValidationError}; + + #[cfg(not(windows))] + #[test] + fn test_absolute_system_path_buf_on_unix() { + assert!(AbsoluteSystemPathBuf::new("/Users/user").is_ok()); + assert_matches!( + AbsoluteSystemPathBuf::new("./Users/user/"), + Err(PathValidationError::NotAbsolute(_)) + ); + + assert_matches!( + AbsoluteSystemPathBuf::new("Users"), + Err(PathValidationError::NotAbsolute(_)) + ); + } + + #[cfg(windows)] + #[test] + fn test_absolute_system_path_buf_on_windows() { + assert!(AbsoluteSystemPathBuf::new("C:\\Users\\user").is_ok()); + assert_matches!( + AbsoluteSystemPathBuf::new(".\\Users\\user\\"), + Err(PathValidationError::NotAbsolute(_)) + ); + assert_matches!( + AbsoluteSystemPathBuf::new("Users"), + Err(PathValidationError::NotAbsolute(_)) + ); + assert_matches!( + AbsoluteSystemPathBuf::new("/Users/home"), + Err(PathValidationError::NotAbsolute(_)) + ) + } +} diff --git a/crates/turbopath/src/anchored_system_path_buf.rs b/crates/turbopath/src/anchored_system_path_buf.rs new file mode 100644 index 0000000000000..6b9e67aed986d --- /dev/null +++ b/crates/turbopath/src/anchored_system_path_buf.rs @@ -0,0 +1,51 @@ +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use crate::{AbsoluteSystemPathBuf, IntoSystem, PathValidationError}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize)] +pub struct AnchoredSystemPathBuf(PathBuf); + +impl TryFrom<&Path> for AnchoredSystemPathBuf { + type Error = PathValidationError; + + fn try_from(path: &Path) -> Result { + if path.is_absolute() { + return Err(PathValidationError::NotRelative(path.to_path_buf())); + } + + Ok(AnchoredSystemPathBuf(path.into_system()?)) + } +} + +impl AnchoredSystemPathBuf { + pub fn new( + root: &AbsoluteSystemPathBuf, + path: &AbsoluteSystemPathBuf, + ) -> Result { + let stripped_path = path + .as_path() + .strip_prefix(root.as_path()) + .map_err(|_| PathValidationError::NotParent(root.to_string(), path.to_string()))? + .to_path_buf(); + + Ok(AnchoredSystemPathBuf(stripped_path)) + } + + pub fn as_path(&self) -> &Path { + self.0.as_path() + } + + pub fn to_str(&self) -> Result<&str, PathValidationError> { + self.0 + .to_str() + .ok_or_else(|| PathValidationError::InvalidUnicode(self.0.clone())) + } +} + +impl Into for AnchoredSystemPathBuf { + fn into(self) -> PathBuf { + self.0 + } +} diff --git a/crates/turbopath/src/lib.rs b/crates/turbopath/src/lib.rs new file mode 100644 index 0000000000000..fa0b3c902ce3b --- /dev/null +++ b/crates/turbopath/src/lib.rs @@ -0,0 +1,59 @@ +#![feature(assert_matches)] + +mod absolute_system_path_buf; +mod anchored_system_path_buf; +mod relative_system_path_buf; +mod relative_unix_path_buf; + +use std::path::{Path, PathBuf}; + +pub use absolute_system_path_buf::AbsoluteSystemPathBuf; +pub use anchored_system_path_buf::AnchoredSystemPathBuf; +use path_slash::{PathBufExt, PathExt}; +pub use relative_system_path_buf::RelativeSystemPathBuf; +pub use relative_unix_path_buf::RelativeUnixPathBuf; +use thiserror::Error; + +// Custom error type for path validation errors +#[derive(Debug, Error)] +pub enum PathValidationError { + #[error("Path is non-UTF-8: {0}")] + InvalidUnicode(PathBuf), + #[error("Path is not absolute: {0}")] + NotAbsolute(PathBuf), + #[error("Path is not relative: {0}")] + NotRelative(PathBuf), + #[error("Path {0} is not parent of {1}")] + NotParent(String, String), +} + +trait IntoSystem { + fn into_system(self) -> Result; +} + +trait IntoUnix { + fn into_unix(self) -> Result; +} + +impl IntoSystem for &Path { + fn into_system(self) -> Result { + let path_str = self + .to_str() + .ok_or_else(|| PathValidationError::InvalidUnicode(self.to_owned()))?; + + Ok(PathBuf::from_slash(path_str)) + } +} + +impl IntoUnix for &Path { + /// NOTE: `into_unix` *only* converts Windows paths to Unix paths *on* a + /// Windows system. Do not pass a Windows path on a Unix system and + /// assume it'll be converted. + fn into_unix(self) -> Result { + Ok(PathBuf::from( + self.to_slash() + .ok_or_else(|| PathValidationError::InvalidUnicode(self.to_owned()))? + .as_ref(), + )) + } +} diff --git a/crates/turbopath/src/relative_system_path_buf.rs b/crates/turbopath/src/relative_system_path_buf.rs new file mode 100644 index 0000000000000..663c18692815c --- /dev/null +++ b/crates/turbopath/src/relative_system_path_buf.rs @@ -0,0 +1,101 @@ +use std::{ + ffi::OsStr, + fmt, + path::{Components, Path, PathBuf}, +}; + +use serde::Serialize; + +use crate::{IntoSystem, PathValidationError}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize)] +pub struct RelativeSystemPathBuf(PathBuf); + +impl RelativeSystemPathBuf { + /// Create a new RelativeSystemPathBuf from `unchecked_path`. + /// Validates that `unchecked_path` is relative and converts it to a system + /// path + /// + /// # Arguments + /// + /// * `unchecked_path`: Path to be converted to a RelativeSystemPathBuf + /// + /// returns: Result + /// + /// # Examples + /// + /// ``` + /// use std::path::{Path, PathBuf}; + /// use turbopath::RelativeSystemPathBuf; + /// let path = PathBuf::from("Users/user"); + /// let relative_path = RelativeSystemPathBuf::new(path).unwrap(); + /// #[cfg(windows)] + /// assert_eq!(relative_path.as_path(), Path::new("Users\\user")); + /// assert_eq!(relative_path.as_path(), Path::new("Users/user")); + /// ``` + pub fn new(unchecked_path: impl Into) -> Result { + let unchecked_path = unchecked_path.into(); + if unchecked_path.is_absolute() { + return Err(PathValidationError::NotRelative(unchecked_path)); + } + + let system_path = unchecked_path.into_system()?; + Ok(RelativeSystemPathBuf(system_path)) + } + + pub fn as_path(&self) -> &Path { + &self.0 + } + + pub fn components(&self) -> Components<'_> { + self.0.components() + } + + pub fn parent(&self) -> Option { + self.0 + .parent() + .map(|p| RelativeSystemPathBuf(p.to_path_buf())) + } + + pub fn starts_with>(&self, base: P) -> bool { + self.0.starts_with(base.as_ref()) + } + + pub fn ends_with>(&self, child: P) -> bool { + self.0.ends_with(child.as_ref()) + } + + pub fn join>(&self, path: P) -> RelativeSystemPathBuf { + RelativeSystemPathBuf(self.0.join(path)) + } + + pub fn to_str(&self) -> Result<&str, PathValidationError> { + self.0 + .to_str() + .ok_or_else(|| PathValidationError::InvalidUnicode(self.0.clone())) + } + + pub fn file_name(&self) -> Option<&OsStr> { + self.0.file_name() + } + + pub fn into_path_buf(self) -> PathBuf { + self.0 + } + + pub fn extension(&self) -> Option<&OsStr> { + self.0.extension() + } +} + +impl fmt::Display for RelativeSystemPathBuf { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.display().fmt(f) + } +} + +impl AsRef for RelativeSystemPathBuf { + fn as_ref(&self) -> &Path { + self.0.as_ref() + } +} diff --git a/crates/turbopath/src/relative_unix_path_buf.rs b/crates/turbopath/src/relative_unix_path_buf.rs new file mode 100644 index 0000000000000..c20505dddd4e7 --- /dev/null +++ b/crates/turbopath/src/relative_unix_path_buf.rs @@ -0,0 +1,130 @@ +use std::{ + ffi::OsStr, + path::{Components, Path, PathBuf}, +}; + +use serde::Serialize; + +use crate::{IntoUnix, PathValidationError}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize)] +pub struct RelativeUnixPathBuf(PathBuf); + +impl RelativeUnixPathBuf { + /// Create a new RelativeUnixPathBuf from a PathBuf by calling `into_unix()` + /// + /// NOTE: `into_unix` *only* converts Windows paths to Unix paths *on* a + /// Windows system. Do not pass a Windows path on a Unix system and + /// assume it'll be converted. + /// + /// # Arguments + /// + /// * `path`: + /// + /// returns: Result + /// + /// # Examples + /// + /// ``` + /// ``` + pub fn new(path: impl Into) -> Result { + let path = path.into(); + if path.is_absolute() { + return Err(PathValidationError::NotRelative(path)); + } + + Ok(RelativeUnixPathBuf(path.into_unix()?)) + } + + pub fn as_path(&self) -> &Path { + &self.0 + } + + pub fn components(&self) -> Components<'_> { + self.0.components() + } + + pub fn parent(&self) -> Option { + self.0 + .parent() + .map(|p| RelativeUnixPathBuf(p.to_path_buf())) + } + + pub fn starts_with>(&self, base: P) -> bool { + self.0.starts_with(base.as_ref()) + } + + pub fn ends_with>(&self, child: P) -> bool { + self.0.ends_with(child.as_ref()) + } + + pub fn join>(&self, path: P) -> RelativeUnixPathBuf { + RelativeUnixPathBuf(self.0.join(path)) + } + + pub fn to_str(&self) -> Result<&str, PathValidationError> { + self.0 + .to_str() + .ok_or_else(|| PathValidationError::InvalidUnicode(self.0.clone())) + } + + pub fn file_name(&self) -> Option<&OsStr> { + self.0.file_name() + } + + pub fn extension(&self) -> Option<&OsStr> { + self.0.extension() + } + + pub fn into_path_buf(self) -> PathBuf { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_relative_unix_path_buf() { + let path = RelativeUnixPathBuf::new(PathBuf::from("foo/bar")).unwrap(); + assert_eq!(path.as_path(), Path::new("foo/bar")); + assert_eq!(path.components().count(), 2); + assert_eq!(path.parent().unwrap().as_path(), Path::new("foo")); + assert!(path.starts_with("foo")); + assert!(path.ends_with("bar")); + assert_eq!(path.join("baz").as_path(), Path::new("foo/bar/baz")); + assert_eq!(path.to_str().unwrap(), "foo/bar"); + assert_eq!(path.file_name(), Some(OsStr::new("bar"))); + assert_eq!(path.extension(), None); + } + + #[test] + fn test_relative_unix_path_buf_with_extension() { + let path = RelativeUnixPathBuf::new(PathBuf::from("foo/bar.txt")).unwrap(); + assert_eq!(path.as_path(), Path::new("foo/bar.txt")); + assert_eq!(path.components().count(), 2); + assert_eq!(path.parent().unwrap().as_path(), Path::new("foo")); + assert!(path.starts_with("foo")); + assert!(path.ends_with("bar.txt")); + assert_eq!(path.join("baz").as_path(), Path::new("foo/bar.txt/baz")); + assert_eq!(path.to_str().unwrap(), "foo/bar.txt"); + assert_eq!(path.file_name(), Some(OsStr::new("bar.txt"))); + assert_eq!(path.extension(), Some(OsStr::new("txt"))); + } + + #[test] + fn test_relative_unix_path_buf_errors() { + #[cfg(not(windows))] + assert!(RelativeUnixPathBuf::new(PathBuf::from("/foo/bar")).is_err()); + #[cfg(windows)] + assert!(RelativeUnixPathBuf::new(PathBuf::from("C:\\foo\\bar")).is_err()); + } + + #[cfg(windows)] + #[test] + fn test_convert_from_windows_path() { + let path = RelativeUnixPathBuf::new(PathBuf::from("foo\\bar")).unwrap(); + assert_eq!(path.as_path(), Path::new("foo/bar")); + } +}