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