paths.rs

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