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 PathLikeWithPosition<P> {
100 pub path_like: P,
101 pub row: Option<u32>,
102 // Absent if row is absent.
103 pub column: Option<u32>,
104}
105
106impl<P> PathLikeWithPosition<P> {
107 /// Parses a string that possibly has `:row:column` suffix.
108 /// Ignores trailing `:`s, so `test.rs:22:` is parsed as `test.rs:22`.
109 /// If any of the row/column component parsing fails, the whole string is then parsed as a path like.
110 pub fn parse_str<E>(
111 s: &str,
112 parse_path_like_str: impl Fn(&str) -> Result<P, E>,
113 ) -> Result<Self, E> {
114 let fallback = |fallback_str| {
115 Ok(Self {
116 path_like: parse_path_like_str(fallback_str)?,
117 row: None,
118 column: None,
119 })
120 };
121
122 let trimmed = s.trim();
123
124 #[cfg(target_os = "windows")]
125 {
126 let is_absolute = trimmed.starts_with(r"\\?\");
127 if is_absolute {
128 return Self::parse_absolute_path(trimmed, parse_path_like_str);
129 }
130 }
131
132 match trimmed.split_once(FILE_ROW_COLUMN_DELIMITER) {
133 Some((path_like_str, maybe_row_and_col_str)) => {
134 let path_like_str = path_like_str.trim();
135 let maybe_row_and_col_str = maybe_row_and_col_str.trim();
136 if path_like_str.is_empty() {
137 fallback(s)
138 } else if maybe_row_and_col_str.is_empty() {
139 fallback(path_like_str)
140 } else {
141 let (row_parse_result, maybe_col_str) =
142 match maybe_row_and_col_str.split_once(FILE_ROW_COLUMN_DELIMITER) {
143 Some((maybe_row_str, maybe_col_str)) => {
144 (maybe_row_str.parse::<u32>(), maybe_col_str.trim())
145 }
146 None => (maybe_row_and_col_str.parse::<u32>(), ""),
147 };
148
149 match row_parse_result {
150 Ok(row) => {
151 if maybe_col_str.is_empty() {
152 Ok(Self {
153 path_like: parse_path_like_str(path_like_str)?,
154 row: Some(row),
155 column: None,
156 })
157 } else {
158 let (maybe_col_str, _) =
159 maybe_col_str.split_once(':').unwrap_or((maybe_col_str, ""));
160 match maybe_col_str.parse::<u32>() {
161 Ok(col) => Ok(Self {
162 path_like: parse_path_like_str(path_like_str)?,
163 row: Some(row),
164 column: Some(col),
165 }),
166 Err(_) => Ok(Self {
167 path_like: parse_path_like_str(path_like_str)?,
168 row: Some(row),
169 column: None,
170 }),
171 }
172 }
173 }
174 Err(_) => Ok(Self {
175 path_like: parse_path_like_str(path_like_str)?,
176 row: None,
177 column: None,
178 }),
179 }
180 }
181 }
182 None => fallback(s),
183 }
184 }
185
186 /// This helper function is used for parsing absolute paths on Windows. It exists because absolute paths on Windows are quite different from other platforms. See [this page](https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats#dos-device-paths) for more information.
187 #[cfg(target_os = "windows")]
188 fn parse_absolute_path<E>(
189 s: &str,
190 parse_path_like_str: impl Fn(&str) -> Result<P, E>,
191 ) -> Result<Self, E> {
192 let fallback = |fallback_str| {
193 Ok(Self {
194 path_like: parse_path_like_str(fallback_str)?,
195 row: None,
196 column: None,
197 })
198 };
199
200 let mut iterator = s.split(FILE_ROW_COLUMN_DELIMITER);
201
202 let drive_prefix = iterator.next().unwrap_or_default();
203 let file_path = iterator.next().unwrap_or_default();
204
205 // TODO: How to handle drives without a letter? UNC paths?
206 let complete_path = drive_prefix.replace("\\\\?\\", "") + ":" + &file_path;
207
208 if let Some(row_str) = iterator.next() {
209 if let Some(column_str) = iterator.next() {
210 match row_str.parse::<u32>() {
211 Ok(row) => match column_str.parse::<u32>() {
212 Ok(col) => {
213 return Ok(Self {
214 path_like: parse_path_like_str(&complete_path)?,
215 row: Some(row),
216 column: Some(col),
217 });
218 }
219
220 Err(_) => {
221 return Ok(Self {
222 path_like: parse_path_like_str(&complete_path)?,
223 row: Some(row),
224 column: None,
225 });
226 }
227 },
228
229 Err(_) => {
230 return fallback(&complete_path);
231 }
232 }
233 }
234 }
235 return fallback(&complete_path);
236 }
237
238 pub fn map_path_like<P2, E>(
239 self,
240 mapping: impl FnOnce(P) -> Result<P2, E>,
241 ) -> Result<PathLikeWithPosition<P2>, E> {
242 Ok(PathLikeWithPosition {
243 path_like: mapping(self.path_like)?,
244 row: self.row,
245 column: self.column,
246 })
247 }
248
249 pub fn to_string(&self, path_like_to_string: impl Fn(&P) -> String) -> String {
250 let path_like_string = path_like_to_string(&self.path_like);
251 if let Some(row) = self.row {
252 if let Some(column) = self.column {
253 format!("{path_like_string}:{row}:{column}")
254 } else {
255 format!("{path_like_string}:{row}")
256 }
257 } else {
258 path_like_string
259 }
260 }
261}
262
263#[derive(Clone, Debug, Default)]
264pub struct PathMatcher {
265 sources: Vec<String>,
266 glob: GlobSet,
267}
268
269// impl std::fmt::Display for PathMatcher {
270// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271// self.sources.fmt(f)
272// }
273// }
274
275impl PartialEq for PathMatcher {
276 fn eq(&self, other: &Self) -> bool {
277 self.sources.eq(&other.sources)
278 }
279}
280
281impl Eq for PathMatcher {}
282
283impl PathMatcher {
284 pub fn new(globs: &[String]) -> Result<Self, globset::Error> {
285 let globs = globs
286 .into_iter()
287 .map(|glob| Glob::new(&glob))
288 .collect::<Result<Vec<_>, _>>()?;
289 let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect();
290 let mut glob_builder = GlobSetBuilder::new();
291 for single_glob in globs {
292 glob_builder.add(single_glob);
293 }
294 let glob = glob_builder.build()?;
295 Ok(PathMatcher { glob, sources })
296 }
297
298 pub fn sources(&self) -> &[String] {
299 &self.sources
300 }
301
302 pub fn is_match<P: AsRef<Path>>(&self, other: P) -> bool {
303 let other_path = other.as_ref();
304 self.sources.iter().any(|source| {
305 let as_bytes = other_path.as_os_str().as_encoded_bytes();
306 as_bytes.starts_with(source.as_bytes()) || as_bytes.ends_with(source.as_bytes())
307 }) || self.glob.is_match(other_path)
308 || self.check_with_end_separator(other_path)
309 }
310
311 fn check_with_end_separator(&self, path: &Path) -> bool {
312 let path_str = path.to_string_lossy();
313 let separator = std::path::MAIN_SEPARATOR_STR;
314 if path_str.ends_with(separator) {
315 self.glob.is_match(path)
316 } else {
317 self.glob.is_match(path_str.to_string() + separator)
318 }
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 type TestPath = PathLikeWithPosition<String>;
327
328 fn parse_str(s: &str) -> TestPath {
329 TestPath::parse_str(s, |s| Ok::<_, std::convert::Infallible>(s.to_string()))
330 .expect("infallible")
331 }
332
333 #[test]
334 fn path_with_position_parsing_positive() {
335 let input_and_expected = [
336 (
337 "test_file.rs",
338 PathLikeWithPosition {
339 path_like: "test_file.rs".to_string(),
340 row: None,
341 column: None,
342 },
343 ),
344 (
345 "test_file.rs:1",
346 PathLikeWithPosition {
347 path_like: "test_file.rs".to_string(),
348 row: Some(1),
349 column: None,
350 },
351 ),
352 (
353 "test_file.rs:1:2",
354 PathLikeWithPosition {
355 path_like: "test_file.rs".to_string(),
356 row: Some(1),
357 column: Some(2),
358 },
359 ),
360 ];
361
362 for (input, expected) in input_and_expected {
363 let actual = parse_str(input);
364 assert_eq!(
365 actual, expected,
366 "For positive case input str '{input}', got a parse mismatch"
367 );
368 }
369 }
370
371 #[test]
372 fn path_with_position_parsing_negative() {
373 for (input, row, column) in [
374 ("test_file.rs:a", None, None),
375 ("test_file.rs:a:b", None, None),
376 ("test_file.rs::", None, None),
377 ("test_file.rs::1", None, None),
378 ("test_file.rs:1::", Some(1), None),
379 ("test_file.rs::1:2", None, None),
380 ("test_file.rs:1::2", Some(1), None),
381 ("test_file.rs:1:2:3", Some(1), Some(2)),
382 ] {
383 let actual = parse_str(input);
384 assert_eq!(
385 actual,
386 PathLikeWithPosition {
387 path_like: "test_file.rs".to_string(),
388 row,
389 column,
390 },
391 "For negative case input str '{input}', got a parse mismatch"
392 );
393 }
394 }
395
396 // Trim off trailing `:`s for otherwise valid input.
397 #[test]
398 fn path_with_position_parsing_special() {
399 #[cfg(not(target_os = "windows"))]
400 let input_and_expected = [
401 (
402 "test_file.rs:",
403 PathLikeWithPosition {
404 path_like: "test_file.rs".to_string(),
405 row: None,
406 column: None,
407 },
408 ),
409 (
410 "test_file.rs:1:",
411 PathLikeWithPosition {
412 path_like: "test_file.rs".to_string(),
413 row: Some(1),
414 column: None,
415 },
416 ),
417 (
418 "crates/file_finder/src/file_finder.rs:1902:13:",
419 PathLikeWithPosition {
420 path_like: "crates/file_finder/src/file_finder.rs".to_string(),
421 row: Some(1902),
422 column: Some(13),
423 },
424 ),
425 ];
426
427 #[cfg(target_os = "windows")]
428 let input_and_expected = [
429 (
430 "test_file.rs:",
431 PathLikeWithPosition {
432 path_like: "test_file.rs".to_string(),
433 row: None,
434 column: None,
435 },
436 ),
437 (
438 "test_file.rs:1:",
439 PathLikeWithPosition {
440 path_like: "test_file.rs".to_string(),
441 row: Some(1),
442 column: None,
443 },
444 ),
445 (
446 "\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:",
447 PathLikeWithPosition {
448 path_like: "C:\\Users\\someone\\test_file.rs".to_string(),
449 row: Some(1902),
450 column: Some(13),
451 },
452 ),
453 (
454 "\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:15:",
455 PathLikeWithPosition {
456 path_like: "C:\\Users\\someone\\test_file.rs".to_string(),
457 row: Some(1902),
458 column: Some(13),
459 },
460 ),
461 (
462 "\\\\?\\C:\\Users\\someone\\test_file.rs:1902:::15:",
463 PathLikeWithPosition {
464 path_like: "C:\\Users\\someone\\test_file.rs".to_string(),
465 row: Some(1902),
466 column: None,
467 },
468 ),
469 ];
470
471 for (input, expected) in input_and_expected {
472 let actual = parse_str(input);
473 assert_eq!(
474 actual, expected,
475 "For special case input str '{input}', got a parse mismatch"
476 );
477 }
478 }
479
480 #[test]
481 fn test_path_compact() {
482 let path: PathBuf = [
483 home_dir().to_string_lossy().to_string(),
484 "some_file.txt".to_string(),
485 ]
486 .iter()
487 .collect();
488 if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
489 assert_eq!(path.compact().to_str(), Some("~/some_file.txt"));
490 } else {
491 assert_eq!(path.compact().to_str(), path.to_str());
492 }
493 }
494
495 #[test]
496 fn test_icon_stem_or_suffix() {
497 // No dots in name
498 let path = Path::new("/a/b/c/file_name.rs");
499 assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
500
501 // Single dot in name
502 let path = Path::new("/a/b/c/file.name.rs");
503 assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
504
505 // No suffix
506 let path = Path::new("/a/b/c/file");
507 assert_eq!(path.icon_stem_or_suffix(), Some("file"));
508
509 // Multiple dots in name
510 let path = Path::new("/a/b/c/long.file.name.rs");
511 assert_eq!(path.icon_stem_or_suffix(), Some("rs"));
512
513 // Hidden file, no extension
514 let path = Path::new("/a/b/c/.gitignore");
515 assert_eq!(path.icon_stem_or_suffix(), Some("gitignore"));
516
517 // Hidden file, with extension
518 let path = Path::new("/a/b/c/.eslintrc.js");
519 assert_eq!(path.icon_stem_or_suffix(), Some("eslintrc.js"));
520 }
521
522 #[test]
523 fn test_extension_or_hidden_file_name() {
524 // No dots in name
525 let path = Path::new("/a/b/c/file_name.rs");
526 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
527
528 // Single dot in name
529 let path = Path::new("/a/b/c/file.name.rs");
530 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
531
532 // Multiple dots in name
533 let path = Path::new("/a/b/c/long.file.name.rs");
534 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
535
536 // Hidden file, no extension
537 let path = Path::new("/a/b/c/.gitignore");
538 assert_eq!(path.extension_or_hidden_file_name(), Some("gitignore"));
539
540 // Hidden file, with extension
541 let path = Path::new("/a/b/c/.eslintrc.js");
542 assert_eq!(path.extension_or_hidden_file_name(), Some("js"));
543 }
544
545 #[test]
546 fn edge_of_glob() {
547 let path = Path::new("/work/node_modules");
548 let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
549 assert!(
550 path_matcher.is_match(path),
551 "Path matcher should match {path:?}"
552 );
553 }
554
555 #[test]
556 fn project_search() {
557 let path = Path::new("/Users/someonetoignore/work/zed/zed.dev/node_modules");
558 let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
559 assert!(
560 path_matcher.is_match(path),
561 "Path matcher should match {path:?}"
562 );
563 }
564}