1use std::sync::OnceLock;
2use std::{
3 ffi::OsStr,
4 path::{Path, PathBuf},
5};
6
7use globset::{Glob, GlobSet, GlobSetBuilder};
8use serde::{Deserialize, Serialize};
9
10/// Returns the path to the user's home directory.
11pub fn home_dir() -> &'static PathBuf {
12 static HOME_DIR: OnceLock<PathBuf> = OnceLock::new();
13 HOME_DIR.get_or_init(|| dirs::home_dir().expect("failed to determine home directory"))
14}
15
16pub trait PathExt {
17 fn compact(&self) -> PathBuf;
18 fn icon_stem_or_suffix(&self) -> Option<&str>;
19 fn extension_or_hidden_file_name(&self) -> Option<&str>;
20 fn try_from_bytes<'a>(bytes: &'a [u8]) -> anyhow::Result<Self>
21 where
22 Self: From<&'a Path>,
23 {
24 #[cfg(unix)]
25 {
26 use std::os::unix::prelude::OsStrExt;
27 Ok(Self::from(Path::new(OsStr::from_bytes(bytes))))
28 }
29 #[cfg(windows)]
30 {
31 use anyhow::anyhow;
32 use tendril::fmt::{Format, WTF8};
33 WTF8::validate(bytes)
34 .then(|| {
35 // Safety: bytes are valid WTF-8 sequence.
36 Self::from(Path::new(unsafe {
37 OsStr::from_encoded_bytes_unchecked(bytes)
38 }))
39 })
40 .ok_or_else(|| anyhow!("Invalid WTF-8 sequence: {bytes:?}"))
41 }
42 }
43}
44
45impl<T: AsRef<Path>> PathExt for T {
46 /// Compacts a given file path by replacing the user's home directory
47 /// prefix with a tilde (`~`).
48 ///
49 /// # Returns
50 ///
51 /// * A `PathBuf` containing the compacted file path. If the input path
52 /// does not have the user's home directory prefix, or if we are not on
53 /// Linux or macOS, the original path is returned unchanged.
54 fn compact(&self) -> PathBuf {
55 if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
56 match self.as_ref().strip_prefix(home_dir().as_path()) {
57 Ok(relative_path) => {
58 let mut shortened_path = PathBuf::new();
59 shortened_path.push("~");
60 shortened_path.push(relative_path);
61 shortened_path
62 }
63 Err(_) => self.as_ref().to_path_buf(),
64 }
65 } else {
66 self.as_ref().to_path_buf()
67 }
68 }
69
70 /// Returns either the suffix if available, or the file stem otherwise to determine which file icon to use
71 fn icon_stem_or_suffix(&self) -> Option<&str> {
72 let path = self.as_ref();
73 let file_name = path.file_name()?.to_str()?;
74 if file_name.starts_with('.') {
75 return file_name.strip_prefix('.');
76 }
77
78 path.extension()
79 .and_then(|e| e.to_str())
80 .or_else(|| path.file_stem()?.to_str())
81 }
82
83 /// Returns a file's extension or, if the file is hidden, its name without the leading dot
84 fn extension_or_hidden_file_name(&self) -> Option<&str> {
85 if let Some(extension) = self.as_ref().extension() {
86 return extension.to_str();
87 }
88
89 self.as_ref().file_name()?.to_str()?.split('.').last()
90 }
91}
92
93/// A delimiter to use in `path_query:row_number:column_number` strings parsing.
94pub const FILE_ROW_COLUMN_DELIMITER: char = ':';
95
96/// A representation of a path-like string with optional row and column numbers.
97/// Matching values example: `te`, `test.rs:22`, `te:22:5`, etc.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
99pub struct PathWithPosition {
100 pub path: PathBuf,
101 pub row: Option<u32>,
102 // Absent if row is absent.
103 pub column: Option<u32>,
104}
105
106impl PathWithPosition {
107 /// Returns a PathWithPosition from a path.
108 pub fn from_path(path: PathBuf) -> Self {
109 Self {
110 path,
111 row: None,
112 column: None,
113 }
114 }
115 /// Parses a string that possibly has `:row:column` suffix.
116 /// Ignores trailing `:`s, so `test.rs:22:` is parsed as `test.rs:22`.
117 /// If the suffix parsing fails, the whole string is parsed as a path.
118 pub fn parse_str(s: &str) -> Self {
119 let fallback = |fallback_str| Self {
120 path: Path::new(fallback_str).to_path_buf(),
121 row: None,
122 column: None,
123 };
124
125 let trimmed = s.trim();
126 let path = Path::new(trimmed);
127 let maybe_file_name_with_row_col = path
128 .file_name()
129 .unwrap_or_default()
130 .to_str()
131 .unwrap_or_default();
132 if maybe_file_name_with_row_col.is_empty() {
133 return fallback(s);
134 }
135
136 match maybe_file_name_with_row_col.split_once(FILE_ROW_COLUMN_DELIMITER) {
137 Some((file_name, maybe_row_and_col_str)) => {
138 let file_name = file_name.trim();
139 let maybe_row_and_col_str = maybe_row_and_col_str.trim();
140 if file_name.is_empty() {
141 return fallback(s);
142 }
143
144 let suffix_length = maybe_row_and_col_str.len() + 1;
145 let path_without_suffix = &trimmed[..trimmed.len() - suffix_length];
146
147 if maybe_row_and_col_str.is_empty() {
148 fallback(path_without_suffix)
149 } else {
150 let (row_parse_result, maybe_col_str) =
151 match maybe_row_and_col_str.split_once(FILE_ROW_COLUMN_DELIMITER) {
152 Some((maybe_row_str, maybe_col_str)) => {
153 (maybe_row_str.parse::<u32>(), maybe_col_str.trim())
154 }
155 None => (maybe_row_and_col_str.parse::<u32>(), ""),
156 };
157
158 let path = Path::new(path_without_suffix).to_path_buf();
159
160 match row_parse_result {
161 Ok(row) => {
162 if maybe_col_str.is_empty() {
163 Self {
164 path,
165 row: Some(row),
166 column: None,
167 }
168 } else {
169 let (maybe_col_str, _) =
170 maybe_col_str.split_once(':').unwrap_or((maybe_col_str, ""));
171 match maybe_col_str.parse::<u32>() {
172 Ok(col) => Self {
173 path,
174 row: Some(row),
175 column: Some(col),
176 },
177 Err(_) => Self {
178 path,
179 row: Some(row),
180 column: None,
181 },
182 }
183 }
184 }
185 Err(_) => Self {
186 path,
187 row: None,
188 column: None,
189 },
190 }
191 }
192 }
193 None => fallback(s),
194 }
195 }
196
197 pub fn map_path<E>(
198 self,
199 mapping: impl FnOnce(PathBuf) -> Result<PathBuf, E>,
200 ) -> Result<PathWithPosition, E> {
201 Ok(PathWithPosition {
202 path: mapping(self.path)?,
203 row: self.row,
204 column: self.column,
205 })
206 }
207
208 pub fn to_string(&self, path_to_string: impl Fn(&PathBuf) -> String) -> String {
209 let path_string = path_to_string(&self.path);
210 if let Some(row) = self.row {
211 if let Some(column) = self.column {
212 format!("{path_string}:{row}:{column}")
213 } else {
214 format!("{path_string}:{row}")
215 }
216 } else {
217 path_string
218 }
219 }
220}
221
222#[derive(Clone, Debug, Default)]
223pub struct PathMatcher {
224 sources: Vec<String>,
225 glob: GlobSet,
226}
227
228// impl std::fmt::Display for PathMatcher {
229// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230// self.sources.fmt(f)
231// }
232// }
233
234impl PartialEq for PathMatcher {
235 fn eq(&self, other: &Self) -> bool {
236 self.sources.eq(&other.sources)
237 }
238}
239
240impl Eq for PathMatcher {}
241
242impl PathMatcher {
243 pub fn new(globs: &[String]) -> Result<Self, globset::Error> {
244 let globs = globs
245 .into_iter()
246 .map(|glob| Glob::new(&glob))
247 .collect::<Result<Vec<_>, _>>()?;
248 let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect();
249 let mut glob_builder = GlobSetBuilder::new();
250 for single_glob in globs {
251 glob_builder.add(single_glob);
252 }
253 let glob = glob_builder.build()?;
254 Ok(PathMatcher { glob, sources })
255 }
256
257 pub fn sources(&self) -> &[String] {
258 &self.sources
259 }
260
261 pub fn is_match<P: AsRef<Path>>(&self, other: P) -> bool {
262 let other_path = other.as_ref();
263 self.sources.iter().any(|source| {
264 let as_bytes = other_path.as_os_str().as_encoded_bytes();
265 as_bytes.starts_with(source.as_bytes()) || as_bytes.ends_with(source.as_bytes())
266 }) || self.glob.is_match(other_path)
267 || self.check_with_end_separator(other_path)
268 }
269
270 fn check_with_end_separator(&self, path: &Path) -> bool {
271 let path_str = path.to_string_lossy();
272 let separator = std::path::MAIN_SEPARATOR_STR;
273 if path_str.ends_with(separator) {
274 self.glob.is_match(path)
275 } else {
276 self.glob.is_match(path_str.to_string() + separator)
277 }
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn path_with_position_parsing_positive() {
287 let input_and_expected = [
288 (
289 "test_file.rs",
290 PathWithPosition {
291 path: PathBuf::from("test_file.rs"),
292 row: None,
293 column: None,
294 },
295 ),
296 (
297 "test_file.rs:1",
298 PathWithPosition {
299 path: PathBuf::from("test_file.rs"),
300 row: Some(1),
301 column: None,
302 },
303 ),
304 (
305 "test_file.rs:1:2",
306 PathWithPosition {
307 path: PathBuf::from("test_file.rs"),
308 row: Some(1),
309 column: Some(2),
310 },
311 ),
312 ];
313
314 for (input, expected) in input_and_expected {
315 let actual = PathWithPosition::parse_str(input);
316 assert_eq!(
317 actual, expected,
318 "For positive case input str '{input}', got a parse mismatch"
319 );
320 }
321 }
322
323 #[test]
324 fn path_with_position_parsing_negative() {
325 for (input, row, column) in [
326 ("test_file.rs:a", None, None),
327 ("test_file.rs:a:b", None, None),
328 ("test_file.rs::", None, None),
329 ("test_file.rs::1", None, None),
330 ("test_file.rs:1::", Some(1), None),
331 ("test_file.rs::1:2", None, None),
332 ("test_file.rs:1::2", Some(1), None),
333 ("test_file.rs:1:2:3", Some(1), Some(2)),
334 ] {
335 let actual = PathWithPosition::parse_str(input);
336 assert_eq!(
337 actual,
338 PathWithPosition {
339 path: PathBuf::from("test_file.rs"),
340 row,
341 column,
342 },
343 "For negative case input str '{input}', got a parse mismatch"
344 );
345 }
346 }
347
348 // Trim off trailing `:`s for otherwise valid input.
349 #[test]
350 fn path_with_position_parsing_special() {
351 #[cfg(not(target_os = "windows"))]
352 let input_and_expected = [
353 (
354 "test_file.rs:",
355 PathWithPosition {
356 path: PathBuf::from("test_file.rs"),
357 row: None,
358 column: None,
359 },
360 ),
361 (
362 "test_file.rs:1:",
363 PathWithPosition {
364 path: PathBuf::from("test_file.rs"),
365 row: Some(1),
366 column: None,
367 },
368 ),
369 (
370 "crates/file_finder/src/file_finder.rs:1902:13:",
371 PathWithPosition {
372 path: PathBuf::from("crates/file_finder/src/file_finder.rs"),
373 row: Some(1902),
374 column: Some(13),
375 },
376 ),
377 ];
378
379 #[cfg(target_os = "windows")]
380 let input_and_expected = [
381 (
382 "test_file.rs:",
383 PathWithPosition {
384 path: PathBuf::from("test_file.rs"),
385 row: None,
386 column: None,
387 },
388 ),
389 (
390 "test_file.rs:1:",
391 PathWithPosition {
392 path: PathBuf::from("test_file.rs"),
393 row: Some(1),
394 column: None,
395 },
396 ),
397 (
398 "\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:",
399 PathWithPosition {
400 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
401 row: Some(1902),
402 column: Some(13),
403 },
404 ),
405 (
406 "\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:15:",
407 PathWithPosition {
408 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
409 row: Some(1902),
410 column: Some(13),
411 },
412 ),
413 (
414 "\\\\?\\C:\\Users\\someone\\test_file.rs:1902:::15:",
415 PathWithPosition {
416 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
417 row: Some(1902),
418 column: None,
419 },
420 ),
421 (
422 "C:\\Users\\someone\\test_file.rs:1902:13:",
423 PathWithPosition {
424 path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
425 row: Some(1902),
426 column: Some(13),
427 },
428 ),
429 (
430 "crates/utils/paths.rs",
431 PathWithPosition {
432 path: PathBuf::from("crates\\utils\\paths.rs"),
433 row: None,
434 column: None,
435 },
436 ),
437 (
438 "crates/utils/paths.rs:101",
439 PathWithPosition {
440 path: PathBuf::from("crates\\utils\\paths.rs"),
441 row: Some(101),
442 column: None,
443 },
444 ),
445 ];
446
447 for (input, expected) in input_and_expected {
448 let actual = PathWithPosition::parse_str(input);
449 assert_eq!(
450 actual, expected,
451 "For special case input str '{input}', got a parse mismatch"
452 );
453 }
454 }
455
456 #[test]
457 fn test_path_compact() {
458 let path: PathBuf = [
459 home_dir().to_string_lossy().to_string(),
460 "some_file.txt".to_string(),
461 ]
462 .iter()
463 .collect();
464 if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
465 assert_eq!(path.compact().to_str(), Some("~/some_file.txt"));
466 } else {
467 assert_eq!(path.compact().to_str(), path.to_str());
468 }
469 }
470
471 #[test]
472 fn test_icon_stem_or_suffix() {
473 // No dots in name
474 let path = Path::new("/a/b/c/file_name.rs");
475 assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
476
477 // Single dot in name
478 let path = Path::new("/a/b/c/file.name.rs");
479 assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
480
481 // No suffix
482 let path = Path::new("/a/b/c/file");
483 assert_eq!(path.icon_stem_or_suffix(), Some("file"));
484
485 // Multiple dots in name
486 let path = Path::new("/a/b/c/long.file.name.rs");
487 assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
488
489 // Hidden file, no extension
490 let path = Path::new("/a/b/c/.gitignore");
491 assert_eq!(path.icon_stem_or_suffix(), Some("gitignore"));
492
493 // Hidden file, with extension
494 let path = Path::new("/a/b/c/.eslintrc.js");
495 assert_eq!(path.icon_stem_or_suffix(), Some("eslintrc.js"));
496 }
497
498 #[test]
499 fn test_extension_or_hidden_file_name() {
500 // No dots in name
501 let path = Path::new("/a/b/c/file_name.rs");
502 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
503
504 // Single dot in name
505 let path = Path::new("/a/b/c/file.name.rs");
506 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
507
508 // Multiple dots in name
509 let path = Path::new("/a/b/c/long.file.name.rs");
510 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
511
512 // Hidden file, no extension
513 let path = Path::new("/a/b/c/.gitignore");
514 assert_eq!(path.extension_or_hidden_file_name(), Some("gitignore"));
515
516 // Hidden file, with extension
517 let path = Path::new("/a/b/c/.eslintrc.js");
518 assert_eq!(path.extension_or_hidden_file_name(), Some("js"));
519 }
520
521 #[test]
522 fn edge_of_glob() {
523 let path = Path::new("/work/node_modules");
524 let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
525 assert!(
526 path_matcher.is_match(path),
527 "Path matcher should match {path:?}"
528 );
529 }
530
531 #[test]
532 fn project_search() {
533 let path = Path::new("/Users/someonetoignore/work/zed/zed.dev/node_modules");
534 let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
535 assert!(
536 path_matcher.is_match(path),
537 "Path matcher should match {path:?}"
538 );
539 }
540}