Skip to main content

hadris_path/
lib.rs

1//! Lexical path handling for virtual filesystems and archives.
2//!
3//! Unlike `std::path`, this crate does not model host operating-system paths
4//! and never performs filesystem I/O. Its borrowed path views and component
5//! iterators are allocation-free and available in `no_std` environments.
6
7#![no_std]
8#![deny(missing_docs)]
9
10#[cfg(any(feature = "alloc", test))]
11extern crate alloc;
12
13/// Separator policy used while parsing a virtual path.
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
15pub enum Separators {
16    /// Only `/` separates components.
17    #[default]
18    Slash,
19    /// Both `/` and `\` separate components.
20    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/// A lexical component of a virtual path.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Component<'a> {
32    /// One or more separators at the beginning of the path.
33    Root,
34    /// A `.` component.
35    Current,
36    /// A `..` component.
37    Parent,
38    /// A normal path component.
39    Normal(&'a str),
40}
41
42/// A borrowed virtual path with an explicit separator policy.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct VPath<'a> {
45    raw: &'a str,
46    separators: Separators,
47}
48
49impl<'a> VPath<'a> {
50    /// Creates a slash-delimited virtual path.
51    pub const fn new(path: &'a str) -> Self {
52        Self::with_separators(path, Separators::Slash)
53    }
54
55    /// Creates a virtual path with the given separator policy.
56    pub const fn with_separators(path: &'a str, separators: Separators) -> Self {
57        Self {
58            raw: path,
59            separators,
60        }
61    }
62
63    /// Returns the original, unnormalized path string.
64    pub const fn as_str(self) -> &'a str {
65        self.raw
66    }
67
68    /// Returns the separator policy.
69    pub const fn separators(self) -> Separators {
70        self.separators
71    }
72
73    /// Iterates over lexical components.
74    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    /// Returns whether the path begins with a recognized separator.
88    pub fn is_absolute(self) -> bool {
89        matches!(self.components().next(), Some(Component::Root))
90    }
91
92    /// Returns the last normal component.
93    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    /// Splits the path into its raw parent view and final normal component.
106    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    /// Returns the raw parent path when this path ends in a normal component.
137    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/// Allocation-free iterator over lexical components.
161#[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/// An invalid lexical path.
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum PathError {
202    /// A parent component would escape the virtual root.
203    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    /// Normalizes separators and lexical `.`/`..` components.
219    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/// Compatibility helper that returns a normalized `(directory, filename)` pair.
244#[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}