1#![no_std]
8#![deny(missing_docs)]
9
10#[cfg(any(feature = "alloc", test))]
11extern crate alloc;
12
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
15pub enum Separators {
16 #[default]
18 Slash,
19 SlashOrBackslash,
21}
22
23impl Separators {
24 const fn matches(self, byte: u8) -> bool {
25 byte == b'/' || matches!(self, Self::SlashOrBackslash) && byte == b'\\'
26 }
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Component<'a> {
32 Root,
34 Current,
36 Parent,
38 Normal(&'a str),
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct VPath<'a> {
45 raw: &'a str,
46 separators: Separators,
47}
48
49impl<'a> VPath<'a> {
50 pub const fn new(path: &'a str) -> Self {
52 Self::with_separators(path, Separators::Slash)
53 }
54
55 pub const fn with_separators(path: &'a str, separators: Separators) -> Self {
57 Self {
58 raw: path,
59 separators,
60 }
61 }
62
63 pub const fn as_str(self) -> &'a str {
65 self.raw
66 }
67
68 pub const fn separators(self) -> Separators {
70 self.separators
71 }
72
73 pub fn components(self) -> Components<'a> {
75 Components {
76 path: self.raw,
77 separators: self.separators,
78 offset: 0,
79 root_pending: self
80 .raw
81 .as_bytes()
82 .first()
83 .is_some_and(|byte| self.separators.matches(*byte)),
84 }
85 }
86
87 pub fn is_absolute(self) -> bool {
89 matches!(self.components().next(), Some(Component::Root))
90 }
91
92 pub fn file_name(self) -> Option<&'a str> {
94 let mut result = None;
95 for component in self.components() {
96 match component {
97 Component::Normal(name) => result = Some(name),
98 Component::Current | Component::Root => {}
99 Component::Parent => result = None,
100 }
101 }
102 result
103 }
104
105 pub fn split_file(self) -> Option<(Self, &'a str)> {
107 let bytes = self.raw.as_bytes();
108 let mut end = bytes.len();
109 while end > 0 && self.separators.matches(bytes[end - 1]) {
110 end -= 1;
111 }
112 if end == 0 {
113 return None;
114 }
115 let mut start = end;
116 while start > 0 && !self.separators.matches(bytes[start - 1]) {
117 start -= 1;
118 }
119 let name = &self.raw[start..end];
120 if matches!(name, "." | "..") {
121 return None;
122 }
123 let mut parent_end = start;
124 while parent_end > 0 && self.separators.matches(bytes[parent_end - 1]) {
125 parent_end -= 1;
126 }
127 if parent_end == 0 && start > 0 {
128 parent_end = 1;
129 }
130 Some((
131 Self::with_separators(&self.raw[..parent_end], self.separators),
132 name,
133 ))
134 }
135
136 pub fn parent(self) -> Option<Self> {
138 self.split_file().map(|(parent, _)| parent)
139 }
140}
141
142impl<'a> From<&'a str> for VPath<'a> {
143 fn from(path: &'a str) -> Self {
144 Self::new(path)
145 }
146}
147
148impl AsRef<str> for VPath<'_> {
149 fn as_ref(&self) -> &str {
150 self.raw
151 }
152}
153
154impl core::fmt::Display for VPath<'_> {
155 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
156 f.write_str(self.raw)
157 }
158}
159
160#[derive(Debug, Clone)]
162pub struct Components<'a> {
163 path: &'a str,
164 separators: Separators,
165 offset: usize,
166 root_pending: bool,
167}
168
169impl<'a> Iterator for Components<'a> {
170 type Item = Component<'a>;
171
172 fn next(&mut self) -> Option<Self::Item> {
173 let bytes = self.path.as_bytes();
174 if self.root_pending {
175 self.root_pending = false;
176 while self.offset < bytes.len() && self.separators.matches(bytes[self.offset]) {
177 self.offset += 1;
178 }
179 return Some(Component::Root);
180 }
181 while self.offset < bytes.len() && self.separators.matches(bytes[self.offset]) {
182 self.offset += 1;
183 }
184 if self.offset == bytes.len() {
185 return None;
186 }
187 let start = self.offset;
188 while self.offset < bytes.len() && !self.separators.matches(bytes[self.offset]) {
189 self.offset += 1;
190 }
191 Some(match &self.path[start..self.offset] {
192 "." => Component::Current,
193 ".." => Component::Parent,
194 normal => Component::Normal(normal),
195 })
196 }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum PathError {
202 EscapesRoot,
204}
205
206impl core::fmt::Display for PathError {
207 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
208 match self {
209 Self::EscapesRoot => f.write_str("parent component escapes the virtual root"),
210 }
211 }
212}
213
214impl core::error::Error for PathError {}
215
216#[cfg(feature = "alloc")]
217impl VPath<'_> {
218 pub fn normalize(self) -> Result<alloc::string::String, PathError> {
220 use alloc::string::String;
221 use alloc::vec::Vec;
222
223 let absolute = self.is_absolute();
224 let mut stack = Vec::new();
225 for component in self.components() {
226 match component {
227 Component::Root | Component::Current => {}
228 Component::Normal(value) => stack.push(value),
229 Component::Parent => {
230 stack.pop().ok_or(PathError::EscapesRoot)?;
231 }
232 }
233 }
234 let mut normalized = String::new();
235 if absolute {
236 normalized.push('/');
237 }
238 normalized.push_str(&stack.join("/"));
239 Ok(normalized)
240 }
241}
242
243#[cfg(feature = "alloc")]
245pub fn split_path(path: &str) -> Option<(alloc::string::String, alloc::string::String)> {
246 use alloc::string::{String, ToString};
247 use alloc::vec::Vec;
248
249 let mut parts = Vec::new();
250 for component in VPath::new(path).components() {
251 match component {
252 Component::Normal(value) => parts.push(value),
253 Component::Root | Component::Current => {}
254 Component::Parent => return None,
255 }
256 }
257 let filename = parts.last()?.to_string();
258 let directory = if parts.len() > 1 {
259 parts[..parts.len() - 1].join("/")
260 } else {
261 String::new()
262 };
263 Some((directory, filename))
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn components_preserve_lexical_meaning() {
272 let components: alloc::vec::Vec<_> = VPath::new("/a//./b/../c").components().collect();
273 assert_eq!(
274 components,
275 [
276 Component::Root,
277 Component::Normal("a"),
278 Component::Current,
279 Component::Normal("b"),
280 Component::Parent,
281 Component::Normal("c"),
282 ]
283 );
284 }
285
286 #[test]
287 fn separator_policy_is_explicit() {
288 let slash: alloc::vec::Vec<_> = VPath::new(r"a\b/c").components().collect();
289 assert_eq!(slash, [Component::Normal(r"a\b"), Component::Normal("c")]);
290 let both: alloc::vec::Vec<_> =
291 VPath::with_separators(r"a\b/c", Separators::SlashOrBackslash)
292 .components()
293 .collect();
294 assert_eq!(
295 both,
296 [
297 Component::Normal("a"),
298 Component::Normal("b"),
299 Component::Normal("c")
300 ]
301 );
302 }
303
304 #[test]
305 fn parent_and_file_name_are_borrowed() {
306 let path = VPath::new("/docs/api/readme.md/");
307 assert_eq!(path.file_name(), Some("readme.md"));
308 let (parent, name) = path.split_file().unwrap();
309 assert_eq!(parent.as_str(), "/docs/api");
310 assert_eq!(name, "readme.md");
311 }
312
313 #[cfg(feature = "alloc")]
314 #[test]
315 fn normalization_rejects_root_escape() {
316 assert_eq!(VPath::new("a/./b/../c").normalize().unwrap(), "a/c");
317 assert_eq!(VPath::new("../a").normalize(), Err(PathError::EscapesRoot));
318 assert!(split_path("a/../file").is_none());
319 }
320}