nu_path/
dots.rs

1#[cfg(windows)]
2use omnipath::WinPathExt;
3use std::path::{Component, Path, PathBuf, Prefix};
4
5/// Normalize the path, expanding occurrences of n-dots.
6///
7/// It performs the same normalization as `nu_path::components()`, except it also expands n-dots,
8/// such as "..." and "....", into multiple "..".
9///
10/// The resulting path will use platform-specific path separators, regardless of what path separators was used in the input.
11pub fn expand_ndots(path: impl AsRef<Path>) -> PathBuf {
12    // Returns whether a path component is n-dots.
13    fn is_ndots(s: &std::ffi::OsStr) -> bool {
14        s.as_encoded_bytes().iter().all(|c| *c == b'.') && s.len() >= 3
15    }
16
17    let path = path.as_ref();
18
19    let mut result = PathBuf::with_capacity(path.as_os_str().len());
20    let mut has_special_prefix = false;
21    for component in crate::components(path) {
22        match component {
23            Component::Normal(s) if is_ndots(s) => {
24                let n = s.len();
25                // Push ".." to the path (n - 1) times.
26                for _ in 0..n - 1 {
27                    result.push("..");
28                }
29            }
30            Component::Prefix(prefix) => {
31                match prefix.kind() {
32                    Prefix::Disk(_) => {
33                        // Here, only the disk letter gets parsed as prefix,
34                        // so the following RootDir component makes sense
35                    }
36                    _ => {
37                        has_special_prefix = true;
38                    }
39                }
40                result.push(component)
41            }
42            Component::RootDir if has_special_prefix => {
43                // Ignore; this would add a trailing backslash to the path that wasn't in the input
44            }
45            _ => result.push(component),
46        }
47    }
48
49    result
50}
51
52/// Normalize the path, expanding occurrences of "." and "..".
53///
54/// It performs the same normalization as `nu_path::components()`, except it also expands ".."
55/// when its preceding component is a normal component, ignoring the possibility of symlinks.
56/// In other words, it operates on the lexical structure of the path.
57///
58/// This won't expand "/.." even though the parent directory of "/" is often
59/// considered to be itself.
60///
61/// The resulting path will use platform-specific path separators, regardless of what path separators was used in the input.
62pub fn expand_dots(path: impl AsRef<Path>) -> PathBuf {
63    // Check if the last component of the path is a normal component.
64    fn last_component_is_normal(path: &Path) -> bool {
65        matches!(path.components().next_back(), Some(Component::Normal(_)))
66    }
67
68    let path = path.as_ref();
69
70    let mut has_special_prefix = false;
71    let mut result = PathBuf::with_capacity(path.as_os_str().len());
72    for component in crate::components(path) {
73        match component {
74            Component::ParentDir if last_component_is_normal(&result) => {
75                result.pop();
76            }
77            Component::CurDir if last_component_is_normal(&result) => {
78                // no-op
79            }
80            Component::Prefix(prefix) => {
81                match prefix.kind() {
82                    Prefix::Disk(_) => {
83                        // Here, only the disk letter gets parsed as prefix,
84                        // so the following RootDir component makes sense
85                    }
86                    _ => {
87                        has_special_prefix = true;
88                    }
89                }
90                result.push(component)
91            }
92            Component::RootDir if has_special_prefix => {
93                // Ignore; this would add a trailing backslash to the path that wasn't in the input
94            }
95            _ => {
96                let prev_component = result.components().next_back();
97                if prev_component == Some(Component::RootDir) && component == Component::ParentDir {
98                    continue;
99                }
100                result.push(component)
101            }
102        }
103    }
104
105    simiplified(&result)
106}
107
108/// Expand ndots, but only if it looks like it probably contains them, because there is some lossy
109/// path normalization that happens.
110pub fn expand_ndots_safe(path: impl AsRef<Path>) -> PathBuf {
111    let string = path.as_ref().to_string_lossy();
112
113    // Use ndots if it contains at least `...`, since that's the minimum trigger point.
114    // Don't use it if it contains ://, because that looks like a URL scheme and the path normalization
115    // will mess with that.
116    // Don't use it if it starts with `./`, as to not break golang wildcard syntax
117    // (since generally you're probably not using `./` with ndots)
118    if string.contains("...") && !string.contains("://") && !string.starts_with("./") {
119        expand_ndots(path)
120    } else {
121        path.as_ref().to_owned()
122    }
123}
124
125#[cfg(windows)]
126fn simiplified(path: &std::path::Path) -> PathBuf {
127    path.to_winuser_path()
128        .unwrap_or_else(|_| path.to_path_buf())
129}
130
131#[cfg(not(windows))]
132fn simiplified(path: &std::path::Path) -> PathBuf {
133    path.to_path_buf()
134}
135
136#[cfg(test)]
137mod test_expand_ndots {
138    use super::*;
139    use crate::assert_path_eq;
140
141    #[test]
142    fn empty_path() {
143        let path = Path::new("");
144        assert_path_eq!(expand_ndots(path), "");
145    }
146
147    #[test]
148    fn root_dir() {
149        let path = Path::new("/");
150        let expected = if cfg!(windows) { "\\" } else { "/" };
151        assert_path_eq!(expand_ndots(path), expected);
152    }
153
154    #[test]
155    fn two_dots() {
156        let path = Path::new("..");
157        assert_path_eq!(expand_ndots(path), "..");
158    }
159
160    #[test]
161    fn three_dots() {
162        let path = Path::new("...");
163        let expected = if cfg!(windows) { r"..\.." } else { "../.." };
164        assert_path_eq!(expand_ndots(path), expected);
165    }
166
167    #[test]
168    fn five_dots() {
169        let path = Path::new(".....");
170        let expected = if cfg!(windows) {
171            r"..\..\..\.."
172        } else {
173            "../../../.."
174        };
175        assert_path_eq!(expand_ndots(path), expected);
176    }
177
178    #[test]
179    fn three_dots_with_trailing_slash() {
180        let path = Path::new("/tmp/.../");
181        let expected = if cfg!(windows) {
182            r"\tmp\..\..\"
183        } else {
184            "/tmp/../../"
185        };
186        assert_path_eq!(expand_ndots(path), expected);
187    }
188
189    #[test]
190    fn filenames_with_dots() {
191        let path = Path::new("...foo.../");
192        let expected = if cfg!(windows) {
193            r"...foo...\"
194        } else {
195            "...foo.../"
196        };
197        assert_path_eq!(expand_ndots(path), expected);
198    }
199
200    #[test]
201    fn multiple_ndots() {
202        let path = Path::new("..././...");
203        let expected = if cfg!(windows) {
204            r"..\..\..\.."
205        } else {
206            "../../../.."
207        };
208        assert_path_eq!(expand_ndots(path), expected);
209    }
210
211    #[test]
212    fn trailing_dots() {
213        let path = Path::new("/foo/bar/..");
214        let expected = if cfg!(windows) {
215            r"\foo\bar\.."
216        } else {
217            "/foo/bar/.."
218        };
219        assert_path_eq!(expand_ndots(path), expected);
220    }
221
222    #[test]
223    fn leading_dot_slash() {
224        let path = Path::new("./...");
225        assert_path_eq!(expand_ndots_safe(path), "./...");
226    }
227
228    #[test]
229    fn unc_share_no_dots() {
230        let path = Path::new(r"\\server\share");
231        assert_path_eq!(expand_ndots(path), path);
232    }
233
234    #[test]
235    fn unc_file_no_dots() {
236        let path = Path::new(r"\\server\share\dir\file.nu");
237        assert_path_eq!(expand_ndots(path), path);
238    }
239
240    #[test]
241    fn verbatim_no_dots() {
242        let path = Path::new(r"\\?\pictures\elephants");
243        assert_path_eq!(expand_ndots(path), path);
244    }
245
246    #[test]
247    fn verbatim_unc_share_no_dots() {
248        let path = Path::new(r"\\?\UNC\server\share");
249        assert_path_eq!(expand_ndots(path), path);
250    }
251
252    #[test]
253    fn verbatim_unc_file_no_dots() {
254        let path = Path::new(r"\\?\UNC\server\share\dir\file.nu");
255        assert_path_eq!(expand_ndots(path), path);
256    }
257
258    #[test]
259    fn verbatim_disk_no_dots() {
260        let path = Path::new(r"\\?\c:\");
261        assert_path_eq!(expand_ndots(path), path);
262    }
263
264    #[test]
265    fn device_path_no_dots() {
266        let path = Path::new(r"\\.\CON");
267        assert_path_eq!(expand_ndots(path), path);
268    }
269
270    #[test]
271    fn disk_no_dots() {
272        let path = Path::new(r"c:\Users\Ellie\nu_scripts");
273        assert_path_eq!(expand_ndots(path), path);
274    }
275}
276
277#[cfg(test)]
278mod test_expand_dots {
279    use super::*;
280    use crate::assert_path_eq;
281
282    #[test]
283    fn empty_path() {
284        let path = Path::new("");
285        assert_path_eq!(expand_dots(path), "");
286    }
287
288    #[test]
289    fn single_dot() {
290        let path = Path::new("./");
291        let expected = if cfg!(windows) { r".\" } else { "./" };
292        assert_path_eq!(expand_dots(path), expected);
293    }
294
295    #[test]
296    fn more_single_dots() {
297        let path = Path::new("././.");
298        let expected = ".";
299        assert_path_eq!(expand_dots(path), expected);
300    }
301
302    #[test]
303    fn double_dots() {
304        let path = Path::new("../../..");
305        let expected = if cfg!(windows) {
306            r"..\..\.."
307        } else {
308            "../../.."
309        };
310        assert_path_eq!(expand_dots(path), expected);
311    }
312
313    #[test]
314    fn backtrack_once() {
315        let path = Path::new("/foo/bar/../baz/");
316        let expected = if cfg!(windows) {
317            r"\foo\baz\"
318        } else {
319            "/foo/baz/"
320        };
321        assert_path_eq!(expand_dots(path), expected);
322    }
323
324    #[test]
325    fn backtrack_to_root() {
326        let path = Path::new("/foo/bar/../../../../baz");
327        let expected = if cfg!(windows) { r"\baz" } else { "/baz" };
328        assert_path_eq!(expand_dots(path), expected);
329    }
330
331    #[test]
332    fn unc_share_no_dots() {
333        let path = Path::new(r"\\server\share");
334        assert_path_eq!(expand_dots(path), path);
335    }
336
337    #[test]
338    fn unc_file_no_dots() {
339        let path = Path::new(r"\\server\share\dir\file.nu");
340        assert_path_eq!(expand_dots(path), path);
341    }
342
343    #[test]
344    #[ignore = "bug in upstream library"]
345    fn verbatim_no_dots() {
346        // omnipath::windows::sys::Path::to_winuser_path seems to turn this verbatim path into a device path
347        let path = Path::new(r"\\?\pictures\elephants");
348        assert_path_eq!(expand_dots(path), path);
349    }
350
351    #[cfg_attr(not(windows), ignore = "only for Windows")]
352    #[test]
353    fn verbatim_unc_share_no_dots() {
354        let path = Path::new(r"\\?\UNC\server\share");
355        let expected = Path::new(r"\\server\share");
356        assert_path_eq!(expand_dots(path), expected);
357    }
358
359    #[cfg_attr(not(windows), ignore = "only for Windows")]
360    #[test]
361    fn verbatim_unc_file_no_dots() {
362        let path = Path::new(r"\\?\UNC\server\share\dir\file.nu");
363        let expected = Path::new(r"\\server\share\dir\file.nu");
364        assert_path_eq!(expand_dots(path), expected);
365    }
366
367    #[cfg_attr(not(windows), ignore = "only for Windows")]
368    #[test]
369    fn verbatim_disk_no_dots() {
370        let path = Path::new(r"\\?\C:\");
371        let expected = Path::new(r"C:\");
372        assert_path_eq!(expand_dots(path), expected);
373    }
374
375    #[test]
376    fn device_path_no_dots() {
377        let path = Path::new(r"\\.\CON");
378        assert_path_eq!(expand_dots(path), path);
379    }
380
381    #[test]
382    fn disk_no_dots() {
383        let path = Path::new(r"c:\Users\Ellie\nu_scripts");
384        assert_path_eq!(expand_dots(path), path);
385    }
386}