Skip to main content

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