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