paths.rs

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