1use std::cmp;
2use std::sync::OnceLock;
3use std::{
4 ffi::OsStr,
5 path::{Path, PathBuf},
6 sync::LazyLock,
7};
8
9use globset::{Glob, GlobSet, GlobSetBuilder};
10use regex::Regex;
11use serde::{Deserialize, Serialize};
12
13use crate::NumericPrefixWithSuffix;
14
15/// Returns the path to the user's home directory.
16pub fn home_dir() -> &'static PathBuf {
17 static HOME_DIR: OnceLock<PathBuf> = OnceLock::new();
18 HOME_DIR.get_or_init(|| dirs::home_dir().expect("failed to determine home directory"))
19}
20
21pub trait PathExt {
22 fn compact(&self) -> PathBuf;
23 fn icon_stem_or_suffix(&self) -> Option<&str>;
24 fn extension_or_hidden_file_name(&self) -> Option<&str>;
25 fn try_from_bytes<'a>(bytes: &'a [u8]) -> anyhow::Result<Self>
26 where
27 Self: From<&'a Path>,
28 {
29 #[cfg(unix)]
30 {
31 use std::os::unix::prelude::OsStrExt;
32 Ok(Self::from(Path::new(OsStr::from_bytes(bytes))))
33 }
34 #[cfg(windows)]
35 {
36 use anyhow::anyhow;
37 use tendril::fmt::{Format, WTF8};
38 WTF8::validate(bytes)
39 .then(|| {
40 // Safety: bytes are valid WTF-8 sequence.
41 Self::from(Path::new(unsafe {
42 OsStr::from_encoded_bytes_unchecked(bytes)
43 }))
44 })
45 .ok_or_else(|| anyhow!("Invalid WTF-8 sequence: {bytes:?}"))
46 }
47 }
48}
49
50impl<T: AsRef<Path>> PathExt for T {
51 /// Compacts a given file path by replacing the user's home directory
52 /// prefix with a tilde (`~`).
53 ///
54 /// # Returns
55 ///
56 /// * A `PathBuf` containing the compacted file path. If the input path
57 /// does not have the user's home directory prefix, or if we are not on
58 /// Linux or macOS, the original path is returned unchanged.
59 fn compact(&self) -> PathBuf {
60 if cfg!(any(target_os = "linux", target_os = "freebsd")) || cfg!(target_os = "macos") {
61 match self.as_ref().strip_prefix(home_dir().as_path()) {
62 Ok(relative_path) => {
63 let mut shortened_path = PathBuf::new();
64 shortened_path.push("~");
65 shortened_path.push(relative_path);
66 shortened_path
67 }
68 Err(_) => self.as_ref().to_path_buf(),
69 }
70 } else {
71 self.as_ref().to_path_buf()
72 }
73 }
74
75 /// Returns either the suffix if available, or the file stem otherwise to determine which file icon to use
76 fn icon_stem_or_suffix(&self) -> Option<&str> {
77 let path = self.as_ref();
78 let file_name = path.file_name()?.to_str()?;
79 if file_name.starts_with('.') {
80 return file_name.strip_prefix('.');
81 }
82
83 path.extension()
84 .and_then(|e| e.to_str())
85 .or_else(|| path.file_stem()?.to_str())
86 }
87
88 /// Returns a file's extension or, if the file is hidden, its name without the leading dot
89 fn extension_or_hidden_file_name(&self) -> Option<&str> {
90 if let Some(extension) = self.as_ref().extension() {
91 return extension.to_str();
92 }
93
94 self.as_ref().file_name()?.to_str()?.split('.').last()
95 }
96}
97
98/// A delimiter to use in `path_query:row_number:column_number` strings parsing.
99pub const FILE_ROW_COLUMN_DELIMITER: char = ':';
100
101const ROW_COL_CAPTURE_REGEX: &str = r"(?x)
102 ([^\(]+)(?:
103 \((\d+),(\d+)\) # filename(row,column)
104 |
105 \((\d+)\)() # filename(row)
106 )
107 |
108 (.+?)(?:
109 \:+(\d+)\:(\d+)\:*$ # filename:row:column
110 |
111 \:+(\d+)\:*()$ # filename:row
112 |
113 \:*()()$ # filename:
114 )";
115
116/// A representation of a path-like string with optional row and column numbers.
117/// Matching values example: `te`, `test.rs:22`, `te:22:5`, `test.c(22)`, `test.c(22,5)`etc.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
119pub struct PathWithPosition {
120 pub path: PathBuf,
121 pub row: Option<u32>,
122 // Absent if row is absent.
123 pub column: Option<u32>,
124}
125
126impl PathWithPosition {
127 /// Returns a PathWithPosition from a path.
128 pub fn from_path(path: PathBuf) -> Self {
129 Self {
130 path,
131 row: None,
132 column: None,
133 }
134 }
135
136 /// Parses a string that possibly has `:row:column` or `(row, column)` suffix.
137 /// Parenthesis format is used by [MSBuild](https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-diagnostic-format-for-tasks) compatible tools
138 /// Ignores trailing `:`s, so `test.rs:22:` is parsed as `test.rs:22`.
139 /// If the suffix parsing fails, the whole string is parsed as a path.
140 ///
141 /// Be mindful that `test_file:10:1:` is a valid posix filename.
142 /// `PathWithPosition` class assumes that the ending position-like suffix is **not** part of the filename.
143 ///
144 /// # Examples
145 ///
146 /// ```
147 /// # use util::paths::PathWithPosition;
148 /// # use std::path::PathBuf;
149 /// assert_eq!(PathWithPosition::parse_str("test_file"), PathWithPosition {
150 /// path: PathBuf::from("test_file"),
151 /// row: None,
152 /// column: None,
153 /// });
154 /// assert_eq!(PathWithPosition::parse_str("test_file:10"), PathWithPosition {
155 /// path: PathBuf::from("test_file"),
156 /// row: Some(10),
157 /// column: None,
158 /// });
159 /// assert_eq!(PathWithPosition::parse_str("test_file.rs"), PathWithPosition {
160 /// path: PathBuf::from("test_file.rs"),
161 /// row: None,
162 /// column: None,
163 /// });
164 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1"), PathWithPosition {
165 /// path: PathBuf::from("test_file.rs"),
166 /// row: Some(1),
167 /// column: None,
168 /// });
169 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1:2"), PathWithPosition {
170 /// path: PathBuf::from("test_file.rs"),
171 /// row: Some(1),
172 /// column: Some(2),
173 /// });
174 /// ```
175 ///
176 /// # Expected parsing results when encounter ill-formatted inputs.
177 /// ```
178 /// # use util::paths::PathWithPosition;
179 /// # use std::path::PathBuf;
180 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:a"), PathWithPosition {
181 /// path: PathBuf::from("test_file.rs:a"),
182 /// row: None,
183 /// column: None,
184 /// });
185 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:a:b"), PathWithPosition {
186 /// path: PathBuf::from("test_file.rs:a:b"),
187 /// row: None,
188 /// column: None,
189 /// });
190 /// assert_eq!(PathWithPosition::parse_str("test_file.rs::"), PathWithPosition {
191 /// path: PathBuf::from("test_file.rs"),
192 /// row: None,
193 /// column: None,
194 /// });
195 /// assert_eq!(PathWithPosition::parse_str("test_file.rs::1"), PathWithPosition {
196 /// path: PathBuf::from("test_file.rs"),
197 /// row: Some(1),
198 /// column: None,
199 /// });
200 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1::"), PathWithPosition {
201 /// path: PathBuf::from("test_file.rs"),
202 /// row: Some(1),
203 /// column: None,
204 /// });
205 /// assert_eq!(PathWithPosition::parse_str("test_file.rs::1:2"), PathWithPosition {
206 /// path: PathBuf::from("test_file.rs"),
207 /// row: Some(1),
208 /// column: Some(2),
209 /// });
210 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1::2"), PathWithPosition {
211 /// path: PathBuf::from("test_file.rs:1"),
212 /// row: Some(2),
213 /// column: None,
214 /// });
215 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1:2:3"), PathWithPosition {
216 /// path: PathBuf::from("test_file.rs:1"),
217 /// row: Some(2),
218 /// column: Some(3),
219 /// });
220 /// ```
221 pub fn parse_str(s: &str) -> Self {
222 let trimmed = s.trim();
223 let path = Path::new(trimmed);
224 let maybe_file_name_with_row_col = path.file_name().unwrap_or_default().to_string_lossy();
225 if maybe_file_name_with_row_col.is_empty() {
226 return Self {
227 path: Path::new(s).to_path_buf(),
228 row: None,
229 column: None,
230 };
231 }
232
233 // Let's avoid repeated init cost on this. It is subject to thread contention, but
234 // so far this code isn't called from multiple hot paths. Getting contention here
235 // in the future seems unlikely.
236 static SUFFIX_RE: LazyLock<Regex> =
237 LazyLock::new(|| Regex::new(ROW_COL_CAPTURE_REGEX).unwrap());
238 match SUFFIX_RE
239 .captures(&maybe_file_name_with_row_col)
240 .map(|caps| caps.extract())
241 {
242 Some((_, [file_name, maybe_row, maybe_column])) => {
243 let row = maybe_row.parse::<u32>().ok();
244 let column = maybe_column.parse::<u32>().ok();
245
246 let suffix_length = maybe_file_name_with_row_col.len() - file_name.len();
247 let path_without_suffix = &trimmed[..trimmed.len() - suffix_length];
248
249 Self {
250 path: Path::new(path_without_suffix).to_path_buf(),
251 row,
252 column,
253 }
254 }
255 None => Self {
256 path: Path::new(s).to_path_buf(),
257 row: None,
258 column: None,
259 },
260 }
261 }
262
263 pub fn map_path<E>(
264 self,
265 mapping: impl FnOnce(PathBuf) -> Result<PathBuf, E>,
266 ) -> Result<PathWithPosition, E> {
267 Ok(PathWithPosition {
268 path: mapping(self.path)?,
269 row: self.row,
270 column: self.column,
271 })
272 }
273
274 pub fn to_string(&self, path_to_string: impl Fn(&PathBuf) -> String) -> String {
275 let path_string = path_to_string(&self.path);
276 if let Some(row) = self.row {
277 if let Some(column) = self.column {
278 format!("{path_string}:{row}:{column}")
279 } else {
280 format!("{path_string}:{row}")
281 }
282 } else {
283 path_string
284 }
285 }
286}
287
288#[derive(Clone, Debug, Default)]
289pub struct PathMatcher {
290 sources: Vec<String>,
291 glob: GlobSet,
292}
293
294// impl std::fmt::Display for PathMatcher {
295// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296// self.sources.fmt(f)
297// }
298// }
299
300impl PartialEq for PathMatcher {
301 fn eq(&self, other: &Self) -> bool {
302 self.sources.eq(&other.sources)
303 }
304}
305
306impl Eq for PathMatcher {}
307
308impl PathMatcher {
309 pub fn new(globs: &[String]) -> Result<Self, globset::Error> {
310 let globs = globs
311 .iter()
312 .map(|glob| Glob::new(glob))
313 .collect::<Result<Vec<_>, _>>()?;
314 let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect();
315 let mut glob_builder = GlobSetBuilder::new();
316 for single_glob in globs {
317 glob_builder.add(single_glob);
318 }
319 let glob = glob_builder.build()?;
320 Ok(PathMatcher { glob, sources })
321 }
322
323 pub fn sources(&self) -> &[String] {
324 &self.sources
325 }
326
327 pub fn is_match<P: AsRef<Path>>(&self, other: P) -> bool {
328 let other_path = other.as_ref();
329 self.sources.iter().any(|source| {
330 let as_bytes = other_path.as_os_str().as_encoded_bytes();
331 as_bytes.starts_with(source.as_bytes()) || as_bytes.ends_with(source.as_bytes())
332 }) || self.glob.is_match(other_path)
333 || self.check_with_end_separator(other_path)
334 }
335
336 fn check_with_end_separator(&self, path: &Path) -> bool {
337 let path_str = path.to_string_lossy();
338 let separator = std::path::MAIN_SEPARATOR_STR;
339 if path_str.ends_with(separator) {
340 false
341 } else {
342 self.glob.is_match(path_str.to_string() + separator)
343 }
344 }
345}
346
347pub fn compare_paths(
348 (path_a, a_is_file): (&Path, bool),
349 (path_b, b_is_file): (&Path, bool),
350) -> cmp::Ordering {
351 let mut components_a = path_a.components().peekable();
352 let mut components_b = path_b.components().peekable();
353 loop {
354 match (components_a.next(), components_b.next()) {
355 (Some(component_a), Some(component_b)) => {
356 let a_is_file = components_a.peek().is_none() && a_is_file;
357 let b_is_file = components_b.peek().is_none() && b_is_file;
358 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
359 let path_a = Path::new(component_a.as_os_str());
360 let path_string_a = if a_is_file {
361 path_a.file_stem()
362 } else {
363 path_a.file_name()
364 }
365 .map(|s| s.to_string_lossy());
366 let num_and_remainder_a = path_string_a
367 .as_deref()
368 .map(NumericPrefixWithSuffix::from_numeric_prefixed_str);
369
370 let path_b = Path::new(component_b.as_os_str());
371 let path_string_b = if b_is_file {
372 path_b.file_stem()
373 } else {
374 path_b.file_name()
375 }
376 .map(|s| s.to_string_lossy());
377 let num_and_remainder_b = path_string_b
378 .as_deref()
379 .map(NumericPrefixWithSuffix::from_numeric_prefixed_str);
380
381 num_and_remainder_a.cmp(&num_and_remainder_b).then_with(|| {
382 if a_is_file && b_is_file {
383 let ext_a = path_a.extension().unwrap_or_default();
384 let ext_b = path_b.extension().unwrap_or_default();
385 ext_a.cmp(ext_b)
386 } else {
387 cmp::Ordering::Equal
388 }
389 })
390 });
391 if !ordering.is_eq() {
392 return ordering;
393 }
394 }
395 (Some(_), None) => break cmp::Ordering::Greater,
396 (None, Some(_)) => break cmp::Ordering::Less,
397 (None, None) => break cmp::Ordering::Equal,
398 }
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn compare_paths_with_dots() {
408 let mut paths = vec![
409 (Path::new("test_dirs"), false),
410 (Path::new("test_dirs/1.46"), false),
411 (Path::new("test_dirs/1.46/bar_1"), true),
412 (Path::new("test_dirs/1.46/bar_2"), true),
413 (Path::new("test_dirs/1.45"), false),
414 (Path::new("test_dirs/1.45/foo_2"), true),
415 (Path::new("test_dirs/1.45/foo_1"), true),
416 ];
417 paths.sort_by(|&a, &b| compare_paths(a, b));
418 assert_eq!(
419 paths,
420 vec![
421 (Path::new("test_dirs"), false),
422 (Path::new("test_dirs/1.45"), false),
423 (Path::new("test_dirs/1.45/foo_1"), true),
424 (Path::new("test_dirs/1.45/foo_2"), true),
425 (Path::new("test_dirs/1.46"), false),
426 (Path::new("test_dirs/1.46/bar_1"), true),
427 (Path::new("test_dirs/1.46/bar_2"), true),
428 ]
429 );
430 let mut paths = vec![
431 (Path::new("root1/one.txt"), true),
432 (Path::new("root1/one.two.txt"), true),
433 ];
434 paths.sort_by(|&a, &b| compare_paths(a, b));
435 assert_eq!(
436 paths,
437 vec![
438 (Path::new("root1/one.txt"), true),
439 (Path::new("root1/one.two.txt"), true),
440 ]
441 );
442 }
443
444 #[test]
445 fn compare_paths_with_same_name_different_extensions() {
446 let mut paths = vec![
447 (Path::new("test_dirs/file.rs"), true),
448 (Path::new("test_dirs/file.txt"), true),
449 (Path::new("test_dirs/file.md"), true),
450 (Path::new("test_dirs/file"), true),
451 (Path::new("test_dirs/file.a"), true),
452 ];
453 paths.sort_by(|&a, &b| compare_paths(a, b));
454 assert_eq!(
455 paths,
456 vec![
457 (Path::new("test_dirs/file"), true),
458 (Path::new("test_dirs/file.a"), true),
459 (Path::new("test_dirs/file.md"), true),
460 (Path::new("test_dirs/file.rs"), true),
461 (Path::new("test_dirs/file.txt"), true),
462 ]
463 );
464 }
465
466 #[test]
467 fn compare_paths_case_semi_sensitive() {
468 let mut paths = vec![
469 (Path::new("test_DIRS"), false),
470 (Path::new("test_DIRS/foo_1"), true),
471 (Path::new("test_DIRS/foo_2"), true),
472 (Path::new("test_DIRS/bar"), true),
473 (Path::new("test_DIRS/BAR"), true),
474 (Path::new("test_dirs"), false),
475 (Path::new("test_dirs/foo_1"), true),
476 (Path::new("test_dirs/foo_2"), true),
477 (Path::new("test_dirs/bar"), true),
478 (Path::new("test_dirs/BAR"), true),
479 ];
480 paths.sort_by(|&a, &b| compare_paths(a, b));
481 assert_eq!(
482 paths,
483 vec![
484 (Path::new("test_dirs"), false),
485 (Path::new("test_dirs/bar"), true),
486 (Path::new("test_dirs/BAR"), true),
487 (Path::new("test_dirs/foo_1"), true),
488 (Path::new("test_dirs/foo_2"), true),
489 (Path::new("test_DIRS"), false),
490 (Path::new("test_DIRS/bar"), true),
491 (Path::new("test_DIRS/BAR"), true),
492 (Path::new("test_DIRS/foo_1"), true),
493 (Path::new("test_DIRS/foo_2"), true),
494 ]
495 );
496 }
497
498 #[test]
499 fn path_with_position_parse_posix_path() {
500 // Test POSIX filename edge cases
501 // Read more at https://en.wikipedia.org/wiki/Filename
502 assert_eq!(
503 PathWithPosition::parse_str(" test_file"),
504 PathWithPosition {
505 path: PathBuf::from("test_file"),
506 row: None,
507 column: None
508 }
509 );
510
511 assert_eq!(
512 PathWithPosition::parse_str("a:bc:.zip:1"),
513 PathWithPosition {
514 path: PathBuf::from("a:bc:.zip"),
515 row: Some(1),
516 column: None
517 }
518 );
519
520 assert_eq!(
521 PathWithPosition::parse_str("one.second.zip:1"),
522 PathWithPosition {
523 path: PathBuf::from("one.second.zip"),
524 row: Some(1),
525 column: None
526 }
527 );
528
529 // Trim off trailing `:`s for otherwise valid input.
530 assert_eq!(
531 PathWithPosition::parse_str("test_file:10:1:"),
532 PathWithPosition {
533 path: PathBuf::from("test_file"),
534 row: Some(10),
535 column: Some(1)
536 }
537 );
538
539 assert_eq!(
540 PathWithPosition::parse_str("test_file.rs:"),
541 PathWithPosition {
542 path: PathBuf::from("test_file.rs"),
543 row: None,
544 column: None
545 }
546 );
547
548 assert_eq!(
549 PathWithPosition::parse_str("test_file.rs:1:"),
550 PathWithPosition {
551 path: PathBuf::from("test_file.rs"),
552 row: Some(1),
553 column: None
554 }
555 );
556 }
557
558 #[test]
559 #[cfg(not(target_os = "windows"))]
560 fn path_with_position_parse_posix_path_with_suffix() {
561 assert_eq!(
562 PathWithPosition::parse_str("app-editors:zed-0.143.6:20240710-201212.log:34:"),
563 PathWithPosition {
564 path: PathBuf::from("app-editors:zed-0.143.6:20240710-201212.log"),
565 row: Some(34),
566 column: None,
567 }
568 );
569
570 assert_eq!(
571 PathWithPosition::parse_str("crates/file_finder/src/file_finder.rs:1902:13:"),
572 PathWithPosition {
573 path: PathBuf::from("crates/file_finder/src/file_finder.rs"),
574 row: Some(1902),
575 column: Some(13),
576 }
577 );
578
579 assert_eq!(
580 PathWithPosition::parse_str("crate/utils/src/test:today.log:34"),
581 PathWithPosition {
582 path: PathBuf::from("crate/utils/src/test:today.log"),
583 row: Some(34),
584 column: None,
585 }
586 );
587 }
588
589 #[test]
590 #[cfg(target_os = "windows")]
591 fn path_with_position_parse_windows_path() {
592 assert_eq!(
593 PathWithPosition::parse_str("crates\\utils\\paths.rs"),
594 PathWithPosition {
595 path: PathBuf::from("crates\\utils\\paths.rs"),
596 row: None,
597 column: None
598 }
599 );
600
601 assert_eq!(
602 PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs"),
603 PathWithPosition {
604 path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
605 row: None,
606 column: None
607 }
608 );
609 }
610
611 #[test]
612 #[cfg(target_os = "windows")]
613 fn path_with_position_parse_windows_path_with_suffix() {
614 assert_eq!(
615 PathWithPosition::parse_str("crates\\utils\\paths.rs:101"),
616 PathWithPosition {
617 path: PathBuf::from("crates\\utils\\paths.rs"),
618 row: Some(101),
619 column: None
620 }
621 );
622
623 assert_eq!(
624 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1:20"),
625 PathWithPosition {
626 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
627 row: Some(1),
628 column: Some(20)
629 }
630 );
631
632 assert_eq!(
633 PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902,13)"),
634 PathWithPosition {
635 path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
636 row: Some(1902),
637 column: Some(13)
638 }
639 );
640
641 // Trim off trailing `:`s for otherwise valid input.
642 assert_eq!(
643 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:"),
644 PathWithPosition {
645 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
646 row: Some(1902),
647 column: Some(13)
648 }
649 );
650
651 assert_eq!(
652 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:15:"),
653 PathWithPosition {
654 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs:1902"),
655 row: Some(13),
656 column: Some(15)
657 }
658 );
659
660 assert_eq!(
661 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:::15:"),
662 PathWithPosition {
663 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs:1902"),
664 row: Some(15),
665 column: None
666 }
667 );
668
669 assert_eq!(
670 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs(1902,13):"),
671 PathWithPosition {
672 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
673 row: Some(1902),
674 column: Some(13),
675 }
676 );
677
678 assert_eq!(
679 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs(1902):"),
680 PathWithPosition {
681 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
682 row: Some(1902),
683 column: None,
684 }
685 );
686
687 assert_eq!(
688 PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs:1902:13:"),
689 PathWithPosition {
690 path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
691 row: Some(1902),
692 column: Some(13),
693 }
694 );
695
696 assert_eq!(
697 PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902,13):"),
698 PathWithPosition {
699 path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
700 row: Some(1902),
701 column: Some(13),
702 }
703 );
704
705 assert_eq!(
706 PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902):"),
707 PathWithPosition {
708 path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
709 row: Some(1902),
710 column: None,
711 }
712 );
713
714 assert_eq!(
715 PathWithPosition::parse_str("crates/utils/paths.rs:101"),
716 PathWithPosition {
717 path: PathBuf::from("crates\\utils\\paths.rs"),
718 row: Some(101),
719 column: None,
720 }
721 );
722 }
723
724 #[test]
725 fn test_path_compact() {
726 let path: PathBuf = [
727 home_dir().to_string_lossy().to_string(),
728 "some_file.txt".to_string(),
729 ]
730 .iter()
731 .collect();
732 if cfg!(any(target_os = "linux", target_os = "freebsd")) || cfg!(target_os = "macos") {
733 assert_eq!(path.compact().to_str(), Some("~/some_file.txt"));
734 } else {
735 assert_eq!(path.compact().to_str(), path.to_str());
736 }
737 }
738
739 #[test]
740 fn test_icon_stem_or_suffix() {
741 // No dots in name
742 let path = Path::new("/a/b/c/file_name.rs");
743 assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
744
745 // Single dot in name
746 let path = Path::new("/a/b/c/file.name.rs");
747 assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
748
749 // No suffix
750 let path = Path::new("/a/b/c/file");
751 assert_eq!(path.icon_stem_or_suffix(), Some("file"));
752
753 // Multiple dots in name
754 let path = Path::new("/a/b/c/long.file.name.rs");
755 assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
756
757 // Hidden file, no extension
758 let path = Path::new("/a/b/c/.gitignore");
759 assert_eq!(path.icon_stem_or_suffix(), Some("gitignore"));
760
761 // Hidden file, with extension
762 let path = Path::new("/a/b/c/.eslintrc.js");
763 assert_eq!(path.icon_stem_or_suffix(), Some("eslintrc.js"));
764 }
765
766 #[test]
767 fn test_extension_or_hidden_file_name() {
768 // No dots in name
769 let path = Path::new("/a/b/c/file_name.rs");
770 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
771
772 // Single dot in name
773 let path = Path::new("/a/b/c/file.name.rs");
774 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
775
776 // Multiple dots in name
777 let path = Path::new("/a/b/c/long.file.name.rs");
778 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
779
780 // Hidden file, no extension
781 let path = Path::new("/a/b/c/.gitignore");
782 assert_eq!(path.extension_or_hidden_file_name(), Some("gitignore"));
783
784 // Hidden file, with extension
785 let path = Path::new("/a/b/c/.eslintrc.js");
786 assert_eq!(path.extension_or_hidden_file_name(), Some("js"));
787 }
788
789 #[test]
790 fn edge_of_glob() {
791 let path = Path::new("/work/node_modules");
792 let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
793 assert!(
794 path_matcher.is_match(path),
795 "Path matcher should match {path:?}"
796 );
797 }
798
799 #[test]
800 fn project_search() {
801 let path = Path::new("/Users/someonetoignore/work/zed/zed.dev/node_modules");
802 let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
803 assert!(
804 path_matcher.is_match(path),
805 "Path matcher should match {path:?}"
806 );
807 }
808}