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