1pub 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
17pub 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
37pub 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
60pub 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 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
130pub 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
145pub 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
163pub 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 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 let space_escape = if windows { r"[\^`]\s" } else { r"[\\]\s" };
216 r"[\w@.\-+#$%?!,;~&]|".to_owned() + space_escape
219}
220
221fn 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 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 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
263pub 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
280pub 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
297pub 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 assert_ne!(component, tilde);
330 component_count += 1;
331 }
332
333 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 #[test]
372 fn path_regex_unix() {
373 let regex = compile_path_regex("^", "$", false, false);
375 assert_match!(regex, "${FOO}/hello\\ world");
376 assert_match!(regex, "${FOO}/\\ ");
377 }
378
379 #[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 #[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}