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