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