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