paths.rs

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