Skip to main content

helix_stdx/
path.rs

1//! Functions for working with [Path].
2
3pub use etcetera::home_dir;
4use regex_cursor::{engines::meta::Regex, Input};
5use ropey::RopeSlice;
6use std::sync::LazyLock;
7
8use std::{
9    borrow::Cow,
10    ffi::OsString,
11    ops::Range,
12    path::{Component, Path, PathBuf, MAIN_SEPARATOR_STR},
13};
14
15use crate::env::current_working_dir;
16
17/// Replaces users home directory from `path` with tilde `~` if the directory
18/// is available, otherwise returns the path unchanged.
19pub fn fold_home_dir<'a, P>(path: P) -> Cow<'a, Path>
20where
21    P: Into<Cow<'a, Path>>,
22{
23    let path = path.into();
24    if let Ok(home) = home_dir() {
25        if let Ok(stripped) = path.strip_prefix(&home) {
26            let mut path = OsString::with_capacity(2 + stripped.as_os_str().len());
27            path.push("~");
28            path.push(MAIN_SEPARATOR_STR);
29            path.push(stripped);
30            return Cow::Owned(PathBuf::from(path));
31        }
32    }
33
34    path
35}
36
37/// Expands tilde `~` into users home directory if available, otherwise returns the path
38/// unchanged.
39///
40/// The tilde will only be expanded when present as the first component of the path
41/// and only slash follows it.
42pub fn expand_tilde<'a, P>(path: P) -> Cow<'a, Path>
43where
44    P: Into<Cow<'a, Path>>,
45{
46    let path = path.into();
47    let mut components = path.components();
48    if let Some(Component::Normal(c)) = components.next() {
49        if c == "~" {
50            if let Ok(mut buf) = home_dir() {
51                buf.push(components);
52                return Cow::Owned(buf);
53            }
54        }
55    }
56
57    path
58}
59
60/// Normalize a path without resolving symlinks.
61// Strategy: start from the first component and move up. Canonicalize previous path,
62// join component, canonicalize new path, strip prefix and join to the final result.
63pub fn normalize(path: impl AsRef<Path>) -> PathBuf {
64    let mut components = path.as_ref().components().peekable();
65    let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().copied() {
66        components.next();
67        PathBuf::from(c.as_os_str())
68    } else {
69        PathBuf::new()
70    };
71
72    for component in components {
73        match component {
74            Component::Prefix(..) => unreachable!(),
75            Component::RootDir => {
76                ret.push(component.as_os_str());
77            }
78            Component::CurDir => {}
79            #[cfg(not(windows))]
80            Component::ParentDir => {
81                ret.pop();
82            }
83            #[cfg(windows)]
84            Component::ParentDir => {
85                if let Some(head) = ret.components().next_back() {
86                    match head {
87                        Component::Prefix(_) | Component::RootDir => {}
88                        Component::CurDir => unreachable!(),
89                        // If we left previous component as ".." it means we met a symlink before and we can't pop path.
90                        Component::ParentDir => {
91                            ret.push("..");
92                        }
93                        Component::Normal(_) => {
94                            if ret.is_symlink() {
95                                ret.push("..");
96                            } else {
97                                ret.pop();
98                            }
99                        }
100                    }
101                }
102            }
103            #[cfg(not(windows))]
104            Component::Normal(c) => {
105                ret.push(c);
106            }
107            #[cfg(windows)]
108            Component::Normal(c) => 'normal: {
109                use std::fs::canonicalize;
110
111                let new_path = ret.join(c);
112                if new_path.is_symlink() {
113                    ret = new_path;
114                    break 'normal;
115                }
116                let (can_new, can_old) = (canonicalize(&new_path), canonicalize(&ret));
117                match (can_new, can_old) {
118                    (Ok(can_new), Ok(can_old)) => {
119                        let striped = can_new.strip_prefix(can_old);
120                        ret.push(striped.unwrap_or_else(|_| c.as_ref()));
121                    }
122                    _ => ret.push(c),
123                }
124            }
125        }
126    }
127    dunce::simplified(&ret).to_path_buf()
128}
129
130/// Returns the canonical, absolute form of a path with all intermediate components normalized.
131///
132/// This function is used instead of [`std::fs::canonicalize`] because we don't want to verify
133/// here if the path exists, just normalize it's components.
134pub fn canonicalize(path: impl AsRef<Path>) -> PathBuf {
135    let path = expand_tilde(path.as_ref());
136    let path = if path.is_relative() {
137        Cow::Owned(current_working_dir().join(path))
138    } else {
139        path
140    };
141
142    normalize(path)
143}
144
145/// Convert path into a relative path
146pub fn get_relative_path<'a, P>(path: P) -> Cow<'a, Path>
147where
148    P: Into<Cow<'a, Path>>,
149{
150    let path = path.into();
151    if path.is_absolute() {
152        let cwdir = normalize(current_working_dir());
153        if let Ok(stripped) = normalize(&path).strip_prefix(cwdir) {
154            return Cow::Owned(PathBuf::from(stripped));
155        }
156
157        return fold_home_dir(path);
158    }
159
160    path
161}
162
163/// Returns a truncated filepath where the basepart of the path is reduced to the first
164/// char of the folder and the whole filename appended.
165///
166/// Also strip the current working directory from the beginning of the path.
167/// Note that this function does not check if the truncated path is unambiguous.
168///
169/// ```
170///    use helix_stdx::path::get_truncated_path;
171///    use std::path::Path;
172///
173///    assert_eq!(
174///         get_truncated_path("/home/cnorris/documents/jokes.txt").as_path(),
175///         Path::new("/h/c/d/jokes.txt")
176///     );
177///     assert_eq!(
178///         get_truncated_path("jokes.txt").as_path(),
179///         Path::new("jokes.txt")
180///     );
181///     assert_eq!(
182///         get_truncated_path("/jokes.txt").as_path(),
183///         Path::new("/jokes.txt")
184///     );
185///     assert_eq!(
186///         get_truncated_path("/h/c/d/jokes.txt").as_path(),
187///         Path::new("/h/c/d/jokes.txt")
188///     );
189///     assert_eq!(get_truncated_path("").as_path(), Path::new(""));
190/// ```
191///
192pub fn get_truncated_path(path: impl AsRef<Path>) -> PathBuf {
193    let cwd = current_working_dir();
194    let path = path.as_ref();
195    let path = path.strip_prefix(cwd).unwrap_or(path);
196    let file = path.file_name().unwrap_or_default();
197    let base = path.parent().unwrap_or_else(|| Path::new(""));
198    let mut ret = PathBuf::with_capacity(file.len());
199    // A char can't be directly pushed to a PathBuf
200    let mut first_char_buffer = String::new();
201    for d in base {
202        let Some(first_char) = d.to_string_lossy().chars().next() else {
203            break;
204        };
205        first_char_buffer.push(first_char);
206        ret.push(&first_char_buffer);
207        first_char_buffer.clear();
208    }
209    ret.push(file);
210    ret
211}
212
213fn path_component_regex(windows: bool) -> String {
214    // TODO: support backslash path escape on windows (when using git bash for example)
215    let space_escape = if windows { r"[\^`]\s" } else { r"[\\]\s" };
216    // partially baesd on what's allowed in an url but with some care to avoid
217    // false positives (like any kind of brackets or quotes)
218    r"[\w@.\-+#$%?!,;~&]|".to_owned() + space_escape
219}
220
221/// Regex for delimited environment captures like `${HOME}`.
222fn braced_env_regex(windows: bool) -> String {
223    r"\$\{(?:".to_owned() + &path_component_regex(windows) + r"|[/:=])+\}"
224}
225
226fn compile_path_regex(
227    prefix: &str,
228    postfix: &str,
229    match_single_file: bool,
230    windows: bool,
231) -> Regex {
232    let first_component = format!(
233        "(?:{}|(?:{}))",
234        braced_env_regex(windows),
235        path_component_regex(windows)
236    );
237    // For all components except the first we allow an equals so that `foo=/
238    // bar/baz` does not include foo. This is primarily intended for url queries
239    // (where an equals is never in the first component)
240    let component = format!("(?:{first_component}|=)");
241    let sep = if windows { r"[/\\]" } else { "/" };
242    let url_prefix = r"[\w+\-.]+://??";
243    let path_prefix = if windows {
244        // single slash handles most windows prefixes (like\\server\...) but `\
245        // \?\C:\..` (and C:\) needs special handling, since we don't allow : in path
246        // components (so that colon separated paths and <path>:<line> work)
247        r"\\\\\?\\\w:|\w:|\\|"
248    } else {
249        ""
250    };
251    let path_start = format!("(?:{first_component}+|~|{path_prefix}{url_prefix})");
252    let optional = if match_single_file {
253        format!("|{path_start}")
254    } else {
255        String::new()
256    };
257    let path_regex = format!(
258        "{prefix}(?:{path_start}?(?:(?:{sep}{component}+)+{sep}?|{sep}){optional}){postfix}"
259    );
260    Regex::new(&path_regex).unwrap()
261}
262
263/// If `src` ends with a path then this function returns the part of the slice.
264pub fn get_path_suffix(src: RopeSlice<'_>, match_single_file: bool) -> Option<RopeSlice<'_>> {
265    let regex = if match_single_file {
266        static REGEX: LazyLock<Regex> =
267            LazyLock::new(|| compile_path_regex("", "$", true, cfg!(windows)));
268        &*REGEX
269    } else {
270        static REGEX: LazyLock<Regex> =
271            LazyLock::new(|| compile_path_regex("", "$", false, cfg!(windows)));
272        &*REGEX
273    };
274
275    regex
276        .find(Input::new(src))
277        .map(|mat| src.byte_slice(mat.range()))
278}
279
280/// Returns an iterator of the **byte** ranges in src that contain a path.
281pub fn find_paths(
282    src: RopeSlice<'_>,
283    match_single_file: bool,
284) -> impl Iterator<Item = Range<usize>> + '_ {
285    let regex = if match_single_file {
286        static REGEX: LazyLock<Regex> =
287            LazyLock::new(|| compile_path_regex("", "", true, cfg!(windows)));
288        &*REGEX
289    } else {
290        static REGEX: LazyLock<Regex> =
291            LazyLock::new(|| compile_path_regex("", "", false, cfg!(windows)));
292        &*REGEX
293    };
294    regex.find_iter(Input::new(src)).map(|mat| mat.range())
295}
296
297/// Performs substitution of `~` and environment variables, see [`env::expand`](crate::env::expand) and [`expand_tilde`]
298pub fn expand<T: AsRef<Path> + ?Sized>(path: &T) -> Cow<'_, Path> {
299    let path = path.as_ref();
300    let path = expand_tilde(path);
301    match crate::env::expand(&*path) {
302        Cow::Borrowed(_) => path,
303        Cow::Owned(path) => PathBuf::from(path).into(),
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use std::{
310        ffi::OsStr,
311        path::{Component, Path},
312    };
313
314    use regex_cursor::Input;
315    use ropey::RopeSlice;
316
317    use crate::path::{self, compile_path_regex};
318
319    #[test]
320    fn expand_tilde() {
321        for path in ["~", "~/foo"] {
322            let expanded = path::expand_tilde(Path::new(path));
323
324            let tilde = Component::Normal(OsStr::new("~"));
325
326            let mut component_count = 0;
327            for component in expanded.components() {
328                // No tilde left.
329                assert_ne!(component, tilde);
330                component_count += 1;
331            }
332
333            // The path was at least expanded to something.
334            assert_ne!(component_count, 0);
335        }
336    }
337
338    macro_rules! assert_match {
339        ($regex: expr, $haystack: expr) => {
340            let haystack = Input::new(RopeSlice::from($haystack));
341            assert!(
342                $regex.is_match(haystack),
343                "regex should match {}",
344                $haystack
345            );
346        };
347    }
348    macro_rules! assert_no_match {
349        ($regex: expr, $haystack: expr) => {
350            let haystack = Input::new(RopeSlice::from($haystack));
351            assert!(
352                !$regex.is_match(haystack),
353                "regex should not match {}",
354                $haystack
355            );
356        };
357    }
358
359    macro_rules! assert_matches {
360        ($regex: expr, $haystack: expr, [$($matches: expr),*]) => {
361            let src = $haystack;
362            let matches: Vec<_> = $regex
363                .find_iter(Input::new(RopeSlice::from(src)))
364                .map(|it| &src[it.range()])
365                .collect();
366            assert_eq!(matches, vec![$($matches),*]);
367        };
368    }
369
370    /// Linux-only path
371    #[test]
372    fn path_regex_unix() {
373        // due to ambiguity with the `\` path separator we can't support space escapes `\ ` on windows
374        let regex = compile_path_regex("^", "$", false, false);
375        assert_match!(regex, "${FOO}/hello\\ world");
376        assert_match!(regex, "${FOO}/\\ ");
377    }
378
379    /// Windows-only paths
380    #[test]
381    fn path_regex_windows() {
382        let regex = compile_path_regex("^", "$", false, true);
383        assert_match!(regex, "${FOO}/hello^ world");
384        assert_match!(regex, "${FOO}/hello` world");
385        assert_match!(regex, "${FOO}/^ ");
386        assert_match!(regex, "${FOO}/` ");
387        assert_match!(regex, r"foo\bar");
388        assert_match!(regex, r"foo\bar");
389        assert_match!(regex, r"..\bar");
390        assert_match!(regex, r"..\");
391        assert_match!(regex, r"C:\");
392        assert_match!(regex, r"\\?\C:\foo");
393        assert_match!(regex, r"\\server\foo");
394    }
395
396    /// Paths that should work on all platforms
397    #[test]
398    fn path_regex() {
399        for windows in [false, true] {
400            let regex = compile_path_regex("^", "$", false, windows);
401            assert_no_match!(regex, "foo");
402            assert_no_match!(regex, "");
403            assert_match!(regex, "https://github.com/notifications/query=foo");
404            assert_match!(regex, "file:///foo/bar");
405            assert_match!(regex, "foo/bar");
406            assert_match!(regex, "$HOME/foo");
407            assert_match!(regex, "${FOO:-bar}/baz");
408            assert_match!(regex, "foo/bar_");
409            assert_match!(regex, "/home/bar");
410            assert_match!(regex, "foo/");
411            assert_match!(regex, "./");
412            assert_match!(regex, "../");
413            assert_match!(regex, "../..");
414            assert_match!(regex, "./foo");
415            assert_match!(regex, "./foo.rs");
416            assert_match!(regex, "/");
417            assert_match!(regex, "~/");
418            assert_match!(regex, "~/foo");
419            assert_match!(regex, "~/foo");
420            assert_match!(regex, "~/foo/../baz");
421            assert_match!(regex, "${HOME}/foo");
422            assert_match!(regex, "$HOME/foo");
423            assert_match!(regex, "/$FOO");
424            assert_match!(regex, "/${FOO}");
425            assert_match!(regex, "/${FOO}/${BAR}");
426            assert_match!(regex, "/${FOO}/${BAR}/foo");
427            assert_match!(regex, "/${FOO}/${BAR}");
428            assert_match!(regex, "${FOO}/hello_$WORLD");
429            assert_match!(regex, "${FOO}/hello_${WORLD}");
430            let regex = compile_path_regex("", "", false, windows);
431            assert_no_match!(regex, "");
432            assert_matches!(
433                regex,
434                r#"${FOO}/hello_${WORLD}  ${FOO}/hello_${WORLD} foo("./bar", "/home/foo")""#,
435                [
436                    "${FOO}/hello_${WORLD}",
437                    "${FOO}/hello_${WORLD}",
438                    "./bar",
439                    "/home/foo"
440                ]
441            );
442            assert_matches!(
443                regex,
444                r#"--> helix-stdx/src/path.rs:427:13"#,
445                ["helix-stdx/src/path.rs"]
446            );
447            assert_matches!(
448                regex,
449                r#"PATH=/foo/bar:/bar/baz:${foo:-/foo}/bar:${PATH}"#,
450                ["/foo/bar", "/bar/baz", "${foo:-/foo}/bar"]
451            );
452            let regex = compile_path_regex("^", "$", true, windows);
453            assert_no_match!(regex, "");
454            assert_match!(regex, "foo");
455            assert_match!(regex, "foo/");
456            assert_match!(regex, "$FOO");
457            assert_match!(regex, "${BAR}");
458        }
459    }
460}