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        other.as_ref().starts_with(&self.maybe_path)
223            || self.glob.is_match(&other)
224            || self.check_with_end_separator(other.as_ref())
225    }
226
227    fn check_with_end_separator(&self, path: &Path) -> bool {
228        let path_str = path.to_string_lossy();
229        let separator = std::path::MAIN_SEPARATOR_STR;
230        if path_str.ends_with(separator) {
231            self.glob.is_match(path)
232        } else {
233            self.glob.is_match(path_str.to_string() + separator)
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    type TestPath = PathLikeWithPosition<String>;
243
244    fn parse_str(s: &str) -> TestPath {
245        TestPath::parse_str(s, |s| Ok::<_, std::convert::Infallible>(s.to_string()))
246            .expect("infallible")
247    }
248
249    #[test]
250    fn path_with_position_parsing_positive() {
251        let input_and_expected = [
252            (
253                "test_file.rs",
254                PathLikeWithPosition {
255                    path_like: "test_file.rs".to_string(),
256                    row: None,
257                    column: None,
258                },
259            ),
260            (
261                "test_file.rs:1",
262                PathLikeWithPosition {
263                    path_like: "test_file.rs".to_string(),
264                    row: Some(1),
265                    column: None,
266                },
267            ),
268            (
269                "test_file.rs:1:2",
270                PathLikeWithPosition {
271                    path_like: "test_file.rs".to_string(),
272                    row: Some(1),
273                    column: Some(2),
274                },
275            ),
276        ];
277
278        for (input, expected) in input_and_expected {
279            let actual = parse_str(input);
280            assert_eq!(
281                actual, expected,
282                "For positive case input str '{input}', got a parse mismatch"
283            );
284        }
285    }
286
287    #[test]
288    fn path_with_position_parsing_negative() {
289        for input in [
290            "test_file.rs:a",
291            "test_file.rs:a:b",
292            "test_file.rs::",
293            "test_file.rs::1",
294            "test_file.rs:1::",
295            "test_file.rs::1:2",
296            "test_file.rs:1::2",
297            "test_file.rs:1:2:3",
298        ] {
299            let actual = parse_str(input);
300            assert_eq!(
301                actual,
302                PathLikeWithPosition {
303                    path_like: input.to_string(),
304                    row: None,
305                    column: None,
306                },
307                "For negative case input str '{input}', got a parse mismatch"
308            );
309        }
310    }
311
312    // Trim off trailing `:`s for otherwise valid input.
313    #[test]
314    fn path_with_position_parsing_special() {
315        let input_and_expected = [
316            (
317                "test_file.rs:",
318                PathLikeWithPosition {
319                    path_like: "test_file.rs".to_string(),
320                    row: None,
321                    column: None,
322                },
323            ),
324            (
325                "test_file.rs:1:",
326                PathLikeWithPosition {
327                    path_like: "test_file.rs".to_string(),
328                    row: Some(1),
329                    column: None,
330                },
331            ),
332            (
333                "crates/file_finder/src/file_finder.rs:1902:13:",
334                PathLikeWithPosition {
335                    path_like: "crates/file_finder/src/file_finder.rs".to_string(),
336                    row: Some(1902),
337                    column: Some(13),
338                },
339            ),
340        ];
341
342        for (input, expected) in input_and_expected {
343            let actual = parse_str(input);
344            assert_eq!(
345                actual, expected,
346                "For special case input str '{input}', got a parse mismatch"
347            );
348        }
349    }
350
351    #[test]
352    fn test_path_compact() {
353        let path: PathBuf = [
354            HOME.to_string_lossy().to_string(),
355            "some_file.txt".to_string(),
356        ]
357        .iter()
358        .collect();
359        if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
360            assert_eq!(path.compact().to_str(), Some("~/some_file.txt"));
361        } else {
362            assert_eq!(path.compact().to_str(), path.to_str());
363        }
364    }
365
366    #[test]
367    fn test_icon_suffix() {
368        // No dots in name
369        let path = Path::new("/a/b/c/file_name.rs");
370        assert_eq!(path.icon_suffix(), Some("rs"));
371
372        // Single dot in name
373        let path = Path::new("/a/b/c/file.name.rs");
374        assert_eq!(path.icon_suffix(), Some("rs"));
375
376        // Multiple dots in name
377        let path = Path::new("/a/b/c/long.file.name.rs");
378        assert_eq!(path.icon_suffix(), Some("rs"));
379
380        // Hidden file, no extension
381        let path = Path::new("/a/b/c/.gitignore");
382        assert_eq!(path.icon_suffix(), Some("gitignore"));
383
384        // Hidden file, with extension
385        let path = Path::new("/a/b/c/.eslintrc.js");
386        assert_eq!(path.icon_suffix(), Some("eslintrc.js"));
387    }
388
389    #[test]
390    fn test_extension_or_hidden_file_name() {
391        // No dots in name
392        let path = Path::new("/a/b/c/file_name.rs");
393        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
394
395        // Single dot in name
396        let path = Path::new("/a/b/c/file.name.rs");
397        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
398
399        // Multiple dots in name
400        let path = Path::new("/a/b/c/long.file.name.rs");
401        assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
402
403        // Hidden file, no extension
404        let path = Path::new("/a/b/c/.gitignore");
405        assert_eq!(path.extension_or_hidden_file_name(), Some("gitignore"));
406
407        // Hidden file, with extension
408        let path = Path::new("/a/b/c/.eslintrc.js");
409        assert_eq!(path.extension_or_hidden_file_name(), Some("js"));
410    }
411
412    #[test]
413    fn edge_of_glob() {
414        let path = Path::new("/work/node_modules");
415        let path_matcher = PathMatcher::new("**/node_modules/**").unwrap();
416        assert!(
417            path_matcher.is_match(&path),
418            "Path matcher {path_matcher} should match {path:?}"
419        );
420    }
421}