paths.rs

  1use std::{
  2    ffi::OsStr,
  3    path::{Path, PathBuf},
  4};
  5
  6use globset::{Glob, GlobMatcher};
  7use serde::{Deserialize, Serialize};
  8
  9lazy_static::lazy_static! {
 10    pub static ref HOME: PathBuf = dirs::home_dir().expect("failed to determine home directory");
 11    pub static ref CONFIG_DIR: PathBuf = HOME.join(".config").join("zed");
 12    pub static ref CONVERSATIONS_DIR: PathBuf = HOME.join(".config/zed/conversations");
 13    pub static ref EMBEDDINGS_DIR: PathBuf = HOME.join(".config/zed/embeddings");
 14    pub static ref THEMES_DIR: PathBuf = HOME.join(".config/zed/themes");
 15    pub static ref LOGS_DIR: PathBuf = HOME.join("Library/Logs/Zed");
 16    pub static ref SUPPORT_DIR: PathBuf = HOME.join("Library/Application Support/Zed");
 17    pub static ref PLUGINS_DIR: PathBuf = HOME.join("Library/Application Support/Zed/plugins");
 18    pub static ref LANGUAGES_DIR: PathBuf = HOME.join("Library/Application Support/Zed/languages");
 19    pub static ref COPILOT_DIR: PathBuf = HOME.join("Library/Application Support/Zed/copilot");
 20    pub static ref DEFAULT_PRETTIER_DIR: PathBuf = HOME.join("Library/Application Support/Zed/prettier");
 21    pub static ref DB_DIR: PathBuf = HOME.join("Library/Application Support/Zed/db");
 22    pub static ref CRASHES_DIR: PathBuf = HOME.join("Library/Logs/DiagnosticReports");
 23    pub static ref CRASHES_RETIRED_DIR: PathBuf = HOME.join("Library/Logs/DiagnosticReports/Retired");
 24    pub static ref SETTINGS: PathBuf = CONFIG_DIR.join("settings.json");
 25    pub static ref KEYMAP: PathBuf = CONFIG_DIR.join("keymap.json");
 26    pub static ref LAST_USERNAME: PathBuf = CONFIG_DIR.join("last-username.txt");
 27    pub static ref LOG: PathBuf = LOGS_DIR.join("Zed.log");
 28    pub static ref OLD_LOG: PathBuf = LOGS_DIR.join("Zed.log.old");
 29    pub static ref LOCAL_SETTINGS_RELATIVE_PATH: &'static Path = Path::new(".zed/settings.json");
 30}
 31
 32pub mod legacy {
 33    use std::path::PathBuf;
 34
 35    lazy_static::lazy_static! {
 36        static ref CONFIG_DIR: PathBuf = super::HOME.join(".zed");
 37        pub static ref SETTINGS: PathBuf = CONFIG_DIR.join("settings.json");
 38        pub static ref KEYMAP: PathBuf = CONFIG_DIR.join("keymap.json");
 39    }
 40}
 41
 42pub trait PathExt {
 43    fn compact(&self) -> PathBuf;
 44    fn icon_suffix(&self) -> Option<&str>;
 45    fn extension_or_hidden_file_name(&self) -> Option<&str>;
 46    fn try_from_bytes<'a>(bytes: &'a [u8]) -> anyhow::Result<Self>
 47    where
 48        Self: From<&'a Path>,
 49    {
 50        #[cfg(unix)]
 51        {
 52            use std::os::unix::prelude::OsStrExt;
 53            Ok(Self::from(Path::new(OsStr::from_bytes(bytes))))
 54        }
 55        #[cfg(windows)]
 56        {
 57            use anyhow::anyhow;
 58            use tendril::fmt::{Format, WTF8};
 59            WTF8::validate(bytes)
 60                .then(|| {
 61                    // Safety: bytes are valid WTF-8 sequence.
 62                    Self::from(Path::new(unsafe {
 63                        OsStr::from_encoded_bytes_unchecked(bytes)
 64                    }))
 65                })
 66                .ok_or_else(|| anyhow!("Invalid WTF-8 sequence: {bytes:?}"))
 67        }
 68    }
 69}
 70
 71impl<T: AsRef<Path>> PathExt for T {
 72    /// Compacts a given file path by replacing the user's home directory
 73    /// prefix with a tilde (`~`).
 74    ///
 75    /// # Returns
 76    ///
 77    /// * A `PathBuf` containing the compacted file path. If the input path
 78    ///   does not have the user's home directory prefix, or if we are not on
 79    ///   Linux or macOS, the original path is returned unchanged.
 80    fn compact(&self) -> PathBuf {
 81        if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
 82            match self.as_ref().strip_prefix(HOME.as_path()) {
 83                Ok(relative_path) => {
 84                    let mut shortened_path = PathBuf::new();
 85                    shortened_path.push("~");
 86                    shortened_path.push(relative_path);
 87                    shortened_path
 88                }
 89                Err(_) => self.as_ref().to_path_buf(),
 90            }
 91        } else {
 92            self.as_ref().to_path_buf()
 93        }
 94    }
 95
 96    /// Returns a suffix of the path that is used to determine which file icon to use
 97    fn icon_suffix(&self) -> Option<&str> {
 98        let file_name = self.as_ref().file_name()?.to_str()?;
 99
100        if file_name.starts_with('.') {
101            return file_name.strip_prefix('.');
102        }
103
104        self.as_ref()
105            .extension()
106            .and_then(|extension| extension.to_str())
107    }
108
109    /// Returns a file's extension or, if the file is hidden, its name without the leading dot
110    fn extension_or_hidden_file_name(&self) -> Option<&str> {
111        if let Some(extension) = self.as_ref().extension() {
112            return extension.to_str();
113        }
114
115        self.as_ref().file_name()?.to_str()?.split('.').last()
116    }
117}
118
119/// A delimiter to use in `path_query:row_number:column_number` strings parsing.
120pub const FILE_ROW_COLUMN_DELIMITER: char = ':';
121
122/// A representation of a path-like string with optional row and column numbers.
123/// Matching values example: `te`, `test.rs:22`, `te:22:5`, etc.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct PathLikeWithPosition<P> {
126    pub path_like: P,
127    pub row: Option<u32>,
128    // Absent if row is absent.
129    pub column: Option<u32>,
130}
131
132impl<P> PathLikeWithPosition<P> {
133    /// Parses a string that possibly has `:row:column` suffix.
134    /// Ignores trailing `:`s, so `test.rs:22:` is parsed as `test.rs:22`.
135    /// If any of the row/column component parsing fails, the whole string is then parsed as a path like.
136    pub fn parse_str<E>(
137        s: &str,
138        parse_path_like_str: impl Fn(&str) -> Result<P, E>,
139    ) -> Result<Self, E> {
140        let fallback = |fallback_str| {
141            Ok(Self {
142                path_like: parse_path_like_str(fallback_str)?,
143                row: None,
144                column: None,
145            })
146        };
147
148        match s.trim().split_once(FILE_ROW_COLUMN_DELIMITER) {
149            Some((path_like_str, maybe_row_and_col_str)) => {
150                let path_like_str = path_like_str.trim();
151                let maybe_row_and_col_str = maybe_row_and_col_str.trim();
152                if path_like_str.is_empty() {
153                    fallback(s)
154                } else if maybe_row_and_col_str.is_empty() {
155                    fallback(path_like_str)
156                } else {
157                    let (row_parse_result, maybe_col_str) =
158                        match maybe_row_and_col_str.split_once(FILE_ROW_COLUMN_DELIMITER) {
159                            Some((maybe_row_str, maybe_col_str)) => {
160                                (maybe_row_str.parse::<u32>(), maybe_col_str.trim())
161                            }
162                            None => (maybe_row_and_col_str.parse::<u32>(), ""),
163                        };
164
165                    match row_parse_result {
166                        Ok(row) => {
167                            if maybe_col_str.is_empty() {
168                                Ok(Self {
169                                    path_like: parse_path_like_str(path_like_str)?,
170                                    row: Some(row),
171                                    column: None,
172                                })
173                            } else {
174                                let maybe_col_str =
175                                    if maybe_col_str.ends_with(FILE_ROW_COLUMN_DELIMITER) {
176                                        &maybe_col_str[..maybe_col_str.len() - 1]
177                                    } else {
178                                        maybe_col_str
179                                    };
180                                match maybe_col_str.parse::<u32>() {
181                                    Ok(col) => Ok(Self {
182                                        path_like: parse_path_like_str(path_like_str)?,
183                                        row: Some(row),
184                                        column: Some(col),
185                                    }),
186                                    Err(_) => fallback(s),
187                                }
188                            }
189                        }
190                        Err(_) => fallback(s),
191                    }
192                }
193            }
194            None => fallback(s),
195        }
196    }
197
198    pub fn map_path_like<P2, E>(
199        self,
200        mapping: impl FnOnce(P) -> Result<P2, E>,
201    ) -> Result<PathLikeWithPosition<P2>, E> {
202        Ok(PathLikeWithPosition {
203            path_like: mapping(self.path_like)?,
204            row: self.row,
205            column: self.column,
206        })
207    }
208
209    pub fn to_string(&self, path_like_to_string: impl Fn(&P) -> String) -> String {
210        let path_like_string = path_like_to_string(&self.path_like);
211        if let Some(row) = self.row {
212            if let Some(column) = self.column {
213                format!("{path_like_string}:{row}:{column}")
214            } else {
215                format!("{path_like_string}:{row}")
216            }
217        } else {
218            path_like_string
219        }
220    }
221}
222
223#[derive(Clone, Debug)]
224pub struct PathMatcher {
225    maybe_path: PathBuf,
226    glob: GlobMatcher,
227}
228
229impl std::fmt::Display for PathMatcher {
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        self.maybe_path.to_string_lossy().fmt(f)
232    }
233}
234
235impl PartialEq for PathMatcher {
236    fn eq(&self, other: &Self) -> bool {
237        self.maybe_path.eq(&other.maybe_path)
238    }
239}
240
241impl Eq for PathMatcher {}
242
243impl PathMatcher {
244    pub fn new(maybe_glob: &str) -> Result<Self, globset::Error> {
245        Ok(PathMatcher {
246            glob: Glob::new(maybe_glob)?.compile_matcher(),
247            maybe_path: PathBuf::from(maybe_glob),
248        })
249    }
250
251    pub fn is_match<P: AsRef<Path>>(&self, other: P) -> bool {
252        let other_path = other.as_ref();
253        other_path.starts_with(&self.maybe_path)
254            || other_path.ends_with(&self.maybe_path)
255            || self.glob.is_match(other_path)
256            || self.check_with_end_separator(other_path)
257    }
258
259    fn check_with_end_separator(&self, path: &Path) -> bool {
260        let path_str = path.to_string_lossy();
261        let separator = std::path::MAIN_SEPARATOR_STR;
262        if path_str.ends_with(separator) {
263            self.glob.is_match(path)
264        } else {
265            self.glob.is_match(path_str.to_string() + separator)
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    type TestPath = PathLikeWithPosition<String>;
275
276    fn parse_str(s: &str) -> TestPath {
277        TestPath::parse_str(s, |s| Ok::<_, std::convert::Infallible>(s.to_string()))
278            .expect("infallible")
279    }
280
281    #[test]
282    fn path_with_position_parsing_positive() {
283        let input_and_expected = [
284            (
285                "test_file.rs",
286                PathLikeWithPosition {
287                    path_like: "test_file.rs".to_string(),
288                    row: None,
289                    column: None,
290                },
291            ),
292            (
293                "test_file.rs:1",
294                PathLikeWithPosition {
295                    path_like: "test_file.rs".to_string(),
296                    row: Some(1),
297                    column: None,
298                },
299            ),
300            (
301                "test_file.rs:1:2",
302                PathLikeWithPosition {
303                    path_like: "test_file.rs".to_string(),
304                    row: Some(1),
305                    column: Some(2),
306                },
307            ),
308        ];
309
310        for (input, expected) in input_and_expected {
311            let actual = parse_str(input);
312            assert_eq!(
313                actual, expected,
314                "For positive case input str '{input}', got a parse mismatch"
315            );
316        }
317    }
318
319    #[test]
320    fn path_with_position_parsing_negative() {
321        for input in [
322            "test_file.rs:a",
323            "test_file.rs:a:b",
324            "test_file.rs::",
325            "test_file.rs::1",
326            "test_file.rs:1::",
327            "test_file.rs::1:2",
328            "test_file.rs:1::2",
329            "test_file.rs:1:2:3",
330        ] {
331            let actual = parse_str(input);
332            assert_eq!(
333                actual,
334                PathLikeWithPosition {
335                    path_like: input.to_string(),
336                    row: None,
337                    column: None,
338                },
339                "For negative case input str '{input}', got a parse mismatch"
340            );
341        }
342    }
343
344    // Trim off trailing `:`s for otherwise valid input.
345    #[test]
346    fn path_with_position_parsing_special() {
347        let input_and_expected = [
348            (
349                "test_file.rs:",
350                PathLikeWithPosition {
351                    path_like: "test_file.rs".to_string(),
352                    row: None,
353                    column: None,
354                },
355            ),
356            (
357                "test_file.rs:1:",
358                PathLikeWithPosition {
359                    path_like: "test_file.rs".to_string(),
360                    row: Some(1),
361                    column: None,
362                },
363            ),
364            (
365                "crates/file_finder/src/file_finder.rs:1902:13:",
366                PathLikeWithPosition {
367                    path_like: "crates/file_finder/src/file_finder.rs".to_string(),
368                    row: Some(1902),
369                    column: Some(13),
370                },
371            ),
372        ];
373
374        for (input, expected) in input_and_expected {
375            let actual = parse_str(input);
376            assert_eq!(
377                actual, expected,
378                "For special case input str '{input}', got a parse mismatch"
379            );
380        }
381    }
382
383    #[test]
384    fn test_path_compact() {
385        let path: PathBuf = [
386            HOME.to_string_lossy().to_string(),
387            "some_file.txt".to_string(),
388        ]
389        .iter()
390        .collect();
391        if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
392            assert_eq!(path.compact().to_str(), Some("~/some_file.txt"));
393        } else {
394            assert_eq!(path.compact().to_str(), path.to_str());
395        }
396    }
397
398    #[test]
399    fn test_icon_suffix() {
400        // No dots in name
401        let path = Path::new("/a/b/c/file_name.rs");
402        assert_eq!(path.icon_suffix(), Some("rs"));
403
404        // Single dot in name
405        let path = Path::new("/a/b/c/file.name.rs");
406        assert_eq!(path.icon_suffix(), Some("rs"));
407
408        // Multiple dots in name
409        let path = Path::new("/a/b/c/long.file.name.rs");
410        assert_eq!(path.icon_suffix(), Some("rs"));
411
412        // Hidden file, no extension
413        let path = Path::new("/a/b/c/.gitignore");
414        assert_eq!(path.icon_suffix(), Some("gitignore"));
415
416        // Hidden file, with extension
417        let path = Path::new("/a/b/c/.eslintrc.js");
418        assert_eq!(path.icon_suffix(), Some("eslintrc.js"));
419    }
420
421    #[test]
422    fn test_extension_or_hidden_file_name() {
423        // No dots in name
424        let path = Path::new("/a/b/c/file_name.rs");
425        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
426
427        // Single dot in name
428        let path = Path::new("/a/b/c/file.name.rs");
429        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
430
431        // Multiple dots in name
432        let path = Path::new("/a/b/c/long.file.name.rs");
433        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
434
435        // Hidden file, no extension
436        let path = Path::new("/a/b/c/.gitignore");
437        assert_eq!(path.extension_or_hidden_file_name(), Some("gitignore"));
438
439        // Hidden file, with extension
440        let path = Path::new("/a/b/c/.eslintrc.js");
441        assert_eq!(path.extension_or_hidden_file_name(), Some("js"));
442    }
443
444    #[test]
445    fn edge_of_glob() {
446        let path = Path::new("/work/node_modules");
447        let path_matcher = PathMatcher::new("**/node_modules/**").unwrap();
448        assert!(
449            path_matcher.is_match(&path),
450            "Path matcher {path_matcher} should match {path:?}"
451        );
452    }
453
454    #[test]
455    fn project_search() {
456        let path = Path::new("/Users/someonetoignore/work/zed/zed.dev/node_modules");
457        let path_matcher = PathMatcher::new("**/node_modules/**").unwrap();
458        assert!(
459            path_matcher.is_match(&path),
460            "Path matcher {path_matcher} should match {path:?}"
461        );
462    }
463}