realhydroper_path/
lib.rs

1/*!
2Work with file paths by text only.
3
4In the Windows operating system, absolute paths may either start with a drive letter followed by
5a colon, or an UNC path prefix (`\\`), or an extended drive letter prefix (`\\?\X:`).
6Therefore, this crate provides a `FlexPath` that is based on a variant ([_FlexPathVariant_]),
7which you don't need to always specify. This variant indicates whether to
8interpret Windows absolute paths or not.
9
10There are two _FlexPathVariant_ variants currently:
11
12- _Common_
13- _Windows_
14
15The constant `FlexPathVariant::native()` is one of these variants
16based on the target platform. For the Windows operating system, it
17is always _Windows_. For other platforms, it's always _Common_.
18
19# Example
20
21```
22use realhydroper_path::FlexPath;
23
24assert_eq!("a", FlexPath::new_common("a/b").resolve("..").to_string());
25assert_eq!("a", FlexPath::new_common("a/b/..").to_string());
26assert_eq!("a/b/c/d/e", FlexPath::from_n_common(["a/b", "c/d", "e/f", ".."]).to_string());
27assert_eq!("../../c/d", FlexPath::new_common("/a/b").relative("/c/d"));
28```
29*/
30
31use lazy_regex::*;
32use std::{path::{Path, PathBuf}, str::FromStr};
33
34pub(crate) mod common;
35pub(crate) mod flexible;
36
37/// Indicates if special absolute paths are considered.
38///
39/// Currently, only two variants are defined, considering that there is
40/// no known operating system with different path support other than Windows:
41/// 
42/// * `Common`
43/// * `Windows`
44#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Debug)]
45pub enum FlexPathVariant {
46    /// Indicates that the path is manipulated in a Unix common way, resulting into forward slashes.
47    Common,
48    /// Indicates that the path is manipulated compatibly with the Windows operating system.
49    Windows,
50}
51
52impl FlexPathVariant {
53    pub(crate) const NATIVE: Self = {
54        #[cfg(target_os = "windows")] {
55            Self::Windows
56        }
57        #[cfg(not(target_os = "windows"))] {
58            Self::Common
59        }
60    };
61
62    /// The variant that represents the build's target platform.
63    pub const fn native() -> Self {
64        Self::NATIVE
65    }
66}
67
68/// The `FlexPath` structure represents an always-resolved textual file path based
69/// on a [_FlexPathVariant_].
70#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
71pub struct FlexPath(String, FlexPathVariant);
72
73impl FlexPath {
74    /// Constructs a `FlexPath` with a given `variant`. This method
75    /// will resolve the specified path.
76    pub fn new(path: &str, variant: FlexPathVariant) -> Self {
77        Self(flexible::resolve_one(path, variant), variant)
78    }
79
80    /// Constructs a `FlexPath` whose variant is `Common`. This method
81    /// will resolve the specified path.
82    pub fn new_common(path: &str) -> Self {
83        Self(flexible::resolve_one(path, FlexPathVariant::Common), FlexPathVariant::Common)
84    }
85
86    /// Constructs a `FlexPath` whose variant is chosen according to the target platform.
87    /// This method will resolve the specified path.
88    pub fn new_native(path: &str) -> Self {
89        Self(flexible::resolve_one(path, FlexPathVariant::NATIVE), FlexPathVariant::NATIVE)
90    }
91
92    /// Constructs a `FlexPath` from multiple paths and a given `variant`.
93    pub fn from_n<'a, T: IntoIterator<Item = &'a str>>(paths: T, variant: FlexPathVariant) -> Self {
94        Self(flexible::resolve_n(paths, variant), variant)
95    }
96
97    /// Constructs a `FlexPath` from multiple paths and a `Common` variant.
98    pub fn from_n_common<'a, T: IntoIterator<Item = &'a str>>(paths: T) -> Self {
99        Self::from_n(paths, FlexPathVariant::Common)
100    }
101
102    /// Constructs a `FlexPath` from multiple paths and a variant based on
103    /// the target platform.
104    pub fn from_n_native<'a, T: IntoIterator<Item = &'a str>>(paths: T) -> Self {
105        Self::from_n(paths, FlexPathVariant::NATIVE)
106    }
107
108    /// Returns the variant this `FlexPath` object is based on.
109    pub fn variant(&self) -> FlexPathVariant {
110        self.1
111    }
112
113    /// Indicates whether the `FlexPath` is absolute or not.
114    pub fn is_absolute(&self) -> bool {
115        flexible::is_absolute(&self.0, self.1)
116    }
117
118    /// Resolves `path2` relative to `path1`.
119    ///
120    /// Behavior:
121    /// - Eliminates the segments `..` and `.`.
122    /// - If `path2` is absolute, this function returns a resolution of solely `path2`.
123    /// - All path separators that are backslashes (`\`) are replaced by forward ones (`/`).
124    /// - If any path is absolute, this function returns an absolute path.
125    /// - Any empty segment and trailing path separators, such as in `a/b/` and `a//b` are eliminated.
126    pub fn resolve(&self, path2: &str) -> FlexPath {
127        FlexPath(flexible::resolve(&self.0, path2, self.1), self.1)
128    }
129
130    /// Resolves multiple paths relative to this path. The
131    /// behavior is similiar to [`.resolve`]. If the given
132    /// set has no items, an empty string is returned.
133    pub fn resolve_n<'a, T: IntoIterator<Item = &'a str>>(&self, paths: T) -> FlexPath {
134        FlexPath(flexible::resolve(&self.0, &flexible::resolve_n(paths, self.1), self.1), self.1)
135    }
136
137    /**
138    Finds the relative path from this path to `to_path`.
139
140    # Behavior:
141
142    - If the paths refer to the same path, this function returns
143    an empty string.
144    - The function ensures that both paths are absolute and resolves
145    any `..` and `.` segments inside.
146    - If both paths have different prefix, `to_path` is returned.
147
148    # Panics
149
150    Panics if given paths are not absolute.
151
152    # Example
153
154    ```
155    use realhydroper_path::FlexPath;
156    assert_eq!("", FlexPath::new_common("/a/b").relative("/a/b"));
157    assert_eq!("c", FlexPath::new_common("/a/b").relative("/a/b/c"));
158    assert_eq!("../../c/d", FlexPath::new_common("/a/b").relative("/c/d"));
159    assert_eq!("../c", FlexPath::new_common("/a/b").relative("/a/c"));
160    ```
161    */
162    pub fn relative(&self, to_path: &str) -> String {
163        flexible::relative(&self.0, to_path, self.1)
164    }
165
166    /// Changes the extension of a path and returns a new string.
167    /// This method adds any lacking dot (`.`) prefix automatically to the
168    /// `extension` argument.
169    ///
170    /// This method allows multiple dots per extension. If that is not
171    /// desired, use [`.change_last_extension`].
172    ///
173    /// # Example
174    /// 
175    /// ```
176    /// use realhydroper_path::FlexPath;
177    /// assert_eq!("a.y", FlexPath::new_common("a.x").change_extension(".y").to_string());
178    /// assert_eq!("a.z", FlexPath::new_common("a.x.y").change_extension(".z").to_string());
179    /// assert_eq!("a.z.w", FlexPath::new_common("a.x.y").change_extension(".z.w").to_string());
180    /// ```
181    ///
182    pub fn change_extension(&self, extension: &str) -> FlexPath {
183        Self(change_extension(&self.0, extension), self.1)
184    }
185
186    /// Changes only the last extension of a path and returns a new string.
187    /// This method adds any lacking dot (`.`) prefix automatically to the
188    /// `extension` argument.
189    ///
190    /// # Panics
191    ///
192    /// Panics if the extension contains more than one dot.
193    ///
194    pub fn change_last_extension(&self, extension: &str) -> FlexPath {
195        Self(change_last_extension(&self.0, extension), self.1)
196    }
197
198    /// Checks if a file path has a specific extension.
199    /// This method adds any lacking dot (`.`) prefix automatically to the
200    /// `extension` argument.
201    pub fn has_extension(&self, extension: &str) -> bool {
202        has_extension(&self.0, extension)
203    }
204
205    /// Checks if a file path has any of multiple specific extensions.
206    /// This method adds any lacking dot (`.`) prefix automatically to each
207    /// extension argument.
208    pub fn has_extensions<'a, T: IntoIterator<Item = &'a str>>(&self, extensions: T) -> bool {
209        has_extensions(&self.0, extensions)
210    }
211
212    /// Returns the base name of a file path.
213    ///
214    /// # Example
215    /// 
216    /// ```
217    /// use realhydroper_path::FlexPath;
218    /// assert_eq!("qux.html", FlexPath::new_common("foo/qux.html").base_name());
219    /// ```
220    pub fn base_name(&self) -> String {
221        base_name(&self.0)
222    }
223
224    /// Returns the base name of a file path, removing any of the specified extensions.
225    /// This method adds any lacking dot (`.`) prefix automatically to each
226    /// extension argument.
227    ///
228    /// # Example
229    /// 
230    /// ```
231    /// use realhydroper_path::FlexPath;
232    /// assert_eq!("qux", FlexPath::new_common("foo/qux.html").base_name_without_ext([".html"]));
233    /// ```
234    pub fn base_name_without_ext<'a, T>(&self, extensions: T) -> String
235        where T: IntoIterator<Item = &'a str>
236    {
237        base_name_without_ext(&self.0, extensions)
238    }
239
240    pub fn to_path_buf(&self) -> PathBuf {
241        PathBuf::from_str(&self.to_string()).unwrap_or(PathBuf::new())
242    }
243}
244
245impl ToString for FlexPath {
246    /// Returns a string representation of the path,
247    /// delimiting segments with either a forward slash (`/`) or backward slash (`\`)
248    /// depending on the path's `FlexPathVariant`.
249    fn to_string(&self) -> String {
250        if self.variant() == FlexPathVariant::Windows {
251            self.0.replace('/', "\\")
252        } else {
253            self.0.clone()
254        }
255    }
256}
257
258static STARTS_WITH_PATH_SEPARATOR: Lazy<Regex> = lazy_regex!(r"^[/\\]");
259
260fn change_extension(path: &str, extension: &str) -> String {
261    let extension = (if extension.starts_with('.') { "" } else { "." }).to_owned() + extension;
262    if regex_find!(r"(\.[^\.]+)+$", path).is_none() {
263        return path.to_owned() + &extension;
264    }
265    regex_replace!(r"(\.[^\.]+)+$", path, |_, _| &extension).into_owned()
266}
267
268fn change_last_extension(path: &str, extension: &str) -> String {
269    let extension = (if extension.starts_with('.') { "" } else { "." }).to_owned() + extension;
270    assert!(
271        extension[1..].find('.').is_none(),
272        "The argument to realhydroper_path::change_last_extension() must only contain one extension; got {}",
273        extension
274    );
275    if regex_find!(r"(\..+)$", path).is_none() {
276        return path.to_owned() + &extension;
277    }
278    regex_replace!(r"(\..+)$", path, |_, _| &extension).into_owned()
279}
280
281/// Adds prefix dot to extension if missing.
282fn extension_arg(extension: &str) -> String {
283    (if extension.starts_with('.') { "" } else { "." }).to_owned() + extension
284}
285
286fn has_extension(path: &str, extension: &str) -> bool {
287    let extension = extension.to_lowercase();
288    let extension = (if extension.starts_with('.') { "" } else { "." }).to_owned() + &extension;
289    path.to_lowercase().ends_with(&extension_arg(&extension))
290}
291
292fn has_extensions<'a, T: IntoIterator<Item = &'a str>>(path: &str, extensions: T) -> bool {
293    extensions.into_iter().any(|ext| has_extension(path, ext))
294}
295
296fn base_name(path: &str) -> String {
297    path.split('/').last().map_or("", |s| s).to_owned()
298}
299
300fn base_name_without_ext<'a, T>(path: &str, extensions: T) -> String
301    where T: IntoIterator<Item = &'a str>
302{
303    let extensions = extensions.into_iter().map(extension_arg).collect::<Vec<String>>();
304    path.split('/').last().map_or("".to_owned(), |base| {
305        regex_replace!(r"(\.[^\.]+)+$", base, |_, prev_ext: &str| {
306            (if extensions.iter().any(|ext| ext == prev_ext) { "" } else { prev_ext }).to_owned()
307        }).into_owned()
308    })
309}
310
311/// Similiar to `std::fs::canonicalize`, but normalizes inexistent paths, and with a
312/// few differences.
313/// 
314/// For Windows, any `\\?\X:`, `X:`, or `\\?\UNC\` prefixes are ensured
315/// to be uppercase and UNC host names and rest characters are always returned in lowercase form.
316/// 
317/// ```ignore
318/// assert_eq!(PathBuf::from_str(r"\\?\C:\program files").unwrap(), normalize_path(r"C:/Program Files/"));
319/// assert_eq!(PathBuf::from_str(r"\\?\UNC\server\foo").unwrap(), normalize_path(r"\\server\foo\"));
320/// assert_eq!(PathBuf::from_str(r"\\?\C:\foo").unwrap(), normalize_path(r"\\?\c:/foo/"));
321/// assert_eq!(PathBuf::from_str(r"\\?\UNC\server\foo").unwrap(), normalize_path(r"\\?\unc\server\Foo\"));
322/// ```
323pub fn normalize_path(p: impl AsRef<Path>) -> PathBuf {
324    let cwd = std::env::current_dir().unwrap_or(PathBuf::from_str("/").unwrap());
325    let p = FlexPath::from_n_native([cwd.to_str().unwrap(), &p.as_ref().to_string_lossy().to_owned()]).to_string();
326    let p = regex_replace!(r"[^\\/][\\/]+$", &p, |a: &str| {
327        a.chars().collect::<Vec<_>>()[0].to_string()
328    }).into_owned();
329
330    // If Windows absolute paths use extended-length syntax already,
331    // ensure to use uppercase prefixes except for UNC host names.
332    if regex_is_match!(r"\\\\\?\\[Uu][Nn][Cc]", &p) {
333        return PathBuf::from_str(&(r"\\?\UNC".to_owned() + &p[7..].to_lowercase())).unwrap_or(PathBuf::new());
334    }
335    if let Some(d) = regex_captures!(r"\\\\\?\\[A-Za-z]\:", &p) {
336        return PathBuf::from_str(&(d.to_uppercase() + &p[6..].to_lowercase())).unwrap_or(PathBuf::new());
337    }
338
339    // Use extended-length syntax for Windows absolute paths
340    if let Some(d) = regex_captures!(r"^[A-Za-z]\:", &p) {
341        return PathBuf::from_str(&(r"\\?\".to_owned() + &d.to_uppercase() + &p[2..].to_lowercase())).unwrap_or(PathBuf::new());
342    }
343    if regex_is_match!(r"^(\\\\([^?]|$))", &p) {
344        return PathBuf::from_str(&(r"\\?\UNC".to_owned() + &p[1..].to_lowercase())).unwrap_or(PathBuf::new());
345    }
346
347    PathBuf::from_str(&p).unwrap_or(PathBuf::new())
348}
349
350#[cfg(test)]
351mod test {
352    use super::*;
353
354    #[test]
355    fn extension_and_base_name() {
356        assert!(FlexPath::new_common("a.x").has_extensions([".x", ".y"]));
357        assert_eq!("a.y", FlexPath::new_common("a.x").change_extension(".y").to_string());
358        assert_eq!("a.0", FlexPath::new_common("a.x.y").change_extension(".0").to_string());
359        assert_eq!("a.0.1", FlexPath::new_common("a.x.y").change_extension(".0.1").to_string());
360
361        assert_eq!("qux.html", FlexPath::new_common("foo/qux.html").base_name());
362        assert_eq!("qux", FlexPath::new_common("foo/qux.html").base_name_without_ext([".html"]));
363    }
364
365    #[test]
366    fn resolution() {
367        assert_eq!("a", FlexPath::from_n_common(["a/b/.."]).to_string());
368        assert_eq!("a", FlexPath::from_n_common(["a", "b", ".."]).to_string());
369        assert_eq!("/a/b", FlexPath::new_common("/c").resolve("/a/b").to_string());
370        assert_eq!("a", FlexPath::new_common("a/b").resolve("..").to_string());
371        assert_eq!("a/b", FlexPath::new_common("a/b/").to_string());
372        assert_eq!("a/b", FlexPath::new_common("a//b").to_string());
373
374        let windows = FlexPathVariant::Windows;
375        assert_eq!(r"\\Whack\a\Box", FlexPath::from_n(["foo", r"\\Whack////a//Box", "..", "Box"], windows).to_string());
376        assert_eq!(r"\\?\X:\", FlexPath::from_n([r"\\?\X:", r".."], windows).to_string());
377        assert_eq!(r"\\?\X:\", FlexPath::from_n([r"\\?\X:\", r".."], windows).to_string());
378        assert_eq!(r"\\?\UNC\Whack\a\Box", FlexPath::from_n([r"\\?\UNC\Whack\a\Box", r"..", "Box"], windows).to_string());
379        assert_eq!(r"C:\a", FlexPath::new("C:/", windows).resolve("a").to_string());
380        assert_eq!(r"D:\", FlexPath::new("C:/", windows).resolve("D:/").to_string());
381        assert_eq!(r"D:\a", FlexPath::new("D:/a", windows).to_string());
382        assert_eq!(r"C:\a\f\b", FlexPath::new("a", windows).resolve("C:/a///f//b").to_string());
383    }
384
385    #[test]
386    fn relativity() {
387        assert_eq!("", FlexPath::new_common("/a/b").relative("/a/b"));
388        assert_eq!("c", FlexPath::new_common("/a/b").relative("/a/b/c"));
389        assert_eq!("../../c/d", FlexPath::new_common("/a/b/c").relative("/a/c/d"));
390        assert_eq!("..", FlexPath::new_common("/a/b/c").relative("/a/b"));
391        assert_eq!("../..", FlexPath::new_common("/a/b/c").relative("/a"));
392        assert_eq!("..", FlexPath::new_common("/a").relative("/"));
393        assert_eq!("a", FlexPath::new_common("/").relative("/a"));
394        assert_eq!("", FlexPath::new_common("/").relative("/"));
395        assert_eq!("../../c/d", FlexPath::new_common("/a/b").relative("/c/d"));
396        assert_eq!("../c", FlexPath::new_common("/a/b").relative("/a/c"));
397
398        let windows = FlexPathVariant::Windows;
399        assert_eq!("", FlexPath::new("C:/", windows).relative("C:/"));
400        assert_eq!("", FlexPath::new("C:/foo", windows).relative("C:/foo"));
401        assert_eq!(r"\\foo", FlexPath::new("C:/", windows).relative(r"\\foo"));
402        assert_eq!("../../foo", FlexPath::new(r"\\a/b", windows).relative(r"\\foo"));
403        assert_eq!("D:/", FlexPath::new("C:/", windows).relative(r"D:"));
404        assert_eq!("../bar", FlexPath::new(r"\\?\C:\foo", windows).relative(r"\\?\C:\bar"));
405    }
406
407    #[test]
408    fn normalization() {
409        assert_eq!(PathBuf::from_str(r"\\?\C:\program files").unwrap(), normalize_path(r"C:/Program Files/"));
410        assert_eq!(PathBuf::from_str(r"\\?\UNC\server\foo").unwrap(), normalize_path(r"\\server\foo\"));
411        assert_eq!(PathBuf::from_str(r"\\?\C:\foo").unwrap(), normalize_path(r"\\?\c:/foo/"));
412        assert_eq!(PathBuf::from_str(r"\\?\UNC\server\foo").unwrap(), normalize_path(r"\\?\unc\server\Foo\"));
413    }
414}