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