Skip to main content

path/
path.rs

1//! Relative path types for deltadb.
2//!
3//! Provides [`RelPath`] and [`RelPathBuf`] — path types that are guaranteed to be
4//! relative, normalized, and valid unicode. Internally stored in POSIX (`/`-delimited)
5//! format regardless of host platform.
6//!
7//! Adapted from Zed's `util::rel_path` module.
8
9use std::{
10    borrow::Cow,
11    path::{Path, PathBuf},
12};
13
14use crate::rel_path::RelPath;
15
16pub mod abs_path;
17pub mod rel_path;
18
19pub trait PathExt {
20    fn to_rel_path_buf(&self) -> anyhow::Result<rel_path::RelPathBuf>;
21}
22
23impl<T: AsRef<Path> + ?Sized> PathExt for T {
24    fn to_rel_path_buf(&self) -> anyhow::Result<rel_path::RelPathBuf> {
25        Ok(RelPath::new(self.as_ref(), PathStyle::local())?.into_owned())
26    }
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub enum PathStyle {
31    Unix,
32    Windows,
33}
34
35impl PathStyle {
36    #[cfg(target_os = "windows")]
37    pub const fn local() -> Self {
38        PathStyle::Windows
39    }
40
41    #[cfg(not(target_os = "windows"))]
42    pub const fn local() -> Self {
43        PathStyle::Unix
44    }
45
46    #[inline]
47    pub fn primary_separator(&self) -> &'static str {
48        match self {
49            PathStyle::Unix => "/",
50            PathStyle::Windows => "\\",
51        }
52    }
53
54    pub fn separators(&self) -> &'static [&'static str] {
55        match self {
56            PathStyle::Unix => &["/"],
57            PathStyle::Windows => &["\\", "/"],
58        }
59    }
60
61    pub fn separators_ch(&self) -> &'static [char] {
62        match self {
63            PathStyle::Unix => &['/'],
64            PathStyle::Windows => &['\\', '/'],
65        }
66    }
67
68    pub fn is_absolute(&self, path_like: &str) -> bool {
69        path_like.starts_with('/')
70            || *self == PathStyle::Windows
71                && (path_like.starts_with('\\')
72                    || path_like
73                        .chars()
74                        .next()
75                        .is_some_and(|c| c.is_ascii_alphabetic())
76                        && path_like[1..]
77                            .strip_prefix(':')
78                            .is_some_and(|path| path.starts_with('/') || path.starts_with('\\')))
79    }
80
81    pub fn is_windows(&self) -> bool {
82        *self == PathStyle::Windows
83    }
84
85    pub fn is_posix(&self) -> bool {
86        *self == PathStyle::Unix
87    }
88
89    pub fn join(self, left: impl AsRef<Path>, right: impl AsRef<Path>) -> Option<String> {
90        let right = right.as_ref().to_str()?;
91        if is_absolute(right, self) {
92            return None;
93        }
94        let left = left.as_ref().to_str()?;
95        if left.is_empty() {
96            Some(right.into())
97        } else {
98            Some(format!(
99                "{left}{}{right}",
100                if left.ends_with(self.primary_separator()) {
101                    ""
102                } else {
103                    self.primary_separator()
104                }
105            ))
106        }
107    }
108
109    pub fn join_path(
110        self,
111        left: impl AsRef<Path>,
112        right: impl AsRef<Path>,
113    ) -> anyhow::Result<PathBuf> {
114        let left = left
115            .as_ref()
116            .to_str()
117            .ok_or_else(|| anyhow::anyhow!("Path contains invalid UTF-8"))?;
118        let right = right.as_ref();
119        let right_string = right
120            .to_str()
121            .ok_or_else(|| anyhow::anyhow!("Path contains invalid UTF-8"))?;
122        let joined = self
123            .join(left, right_string)
124            .ok_or_else(|| anyhow::anyhow!("Path must be relative: {right:?}"))?;
125        Ok(PathBuf::from(self.normalize(&joined)))
126    }
127
128    pub fn normalize(self, path_like: &str) -> String {
129        match self {
130            PathStyle::Windows => {
131                let drive_and_remainder = path_like.split_once(':').filter(|(drive, _)| {
132                    let mut characters = drive.chars();
133                    characters
134                        .next()
135                        .is_some_and(|character| character.is_ascii_alphabetic())
136                        && characters.next().is_none()
137                });
138                let unc_remainder = path_like
139                    .strip_prefix("\\\\")
140                    .or_else(|| path_like.strip_prefix("//"));
141
142                let (prefix, remainder) = if let Some((drive, remainder)) = drive_and_remainder {
143                    if let Some(remainder) = remainder
144                        .strip_prefix('\\')
145                        .or_else(|| remainder.strip_prefix('/'))
146                    {
147                        (format!("{drive}:\\"), remainder)
148                    } else {
149                        (format!("{drive}:"), remainder)
150                    }
151                } else if let Some(remainder) = unc_remainder {
152                    let (server, remainder) = match remainder.split_once(['\\', '/']) {
153                        Some(parts) => parts,
154                        None => return path_like.to_string(),
155                    };
156                    let (share, remainder) = match remainder.split_once(['\\', '/']) {
157                        Some(parts) => parts,
158                        None => return format!("\\\\{server}\\{remainder}"),
159                    };
160                    (format!("\\\\{server}\\{share}\\"), remainder)
161                } else if let Some(remainder) = path_like
162                    .strip_prefix('\\')
163                    .or_else(|| path_like.strip_prefix('/'))
164                {
165                    ("\\".to_string(), remainder)
166                } else {
167                    (String::new(), path_like)
168                };
169
170                let mut components: Vec<&str> = Vec::new();
171                for component in remainder.split(['\\', '/']) {
172                    match component {
173                        "" | "." => {}
174                        ".." => {
175                            if components.last().is_some_and(|c| *c != "..") {
176                                components.pop();
177                            } else if prefix.is_empty() {
178                                components.push(component);
179                            }
180                        }
181                        component => components.push(component),
182                    }
183                }
184
185                let normalized = components.join("\\");
186                if prefix.is_empty() {
187                    normalized
188                } else {
189                    format!("{prefix}{normalized}")
190                }
191            }
192            PathStyle::Unix => {
193                let is_absolute = path_like.starts_with('/');
194                let remainder = if is_absolute {
195                    path_like.trim_start_matches('/')
196                } else {
197                    path_like
198                };
199
200                let mut components = Vec::new();
201                for component in remainder.split(self.separators_ch()) {
202                    match component {
203                        "" | "." => {}
204                        ".." => {
205                            if components
206                                .last()
207                                .is_some_and(|component| *component != "..")
208                            {
209                                components.pop();
210                            } else if !is_absolute {
211                                components.push(component);
212                            }
213                        }
214                        component => components.push(component),
215                    }
216                }
217
218                let normalized = components.join(self.primary_separator());
219                if is_absolute && normalized.is_empty() {
220                    "/".to_string()
221                } else if is_absolute {
222                    format!("/{normalized}")
223                } else {
224                    normalized
225                }
226            }
227        }
228    }
229
230    pub fn split(self, path_like: &str) -> (Option<&str>, &str) {
231        let Some(pos) = path_like.rfind(self.primary_separator()) else {
232            return (None, path_like);
233        };
234        let filename_start = pos + self.primary_separator().len();
235        (
236            Some(&path_like[..filename_start]),
237            &path_like[filename_start..],
238        )
239    }
240
241    pub fn strip_prefix<'a>(
242        &self,
243        child: &'a Path,
244        parent: &'a Path,
245    ) -> Option<std::borrow::Cow<'a, RelPath>> {
246        let parent = parent.to_str()?;
247        if parent.is_empty() {
248            return RelPath::new(child, *self).ok();
249        }
250        let parent = self
251            .separators()
252            .iter()
253            .find_map(|sep| parent.strip_suffix(sep))
254            .unwrap_or(parent);
255        let child = child.to_str()?;
256
257        // Match behavior of std::path::Path, which is case-insensitive for drive letters (e.g., "C:" == "c:")
258        let stripped = if self.is_windows()
259            && child.as_bytes().get(1) == Some(&b':')
260            && parent.as_bytes().get(1) == Some(&b':')
261            && child.as_bytes()[0].eq_ignore_ascii_case(&parent.as_bytes()[0])
262        {
263            child[2..].strip_prefix(&parent[2..])?
264        } else {
265            child.strip_prefix(parent)?
266        };
267        if let Some(relative) = self
268            .separators()
269            .iter()
270            .find_map(|sep| stripped.strip_prefix(sep))
271        {
272            RelPath::new(relative.as_ref(), *self).ok()
273        } else if stripped.is_empty() {
274            Some(Cow::Borrowed(RelPath::empty()))
275        } else {
276            None
277        }
278    }
279}
280
281fn is_absolute(path_like: &str, path_style: PathStyle) -> bool {
282    path_like.starts_with('/')
283        || path_style == PathStyle::Windows
284            && (path_like.starts_with('\\')
285                || path_like
286                    .chars()
287                    .next()
288                    .is_some_and(|c| c.is_ascii_alphabetic())
289                    && path_like[1..]
290                        .strip_prefix(':')
291                        .is_some_and(|path| path.starts_with('/') || path.starts_with('\\')))
292}
293
294/// Normalizes a path by resolving `.` and `..` components without
295/// requiring the path to exist on disk (unlike `canonicalize`).
296pub fn normalize_path(path: &Path) -> PathBuf {
297    use std::path::Component;
298    let mut components = path.components().peekable();
299    let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() {
300        components.next();
301        PathBuf::from(c.as_os_str())
302    } else {
303        PathBuf::new()
304    };
305
306    for component in components {
307        match component {
308            Component::Prefix(..) => unreachable!(),
309            Component::RootDir => {
310                ret.push(component.as_os_str());
311            }
312            Component::CurDir => {}
313            Component::ParentDir => {
314                ret.pop();
315            }
316            Component::Normal(c) => {
317                ret.push(c);
318            }
319        }
320    }
321    ret
322}