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