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 CONTEXTS_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 source: String,
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.source.fmt(f)
353 }
354}
355
356impl PartialEq for PathMatcher {
357 fn eq(&self, other: &Self) -> bool {
358 self.source.eq(&other.source)
359 }
360}
361
362impl Eq for PathMatcher {}
363
364impl PathMatcher {
365 pub fn new(source: &str) -> Result<Self, globset::Error> {
366 Ok(PathMatcher {
367 glob: Glob::new(source)?.compile_matcher(),
368 source: String::from(source),
369 })
370 }
371
372 pub fn source(&self) -> &str {
373 &self.source
374 }
375
376 pub fn is_match<P: AsRef<Path>>(&self, other: P) -> bool {
377 let other_path = other.as_ref();
378 other_path.starts_with(Path::new(&self.source))
379 || other_path.ends_with(Path::new(&self.source))
380 || self.glob.is_match(other_path)
381 || self.check_with_end_separator(other_path)
382 }
383
384 fn check_with_end_separator(&self, path: &Path) -> bool {
385 let path_str = path.to_string_lossy();
386 let separator = std::path::MAIN_SEPARATOR_STR;
387 if path_str.ends_with(separator) {
388 self.glob.is_match(path)
389 } else {
390 self.glob.is_match(path_str.to_string() + separator)
391 }
392 }
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 type TestPath = PathLikeWithPosition<String>;
400
401 fn parse_str(s: &str) -> TestPath {
402 TestPath::parse_str(s, |s| Ok::<_, std::convert::Infallible>(s.to_string()))
403 .expect("infallible")
404 }
405
406 #[test]
407 fn path_with_position_parsing_positive() {
408 let input_and_expected = [
409 (
410 "test_file.rs",
411 PathLikeWithPosition {
412 path_like: "test_file.rs".to_string(),
413 row: None,
414 column: None,
415 },
416 ),
417 (
418 "test_file.rs:1",
419 PathLikeWithPosition {
420 path_like: "test_file.rs".to_string(),
421 row: Some(1),
422 column: None,
423 },
424 ),
425 (
426 "test_file.rs:1:2",
427 PathLikeWithPosition {
428 path_like: "test_file.rs".to_string(),
429 row: Some(1),
430 column: Some(2),
431 },
432 ),
433 ];
434
435 for (input, expected) in input_and_expected {
436 let actual = parse_str(input);
437 assert_eq!(
438 actual, expected,
439 "For positive case input str '{input}', got a parse mismatch"
440 );
441 }
442 }
443
444 #[test]
445 fn path_with_position_parsing_negative() {
446 for (input, row, column) in [
447 ("test_file.rs:a", None, None),
448 ("test_file.rs:a:b", None, None),
449 ("test_file.rs::", None, None),
450 ("test_file.rs::1", None, None),
451 ("test_file.rs:1::", Some(1), None),
452 ("test_file.rs::1:2", None, None),
453 ("test_file.rs:1::2", Some(1), None),
454 ("test_file.rs:1:2:3", Some(1), Some(2)),
455 ] {
456 let actual = parse_str(input);
457 assert_eq!(
458 actual,
459 PathLikeWithPosition {
460 path_like: "test_file.rs".to_string(),
461 row,
462 column,
463 },
464 "For negative case input str '{input}', got a parse mismatch"
465 );
466 }
467 }
468
469 // Trim off trailing `:`s for otherwise valid input.
470 #[test]
471 fn path_with_position_parsing_special() {
472 #[cfg(not(target_os = "windows"))]
473 let input_and_expected = [
474 (
475 "test_file.rs:",
476 PathLikeWithPosition {
477 path_like: "test_file.rs".to_string(),
478 row: None,
479 column: None,
480 },
481 ),
482 (
483 "test_file.rs:1:",
484 PathLikeWithPosition {
485 path_like: "test_file.rs".to_string(),
486 row: Some(1),
487 column: None,
488 },
489 ),
490 (
491 "crates/file_finder/src/file_finder.rs:1902:13:",
492 PathLikeWithPosition {
493 path_like: "crates/file_finder/src/file_finder.rs".to_string(),
494 row: Some(1902),
495 column: Some(13),
496 },
497 ),
498 ];
499
500 #[cfg(target_os = "windows")]
501 let input_and_expected = [
502 (
503 "test_file.rs:",
504 PathLikeWithPosition {
505 path_like: "test_file.rs".to_string(),
506 row: None,
507 column: None,
508 },
509 ),
510 (
511 "test_file.rs:1:",
512 PathLikeWithPosition {
513 path_like: "test_file.rs".to_string(),
514 row: Some(1),
515 column: None,
516 },
517 ),
518 (
519 "\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:",
520 PathLikeWithPosition {
521 path_like: "C:\\Users\\someone\\test_file.rs".to_string(),
522 row: Some(1902),
523 column: Some(13),
524 },
525 ),
526 (
527 "\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:15:",
528 PathLikeWithPosition {
529 path_like: "C:\\Users\\someone\\test_file.rs".to_string(),
530 row: Some(1902),
531 column: Some(13),
532 },
533 ),
534 (
535 "\\\\?\\C:\\Users\\someone\\test_file.rs:1902:::15:",
536 PathLikeWithPosition {
537 path_like: "C:\\Users\\someone\\test_file.rs".to_string(),
538 row: Some(1902),
539 column: None,
540 },
541 ),
542 ];
543
544 for (input, expected) in input_and_expected {
545 let actual = parse_str(input);
546 assert_eq!(
547 actual, expected,
548 "For special case input str '{input}', got a parse mismatch"
549 );
550 }
551 }
552
553 #[test]
554 fn test_path_compact() {
555 let path: PathBuf = [
556 HOME.to_string_lossy().to_string(),
557 "some_file.txt".to_string(),
558 ]
559 .iter()
560 .collect();
561 if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
562 assert_eq!(path.compact().to_str(), Some("~/some_file.txt"));
563 } else {
564 assert_eq!(path.compact().to_str(), path.to_str());
565 }
566 }
567
568 #[test]
569 fn test_icon_stem_or_suffix() {
570 // No dots 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 // Single dot in name
575 let path = Path::new("/a/b/c/file.name.rs");
576 assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
577
578 // No suffix
579 let path = Path::new("/a/b/c/file");
580 assert_eq!(path.icon_stem_or_suffix(), Some("file"));
581
582 // Multiple dots in name
583 let path = Path::new("/a/b/c/long.file.name.rs");
584 assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
585
586 // Hidden file, no extension
587 let path = Path::new("/a/b/c/.gitignore");
588 assert_eq!(path.icon_stem_or_suffix(), Some("gitignore"));
589
590 // Hidden file, with extension
591 let path = Path::new("/a/b/c/.eslintrc.js");
592 assert_eq!(path.icon_stem_or_suffix(), Some("eslintrc.js"));
593 }
594
595 #[test]
596 fn test_extension_or_hidden_file_name() {
597 // No dots 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 // Single dot in name
602 let path = Path::new("/a/b/c/file.name.rs");
603 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
604
605 // Multiple dots in name
606 let path = Path::new("/a/b/c/long.file.name.rs");
607 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
608
609 // Hidden file, no extension
610 let path = Path::new("/a/b/c/.gitignore");
611 assert_eq!(path.extension_or_hidden_file_name(), Some("gitignore"));
612
613 // Hidden file, with extension
614 let path = Path::new("/a/b/c/.eslintrc.js");
615 assert_eq!(path.extension_or_hidden_file_name(), Some("js"));
616 }
617
618 #[test]
619 fn edge_of_glob() {
620 let path = Path::new("/work/node_modules");
621 let path_matcher = PathMatcher::new("**/node_modules/**").unwrap();
622 assert!(
623 path_matcher.is_match(path),
624 "Path matcher {path_matcher} should match {path:?}"
625 );
626 }
627
628 #[test]
629 fn project_search() {
630 let path = Path::new("/Users/someonetoignore/work/zed/zed.dev/node_modules");
631 let path_matcher = PathMatcher::new("**/node_modules/**").unwrap();
632 assert!(
633 path_matcher.is_match(path),
634 "Path matcher {path_matcher} should match {path:?}"
635 );
636 }
637}