Skip to main content

turbo_unix_path/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::borrow::Cow;
4
5use smallvec::SmallVec;
6
7/// Converts system paths into Unix paths. This is a noop on Unix systems, and replaces backslash
8/// directory separators with forward slashes on Windows.
9#[inline]
10pub fn sys_to_unix(path: &str) -> Cow<'_, str> {
11    #[cfg(not(windows))]
12    {
13        Cow::from(path)
14    }
15    #[cfg(windows)]
16    {
17        Cow::Owned(path.replace(std::path::MAIN_SEPARATOR_STR, "/"))
18    }
19}
20
21/// Converts Unix paths into system paths. This is a noop on Unix systems, and replaces forward
22/// slash directory separators with backslashes on Windows.
23#[inline]
24pub fn unix_to_sys(path: &str) -> Cow<'_, str> {
25    #[cfg(not(windows))]
26    {
27        Cow::from(path)
28    }
29    #[cfg(windows)]
30    {
31        Cow::Owned(path.replace('/', std::path::MAIN_SEPARATOR_STR))
32    }
33}
34
35/// Joins two /-separated paths into a normalized path.
36/// Paths are concatenated with /.
37///
38/// see also [normalize_path] for normalization.
39/// Returns `None` if the joined path would leave the filesystem root.
40pub fn join_path(fs_path: &str, join: &str) -> Option<String> {
41    debug_assert!(
42        !cfg!(windows) || !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 = SmallVec::<[&str; 8]>::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 = SmallVec::<[&str; 8]>::new();
89    segments.push(".");
90    // Keeps track of our directory depth so that we can pop directories when encountering a "..".
91    // If this is positive, then we're inside a directory and we can pop that. If it's 0, then we
92    // can't pop the directory and we must keep the ".." in our segments. This is not the same as
93    // the segments.len(), because we cannot pop a kept ".." when encountering another "..".
94    let mut depth = 0;
95    let mut popped_dot = false;
96    for segment in str.split('/') {
97        match segment {
98            "." => {}
99            ".." => {
100                if depth > 0 {
101                    depth -= 1;
102                    segments.pop();
103                } else {
104                    // The first time we push a "..", we need to remove the "." we include by
105                    // default.
106                    if !popped_dot {
107                        popped_dot = true;
108                        segments.pop();
109                    }
110                    segments.push(segment);
111                }
112            }
113            segment => {
114                segments.push(segment);
115                depth += 1;
116            }
117        }
118    }
119    segments.join("/")
120}
121
122/// Returns the path of `target` relative to `from`, as a plain path: `"c"`, `"../c"`, or `"."` when
123/// the two are equal.
124///
125/// The result is not prefixed with `./`, so it is a path and not a module request. Use
126/// [`get_relative_request_to`] to build an import specifier, where a bare `"c"` would be read as a
127/// package name rather than as a file next to `from`.
128///
129/// Returns `"."` by reference when the paths are identical, or `target` by reference when `from`
130/// is empty.
131pub fn get_relative_path_to<'a>(from: &str, target: &'a str) -> Cow<'a, str> {
132    if from.is_empty() && !target.is_empty() {
133        return Cow::Borrowed(target);
134    }
135
136    relative_to(from, target, false)
137}
138
139/// Returns the path of `target` relative to `from`, as an explicitly relative module request:
140/// `"./c"`, `"../c"`, or `"."` when the two are equal.
141///
142/// The `./` prefix is what makes the result a relative request: without it, `"c"` resolves as the
143/// package `c` instead of the file `c` next to `from`. Use [`get_relative_path_to`] when a plain
144/// path is wanted instead.
145///
146/// Returns `"."` by reference when the paths are identical.
147pub fn get_relative_request_to<'a>(from: &str, target: &'a str) -> Cow<'a, str> {
148    relative_to(from, target, true)
149}
150
151/// Shared by [`get_relative_path_to`] and [`get_relative_request_to`]; `explicitly_relative` adds
152/// the leading `./` that distinguishes a request from a path.
153fn relative_to<'a>(from: &str, target: &'a str, explicitly_relative: bool) -> Cow<'a, str> {
154    fn split(s: &str) -> impl Iterator<Item = &str> {
155        let mut iterator = s.split('/');
156        if s.is_empty() {
157            iterator.next();
158        }
159        iterator
160    }
161
162    let mut from_segments = split(from).peekable();
163    let mut target_segments = split(target).peekable();
164    while from_segments.peek() == target_segments.peek() {
165        from_segments.next();
166        if target_segments.next().is_none() {
167            return Cow::Borrowed(".");
168        }
169    }
170    let mut result = SmallVec::<[&str; 8]>::new();
171    if from_segments.peek().is_some() {
172        while from_segments.next().is_some() {
173            result.push("..");
174        }
175    } else if explicitly_relative {
176        // Nothing to walk up, so the path would be bare (`c`) without this.
177        result.push(".");
178    }
179    for segment in target_segments {
180        result.push(segment);
181    }
182    Cow::Owned(result.join("/"))
183}
184
185pub fn get_parent_path(path: &str) -> &str {
186    match str::rfind(path, '/') {
187        Some(index) => &path[..index],
188        None => "",
189    }
190}
191
192#[cfg(test)]
193mod tests {
194
195    use rstest::*;
196
197    use super::*;
198
199    #[rstest]
200    #[case("file.js")]
201    #[case("a/b/c/d/e/file.js")]
202    fn test_normalize_path_no_op(#[case] path: &str) {
203        assert_eq!(path, normalize_path(path).unwrap());
204    }
205
206    #[rstest]
207    #[case("/file.js", "file.js")]
208    #[case("./file.js", "file.js")]
209    #[case("././file.js", "file.js")]
210    #[case("a/../c/../file.js", "file.js")]
211    fn test_normalize_path(#[case] path: &str, #[case] normalized: &str) {
212        assert_eq!(normalized, normalize_path(path).unwrap());
213    }
214
215    #[rstest]
216    #[case("../file.js")]
217    #[case("a/../../file.js")]
218    fn test_normalize_path_invalid(#[case] path: &str) {
219        assert_eq!(None, normalize_path(path));
220    }
221
222    #[rstest]
223    #[case("a/b/c", "a/b/c", ".", true)]
224    #[case("a/c/d", "a/b/c", "../../b/c", false)]
225    #[case("", "a/b/c", "a/b/c", true)]
226    #[case("", "", ".", true)]
227    #[case("a/b", "a/b/c", "c", false)]
228    #[case("a/b/c", "", "../../..", false)]
229    #[case("a/b/c", "c/b/a", "../../../c/b/a", false)]
230    #[case("file:///a/b/c", "file:///c/b/a", "../../../c/b/a", false)]
231    fn test_get_relative_path_to(
232        #[case] from: &str,
233        #[case] target: &str,
234        #[case] expected: &str,
235        #[case] borrowed: bool,
236    ) {
237        let relative = get_relative_path_to(from, target);
238        assert_eq!(relative, expected);
239        assert_eq!(matches!(relative, Cow::Borrowed(_)), borrowed);
240    }
241
242    /// The same cases as [`test_get_relative_path_to`], so the two forms can be compared row by
243    /// row. They differ only where the result would otherwise be a bare path, which is exactly
244    /// where a request needs its `./`.
245    #[rstest]
246    #[case("a/b/c", "a/b/c", ".", true)]
247    #[case("a/c/d", "a/b/c", "../../b/c", false)]
248    #[case("", "a/b/c", "./a/b/c", false)]
249    #[case("", "", ".", true)]
250    #[case("a/b", "a/b/c", "./c", false)]
251    #[case("a/b", "a/b/c/d", "./c/d", false)]
252    #[case("a/b/c", "", "../../..", false)]
253    #[case("a/b/c", "c/b/a", "../../../c/b/a", false)]
254    #[case("file:///a/b/c", "file:///c/b/a", "../../../c/b/a", false)]
255    fn test_get_relative_request_to(
256        #[case] from: &str,
257        #[case] target: &str,
258        #[case] expected: &str,
259        #[case] borrowed: bool,
260    ) {
261        let relative = get_relative_request_to(from, target);
262        assert_eq!(relative, expected);
263        assert_eq!(matches!(relative, Cow::Borrowed(_)), borrowed);
264    }
265}