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 PathWithPosition {
100    pub path: PathBuf,
101    pub row: Option<u32>,
102    // Absent if row is absent.
103    pub column: Option<u32>,
104}
105
106impl PathWithPosition {
107    /// Returns a PathWithPosition from a path.
108    pub fn from_path(path: PathBuf) -> Self {
109        Self {
110            path,
111            row: None,
112            column: None,
113        }
114    }
115    /// Parses a string that possibly has `:row:column` suffix.
116    /// Ignores trailing `:`s, so `test.rs:22:` is parsed as `test.rs:22`.
117    /// If the suffix parsing fails, the whole string is parsed as a path.
118    pub fn parse_str(s: &str) -> Self {
119        let fallback = |fallback_str| Self {
120            path: Path::new(fallback_str).to_path_buf(),
121            row: None,
122            column: None,
123        };
124
125        let trimmed = s.trim();
126        let path = Path::new(trimmed);
127        let maybe_file_name_with_row_col = path
128            .file_name()
129            .unwrap_or_default()
130            .to_str()
131            .unwrap_or_default();
132        if maybe_file_name_with_row_col.is_empty() {
133            return fallback(s);
134        }
135
136        match maybe_file_name_with_row_col.split_once(FILE_ROW_COLUMN_DELIMITER) {
137            Some((file_name, maybe_row_and_col_str)) => {
138                let file_name = file_name.trim();
139                let maybe_row_and_col_str = maybe_row_and_col_str.trim();
140                if file_name.is_empty() {
141                    return fallback(s);
142                }
143
144                let suffix_length = maybe_row_and_col_str.len() + 1;
145                let path_without_suffix = &trimmed[..trimmed.len() - suffix_length];
146
147                if maybe_row_and_col_str.is_empty() {
148                    fallback(path_without_suffix)
149                } else {
150                    let (row_parse_result, maybe_col_str) =
151                        match maybe_row_and_col_str.split_once(FILE_ROW_COLUMN_DELIMITER) {
152                            Some((maybe_row_str, maybe_col_str)) => {
153                                (maybe_row_str.parse::<u32>(), maybe_col_str.trim())
154                            }
155                            None => (maybe_row_and_col_str.parse::<u32>(), ""),
156                        };
157
158                    let path = Path::new(path_without_suffix).to_path_buf();
159
160                    match row_parse_result {
161                        Ok(row) => {
162                            if maybe_col_str.is_empty() {
163                                Self {
164                                    path,
165                                    row: Some(row),
166                                    column: None,
167                                }
168                            } else {
169                                let (maybe_col_str, _) =
170                                    maybe_col_str.split_once(':').unwrap_or((maybe_col_str, ""));
171                                match maybe_col_str.parse::<u32>() {
172                                    Ok(col) => Self {
173                                        path,
174                                        row: Some(row),
175                                        column: Some(col),
176                                    },
177                                    Err(_) => Self {
178                                        path,
179                                        row: Some(row),
180                                        column: None,
181                                    },
182                                }
183                            }
184                        }
185                        Err(_) => Self {
186                            path,
187                            row: None,
188                            column: None,
189                        },
190                    }
191                }
192            }
193            None => fallback(s),
194        }
195    }
196
197    pub fn map_path<E>(
198        self,
199        mapping: impl FnOnce(PathBuf) -> Result<PathBuf, E>,
200    ) -> Result<PathWithPosition, E> {
201        Ok(PathWithPosition {
202            path: mapping(self.path)?,
203            row: self.row,
204            column: self.column,
205        })
206    }
207
208    pub fn to_string(&self, path_to_string: impl Fn(&PathBuf) -> String) -> String {
209        let path_string = path_to_string(&self.path);
210        if let Some(row) = self.row {
211            if let Some(column) = self.column {
212                format!("{path_string}:{row}:{column}")
213            } else {
214                format!("{path_string}:{row}")
215            }
216        } else {
217            path_string
218        }
219    }
220}
221
222#[derive(Clone, Debug, Default)]
223pub struct PathMatcher {
224    sources: Vec<String>,
225    glob: GlobSet,
226}
227
228// impl std::fmt::Display for PathMatcher {
229//     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230//         self.sources.fmt(f)
231//     }
232// }
233
234impl PartialEq for PathMatcher {
235    fn eq(&self, other: &Self) -> bool {
236        self.sources.eq(&other.sources)
237    }
238}
239
240impl Eq for PathMatcher {}
241
242impl PathMatcher {
243    pub fn new(globs: &[String]) -> Result<Self, globset::Error> {
244        let globs = globs
245            .into_iter()
246            .map(|glob| Glob::new(&glob))
247            .collect::<Result<Vec<_>, _>>()?;
248        let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect();
249        let mut glob_builder = GlobSetBuilder::new();
250        for single_glob in globs {
251            glob_builder.add(single_glob);
252        }
253        let glob = glob_builder.build()?;
254        Ok(PathMatcher { glob, sources })
255    }
256
257    pub fn sources(&self) -> &[String] {
258        &self.sources
259    }
260
261    pub fn is_match<P: AsRef<Path>>(&self, other: P) -> bool {
262        let other_path = other.as_ref();
263        self.sources.iter().any(|source| {
264            let as_bytes = other_path.as_os_str().as_encoded_bytes();
265            as_bytes.starts_with(source.as_bytes()) || as_bytes.ends_with(source.as_bytes())
266        }) || self.glob.is_match(other_path)
267            || self.check_with_end_separator(other_path)
268    }
269
270    fn check_with_end_separator(&self, path: &Path) -> bool {
271        let path_str = path.to_string_lossy();
272        let separator = std::path::MAIN_SEPARATOR_STR;
273        if path_str.ends_with(separator) {
274            self.glob.is_match(path)
275        } else {
276            self.glob.is_match(path_str.to_string() + separator)
277        }
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn path_with_position_parsing_positive() {
287        let input_and_expected = [
288            (
289                "test_file.rs",
290                PathWithPosition {
291                    path: PathBuf::from("test_file.rs"),
292                    row: None,
293                    column: None,
294                },
295            ),
296            (
297                "test_file.rs:1",
298                PathWithPosition {
299                    path: PathBuf::from("test_file.rs"),
300                    row: Some(1),
301                    column: None,
302                },
303            ),
304            (
305                "test_file.rs:1:2",
306                PathWithPosition {
307                    path: PathBuf::from("test_file.rs"),
308                    row: Some(1),
309                    column: Some(2),
310                },
311            ),
312        ];
313
314        for (input, expected) in input_and_expected {
315            let actual = PathWithPosition::parse_str(input);
316            assert_eq!(
317                actual, expected,
318                "For positive case input str '{input}', got a parse mismatch"
319            );
320        }
321    }
322
323    #[test]
324    fn path_with_position_parsing_negative() {
325        for (input, row, column) in [
326            ("test_file.rs:a", None, None),
327            ("test_file.rs:a:b", None, None),
328            ("test_file.rs::", None, None),
329            ("test_file.rs::1", None, None),
330            ("test_file.rs:1::", Some(1), None),
331            ("test_file.rs::1:2", None, None),
332            ("test_file.rs:1::2", Some(1), None),
333            ("test_file.rs:1:2:3", Some(1), Some(2)),
334        ] {
335            let actual = PathWithPosition::parse_str(input);
336            assert_eq!(
337                actual,
338                PathWithPosition {
339                    path: PathBuf::from("test_file.rs"),
340                    row,
341                    column,
342                },
343                "For negative case input str '{input}', got a parse mismatch"
344            );
345        }
346    }
347
348    // Trim off trailing `:`s for otherwise valid input.
349    #[test]
350    fn path_with_position_parsing_special() {
351        #[cfg(not(target_os = "windows"))]
352        let input_and_expected = [
353            (
354                "test_file.rs:",
355                PathWithPosition {
356                    path: PathBuf::from("test_file.rs"),
357                    row: None,
358                    column: None,
359                },
360            ),
361            (
362                "test_file.rs:1:",
363                PathWithPosition {
364                    path: PathBuf::from("test_file.rs"),
365                    row: Some(1),
366                    column: None,
367                },
368            ),
369            (
370                "crates/file_finder/src/file_finder.rs:1902:13:",
371                PathWithPosition {
372                    path: PathBuf::from("crates/file_finder/src/file_finder.rs"),
373                    row: Some(1902),
374                    column: Some(13),
375                },
376            ),
377        ];
378
379        #[cfg(target_os = "windows")]
380        let input_and_expected = [
381            (
382                "test_file.rs:",
383                PathWithPosition {
384                    path: PathBuf::from("test_file.rs"),
385                    row: None,
386                    column: None,
387                },
388            ),
389            (
390                "test_file.rs:1:",
391                PathWithPosition {
392                    path: PathBuf::from("test_file.rs"),
393                    row: Some(1),
394                    column: None,
395                },
396            ),
397            (
398                "\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:",
399                PathWithPosition {
400                    path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
401                    row: Some(1902),
402                    column: Some(13),
403                },
404            ),
405            (
406                "\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:15:",
407                PathWithPosition {
408                    path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
409                    row: Some(1902),
410                    column: Some(13),
411                },
412            ),
413            (
414                "\\\\?\\C:\\Users\\someone\\test_file.rs:1902:::15:",
415                PathWithPosition {
416                    path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
417                    row: Some(1902),
418                    column: None,
419                },
420            ),
421            (
422                "C:\\Users\\someone\\test_file.rs:1902:13:",
423                PathWithPosition {
424                    path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
425                    row: Some(1902),
426                    column: Some(13),
427                },
428            ),
429            (
430                "crates/utils/paths.rs",
431                PathWithPosition {
432                    path: PathBuf::from("crates\\utils\\paths.rs"),
433                    row: None,
434                    column: None,
435                },
436            ),
437            (
438                "crates/utils/paths.rs:101",
439                PathWithPosition {
440                    path: PathBuf::from("crates\\utils\\paths.rs"),
441                    row: Some(101),
442                    column: None,
443                },
444            ),
445        ];
446
447        for (input, expected) in input_and_expected {
448            let actual = PathWithPosition::parse_str(input);
449            assert_eq!(
450                actual, expected,
451                "For special case input str '{input}', got a parse mismatch"
452            );
453        }
454    }
455
456    #[test]
457    fn test_path_compact() {
458        let path: PathBuf = [
459            home_dir().to_string_lossy().to_string(),
460            "some_file.txt".to_string(),
461        ]
462        .iter()
463        .collect();
464        if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
465            assert_eq!(path.compact().to_str(), Some("~/some_file.txt"));
466        } else {
467            assert_eq!(path.compact().to_str(), path.to_str());
468        }
469    }
470
471    #[test]
472    fn test_icon_stem_or_suffix() {
473        // No dots in name
474        let path = Path::new("/a/b/c/file_name.rs");
475        assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
476
477        // Single dot in name
478        let path = Path::new("/a/b/c/file.name.rs");
479        assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
480
481        // No suffix
482        let path = Path::new("/a/b/c/file");
483        assert_eq!(path.icon_stem_or_suffix(), Some("file"));
484
485        // Multiple dots in name
486        let path = Path::new("/a/b/c/long.file.name.rs");
487        assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
488
489        // Hidden file, no extension
490        let path = Path::new("/a/b/c/.gitignore");
491        assert_eq!(path.icon_stem_or_suffix(), Some("gitignore"));
492
493        // Hidden file, with extension
494        let path = Path::new("/a/b/c/.eslintrc.js");
495        assert_eq!(path.icon_stem_or_suffix(), Some("eslintrc.js"));
496    }
497
498    #[test]
499    fn test_extension_or_hidden_file_name() {
500        // No dots in name
501        let path = Path::new("/a/b/c/file_name.rs");
502        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
503
504        // Single dot in name
505        let path = Path::new("/a/b/c/file.name.rs");
506        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
507
508        // Multiple dots in name
509        let path = Path::new("/a/b/c/long.file.name.rs");
510        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
511
512        // Hidden file, no extension
513        let path = Path::new("/a/b/c/.gitignore");
514        assert_eq!(path.extension_or_hidden_file_name(), Some("gitignore"));
515
516        // Hidden file, with extension
517        let path = Path::new("/a/b/c/.eslintrc.js");
518        assert_eq!(path.extension_or_hidden_file_name(), Some("js"));
519    }
520
521    #[test]
522    fn edge_of_glob() {
523        let path = Path::new("/work/node_modules");
524        let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
525        assert!(
526            path_matcher.is_match(path),
527            "Path matcher should match {path:?}"
528        );
529    }
530
531    #[test]
532    fn project_search() {
533        let path = Path::new("/Users/someonetoignore/work/zed/zed.dev/node_modules");
534        let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
535        assert!(
536            path_matcher.is_match(path),
537            "Path matcher should match {path:?}"
538        );
539    }
540}