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 other.as_ref().starts_with(&self.maybe_path)
223 || self.glob.is_match(&other)
224 || self.check_with_end_separator(other.as_ref())
225 }
226
227 fn check_with_end_separator(&self, path: &Path) -> bool {
228 let path_str = path.to_string_lossy();
229 let separator = std::path::MAIN_SEPARATOR_STR;
230 if path_str.ends_with(separator) {
231 self.glob.is_match(path)
232 } else {
233 self.glob.is_match(path_str.to_string() + separator)
234 }
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 type TestPath = PathLikeWithPosition<String>;
243
244 fn parse_str(s: &str) -> TestPath {
245 TestPath::parse_str(s, |s| Ok::<_, std::convert::Infallible>(s.to_string()))
246 .expect("infallible")
247 }
248
249 #[test]
250 fn path_with_position_parsing_positive() {
251 let input_and_expected = [
252 (
253 "test_file.rs",
254 PathLikeWithPosition {
255 path_like: "test_file.rs".to_string(),
256 row: None,
257 column: None,
258 },
259 ),
260 (
261 "test_file.rs:1",
262 PathLikeWithPosition {
263 path_like: "test_file.rs".to_string(),
264 row: Some(1),
265 column: None,
266 },
267 ),
268 (
269 "test_file.rs:1:2",
270 PathLikeWithPosition {
271 path_like: "test_file.rs".to_string(),
272 row: Some(1),
273 column: Some(2),
274 },
275 ),
276 ];
277
278 for (input, expected) in input_and_expected {
279 let actual = parse_str(input);
280 assert_eq!(
281 actual, expected,
282 "For positive case input str '{input}', got a parse mismatch"
283 );
284 }
285 }
286
287 #[test]
288 fn path_with_position_parsing_negative() {
289 for input in [
290 "test_file.rs:a",
291 "test_file.rs:a:b",
292 "test_file.rs::",
293 "test_file.rs::1",
294 "test_file.rs:1::",
295 "test_file.rs::1:2",
296 "test_file.rs:1::2",
297 "test_file.rs:1:2:3",
298 ] {
299 let actual = parse_str(input);
300 assert_eq!(
301 actual,
302 PathLikeWithPosition {
303 path_like: input.to_string(),
304 row: None,
305 column: None,
306 },
307 "For negative case input str '{input}', got a parse mismatch"
308 );
309 }
310 }
311
312 // Trim off trailing `:`s for otherwise valid input.
313 #[test]
314 fn path_with_position_parsing_special() {
315 let input_and_expected = [
316 (
317 "test_file.rs:",
318 PathLikeWithPosition {
319 path_like: "test_file.rs".to_string(),
320 row: None,
321 column: None,
322 },
323 ),
324 (
325 "test_file.rs:1:",
326 PathLikeWithPosition {
327 path_like: "test_file.rs".to_string(),
328 row: Some(1),
329 column: None,
330 },
331 ),
332 (
333 "crates/file_finder/src/file_finder.rs:1902:13:",
334 PathLikeWithPosition {
335 path_like: "crates/file_finder/src/file_finder.rs".to_string(),
336 row: Some(1902),
337 column: Some(13),
338 },
339 ),
340 ];
341
342 for (input, expected) in input_and_expected {
343 let actual = parse_str(input);
344 assert_eq!(
345 actual, expected,
346 "For special case input str '{input}', got a parse mismatch"
347 );
348 }
349 }
350
351 #[test]
352 fn test_path_compact() {
353 let path: PathBuf = [
354 HOME.to_string_lossy().to_string(),
355 "some_file.txt".to_string(),
356 ]
357 .iter()
358 .collect();
359 if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
360 assert_eq!(path.compact().to_str(), Some("~/some_file.txt"));
361 } else {
362 assert_eq!(path.compact().to_str(), path.to_str());
363 }
364 }
365
366 #[test]
367 fn test_icon_suffix() {
368 // No dots in name
369 let path = Path::new("/a/b/c/file_name.rs");
370 assert_eq!(path.icon_suffix(), Some("rs"));
371
372 // Single dot in name
373 let path = Path::new("/a/b/c/file.name.rs");
374 assert_eq!(path.icon_suffix(), Some("rs"));
375
376 // Multiple dots in name
377 let path = Path::new("/a/b/c/long.file.name.rs");
378 assert_eq!(path.icon_suffix(), Some("rs"));
379
380 // Hidden file, no extension
381 let path = Path::new("/a/b/c/.gitignore");
382 assert_eq!(path.icon_suffix(), Some("gitignore"));
383
384 // Hidden file, with extension
385 let path = Path::new("/a/b/c/.eslintrc.js");
386 assert_eq!(path.icon_suffix(), Some("eslintrc.js"));
387 }
388
389 #[test]
390 fn test_extension_or_hidden_file_name() {
391 // No dots in name
392 let path = Path::new("/a/b/c/file_name.rs");
393 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
394
395 // Single dot in name
396 let path = Path::new("/a/b/c/file.name.rs");
397 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
398
399 // Multiple dots in name
400 let path = Path::new("/a/b/c/long.file.name.rs");
401 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
402
403 // Hidden file, no extension
404 let path = Path::new("/a/b/c/.gitignore");
405 assert_eq!(path.extension_or_hidden_file_name(), Some("gitignore"));
406
407 // Hidden file, with extension
408 let path = Path::new("/a/b/c/.eslintrc.js");
409 assert_eq!(path.extension_or_hidden_file_name(), Some("js"));
410 }
411
412 #[test]
413 fn edge_of_glob() {
414 let path = Path::new("/work/node_modules");
415 let path_matcher = PathMatcher::new("**/node_modules/**").unwrap();
416 assert!(
417 path_matcher.is_match(&path),
418 "Path matcher {path_matcher} should match {path:?}"
419 );
420 }
421}