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