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