#![no_std]
#![deny(missing_docs)]
#[cfg(any(feature = "alloc", test))]
extern crate alloc;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Separators {
#[default]
Slash,
SlashOrBackslash,
}
impl Separators {
const fn matches(self, byte: u8) -> bool {
byte == b'/' || matches!(self, Self::SlashOrBackslash) && byte == b'\\'
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Component<'a> {
Root,
Current,
Parent,
Normal(&'a str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VPath<'a> {
raw: &'a str,
separators: Separators,
}
impl<'a> VPath<'a> {
pub const fn new(path: &'a str) -> Self {
Self::with_separators(path, Separators::Slash)
}
pub const fn with_separators(path: &'a str, separators: Separators) -> Self {
Self {
raw: path,
separators,
}
}
pub const fn as_str(self) -> &'a str {
self.raw
}
pub const fn separators(self) -> Separators {
self.separators
}
pub fn components(self) -> Components<'a> {
Components {
path: self.raw,
separators: self.separators,
offset: 0,
root_pending: self
.raw
.as_bytes()
.first()
.is_some_and(|byte| self.separators.matches(*byte)),
}
}
pub fn is_absolute(self) -> bool {
matches!(self.components().next(), Some(Component::Root))
}
pub fn file_name(self) -> Option<&'a str> {
let mut result = None;
for component in self.components() {
match component {
Component::Normal(name) => result = Some(name),
Component::Current | Component::Root => {}
Component::Parent => result = None,
}
}
result
}
pub fn split_file(self) -> Option<(Self, &'a str)> {
let bytes = self.raw.as_bytes();
let mut end = bytes.len();
while end > 0 && self.separators.matches(bytes[end - 1]) {
end -= 1;
}
if end == 0 {
return None;
}
let mut start = end;
while start > 0 && !self.separators.matches(bytes[start - 1]) {
start -= 1;
}
let name = &self.raw[start..end];
if matches!(name, "." | "..") {
return None;
}
let mut parent_end = start;
while parent_end > 0 && self.separators.matches(bytes[parent_end - 1]) {
parent_end -= 1;
}
if parent_end == 0 && start > 0 {
parent_end = 1;
}
Some((
Self::with_separators(&self.raw[..parent_end], self.separators),
name,
))
}
pub fn parent(self) -> Option<Self> {
self.split_file().map(|(parent, _)| parent)
}
}
impl<'a> From<&'a str> for VPath<'a> {
fn from(path: &'a str) -> Self {
Self::new(path)
}
}
impl AsRef<str> for VPath<'_> {
fn as_ref(&self) -> &str {
self.raw
}
}
impl core::fmt::Display for VPath<'_> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.raw)
}
}
#[derive(Debug, Clone)]
pub struct Components<'a> {
path: &'a str,
separators: Separators,
offset: usize,
root_pending: bool,
}
impl<'a> Iterator for Components<'a> {
type Item = Component<'a>;
fn next(&mut self) -> Option<Self::Item> {
let bytes = self.path.as_bytes();
if self.root_pending {
self.root_pending = false;
while self.offset < bytes.len() && self.separators.matches(bytes[self.offset]) {
self.offset += 1;
}
return Some(Component::Root);
}
while self.offset < bytes.len() && self.separators.matches(bytes[self.offset]) {
self.offset += 1;
}
if self.offset == bytes.len() {
return None;
}
let start = self.offset;
while self.offset < bytes.len() && !self.separators.matches(bytes[self.offset]) {
self.offset += 1;
}
Some(match &self.path[start..self.offset] {
"." => Component::Current,
".." => Component::Parent,
normal => Component::Normal(normal),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathError {
EscapesRoot,
}
impl core::fmt::Display for PathError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::EscapesRoot => f.write_str("parent component escapes the virtual root"),
}
}
}
impl core::error::Error for PathError {}
#[cfg(feature = "alloc")]
impl VPath<'_> {
pub fn normalize(self) -> Result<alloc::string::String, PathError> {
use alloc::string::String;
use alloc::vec::Vec;
let absolute = self.is_absolute();
let mut stack = Vec::new();
for component in self.components() {
match component {
Component::Root | Component::Current => {}
Component::Normal(value) => stack.push(value),
Component::Parent => {
stack.pop().ok_or(PathError::EscapesRoot)?;
}
}
}
let mut normalized = String::new();
if absolute {
normalized.push('/');
}
normalized.push_str(&stack.join("/"));
Ok(normalized)
}
}
#[cfg(feature = "alloc")]
pub fn split_path(path: &str) -> Option<(alloc::string::String, alloc::string::String)> {
use alloc::string::{String, ToString};
use alloc::vec::Vec;
let mut parts = Vec::new();
for component in VPath::new(path).components() {
match component {
Component::Normal(value) => parts.push(value),
Component::Root | Component::Current => {}
Component::Parent => return None,
}
}
let filename = parts.last()?.to_string();
let directory = if parts.len() > 1 {
parts[..parts.len() - 1].join("/")
} else {
String::new()
};
Some((directory, filename))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn components_preserve_lexical_meaning() {
let components: alloc::vec::Vec<_> = VPath::new("/a//./b/../c").components().collect();
assert_eq!(
components,
[
Component::Root,
Component::Normal("a"),
Component::Current,
Component::Normal("b"),
Component::Parent,
Component::Normal("c"),
]
);
}
#[test]
fn separator_policy_is_explicit() {
let slash: alloc::vec::Vec<_> = VPath::new(r"a\b/c").components().collect();
assert_eq!(slash, [Component::Normal(r"a\b"), Component::Normal("c")]);
let both: alloc::vec::Vec<_> =
VPath::with_separators(r"a\b/c", Separators::SlashOrBackslash)
.components()
.collect();
assert_eq!(
both,
[
Component::Normal("a"),
Component::Normal("b"),
Component::Normal("c")
]
);
}
#[test]
fn parent_and_file_name_are_borrowed() {
let path = VPath::new("/docs/api/readme.md/");
assert_eq!(path.file_name(), Some("readme.md"));
let (parent, name) = path.split_file().unwrap();
assert_eq!(parent.as_str(), "/docs/api");
assert_eq!(name, "readme.md");
}
#[cfg(feature = "alloc")]
#[test]
fn normalization_rejects_root_escape() {
assert_eq!(VPath::new("a/./b/../c").normalize().unwrap(), "a/c");
assert_eq!(VPath::new("../a").normalize(), Err(PathError::EscapesRoot));
assert!(split_path("a/../file").is_none());
}
}