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!(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}