1use 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 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
294pub 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}