paths.rs

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