turbo_unix_path/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::borrow::Cow;
4
5/// Converts system paths into Unix paths. This is a noop on Unix systems, and replaces backslash
6/// directory separators with forward slashes on Windows.
7#[inline]
8pub fn sys_to_unix(path: &str) -> Cow<'_, str> {
9    #[cfg(not(target_family = "windows"))]
10    {
11        Cow::from(path)
12    }
13    #[cfg(target_family = "windows")]
14    {
15        Cow::Owned(path.replace(std::path::MAIN_SEPARATOR_STR, "/"))
16    }
17}
18
19/// Converts Unix paths into system paths. This is a noop on Unix systems, and replaces forward
20/// slash directory separators with backslashes on Windows.
21#[inline]
22pub fn unix_to_sys(path: &str) -> Cow<'_, str> {
23    #[cfg(not(target_family = "windows"))]
24    {
25        Cow::from(path)
26    }
27    #[cfg(target_family = "windows")]
28    {
29        Cow::Owned(path.replace('/', std::path::MAIN_SEPARATOR_STR))
30    }
31}
32
33/// Joins two /-separated paths into a normalized path.
34/// Paths are concatenated with /.
35///
36/// see also [normalize_path] for normalization.
37/// Returns `None` if the joined path would leave the filesystem root.
38pub fn join_path(fs_path: &str, join: &str) -> Option<String> {
39    // Paths that we join are written as source code (eg, `join_path(fs_path, "foo/bar.js")`) and
40    // it's expected that they will never contain a backslash.
41    debug_assert!(
42        !join.contains('\\'),
43        "joined path {join} must not contain a Windows directory '\\', it must be normalized to \
44         Unix '/'"
45    );
46
47    // TODO: figure out why this freezes the benchmarks.
48    // // an absolute path would leave the file system root
49    // if Path::new(join).is_absolute() {
50    //     return None;
51    // }
52
53    if fs_path.is_empty() {
54        normalize_path(join)
55    } else if join.is_empty() {
56        normalize_path(fs_path)
57    } else {
58        normalize_path(&[fs_path, "/", join].concat())
59    }
60}
61
62/// Normalizes a /-separated path into a form that contains no leading /, no double /, no "."
63/// segment, no ".." segment.
64///
65/// Returns None if the path would need to start with ".." to be equal.
66pub fn normalize_path(str: &str) -> Option<String> {
67    let mut segments = Vec::new();
68    for segment in str.split('/') {
69        match segment {
70            "." | "" => {}
71            ".." => {
72                segments.pop()?;
73            }
74            segment => {
75                segments.push(segment);
76            }
77        }
78    }
79    Some(segments.join("/"))
80}
81
82/// Normalizes a /-separated request into a form that contains no leading /, no double /, and no "."
83/// or ".." segments in the middle of the request.
84///
85/// A request might only start with a single "." segment and no ".." segments, or any positive
86/// number of ".." segments but no "." segment.
87pub fn normalize_request(str: &str) -> String {
88    let mut segments = vec!["."];
89    // Keeps track of our directory depth so that we can pop directories when encountering a "..".
90    // If this is positive, then we're inside a directory and we can pop that. If it's 0, then we
91    // can't pop the directory and we must keep the ".." in our segments. This is not the same as
92    // the segments.len(), because we cannot pop a kept ".." when encountering another "..".
93    let mut depth = 0;
94    let mut popped_dot = false;
95    for segment in str.split('/') {
96        match segment {
97            "." => {}
98            ".." => {
99                if depth > 0 {
100                    depth -= 1;
101                    segments.pop();
102                } else {
103                    // The first time we push a "..", we need to remove the "." we include by
104                    // default.
105                    if !popped_dot {
106                        popped_dot = true;
107                        segments.pop();
108                    }
109                    segments.push(segment);
110                }
111            }
112            segment => {
113                segments.push(segment);
114                depth += 1;
115            }
116        }
117    }
118    segments.join("/")
119}
120
121pub fn get_relative_path_to(from: &str, target: &str) -> String {
122    fn split(s: &str) -> impl Iterator<Item = &str> {
123        let empty = s.is_empty();
124        let mut iterator = s.split('/');
125        if empty {
126            iterator.next();
127        }
128        iterator
129    }
130
131    let mut from_segments = split(from).peekable();
132    let mut target_segments = split(target).peekable();
133    while from_segments.peek() == target_segments.peek() {
134        from_segments.next();
135        if target_segments.next().is_none() {
136            return ".".to_string();
137        }
138    }
139    let mut result = Vec::new();
140    if from_segments.peek().is_none() {
141        result.push(".");
142    } else {
143        while from_segments.next().is_some() {
144            result.push("..");
145        }
146    }
147    for segment in target_segments {
148        result.push(segment);
149    }
150    result.join("/")
151}
152
153pub fn get_parent_path(path: &str) -> &str {
154    match str::rfind(path, '/') {
155        Some(index) => &path[..index],
156        None => "",
157    }
158}
159
160#[cfg(test)]
161mod tests {
162
163    use rstest::*;
164
165    use super::*;
166
167    #[rstest]
168    #[case("file.js")]
169    #[case("a/b/c/d/e/file.js")]
170    fn test_normalize_path_no_op(#[case] path: &str) {
171        assert_eq!(path, normalize_path(path).unwrap());
172    }
173
174    #[rstest]
175    #[case("/file.js", "file.js")]
176    #[case("./file.js", "file.js")]
177    #[case("././file.js", "file.js")]
178    #[case("a/../c/../file.js", "file.js")]
179    fn test_normalize_path(#[case] path: &str, #[case] normalized: &str) {
180        assert_eq!(normalized, normalize_path(path).unwrap());
181    }
182
183    #[rstest]
184    #[case("../file.js")]
185    #[case("a/../../file.js")]
186    fn test_normalize_path_invalid(#[case] path: &str) {
187        assert_eq!(None, normalize_path(path));
188    }
189}