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