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