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