paths.rs

  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!(target_os = "linux") || 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)
382                });
383                if !ordering.is_eq() {
384                    return ordering;
385                }
386            }
387            (Some(_), None) => break cmp::Ordering::Greater,
388            (None, Some(_)) => break cmp::Ordering::Less,
389            (None, None) => break cmp::Ordering::Equal,
390        }
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn compare_paths_with_dots() {
400        let mut paths = vec![
401            (Path::new("test_dirs"), false),
402            (Path::new("test_dirs/1.46"), false),
403            (Path::new("test_dirs/1.46/bar_1"), true),
404            (Path::new("test_dirs/1.46/bar_2"), true),
405            (Path::new("test_dirs/1.45"), false),
406            (Path::new("test_dirs/1.45/foo_2"), true),
407            (Path::new("test_dirs/1.45/foo_1"), true),
408        ];
409        paths.sort_by(|&a, &b| compare_paths(a, b));
410        assert_eq!(
411            paths,
412            vec![
413                (Path::new("test_dirs"), false),
414                (Path::new("test_dirs/1.45"), false),
415                (Path::new("test_dirs/1.45/foo_1"), true),
416                (Path::new("test_dirs/1.45/foo_2"), true),
417                (Path::new("test_dirs/1.46"), false),
418                (Path::new("test_dirs/1.46/bar_1"), true),
419                (Path::new("test_dirs/1.46/bar_2"), true),
420            ]
421        );
422        let mut paths = vec![
423            (Path::new("root1/one.txt"), true),
424            (Path::new("root1/one.two.txt"), true),
425        ];
426        paths.sort_by(|&a, &b| compare_paths(a, b));
427        assert_eq!(
428            paths,
429            vec![
430                (Path::new("root1/one.txt"), true),
431                (Path::new("root1/one.two.txt"), true),
432            ]
433        );
434    }
435
436    #[test]
437    fn compare_paths_case_semi_sensitive() {
438        let mut paths = vec![
439            (Path::new("test_DIRS"), false),
440            (Path::new("test_DIRS/foo_1"), true),
441            (Path::new("test_DIRS/foo_2"), true),
442            (Path::new("test_DIRS/bar"), true),
443            (Path::new("test_DIRS/BAR"), true),
444            (Path::new("test_dirs"), false),
445            (Path::new("test_dirs/foo_1"), true),
446            (Path::new("test_dirs/foo_2"), true),
447            (Path::new("test_dirs/bar"), true),
448            (Path::new("test_dirs/BAR"), true),
449        ];
450        paths.sort_by(|&a, &b| compare_paths(a, b));
451        assert_eq!(
452            paths,
453            vec![
454                (Path::new("test_dirs"), false),
455                (Path::new("test_dirs/bar"), true),
456                (Path::new("test_dirs/BAR"), true),
457                (Path::new("test_dirs/foo_1"), true),
458                (Path::new("test_dirs/foo_2"), true),
459                (Path::new("test_DIRS"), false),
460                (Path::new("test_DIRS/bar"), true),
461                (Path::new("test_DIRS/BAR"), true),
462                (Path::new("test_DIRS/foo_1"), true),
463                (Path::new("test_DIRS/foo_2"), true),
464            ]
465        );
466    }
467
468    #[test]
469    fn path_with_position_parse_posix_path() {
470        // Test POSIX filename edge cases
471        // Read more at https://en.wikipedia.org/wiki/Filename
472        assert_eq!(
473            PathWithPosition::parse_str(" test_file"),
474            PathWithPosition {
475                path: PathBuf::from("test_file"),
476                row: None,
477                column: None
478            }
479        );
480
481        assert_eq!(
482            PathWithPosition::parse_str("a:bc:.zip:1"),
483            PathWithPosition {
484                path: PathBuf::from("a:bc:.zip"),
485                row: Some(1),
486                column: None
487            }
488        );
489
490        assert_eq!(
491            PathWithPosition::parse_str("one.second.zip:1"),
492            PathWithPosition {
493                path: PathBuf::from("one.second.zip"),
494                row: Some(1),
495                column: None
496            }
497        );
498
499        // Trim off trailing `:`s for otherwise valid input.
500        assert_eq!(
501            PathWithPosition::parse_str("test_file:10:1:"),
502            PathWithPosition {
503                path: PathBuf::from("test_file"),
504                row: Some(10),
505                column: Some(1)
506            }
507        );
508
509        assert_eq!(
510            PathWithPosition::parse_str("test_file.rs:"),
511            PathWithPosition {
512                path: PathBuf::from("test_file.rs"),
513                row: None,
514                column: None
515            }
516        );
517
518        assert_eq!(
519            PathWithPosition::parse_str("test_file.rs:1:"),
520            PathWithPosition {
521                path: PathBuf::from("test_file.rs"),
522                row: Some(1),
523                column: None
524            }
525        );
526    }
527
528    #[test]
529    #[cfg(not(target_os = "windows"))]
530    fn path_with_position_parse_posix_path_with_suffix() {
531        assert_eq!(
532            PathWithPosition::parse_str("app-editors:zed-0.143.6:20240710-201212.log:34:"),
533            PathWithPosition {
534                path: PathBuf::from("app-editors:zed-0.143.6:20240710-201212.log"),
535                row: Some(34),
536                column: None,
537            }
538        );
539
540        assert_eq!(
541            PathWithPosition::parse_str("crates/file_finder/src/file_finder.rs:1902:13:"),
542            PathWithPosition {
543                path: PathBuf::from("crates/file_finder/src/file_finder.rs"),
544                row: Some(1902),
545                column: Some(13),
546            }
547        );
548
549        assert_eq!(
550            PathWithPosition::parse_str("crate/utils/src/test:today.log:34"),
551            PathWithPosition {
552                path: PathBuf::from("crate/utils/src/test:today.log"),
553                row: Some(34),
554                column: None,
555            }
556        );
557    }
558
559    #[test]
560    #[cfg(target_os = "windows")]
561    fn path_with_position_parse_windows_path() {
562        assert_eq!(
563            PathWithPosition::parse_str("crates\\utils\\paths.rs"),
564            PathWithPosition {
565                path: PathBuf::from("crates\\utils\\paths.rs"),
566                row: None,
567                column: None
568            }
569        );
570
571        assert_eq!(
572            PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs"),
573            PathWithPosition {
574                path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
575                row: None,
576                column: None
577            }
578        );
579    }
580
581    #[test]
582    #[cfg(target_os = "windows")]
583    fn path_with_position_parse_windows_path_with_suffix() {
584        assert_eq!(
585            PathWithPosition::parse_str("crates\\utils\\paths.rs:101"),
586            PathWithPosition {
587                path: PathBuf::from("crates\\utils\\paths.rs"),
588                row: Some(101),
589                column: None
590            }
591        );
592
593        assert_eq!(
594            PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1:20"),
595            PathWithPosition {
596                path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
597                row: Some(1),
598                column: Some(20)
599            }
600        );
601
602        assert_eq!(
603            PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902,13)"),
604            PathWithPosition {
605                path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
606                row: Some(1902),
607                column: Some(13)
608            }
609        );
610
611        // Trim off trailing `:`s for otherwise valid input.
612        assert_eq!(
613            PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:"),
614            PathWithPosition {
615                path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
616                row: Some(1902),
617                column: Some(13)
618            }
619        );
620
621        assert_eq!(
622            PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:15:"),
623            PathWithPosition {
624                path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs:1902"),
625                row: Some(13),
626                column: Some(15)
627            }
628        );
629
630        assert_eq!(
631            PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:::15:"),
632            PathWithPosition {
633                path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs:1902"),
634                row: Some(15),
635                column: None
636            }
637        );
638
639        assert_eq!(
640            PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs(1902,13):"),
641            PathWithPosition {
642                path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
643                row: Some(1902),
644                column: Some(13),
645            }
646        );
647
648        assert_eq!(
649            PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs(1902):"),
650            PathWithPosition {
651                path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
652                row: Some(1902),
653                column: None,
654            }
655        );
656
657        assert_eq!(
658            PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs:1902:13:"),
659            PathWithPosition {
660                path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
661                row: Some(1902),
662                column: Some(13),
663            }
664        );
665
666        assert_eq!(
667            PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902,13):"),
668            PathWithPosition {
669                path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
670                row: Some(1902),
671                column: Some(13),
672            }
673        );
674
675        assert_eq!(
676            PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902):"),
677            PathWithPosition {
678                path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
679                row: Some(1902),
680                column: None,
681            }
682        );
683
684        assert_eq!(
685            PathWithPosition::parse_str("crates/utils/paths.rs:101"),
686            PathWithPosition {
687                path: PathBuf::from("crates\\utils\\paths.rs"),
688                row: Some(101),
689                column: None,
690            }
691        );
692    }
693
694    #[test]
695    fn test_path_compact() {
696        let path: PathBuf = [
697            home_dir().to_string_lossy().to_string(),
698            "some_file.txt".to_string(),
699        ]
700        .iter()
701        .collect();
702        if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
703            assert_eq!(path.compact().to_str(), Some("~/some_file.txt"));
704        } else {
705            assert_eq!(path.compact().to_str(), path.to_str());
706        }
707    }
708
709    #[test]
710    fn test_icon_stem_or_suffix() {
711        // No dots in name
712        let path = Path::new("/a/b/c/file_name.rs");
713        assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
714
715        // Single dot in name
716        let path = Path::new("/a/b/c/file.name.rs");
717        assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
718
719        // No suffix
720        let path = Path::new("/a/b/c/file");
721        assert_eq!(path.icon_stem_or_suffix(), Some("file"));
722
723        // Multiple dots in name
724        let path = Path::new("/a/b/c/long.file.name.rs");
725        assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
726
727        // Hidden file, no extension
728        let path = Path::new("/a/b/c/.gitignore");
729        assert_eq!(path.icon_stem_or_suffix(), Some("gitignore"));
730
731        // Hidden file, with extension
732        let path = Path::new("/a/b/c/.eslintrc.js");
733        assert_eq!(path.icon_stem_or_suffix(), Some("eslintrc.js"));
734    }
735
736    #[test]
737    fn test_extension_or_hidden_file_name() {
738        // No dots in name
739        let path = Path::new("/a/b/c/file_name.rs");
740        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
741
742        // Single dot in name
743        let path = Path::new("/a/b/c/file.name.rs");
744        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
745
746        // Multiple dots in name
747        let path = Path::new("/a/b/c/long.file.name.rs");
748        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
749
750        // Hidden file, no extension
751        let path = Path::new("/a/b/c/.gitignore");
752        assert_eq!(path.extension_or_hidden_file_name(), Some("gitignore"));
753
754        // Hidden file, with extension
755        let path = Path::new("/a/b/c/.eslintrc.js");
756        assert_eq!(path.extension_or_hidden_file_name(), Some("js"));
757    }
758
759    #[test]
760    fn edge_of_glob() {
761        let path = Path::new("/work/node_modules");
762        let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
763        assert!(
764            path_matcher.is_match(path),
765            "Path matcher should match {path:?}"
766        );
767    }
768
769    #[test]
770    fn project_search() {
771        let path = Path::new("/Users/someonetoignore/work/zed/zed.dev/node_modules");
772        let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
773        assert!(
774            path_matcher.is_match(path),
775            "Path matcher should match {path:?}"
776        );
777    }
778}