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