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 path_with_position_parse_posix_path() {
438        // Test POSIX filename edge cases
439        // Read more at https://en.wikipedia.org/wiki/Filename
440        assert_eq!(
441            PathWithPosition::parse_str(" test_file"),
442            PathWithPosition {
443                path: PathBuf::from("test_file"),
444                row: None,
445                column: None
446            }
447        );
448
449        assert_eq!(
450            PathWithPosition::parse_str("a:bc:.zip:1"),
451            PathWithPosition {
452                path: PathBuf::from("a:bc:.zip"),
453                row: Some(1),
454                column: None
455            }
456        );
457
458        assert_eq!(
459            PathWithPosition::parse_str("one.second.zip:1"),
460            PathWithPosition {
461                path: PathBuf::from("one.second.zip"),
462                row: Some(1),
463                column: None
464            }
465        );
466
467        // Trim off trailing `:`s for otherwise valid input.
468        assert_eq!(
469            PathWithPosition::parse_str("test_file:10:1:"),
470            PathWithPosition {
471                path: PathBuf::from("test_file"),
472                row: Some(10),
473                column: Some(1)
474            }
475        );
476
477        assert_eq!(
478            PathWithPosition::parse_str("test_file.rs:"),
479            PathWithPosition {
480                path: PathBuf::from("test_file.rs"),
481                row: None,
482                column: None
483            }
484        );
485
486        assert_eq!(
487            PathWithPosition::parse_str("test_file.rs:1:"),
488            PathWithPosition {
489                path: PathBuf::from("test_file.rs"),
490                row: Some(1),
491                column: None
492            }
493        );
494    }
495
496    #[test]
497    #[cfg(not(target_os = "windows"))]
498    fn path_with_position_parse_posix_path_with_suffix() {
499        assert_eq!(
500            PathWithPosition::parse_str("app-editors:zed-0.143.6:20240710-201212.log:34:"),
501            PathWithPosition {
502                path: PathBuf::from("app-editors:zed-0.143.6:20240710-201212.log"),
503                row: Some(34),
504                column: None,
505            }
506        );
507
508        assert_eq!(
509            PathWithPosition::parse_str("crates/file_finder/src/file_finder.rs:1902:13:"),
510            PathWithPosition {
511                path: PathBuf::from("crates/file_finder/src/file_finder.rs"),
512                row: Some(1902),
513                column: Some(13),
514            }
515        );
516
517        assert_eq!(
518            PathWithPosition::parse_str("crate/utils/src/test:today.log:34"),
519            PathWithPosition {
520                path: PathBuf::from("crate/utils/src/test:today.log"),
521                row: Some(34),
522                column: None,
523            }
524        );
525    }
526
527    #[test]
528    #[cfg(target_os = "windows")]
529    fn path_with_position_parse_windows_path() {
530        assert_eq!(
531            PathWithPosition::parse_str("crates\\utils\\paths.rs"),
532            PathWithPosition {
533                path: PathBuf::from("crates\\utils\\paths.rs"),
534                row: None,
535                column: None
536            }
537        );
538
539        assert_eq!(
540            PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs"),
541            PathWithPosition {
542                path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
543                row: None,
544                column: None
545            }
546        );
547    }
548
549    #[test]
550    #[cfg(target_os = "windows")]
551    fn path_with_position_parse_windows_path_with_suffix() {
552        assert_eq!(
553            PathWithPosition::parse_str("crates\\utils\\paths.rs:101"),
554            PathWithPosition {
555                path: PathBuf::from("crates\\utils\\paths.rs"),
556                row: Some(101),
557                column: None
558            }
559        );
560
561        assert_eq!(
562            PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1:20"),
563            PathWithPosition {
564                path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
565                row: Some(1),
566                column: Some(20)
567            }
568        );
569
570        assert_eq!(
571            PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902,13)"),
572            PathWithPosition {
573                path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
574                row: Some(1902),
575                column: Some(13)
576            }
577        );
578
579        // Trim off trailing `:`s for otherwise valid input.
580        assert_eq!(
581            PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:"),
582            PathWithPosition {
583                path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
584                row: Some(1902),
585                column: Some(13)
586            }
587        );
588
589        assert_eq!(
590            PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:15:"),
591            PathWithPosition {
592                path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs:1902"),
593                row: Some(13),
594                column: Some(15)
595            }
596        );
597
598        assert_eq!(
599            PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:::15:"),
600            PathWithPosition {
601                path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs:1902"),
602                row: Some(15),
603                column: None
604            }
605        );
606
607        assert_eq!(
608            PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs(1902,13):"),
609            PathWithPosition {
610                path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
611                row: Some(1902),
612                column: Some(13),
613            }
614        );
615
616        assert_eq!(
617            PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs(1902):"),
618            PathWithPosition {
619                path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
620                row: Some(1902),
621                column: None,
622            }
623        );
624
625        assert_eq!(
626            PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs:1902:13:"),
627            PathWithPosition {
628                path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
629                row: Some(1902),
630                column: Some(13),
631            }
632        );
633
634        assert_eq!(
635            PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902,13):"),
636            PathWithPosition {
637                path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
638                row: Some(1902),
639                column: Some(13),
640            }
641        );
642
643        assert_eq!(
644            PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902):"),
645            PathWithPosition {
646                path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
647                row: Some(1902),
648                column: None,
649            }
650        );
651
652        assert_eq!(
653            PathWithPosition::parse_str("crates/utils/paths.rs:101"),
654            PathWithPosition {
655                path: PathBuf::from("crates\\utils\\paths.rs"),
656                row: Some(101),
657                column: None,
658            }
659        );
660    }
661
662    #[test]
663    fn test_path_compact() {
664        let path: PathBuf = [
665            home_dir().to_string_lossy().to_string(),
666            "some_file.txt".to_string(),
667        ]
668        .iter()
669        .collect();
670        if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
671            assert_eq!(path.compact().to_str(), Some("~/some_file.txt"));
672        } else {
673            assert_eq!(path.compact().to_str(), path.to_str());
674        }
675    }
676
677    #[test]
678    fn test_icon_stem_or_suffix() {
679        // No dots in name
680        let path = Path::new("/a/b/c/file_name.rs");
681        assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
682
683        // Single dot in name
684        let path = Path::new("/a/b/c/file.name.rs");
685        assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
686
687        // No suffix
688        let path = Path::new("/a/b/c/file");
689        assert_eq!(path.icon_stem_or_suffix(), Some("file"));
690
691        // Multiple dots in name
692        let path = Path::new("/a/b/c/long.file.name.rs");
693        assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
694
695        // Hidden file, no extension
696        let path = Path::new("/a/b/c/.gitignore");
697        assert_eq!(path.icon_stem_or_suffix(), Some("gitignore"));
698
699        // Hidden file, with extension
700        let path = Path::new("/a/b/c/.eslintrc.js");
701        assert_eq!(path.icon_stem_or_suffix(), Some("eslintrc.js"));
702    }
703
704    #[test]
705    fn test_extension_or_hidden_file_name() {
706        // No dots in name
707        let path = Path::new("/a/b/c/file_name.rs");
708        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
709
710        // Single dot in name
711        let path = Path::new("/a/b/c/file.name.rs");
712        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
713
714        // Multiple dots in name
715        let path = Path::new("/a/b/c/long.file.name.rs");
716        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
717
718        // Hidden file, no extension
719        let path = Path::new("/a/b/c/.gitignore");
720        assert_eq!(path.extension_or_hidden_file_name(), Some("gitignore"));
721
722        // Hidden file, with extension
723        let path = Path::new("/a/b/c/.eslintrc.js");
724        assert_eq!(path.extension_or_hidden_file_name(), Some("js"));
725    }
726
727    #[test]
728    fn edge_of_glob() {
729        let path = Path::new("/work/node_modules");
730        let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
731        assert!(
732            path_matcher.is_match(path),
733            "Path matcher should match {path:?}"
734        );
735    }
736
737    #[test]
738    fn project_search() {
739        let path = Path::new("/Users/someonetoignore/work/zed/zed.dev/node_modules");
740        let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
741        assert!(
742            path_matcher.is_match(path),
743            "Path matcher should match {path:?}"
744        );
745    }
746}