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