i_slint_compiler/
pathutils.rs

1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4//! Reimplement some Path handling code: The one in `std` is not available
5//! when running in WASM!
6//!
7//! This is not helped by us using URLs in place of paths *sometimes*.
8
9use smol_str::{format_smolstr, SmolStr, SmolStrBuilder};
10use std::path::{Path, PathBuf};
11
12/// Check whether a `Path` is actually an URL.
13pub fn is_url(path: &Path) -> bool {
14    let Some(path) = path.to_str() else {
15        // URLs can always convert to string in Rust
16        return false;
17    };
18
19    to_url(path).is_some()
20}
21
22/// Convert a `Path` to an `url::Url` if possible
23fn to_url(path: &str) -> Option<url::Url> {
24    let Ok(url) = url::Url::parse(path) else {
25        return None;
26    };
27    if url.scheme().len() == 1 {
28        // "c:/" is a path, not a URL
29        None
30    } else {
31        Some(url)
32    }
33}
34
35#[test]
36fn test_to_url() {
37    #[track_caller]
38    fn th(input: &str, expected: bool) {
39        assert_eq!(to_url(input).is_some(), expected);
40    }
41
42    th("https://foo.bar/", true);
43    th("builtin:/foo/bar/", true);
44    th("user://foo/bar.rs", true);
45    th("/foo/bar/", false);
46    th("../foo/bar", false);
47    th("foo/bar", false);
48    th("./foo/bar", false);
49    // Windows style paths:
50    th("C:\\Documents\\Newsletters\\Summer2018.pdf", false);
51    th("\\Program Files\\Custom Utilities\\StringFinder.exe", false);
52    th("2018\\January.xlsx", false);
53    th("..\\Publications\\TravelBrochure.pdf", false);
54    th("C:\\Projects\\library\\library.sln", false);
55    th("C:Projects\\library\\library.sln", false);
56    th("\\\\system07\\C$\\", false);
57    th("\\\\Server2\\Share\\Test\\Foo.txt", false);
58    th("\\\\.\\C:\\Test\\Foo.txt", false);
59    th("\\\\?\\C:\\Test\\Foo.txt", false);
60    th("\\\\.\\Volume{b75e2c83-0000-0000-0000-602f00000000}\\Test\\Foo.txt", false);
61    th("\\\\?\\Volume{b75e2c83-0000-0000-0000-602f00000000}\\Test\\Foo.txt", false);
62    // Windows style paths - some programs will helpfully convert the backslashes:-(:
63    th("C:/Documents/Newsletters/Summer2018.pdf", false);
64    th("/Program Files/Custom Utilities/StringFinder.exe", false);
65    th("2018/January.xlsx", false);
66    th("../Publications/TravelBrochure.pdf", false);
67    th("C:/Projects/library/library.sln", false);
68    th("C:Projects/library/library.sln", false);
69    th("//system07/C$/", false);
70    th("//Server2/Share/Test/Foo.txt", false);
71    th("//./C:/Test/Foo.txt", false);
72    th("//?/C:/Test/Foo.txt", false);
73    th("//./Volume{b75e2c83-0000-0000-0000-602f00000000}/Test/Foo.txt", false);
74    th("//?/Volume{b75e2c83-0000-0000-0000-602f00000000}/Test/Foo.txt", false);
75    // Some corner case:
76    th("C:///Documents/Newsletters/Summer2018.pdf", false);
77    th("/http://foo/bar/", false);
78    th("../http://foo/bar", false);
79    th("foo/http://foo/bar", false);
80    th("./http://foo/bar", false);
81    th("", false);
82}
83
84// Check whether a path is absolute.
85//
86// This returns true is the Path contains a `url::Url` or starts with anything
87// that is a root prefix (e.g. `/` on Unix or `C:\` on Windows).
88pub fn is_absolute(path: &Path) -> bool {
89    let Some(path) = path.to_str() else {
90        // URLs can always convert to string in Rust
91        return false;
92    };
93
94    if to_url(path).is_some() {
95        return true;
96    }
97
98    matches!(components(path, 0, &None), Some((PathComponent::Root(_), _, _)))
99}
100
101#[test]
102fn test_is_absolute() {
103    #[track_caller]
104    fn th(input: &str, expected: bool) {
105        let path = PathBuf::from(input);
106        assert_eq!(is_absolute(&path), expected);
107    }
108
109    th("https://foo.bar/", true);
110    th("builtin:/foo/bar/", true);
111    th("user://foo/bar.rs", true);
112    th("/foo/bar/", true);
113    th("../foo/bar", false);
114    th("foo/bar", false);
115    th("./foo/bar", false);
116    // Windows style paths:
117    th("C:\\Documents\\Newsletters\\Summer2018.pdf", true);
118    // Windows actually considers this to be a relative path:
119    th("\\Program Files\\Custom Utilities\\StringFinder.exe", true);
120    th("2018\\January.xlsx", false);
121    th("..\\Publications\\TravelBrochure.pdf", false);
122    th("C:\\Projects\\library\\library.sln", true);
123    th("C:Projects\\library\\library.sln", false);
124    th("\\\\system07\\C$\\", true);
125    th("\\\\Server2\\Share\\Test\\Foo.txt", true);
126    th("\\\\.\\C:\\Test\\Foo.txt", true);
127    th("\\\\?\\C:\\Test\\Foo.txt", true);
128    th("\\\\.\\Volume{b75e2c83-0000-0000-0000-602f00000000}\\Test\\Foo.txt", true);
129    th("\\\\?\\Volume{b75e2c83-0000-0000-0000-602f00000000}\\Test\\Foo.txt", true);
130    // Windows style paths - some programs will helpfully convert the backslashes:-(:
131    th("C:/Documents/Newsletters/Summer2018.pdf", true);
132    // Windows actually considers this to be a relative path:
133    th("/Program Files/Custom Utilities/StringFinder.exe", true);
134    th("2018/January.xlsx", false);
135    th("../Publications/TravelBrochure.pdf", false);
136    th("C:/Projects/library/library.sln", true);
137    th("C:Projects/library/library.sln", false);
138    // These are true, but only because '/' is root on Unix!
139    th("//system07/C$/", true);
140    th("//Server2/Share/Test/Foo.txt", true);
141    th("//./C:/Test/Foo.txt", true);
142    th("//?/C:/Test/Foo.txt", true);
143    th("//./Volume{b75e2c83-0000-0000-0000-602f00000000}/Test/Foo.txt", true);
144    th("//?/Volume{b75e2c83-0000-0000-0000-602f00000000}/Test/Foo.txt", true);
145    // Some corner case:
146    th("C:///Documents/Newsletters/Summer2018.pdf", true);
147    th("C:", false);
148    th("C:\\", true);
149    th("C:/", true);
150    th("", false);
151}
152
153#[derive(Debug, PartialEq)]
154enum PathComponent<'a> {
155    Root(&'a str),
156    Empty,
157    SameDirectory(&'a str),
158    ParentDirectory(&'a str),
159    Directory(&'a str),
160    File(&'a str),
161}
162
163/// Find which kind of path separator is used in the `str`
164fn find_path_separator(path: &str) -> char {
165    for c in path.chars() {
166        if c == '/' || c == '\\' {
167            return c;
168        }
169    }
170    '/'
171}
172
173/// Look at the individual parts of a `path`.
174///
175/// This will not work well with URLs, check whether something is an URL first!
176fn components<'a>(
177    path: &'a str,
178    offset: usize,
179    separator: &Option<char>,
180) -> Option<(PathComponent<'a>, usize, char)> {
181    use PathComponent as PC;
182
183    if offset >= path.len() {
184        return None;
185    }
186
187    let b = path.as_bytes();
188
189    if offset == 0 {
190        if b.len() >= 3
191            && b[0].is_ascii_alphabetic()
192            && b[1] == b':'
193            && (b[2] == b'\\' || b[2] == b'/')
194        {
195            return Some((PC::Root(&path[0..3]), 3, b[2] as char));
196        }
197        if b.len() >= 2 && b[0] == b'\\' && b[1] == b'\\' {
198            let second_bs = path[2..]
199                .find('\\')
200                .map(|pos| pos + 2 + 1)
201                .and_then(|pos1| path[pos1..].find('\\').map(|pos2| pos2 + pos1));
202            if let Some(end_offset) = second_bs {
203                return Some((PC::Root(&path[0..=end_offset]), end_offset + 1, '\\'));
204            }
205        }
206        if b[0] == b'/' || b[0] == b'\\' {
207            return Some((PC::Root(&path[0..1]), 1, b[0] as char));
208        }
209    }
210
211    let separator = separator.unwrap_or_else(|| find_path_separator(path));
212
213    let next_component = path[offset..].find(separator).map(|p| p + offset).unwrap_or(path.len());
214    if &path[offset..next_component] == "." {
215        return Some((
216            PC::SameDirectory(&path[offset..next_component]),
217            next_component + 1,
218            separator,
219        ));
220    }
221    if &path[offset..next_component] == ".." {
222        return Some((
223            PC::ParentDirectory(&path[offset..next_component]),
224            next_component + 1,
225            separator,
226        ));
227    }
228
229    if next_component == path.len() {
230        Some((PC::File(&path[offset..next_component]), next_component, separator))
231    } else if next_component == offset {
232        Some((PC::Empty, next_component + 1, separator))
233    } else {
234        Some((PC::Directory(&path[offset..next_component]), next_component + 1, separator))
235    }
236}
237
238#[test]
239fn test_components() {
240    use PathComponent as PC;
241
242    #[track_caller]
243    fn th(input: &str, expected: Option<(PathComponent, usize, char)>) {
244        assert_eq!(components(input, 0, &None), expected);
245    }
246
247    th("/foo/bar/", Some((PC::Root("/"), 1, '/')));
248    th("../foo/bar", Some((PC::ParentDirectory(".."), 3, '/')));
249    th("foo/bar", Some((PC::Directory("foo"), 4, '/')));
250    th("./foo/bar", Some((PC::SameDirectory("."), 2, '/')));
251    // Windows style paths:
252    th("C:\\Documents\\Newsletters\\Summer2018.pdf", Some((PC::Root("C:\\"), 3, '\\')));
253    // Windows actually considers this to be a relative path:
254    th("\\Program Files\\Custom Utilities\\StringFinder.exe", Some((PC::Root("\\"), 1, '\\')));
255    th("2018\\January.xlsx", Some((PC::Directory("2018"), 5, '\\')));
256    th("..\\Publications\\TravelBrochure.pdf", Some((PC::ParentDirectory(".."), 3, '\\')));
257    // TODO: This is wrong, but we are unlikely to need it:-)
258    th("C:Projects\\library\\library.sln", Some((PC::Directory("C:Projects"), 11, '\\')));
259    th("\\\\system07\\C$\\", Some((PC::Root("\\\\system07\\C$\\"), 14, '\\')));
260    th("\\\\Server2\\Share\\Test\\Foo.txt", Some((PC::Root("\\\\Server2\\Share\\"), 16, '\\')));
261    th("\\\\.\\C:\\Test\\Foo.txt", Some((PC::Root("\\\\.\\C:\\"), 7, '\\')));
262    th("\\\\?\\C:\\Test\\Foo.txt", Some((PC::Root("\\\\?\\C:\\"), 7, '\\')));
263    th(
264        "\\\\.\\Volume{b75e2c83-0000-0000-0000-602f00000000}\\Test\\Foo.txt",
265        Some((PC::Root("\\\\.\\Volume{b75e2c83-0000-0000-0000-602f00000000}\\"), 49, '\\')),
266    );
267    th(
268        "\\\\?\\Volume{b75e2c83-0000-0000-0000-602f00000000}\\Test\\Foo.txt",
269        Some((PC::Root("\\\\?\\Volume{b75e2c83-0000-0000-0000-602f00000000}\\"), 49, '\\')),
270    );
271    // Windows style paths - some programs will helpfully convert the backslashes:-(:
272    th("C:/Documents/Newsletters/Summer2018.pdf", Some((PC::Root("C:/"), 3, '/')));
273    // TODO: All the following are wrong, but unlikely to bother us!
274    th("/Program Files/Custom Utilities/StringFinder.exe", Some((PC::Root("/"), 1, '/')));
275    th("//system07/C$/", Some((PC::Root("/"), 1, '/')));
276    th("//Server2/Share/Test/Foo.txt", Some((PC::Root("/"), 1, '/')));
277    th("//./C:/Test/Foo.txt", Some((PC::Root("/"), 1, '/')));
278    th("//?/C:/Test/Foo.txt", Some((PC::Root("/"), 1, '/')));
279    th(
280        "//./Volume{b75e2c83-0000-0000-0000-602f00000000}/Test/Foo.txt",
281        Some((PC::Root("/"), 1, '/')),
282    );
283    th(
284        "//?/Volume{b75e2c83-0000-0000-0000-602f00000000}/Test/Foo.txt",
285        Some((PC::Root("/"), 1, '/')),
286    );
287    // // Some corner case:
288    // th("C:///Documents/Newsletters/Summer2018.pdf", true);
289    // TODO: This is wrong, but unlikely to be needed
290    th("C:", Some((PC::File("C:"), 2, '/')));
291    th("foo", Some((PC::File("foo"), 3, '/')));
292    th("foo/", Some((PC::Directory("foo"), 4, '/')));
293    th("foo\\", Some((PC::Directory("foo"), 4, '\\')));
294    th("", None);
295}
296
297struct Components<'a> {
298    path: &'a str,
299    offset: usize,
300    separator: Option<char>,
301}
302
303fn component_iter(path: &str) -> Components<'_> {
304    Components { path, offset: 0, separator: None }
305}
306
307impl<'a> Iterator for Components<'a> {
308    type Item = PathComponent<'a>;
309
310    fn next(&mut self) -> Option<Self::Item> {
311        let (result, new_offset, separator) = components(self.path, self.offset, &self.separator)?;
312        self.offset = new_offset;
313        self.separator = Some(separator);
314
315        Some(result)
316    }
317}
318
319fn clean_path_string(path: &str) -> SmolStr {
320    use PathComponent as PC;
321
322    let separator = find_path_separator(path);
323    let path = if separator == '\\' {
324        path.replace('/', &format!("{separator}"))
325    } else {
326        path.replace('\\', "/")
327    };
328
329    let mut clean_components = Vec::new();
330
331    for component in component_iter(&path) {
332        match component {
333            PC::Root(v) => {
334                clean_components = vec![PC::Root(v)];
335            }
336            PC::Empty | PC::SameDirectory(_) => { /* nothing to do */ }
337            PC::ParentDirectory(v) => {
338                match clean_components.last() {
339                    Some(PC::Directory(_)) => {
340                        clean_components.pop();
341                    }
342                    Some(PC::File(_)) => unreachable!("Must be the last component"),
343                    Some(PC::SameDirectory(_) | PC::Empty) => {
344                        unreachable!("Will never be in a the vector")
345                    }
346                    Some(PC::ParentDirectory(_)) => {
347                        clean_components.push(PC::ParentDirectory(v));
348                    }
349                    Some(PC::Root(_)) => { /* do nothing */ }
350                    None => {
351                        clean_components.push(PC::ParentDirectory(v));
352                    }
353                };
354            }
355            PC::Directory(v) => clean_components.push(PC::Directory(v)),
356            PC::File(v) => clean_components.push(PC::File(v)),
357        }
358    }
359    if clean_components.is_empty() {
360        SmolStr::new_static(".")
361    } else {
362        let mut result = SmolStrBuilder::default();
363        for c in clean_components {
364            match c {
365                PC::Root(v) => {
366                    result.push_str(v);
367                }
368                PC::Empty | PC::SameDirectory(_) => {
369                    unreachable!("Never in the vector!")
370                }
371                PC::ParentDirectory(v) => {
372                    result.push_str(&format_smolstr!("{v}{separator}"));
373                }
374                PC::Directory(v) => result.push_str(&format_smolstr!("{v}{separator}")),
375                PC::File(v) => {
376                    result.push_str(v);
377                }
378            }
379        }
380        result.finish()
381    }
382}
383
384#[test]
385fn test_clean_path_string() {
386    #[track_caller]
387    fn th(input: &str, expected: &str) {
388        let result = clean_path_string(input);
389        assert_eq!(result, expected);
390    }
391
392    th("../../ab/.././hello.txt", "../../hello.txt");
393    th("/../../ab/.././hello.txt", "/hello.txt");
394    th("ab/.././cb/././///./..", ".");
395    th("ab/.././cb/.\\.\\\\\\\\./..", ".");
396    th("ab\\..\\.\\cb\\././///./..", ".");
397}
398
399/// Return a clean up path without unnecessary `.` and `..` directories in it.
400///
401/// This will *not* look at the file system, so symlinks will not get resolved.
402pub fn clean_path(path: &Path) -> PathBuf {
403    let Some(path_str) = path.to_str() else {
404        return path.to_owned();
405    };
406
407    if let Some(url) = to_url(path_str) {
408        // URL is cleaned up while parsing!
409        PathBuf::from(url.to_string())
410    } else {
411        PathBuf::from(clean_path_string(path_str).to_string())
412    }
413}
414
415fn dirname_string(path: &str) -> String {
416    let separator = find_path_separator(path);
417    let mut result = String::new();
418
419    for component in component_iter(path) {
420        match component {
421            PathComponent::Root(v) => result = v.to_string(),
422            PathComponent::Empty => result.push(separator),
423            PathComponent::SameDirectory(v)
424            | PathComponent::ParentDirectory(v)
425            | PathComponent::Directory(v) => result += &format!("{v}{separator}"),
426            PathComponent::File(_) => { /* nothing to do */ }
427        };
428    }
429
430    if result.is_empty() {
431        String::from(".")
432    } else {
433        result
434    }
435}
436
437#[test]
438fn test_dirname() {
439    #[track_caller]
440    fn th(input: &str, expected: &str) {
441        let result = dirname_string(input);
442        assert_eq!(result, expected);
443    }
444
445    th("/../../ab/.././", "/../../ab/.././");
446    th("ab/.././cb/./././..", "ab/.././cb/./././../");
447    th("hello.txt", ".");
448    th("../hello.txt", "../");
449    th("/hello.txt", "/");
450}
451
452/// Return the part of `Path` before the last path separator.
453pub fn dirname(path: &Path) -> PathBuf {
454    let Some(path_str) = path.to_str() else {
455        return path.to_owned();
456    };
457
458    PathBuf::from(dirname_string(path_str))
459}
460
461/// Join a `path` to a `base_path`, handling URLs in both, matching up
462/// path separators, etc.
463///
464/// The result will be a `clean_path(...)`.
465pub fn join(base: &Path, path: &Path) -> Option<PathBuf> {
466    if is_absolute(path) {
467        return Some(path.to_owned());
468    }
469
470    let Some(base_str) = base.to_str() else {
471        return Some(path.to_owned());
472    };
473    let Some(path_str) = path.to_str() else {
474        return Some(path.to_owned());
475    };
476
477    let path_separator = find_path_separator(path_str);
478
479    if let Some(mut base_url) = to_url(base_str) {
480        let path_str = if path_separator != '/' {
481            path_str.replace(path_separator, "/")
482        } else {
483            path_str.to_string()
484        };
485
486        let base_path = base_url.path();
487        if !base_path.is_empty() && !base_path.ends_with('/') {
488            base_url.set_path(&format_smolstr!("{base_path}/"));
489        }
490
491        Some(PathBuf::from(base_url.join(&path_str).ok()?.to_string()))
492    } else {
493        let base_separator = find_path_separator(base_str);
494        let path_str = if path_separator != base_separator {
495            path_str.replace(path_separator, &base_separator.to_string())
496        } else {
497            path_str.to_string()
498        };
499        let joined = clean_path_string(&format_smolstr!("{base_str}{base_separator}{path_str}"));
500        Some(PathBuf::from(joined.to_string()))
501    }
502}
503
504#[test]
505fn test_join() {
506    #[track_caller]
507    fn th(base: &str, path: &str, expected: Option<&str>) {
508        let base = PathBuf::from(base);
509        let path = PathBuf::from(path);
510        let expected = expected.map(|e| PathBuf::from(e));
511
512        let result = join(&base, &path);
513        assert_eq!(result, expected);
514    }
515
516    th("https://slint.dev/", "/hello.txt", Some("/hello.txt"));
517    th("https://slint.dev/", "../../hello.txt", Some("https://slint.dev/hello.txt"));
518    th("/../../ab/.././", "hello.txt", Some("/hello.txt"));
519    th("ab/.././cb/./././..", "../.././hello.txt", Some("../../hello.txt"));
520    th("builtin:/foo", "..\\bar.slint", Some("builtin:/bar.slint"));
521    th("builtin:/", "..\\bar.slint", Some("builtin:/bar.slint"));
522    th("builtin:/foo/baz", "..\\bar.slint", Some("builtin:/foo/bar.slint"));
523    th("builtin:/foo", "bar.slint", Some("builtin:/foo/bar.slint"));
524    th("builtin:/foo/", "bar.slint", Some("builtin:/foo/bar.slint"));
525    th("builtin:/", "..\\bar.slint", Some("builtin:/bar.slint"));
526}