paths.rs

  1use std::sync::OnceLock;
  2use std::{
  3    ffi::OsStr,
  4    path::{Path, PathBuf},
  5};
  6
  7use globset::{Glob, GlobSet, GlobSetBuilder};
  8use serde::{Deserialize, Serialize};
  9
 10/// Returns the path to the user's home directory.
 11pub fn home_dir() -> &'static PathBuf {
 12    static HOME_DIR: OnceLock<PathBuf> = OnceLock::new();
 13    HOME_DIR.get_or_init(|| dirs::home_dir().expect("failed to determine home directory"))
 14}
 15
 16pub trait PathExt {
 17    fn compact(&self) -> PathBuf;
 18    fn icon_stem_or_suffix(&self) -> Option<&str>;
 19    fn extension_or_hidden_file_name(&self) -> Option<&str>;
 20    fn try_from_bytes<'a>(bytes: &'a [u8]) -> anyhow::Result<Self>
 21    where
 22        Self: From<&'a Path>,
 23    {
 24        #[cfg(unix)]
 25        {
 26            use std::os::unix::prelude::OsStrExt;
 27            Ok(Self::from(Path::new(OsStr::from_bytes(bytes))))
 28        }
 29        #[cfg(windows)]
 30        {
 31            use anyhow::anyhow;
 32            use tendril::fmt::{Format, WTF8};
 33            WTF8::validate(bytes)
 34                .then(|| {
 35                    // Safety: bytes are valid WTF-8 sequence.
 36                    Self::from(Path::new(unsafe {
 37                        OsStr::from_encoded_bytes_unchecked(bytes)
 38                    }))
 39                })
 40                .ok_or_else(|| anyhow!("Invalid WTF-8 sequence: {bytes:?}"))
 41        }
 42    }
 43}
 44
 45impl<T: AsRef<Path>> PathExt for T {
 46    /// Compacts a given file path by replacing the user's home directory
 47    /// prefix with a tilde (`~`).
 48    ///
 49    /// # Returns
 50    ///
 51    /// * A `PathBuf` containing the compacted file path. If the input path
 52    ///   does not have the user's home directory prefix, or if we are not on
 53    ///   Linux or macOS, the original path is returned unchanged.
 54    fn compact(&self) -> PathBuf {
 55        if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
 56            match self.as_ref().strip_prefix(home_dir().as_path()) {
 57                Ok(relative_path) => {
 58                    let mut shortened_path = PathBuf::new();
 59                    shortened_path.push("~");
 60                    shortened_path.push(relative_path);
 61                    shortened_path
 62                }
 63                Err(_) => self.as_ref().to_path_buf(),
 64            }
 65        } else {
 66            self.as_ref().to_path_buf()
 67        }
 68    }
 69
 70    /// Returns either the suffix if available, or the file stem otherwise to determine which file icon to use
 71    fn icon_stem_or_suffix(&self) -> Option<&str> {
 72        let path = self.as_ref();
 73        let file_name = path.file_name()?.to_str()?;
 74        if file_name.starts_with('.') {
 75            return file_name.strip_prefix('.');
 76        }
 77
 78        path.extension()
 79            .and_then(|e| e.to_str())
 80            .or_else(|| path.file_stem()?.to_str())
 81    }
 82
 83    /// Returns a file's extension or, if the file is hidden, its name without the leading dot
 84    fn extension_or_hidden_file_name(&self) -> Option<&str> {
 85        if let Some(extension) = self.as_ref().extension() {
 86            return extension.to_str();
 87        }
 88
 89        self.as_ref().file_name()?.to_str()?.split('.').last()
 90    }
 91}
 92
 93/// A delimiter to use in `path_query:row_number:column_number` strings parsing.
 94pub const FILE_ROW_COLUMN_DELIMITER: char = ':';
 95
 96/// A representation of a path-like string with optional row and column numbers.
 97/// Matching values example: `te`, `test.rs:22`, `te:22:5`, etc.
 98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
 99pub struct PathLikeWithPosition<P> {
100    pub path_like: P,
101    pub row: Option<u32>,
102    // Absent if row is absent.
103    pub column: Option<u32>,
104}
105
106impl<P> PathLikeWithPosition<P> {
107    /// Parses a string that possibly has `:row:column` suffix.
108    /// Ignores trailing `:`s, so `test.rs:22:` is parsed as `test.rs:22`.
109    /// If any of the row/column component parsing fails, the whole string is then parsed as a path like.
110    pub fn parse_str<E>(
111        s: &str,
112        parse_path_like_str: impl Fn(&str) -> Result<P, E>,
113    ) -> Result<Self, E> {
114        let fallback = |fallback_str| {
115            Ok(Self {
116                path_like: parse_path_like_str(fallback_str)?,
117                row: None,
118                column: None,
119            })
120        };
121
122        let trimmed = s.trim();
123
124        #[cfg(target_os = "windows")]
125        {
126            let is_absolute = trimmed.starts_with(r"\\?\");
127            if is_absolute {
128                return Self::parse_absolute_path(trimmed, parse_path_like_str);
129            }
130        }
131
132        match trimmed.split_once(FILE_ROW_COLUMN_DELIMITER) {
133            Some((path_like_str, maybe_row_and_col_str)) => {
134                let path_like_str = path_like_str.trim();
135                let maybe_row_and_col_str = maybe_row_and_col_str.trim();
136                if path_like_str.is_empty() {
137                    fallback(s)
138                } else if maybe_row_and_col_str.is_empty() {
139                    fallback(path_like_str)
140                } else {
141                    let (row_parse_result, maybe_col_str) =
142                        match maybe_row_and_col_str.split_once(FILE_ROW_COLUMN_DELIMITER) {
143                            Some((maybe_row_str, maybe_col_str)) => {
144                                (maybe_row_str.parse::<u32>(), maybe_col_str.trim())
145                            }
146                            None => (maybe_row_and_col_str.parse::<u32>(), ""),
147                        };
148
149                    match row_parse_result {
150                        Ok(row) => {
151                            if maybe_col_str.is_empty() {
152                                Ok(Self {
153                                    path_like: parse_path_like_str(path_like_str)?,
154                                    row: Some(row),
155                                    column: None,
156                                })
157                            } else {
158                                let (maybe_col_str, _) =
159                                    maybe_col_str.split_once(':').unwrap_or((maybe_col_str, ""));
160                                match maybe_col_str.parse::<u32>() {
161                                    Ok(col) => Ok(Self {
162                                        path_like: parse_path_like_str(path_like_str)?,
163                                        row: Some(row),
164                                        column: Some(col),
165                                    }),
166                                    Err(_) => Ok(Self {
167                                        path_like: parse_path_like_str(path_like_str)?,
168                                        row: Some(row),
169                                        column: None,
170                                    }),
171                                }
172                            }
173                        }
174                        Err(_) => Ok(Self {
175                            path_like: parse_path_like_str(path_like_str)?,
176                            row: None,
177                            column: None,
178                        }),
179                    }
180                }
181            }
182            None => fallback(s),
183        }
184    }
185
186    /// This helper function is used for parsing absolute paths on Windows. It exists because absolute paths on Windows are quite different from other platforms. See [this page](https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats#dos-device-paths) for more information.
187    #[cfg(target_os = "windows")]
188    fn parse_absolute_path<E>(
189        s: &str,
190        parse_path_like_str: impl Fn(&str) -> Result<P, E>,
191    ) -> Result<Self, E> {
192        let fallback = |fallback_str| {
193            Ok(Self {
194                path_like: parse_path_like_str(fallback_str)?,
195                row: None,
196                column: None,
197            })
198        };
199
200        let mut iterator = s.split(FILE_ROW_COLUMN_DELIMITER);
201
202        let drive_prefix = iterator.next().unwrap_or_default();
203        let file_path = iterator.next().unwrap_or_default();
204
205        // TODO: How to handle drives without a letter? UNC paths?
206        let complete_path = drive_prefix.replace("\\\\?\\", "") + ":" + &file_path;
207
208        if let Some(row_str) = iterator.next() {
209            if let Some(column_str) = iterator.next() {
210                match row_str.parse::<u32>() {
211                    Ok(row) => match column_str.parse::<u32>() {
212                        Ok(col) => {
213                            return Ok(Self {
214                                path_like: parse_path_like_str(&complete_path)?,
215                                row: Some(row),
216                                column: Some(col),
217                            });
218                        }
219
220                        Err(_) => {
221                            return Ok(Self {
222                                path_like: parse_path_like_str(&complete_path)?,
223                                row: Some(row),
224                                column: None,
225                            });
226                        }
227                    },
228
229                    Err(_) => {
230                        return fallback(&complete_path);
231                    }
232                }
233            }
234        }
235        return fallback(&complete_path);
236    }
237
238    pub fn map_path_like<P2, E>(
239        self,
240        mapping: impl FnOnce(P) -> Result<P2, E>,
241    ) -> Result<PathLikeWithPosition<P2>, E> {
242        Ok(PathLikeWithPosition {
243            path_like: mapping(self.path_like)?,
244            row: self.row,
245            column: self.column,
246        })
247    }
248
249    pub fn to_string(&self, path_like_to_string: impl Fn(&P) -> String) -> String {
250        let path_like_string = path_like_to_string(&self.path_like);
251        if let Some(row) = self.row {
252            if let Some(column) = self.column {
253                format!("{path_like_string}:{row}:{column}")
254            } else {
255                format!("{path_like_string}:{row}")
256            }
257        } else {
258            path_like_string
259        }
260    }
261}
262
263#[derive(Clone, Debug, Default)]
264pub struct PathMatcher {
265    sources: Vec<String>,
266    glob: GlobSet,
267}
268
269// impl std::fmt::Display for PathMatcher {
270//     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271//         self.sources.fmt(f)
272//     }
273// }
274
275impl PartialEq for PathMatcher {
276    fn eq(&self, other: &Self) -> bool {
277        self.sources.eq(&other.sources)
278    }
279}
280
281impl Eq for PathMatcher {}
282
283impl PathMatcher {
284    pub fn new(globs: &[String]) -> Result<Self, globset::Error> {
285        let globs = globs
286            .into_iter()
287            .map(|glob| Glob::new(&glob))
288            .collect::<Result<Vec<_>, _>>()?;
289        let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect();
290        let mut glob_builder = GlobSetBuilder::new();
291        for single_glob in globs {
292            glob_builder.add(single_glob);
293        }
294        let glob = glob_builder.build()?;
295        Ok(PathMatcher { glob, sources })
296    }
297
298    pub fn sources(&self) -> &[String] {
299        &self.sources
300    }
301
302    pub fn is_match<P: AsRef<Path>>(&self, other: P) -> bool {
303        let other_path = other.as_ref();
304        self.sources.iter().any(|source| {
305            let as_bytes = other_path.as_os_str().as_encoded_bytes();
306            as_bytes.starts_with(source.as_bytes()) || as_bytes.ends_with(source.as_bytes())
307        }) || self.glob.is_match(other_path)
308            || self.check_with_end_separator(other_path)
309    }
310
311    fn check_with_end_separator(&self, path: &Path) -> bool {
312        let path_str = path.to_string_lossy();
313        let separator = std::path::MAIN_SEPARATOR_STR;
314        if path_str.ends_with(separator) {
315            self.glob.is_match(path)
316        } else {
317            self.glob.is_match(path_str.to_string() + separator)
318        }
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    type TestPath = PathLikeWithPosition<String>;
327
328    fn parse_str(s: &str) -> TestPath {
329        TestPath::parse_str(s, |s| Ok::<_, std::convert::Infallible>(s.to_string()))
330            .expect("infallible")
331    }
332
333    #[test]
334    fn path_with_position_parsing_positive() {
335        let input_and_expected = [
336            (
337                "test_file.rs",
338                PathLikeWithPosition {
339                    path_like: "test_file.rs".to_string(),
340                    row: None,
341                    column: None,
342                },
343            ),
344            (
345                "test_file.rs:1",
346                PathLikeWithPosition {
347                    path_like: "test_file.rs".to_string(),
348                    row: Some(1),
349                    column: None,
350                },
351            ),
352            (
353                "test_file.rs:1:2",
354                PathLikeWithPosition {
355                    path_like: "test_file.rs".to_string(),
356                    row: Some(1),
357                    column: Some(2),
358                },
359            ),
360        ];
361
362        for (input, expected) in input_and_expected {
363            let actual = parse_str(input);
364            assert_eq!(
365                actual, expected,
366                "For positive case input str '{input}', got a parse mismatch"
367            );
368        }
369    }
370
371    #[test]
372    fn path_with_position_parsing_negative() {
373        for (input, row, column) in [
374            ("test_file.rs:a", None, None),
375            ("test_file.rs:a:b", None, None),
376            ("test_file.rs::", None, None),
377            ("test_file.rs::1", None, None),
378            ("test_file.rs:1::", Some(1), None),
379            ("test_file.rs::1:2", None, None),
380            ("test_file.rs:1::2", Some(1), None),
381            ("test_file.rs:1:2:3", Some(1), Some(2)),
382        ] {
383            let actual = parse_str(input);
384            assert_eq!(
385                actual,
386                PathLikeWithPosition {
387                    path_like: "test_file.rs".to_string(),
388                    row,
389                    column,
390                },
391                "For negative case input str '{input}', got a parse mismatch"
392            );
393        }
394    }
395
396    // Trim off trailing `:`s for otherwise valid input.
397    #[test]
398    fn path_with_position_parsing_special() {
399        #[cfg(not(target_os = "windows"))]
400        let input_and_expected = [
401            (
402                "test_file.rs:",
403                PathLikeWithPosition {
404                    path_like: "test_file.rs".to_string(),
405                    row: None,
406                    column: None,
407                },
408            ),
409            (
410                "test_file.rs:1:",
411                PathLikeWithPosition {
412                    path_like: "test_file.rs".to_string(),
413                    row: Some(1),
414                    column: None,
415                },
416            ),
417            (
418                "crates/file_finder/src/file_finder.rs:1902:13:",
419                PathLikeWithPosition {
420                    path_like: "crates/file_finder/src/file_finder.rs".to_string(),
421                    row: Some(1902),
422                    column: Some(13),
423                },
424            ),
425        ];
426
427        #[cfg(target_os = "windows")]
428        let input_and_expected = [
429            (
430                "test_file.rs:",
431                PathLikeWithPosition {
432                    path_like: "test_file.rs".to_string(),
433                    row: None,
434                    column: None,
435                },
436            ),
437            (
438                "test_file.rs:1:",
439                PathLikeWithPosition {
440                    path_like: "test_file.rs".to_string(),
441                    row: Some(1),
442                    column: None,
443                },
444            ),
445            (
446                "\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:",
447                PathLikeWithPosition {
448                    path_like: "C:\\Users\\someone\\test_file.rs".to_string(),
449                    row: Some(1902),
450                    column: Some(13),
451                },
452            ),
453            (
454                "\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:15:",
455                PathLikeWithPosition {
456                    path_like: "C:\\Users\\someone\\test_file.rs".to_string(),
457                    row: Some(1902),
458                    column: Some(13),
459                },
460            ),
461            (
462                "\\\\?\\C:\\Users\\someone\\test_file.rs:1902:::15:",
463                PathLikeWithPosition {
464                    path_like: "C:\\Users\\someone\\test_file.rs".to_string(),
465                    row: Some(1902),
466                    column: None,
467                },
468            ),
469        ];
470
471        for (input, expected) in input_and_expected {
472            let actual = parse_str(input);
473            assert_eq!(
474                actual, expected,
475                "For special case input str '{input}', got a parse mismatch"
476            );
477        }
478    }
479
480    #[test]
481    fn test_path_compact() {
482        let path: PathBuf = [
483            home_dir().to_string_lossy().to_string(),
484            "some_file.txt".to_string(),
485        ]
486        .iter()
487        .collect();
488        if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
489            assert_eq!(path.compact().to_str(), Some("~/some_file.txt"));
490        } else {
491            assert_eq!(path.compact().to_str(), path.to_str());
492        }
493    }
494
495    #[test]
496    fn test_icon_stem_or_suffix() {
497        // No dots in name
498        let path = Path::new("/a/b/c/file_name.rs");
499        assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
500
501        // Single dot in name
502        let path = Path::new("/a/b/c/file.name.rs");
503        assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
504
505        // No suffix
506        let path = Path::new("/a/b/c/file");
507        assert_eq!(path.icon_stem_or_suffix(), Some("file"));
508
509        // Multiple dots in name
510        let path = Path::new("/a/b/c/long.file.name.rs");
511        assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
512
513        // Hidden file, no extension
514        let path = Path::new("/a/b/c/.gitignore");
515        assert_eq!(path.icon_stem_or_suffix(), Some("gitignore"));
516
517        // Hidden file, with extension
518        let path = Path::new("/a/b/c/.eslintrc.js");
519        assert_eq!(path.icon_stem_or_suffix(), Some("eslintrc.js"));
520    }
521
522    #[test]
523    fn test_extension_or_hidden_file_name() {
524        // No dots in name
525        let path = Path::new("/a/b/c/file_name.rs");
526        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
527
528        // Single dot in name
529        let path = Path::new("/a/b/c/file.name.rs");
530        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
531
532        // Multiple dots in name
533        let path = Path::new("/a/b/c/long.file.name.rs");
534        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
535
536        // Hidden file, no extension
537        let path = Path::new("/a/b/c/.gitignore");
538        assert_eq!(path.extension_or_hidden_file_name(), Some("gitignore"));
539
540        // Hidden file, with extension
541        let path = Path::new("/a/b/c/.eslintrc.js");
542        assert_eq!(path.extension_or_hidden_file_name(), Some("js"));
543    }
544
545    #[test]
546    fn edge_of_glob() {
547        let path = Path::new("/work/node_modules");
548        let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
549        assert!(
550            path_matcher.is_match(path),
551            "Path matcher should match {path:?}"
552        );
553    }
554
555    #[test]
556    fn project_search() {
557        let path = Path::new("/Users/someonetoignore/work/zed/zed.dev/node_modules");
558        let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
559        assert!(
560            path_matcher.is_match(path),
561            "Path matcher should match {path:?}"
562        );
563    }
564}