1use std::cmp;
2use std::path::StripPrefixError;
3use std::sync::{Arc, OnceLock};
4use std::{
5 ffi::OsStr,
6 path::{Path, PathBuf},
7 sync::LazyLock,
8};
9
10use globset::{Glob, GlobSet, GlobSetBuilder};
11use regex::Regex;
12use serde::{Deserialize, Serialize};
13
14use crate::NumericPrefixWithSuffix;
15
16/// Returns the path to the user's home directory.
17pub fn home_dir() -> &'static PathBuf {
18 static HOME_DIR: OnceLock<PathBuf> = OnceLock::new();
19 HOME_DIR.get_or_init(|| dirs::home_dir().expect("failed to determine home directory"))
20}
21
22pub trait PathExt {
23 fn compact(&self) -> PathBuf;
24 fn extension_or_hidden_file_name(&self) -> Option<&str>;
25 fn to_sanitized_string(&self) -> String;
26 fn try_from_bytes<'a>(bytes: &'a [u8]) -> anyhow::Result<Self>
27 where
28 Self: From<&'a Path>,
29 {
30 #[cfg(unix)]
31 {
32 use std::os::unix::prelude::OsStrExt;
33 Ok(Self::from(Path::new(OsStr::from_bytes(bytes))))
34 }
35 #[cfg(windows)]
36 {
37 use anyhow::Context as _;
38 use tendril::fmt::{Format, WTF8};
39 WTF8::validate(bytes)
40 .then(|| {
41 // Safety: bytes are valid WTF-8 sequence.
42 Self::from(Path::new(unsafe {
43 OsStr::from_encoded_bytes_unchecked(bytes)
44 }))
45 })
46 .with_context(|| format!("Invalid WTF-8 sequence: {bytes:?}"))
47 }
48 }
49}
50
51impl<T: AsRef<Path>> PathExt for T {
52 /// Compacts a given file path by replacing the user's home directory
53 /// prefix with a tilde (`~`).
54 ///
55 /// # Returns
56 ///
57 /// * A `PathBuf` containing the compacted file path. If the input path
58 /// does not have the user's home directory prefix, or if we are not on
59 /// Linux or macOS, the original path is returned unchanged.
60 fn compact(&self) -> PathBuf {
61 if cfg!(any(target_os = "linux", target_os = "freebsd")) || cfg!(target_os = "macos") {
62 match self.as_ref().strip_prefix(home_dir().as_path()) {
63 Ok(relative_path) => {
64 let mut shortened_path = PathBuf::new();
65 shortened_path.push("~");
66 shortened_path.push(relative_path);
67 shortened_path
68 }
69 Err(_) => self.as_ref().to_path_buf(),
70 }
71 } else {
72 self.as_ref().to_path_buf()
73 }
74 }
75
76 /// Returns a file's extension or, if the file is hidden, its name without the leading dot
77 fn extension_or_hidden_file_name(&self) -> Option<&str> {
78 let path = self.as_ref();
79 let file_name = path.file_name()?.to_str()?;
80 if file_name.starts_with('.') {
81 return file_name.strip_prefix('.');
82 }
83
84 path.extension()
85 .and_then(|e| e.to_str())
86 .or_else(|| path.file_stem()?.to_str())
87 }
88
89 /// Returns a sanitized string representation of the path.
90 /// Note, on Windows, this assumes that the path is a valid UTF-8 string and
91 /// is not a UNC path.
92 fn to_sanitized_string(&self) -> String {
93 #[cfg(target_os = "windows")]
94 {
95 self.as_ref().to_string_lossy().replace("/", "\\")
96 }
97 #[cfg(not(target_os = "windows"))]
98 {
99 self.as_ref().to_string_lossy().to_string()
100 }
101 }
102}
103
104/// Due to the issue of UNC paths on Windows, which can cause bugs in various parts of Zed, introducing this `SanitizedPath`
105/// leverages Rust's type system to ensure that all paths entering Zed are always "sanitized" by removing the `\\\\?\\` prefix.
106/// On non-Windows operating systems, this struct is effectively a no-op.
107#[derive(Debug, Clone, PartialEq, Eq, Hash)]
108pub struct SanitizedPath(pub Arc<Path>);
109
110impl SanitizedPath {
111 pub fn starts_with(&self, prefix: &SanitizedPath) -> bool {
112 self.0.starts_with(&prefix.0)
113 }
114
115 pub fn as_path(&self) -> &Arc<Path> {
116 &self.0
117 }
118
119 pub fn to_string(&self) -> String {
120 self.0.to_string_lossy().to_string()
121 }
122
123 pub fn to_glob_string(&self) -> String {
124 #[cfg(target_os = "windows")]
125 {
126 self.0.to_string_lossy().replace("/", "\\")
127 }
128 #[cfg(not(target_os = "windows"))]
129 {
130 self.0.to_string_lossy().to_string()
131 }
132 }
133
134 pub fn join(&self, path: &Self) -> Self {
135 self.0.join(&path.0).into()
136 }
137
138 pub fn strip_prefix(&self, base: &Self) -> Result<&Path, StripPrefixError> {
139 self.0.strip_prefix(base.as_path())
140 }
141}
142
143impl From<SanitizedPath> for Arc<Path> {
144 fn from(sanitized_path: SanitizedPath) -> Self {
145 sanitized_path.0
146 }
147}
148
149impl From<SanitizedPath> for PathBuf {
150 fn from(sanitized_path: SanitizedPath) -> Self {
151 sanitized_path.0.as_ref().into()
152 }
153}
154
155impl<T: AsRef<Path>> From<T> for SanitizedPath {
156 #[cfg(not(target_os = "windows"))]
157 fn from(path: T) -> Self {
158 let path = path.as_ref();
159 SanitizedPath(path.into())
160 }
161
162 #[cfg(target_os = "windows")]
163 fn from(path: T) -> Self {
164 let path = path.as_ref();
165 SanitizedPath(dunce::simplified(path).into())
166 }
167}
168
169/// A delimiter to use in `path_query:row_number:column_number` strings parsing.
170pub const FILE_ROW_COLUMN_DELIMITER: char = ':';
171
172const ROW_COL_CAPTURE_REGEX: &str = r"(?xs)
173 ([^\(]+)\:(?:
174 \((\d+)[,:](\d+)\) # filename:(row,column), filename:(row:column)
175 |
176 \((\d+)\)() # filename:(row)
177 )
178 |
179 ([^\(]+)(?:
180 \((\d+)[,:](\d+)\) # filename(row,column), filename(row:column)
181 |
182 \((\d+)\)() # filename(row)
183 )
184 |
185 (.+?)(?:
186 \:+(\d+)\:(\d+)\:*$ # filename:row:column
187 |
188 \:+(\d+)\:*()$ # filename:row
189 )";
190
191/// A representation of a path-like string with optional row and column numbers.
192/// Matching values example: `te`, `test.rs:22`, `te:22:5`, `test.c(22)`, `test.c(22,5)`etc.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
194pub struct PathWithPosition {
195 pub path: PathBuf,
196 pub row: Option<u32>,
197 // Absent if row is absent.
198 pub column: Option<u32>,
199}
200
201impl PathWithPosition {
202 /// Returns a PathWithPosition from a path.
203 pub fn from_path(path: PathBuf) -> Self {
204 Self {
205 path,
206 row: None,
207 column: None,
208 }
209 }
210
211 /// Parses a string that possibly has `:row:column` or `(row, column)` suffix.
212 /// Parenthesis format is used by [MSBuild](https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-diagnostic-format-for-tasks) compatible tools
213 /// Ignores trailing `:`s, so `test.rs:22:` is parsed as `test.rs:22`.
214 /// If the suffix parsing fails, the whole string is parsed as a path.
215 ///
216 /// Be mindful that `test_file:10:1:` is a valid posix filename.
217 /// `PathWithPosition` class assumes that the ending position-like suffix is **not** part of the filename.
218 ///
219 /// # Examples
220 ///
221 /// ```
222 /// # use util::paths::PathWithPosition;
223 /// # use std::path::PathBuf;
224 /// assert_eq!(PathWithPosition::parse_str("test_file"), PathWithPosition {
225 /// path: PathBuf::from("test_file"),
226 /// row: None,
227 /// column: None,
228 /// });
229 /// assert_eq!(PathWithPosition::parse_str("test_file:10"), PathWithPosition {
230 /// path: PathBuf::from("test_file"),
231 /// row: Some(10),
232 /// column: None,
233 /// });
234 /// assert_eq!(PathWithPosition::parse_str("test_file.rs"), PathWithPosition {
235 /// path: PathBuf::from("test_file.rs"),
236 /// row: None,
237 /// column: None,
238 /// });
239 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1"), PathWithPosition {
240 /// path: PathBuf::from("test_file.rs"),
241 /// row: Some(1),
242 /// column: None,
243 /// });
244 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1:2"), PathWithPosition {
245 /// path: PathBuf::from("test_file.rs"),
246 /// row: Some(1),
247 /// column: Some(2),
248 /// });
249 /// ```
250 ///
251 /// # Expected parsing results when encounter ill-formatted inputs.
252 /// ```
253 /// # use util::paths::PathWithPosition;
254 /// # use std::path::PathBuf;
255 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:a"), PathWithPosition {
256 /// path: PathBuf::from("test_file.rs:a"),
257 /// row: None,
258 /// column: None,
259 /// });
260 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:a:b"), PathWithPosition {
261 /// path: PathBuf::from("test_file.rs:a:b"),
262 /// row: None,
263 /// column: None,
264 /// });
265 /// assert_eq!(PathWithPosition::parse_str("test_file.rs::"), PathWithPosition {
266 /// path: PathBuf::from("test_file.rs::"),
267 /// row: None,
268 /// column: None,
269 /// });
270 /// assert_eq!(PathWithPosition::parse_str("test_file.rs::1"), PathWithPosition {
271 /// path: PathBuf::from("test_file.rs"),
272 /// row: Some(1),
273 /// column: None,
274 /// });
275 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1::"), PathWithPosition {
276 /// path: PathBuf::from("test_file.rs"),
277 /// row: Some(1),
278 /// column: None,
279 /// });
280 /// assert_eq!(PathWithPosition::parse_str("test_file.rs::1:2"), PathWithPosition {
281 /// path: PathBuf::from("test_file.rs"),
282 /// row: Some(1),
283 /// column: Some(2),
284 /// });
285 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1::2"), PathWithPosition {
286 /// path: PathBuf::from("test_file.rs:1"),
287 /// row: Some(2),
288 /// column: None,
289 /// });
290 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1:2:3"), PathWithPosition {
291 /// path: PathBuf::from("test_file.rs:1"),
292 /// row: Some(2),
293 /// column: Some(3),
294 /// });
295 /// ```
296 pub fn parse_str(s: &str) -> Self {
297 let trimmed = s.trim();
298 let path = Path::new(trimmed);
299 let maybe_file_name_with_row_col = path.file_name().unwrap_or_default().to_string_lossy();
300 if maybe_file_name_with_row_col.is_empty() {
301 return Self {
302 path: Path::new(s).to_path_buf(),
303 row: None,
304 column: None,
305 };
306 }
307
308 // Let's avoid repeated init cost on this. It is subject to thread contention, but
309 // so far this code isn't called from multiple hot paths. Getting contention here
310 // in the future seems unlikely.
311 static SUFFIX_RE: LazyLock<Regex> =
312 LazyLock::new(|| Regex::new(ROW_COL_CAPTURE_REGEX).unwrap());
313 match SUFFIX_RE
314 .captures(&maybe_file_name_with_row_col)
315 .map(|caps| caps.extract())
316 {
317 Some((_, [file_name, maybe_row, maybe_column])) => {
318 let row = maybe_row.parse::<u32>().ok();
319 let column = maybe_column.parse::<u32>().ok();
320
321 let suffix_length = maybe_file_name_with_row_col.len() - file_name.len();
322 let path_without_suffix = &trimmed[..trimmed.len() - suffix_length];
323
324 Self {
325 path: Path::new(path_without_suffix).to_path_buf(),
326 row,
327 column,
328 }
329 }
330 None => {
331 // The `ROW_COL_CAPTURE_REGEX` deals with separated digits only,
332 // but in reality there could be `foo/bar.py:22:in` inputs which we want to match too.
333 // The regex mentioned is not very extendable with "digit or random string" checks, so do this here instead.
334 let delimiter = ':';
335 let mut path_parts = s
336 .rsplitn(3, delimiter)
337 .collect::<Vec<_>>()
338 .into_iter()
339 .rev()
340 .fuse();
341 let mut path_string = path_parts.next().expect("rsplitn should have the rest of the string as its last parameter that we reversed").to_owned();
342 let mut row = None;
343 let mut column = None;
344 if let Some(maybe_row) = path_parts.next() {
345 if let Ok(parsed_row) = maybe_row.parse::<u32>() {
346 row = Some(parsed_row);
347 if let Some(parsed_column) = path_parts
348 .next()
349 .and_then(|maybe_col| maybe_col.parse::<u32>().ok())
350 {
351 column = Some(parsed_column);
352 }
353 } else {
354 path_string.push(delimiter);
355 path_string.push_str(maybe_row);
356 }
357 }
358 for split in path_parts {
359 path_string.push(delimiter);
360 path_string.push_str(split);
361 }
362
363 Self {
364 path: PathBuf::from(path_string),
365 row,
366 column,
367 }
368 }
369 }
370 }
371
372 pub fn map_path<E>(
373 self,
374 mapping: impl FnOnce(PathBuf) -> Result<PathBuf, E>,
375 ) -> Result<PathWithPosition, E> {
376 Ok(PathWithPosition {
377 path: mapping(self.path)?,
378 row: self.row,
379 column: self.column,
380 })
381 }
382
383 pub fn to_string(&self, path_to_string: impl Fn(&PathBuf) -> String) -> String {
384 let path_string = path_to_string(&self.path);
385 if let Some(row) = self.row {
386 if let Some(column) = self.column {
387 format!("{path_string}:{row}:{column}")
388 } else {
389 format!("{path_string}:{row}")
390 }
391 } else {
392 path_string
393 }
394 }
395}
396
397#[derive(Clone, Debug, Default)]
398pub struct PathMatcher {
399 sources: Vec<String>,
400 glob: GlobSet,
401}
402
403// impl std::fmt::Display for PathMatcher {
404// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405// self.sources.fmt(f)
406// }
407// }
408
409impl PartialEq for PathMatcher {
410 fn eq(&self, other: &Self) -> bool {
411 self.sources.eq(&other.sources)
412 }
413}
414
415impl Eq for PathMatcher {}
416
417impl PathMatcher {
418 pub fn new(globs: impl IntoIterator<Item = impl AsRef<str>>) -> Result<Self, globset::Error> {
419 let globs = globs
420 .into_iter()
421 .map(|as_str| Glob::new(as_str.as_ref()))
422 .collect::<Result<Vec<_>, _>>()?;
423 let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect();
424 let mut glob_builder = GlobSetBuilder::new();
425 for single_glob in globs {
426 glob_builder.add(single_glob);
427 }
428 let glob = glob_builder.build()?;
429 Ok(PathMatcher { glob, sources })
430 }
431
432 pub fn sources(&self) -> &[String] {
433 &self.sources
434 }
435
436 pub fn is_match<P: AsRef<Path>>(&self, other: P) -> bool {
437 let other_path = other.as_ref();
438 self.sources.iter().any(|source| {
439 let as_bytes = other_path.as_os_str().as_encoded_bytes();
440 as_bytes.starts_with(source.as_bytes()) || as_bytes.ends_with(source.as_bytes())
441 }) || self.glob.is_match(other_path)
442 || self.check_with_end_separator(other_path)
443 }
444
445 fn check_with_end_separator(&self, path: &Path) -> bool {
446 let path_str = path.to_string_lossy();
447 let separator = std::path::MAIN_SEPARATOR_STR;
448 if path_str.ends_with(separator) {
449 false
450 } else {
451 self.glob.is_match(path_str.to_string() + separator)
452 }
453 }
454}
455
456pub fn compare_paths(
457 (path_a, a_is_file): (&Path, bool),
458 (path_b, b_is_file): (&Path, bool),
459) -> cmp::Ordering {
460 let mut components_a = path_a.components().peekable();
461 let mut components_b = path_b.components().peekable();
462 loop {
463 match (components_a.next(), components_b.next()) {
464 (Some(component_a), Some(component_b)) => {
465 let a_is_file = components_a.peek().is_none() && a_is_file;
466 let b_is_file = components_b.peek().is_none() && b_is_file;
467 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
468 let path_a = Path::new(component_a.as_os_str());
469 let path_string_a = if a_is_file {
470 path_a.file_stem()
471 } else {
472 path_a.file_name()
473 }
474 .map(|s| s.to_string_lossy());
475 let num_and_remainder_a = path_string_a
476 .as_deref()
477 .map(NumericPrefixWithSuffix::from_numeric_prefixed_str);
478
479 let path_b = Path::new(component_b.as_os_str());
480 let path_string_b = if b_is_file {
481 path_b.file_stem()
482 } else {
483 path_b.file_name()
484 }
485 .map(|s| s.to_string_lossy());
486 let num_and_remainder_b = path_string_b
487 .as_deref()
488 .map(NumericPrefixWithSuffix::from_numeric_prefixed_str);
489
490 num_and_remainder_a.cmp(&num_and_remainder_b).then_with(|| {
491 if a_is_file && b_is_file {
492 let ext_a = path_a.extension().unwrap_or_default();
493 let ext_b = path_b.extension().unwrap_or_default();
494 ext_a.cmp(ext_b)
495 } else {
496 cmp::Ordering::Equal
497 }
498 })
499 });
500 if !ordering.is_eq() {
501 return ordering;
502 }
503 }
504 (Some(_), None) => break cmp::Ordering::Greater,
505 (None, Some(_)) => break cmp::Ordering::Less,
506 (None, None) => break cmp::Ordering::Equal,
507 }
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 #[test]
516 fn compare_paths_with_dots() {
517 let mut paths = vec![
518 (Path::new("test_dirs"), false),
519 (Path::new("test_dirs/1.46"), false),
520 (Path::new("test_dirs/1.46/bar_1"), true),
521 (Path::new("test_dirs/1.46/bar_2"), true),
522 (Path::new("test_dirs/1.45"), false),
523 (Path::new("test_dirs/1.45/foo_2"), true),
524 (Path::new("test_dirs/1.45/foo_1"), true),
525 ];
526 paths.sort_by(|&a, &b| compare_paths(a, b));
527 assert_eq!(
528 paths,
529 vec![
530 (Path::new("test_dirs"), false),
531 (Path::new("test_dirs/1.45"), false),
532 (Path::new("test_dirs/1.45/foo_1"), true),
533 (Path::new("test_dirs/1.45/foo_2"), true),
534 (Path::new("test_dirs/1.46"), false),
535 (Path::new("test_dirs/1.46/bar_1"), true),
536 (Path::new("test_dirs/1.46/bar_2"), true),
537 ]
538 );
539 let mut paths = vec![
540 (Path::new("root1/one.txt"), true),
541 (Path::new("root1/one.two.txt"), true),
542 ];
543 paths.sort_by(|&a, &b| compare_paths(a, b));
544 assert_eq!(
545 paths,
546 vec![
547 (Path::new("root1/one.txt"), true),
548 (Path::new("root1/one.two.txt"), true),
549 ]
550 );
551 }
552
553 #[test]
554 fn compare_paths_with_same_name_different_extensions() {
555 let mut paths = vec![
556 (Path::new("test_dirs/file.rs"), true),
557 (Path::new("test_dirs/file.txt"), true),
558 (Path::new("test_dirs/file.md"), true),
559 (Path::new("test_dirs/file"), true),
560 (Path::new("test_dirs/file.a"), true),
561 ];
562 paths.sort_by(|&a, &b| compare_paths(a, b));
563 assert_eq!(
564 paths,
565 vec![
566 (Path::new("test_dirs/file"), true),
567 (Path::new("test_dirs/file.a"), true),
568 (Path::new("test_dirs/file.md"), true),
569 (Path::new("test_dirs/file.rs"), true),
570 (Path::new("test_dirs/file.txt"), true),
571 ]
572 );
573 }
574
575 #[test]
576 fn compare_paths_case_semi_sensitive() {
577 let mut paths = vec![
578 (Path::new("test_DIRS"), false),
579 (Path::new("test_DIRS/foo_1"), true),
580 (Path::new("test_DIRS/foo_2"), true),
581 (Path::new("test_DIRS/bar"), true),
582 (Path::new("test_DIRS/BAR"), true),
583 (Path::new("test_dirs"), false),
584 (Path::new("test_dirs/foo_1"), true),
585 (Path::new("test_dirs/foo_2"), true),
586 (Path::new("test_dirs/bar"), true),
587 (Path::new("test_dirs/BAR"), true),
588 ];
589 paths.sort_by(|&a, &b| compare_paths(a, b));
590 assert_eq!(
591 paths,
592 vec![
593 (Path::new("test_dirs"), false),
594 (Path::new("test_dirs/bar"), true),
595 (Path::new("test_dirs/BAR"), true),
596 (Path::new("test_dirs/foo_1"), true),
597 (Path::new("test_dirs/foo_2"), true),
598 (Path::new("test_DIRS"), false),
599 (Path::new("test_DIRS/bar"), true),
600 (Path::new("test_DIRS/BAR"), true),
601 (Path::new("test_DIRS/foo_1"), true),
602 (Path::new("test_DIRS/foo_2"), true),
603 ]
604 );
605 }
606
607 #[test]
608 fn path_with_position_parse_posix_path() {
609 // Test POSIX filename edge cases
610 // Read more at https://en.wikipedia.org/wiki/Filename
611 assert_eq!(
612 PathWithPosition::parse_str("test_file"),
613 PathWithPosition {
614 path: PathBuf::from("test_file"),
615 row: None,
616 column: None
617 }
618 );
619
620 assert_eq!(
621 PathWithPosition::parse_str("a:bc:.zip:1"),
622 PathWithPosition {
623 path: PathBuf::from("a:bc:.zip"),
624 row: Some(1),
625 column: None
626 }
627 );
628
629 assert_eq!(
630 PathWithPosition::parse_str("one.second.zip:1"),
631 PathWithPosition {
632 path: PathBuf::from("one.second.zip"),
633 row: Some(1),
634 column: None
635 }
636 );
637
638 // Trim off trailing `:`s for otherwise valid input.
639 assert_eq!(
640 PathWithPosition::parse_str("test_file:10:1:"),
641 PathWithPosition {
642 path: PathBuf::from("test_file"),
643 row: Some(10),
644 column: Some(1)
645 }
646 );
647
648 assert_eq!(
649 PathWithPosition::parse_str("test_file.rs:"),
650 PathWithPosition {
651 path: PathBuf::from("test_file.rs:"),
652 row: None,
653 column: None
654 }
655 );
656
657 assert_eq!(
658 PathWithPosition::parse_str("test_file.rs:1:"),
659 PathWithPosition {
660 path: PathBuf::from("test_file.rs"),
661 row: Some(1),
662 column: None
663 }
664 );
665
666 assert_eq!(
667 PathWithPosition::parse_str("ab\ncd"),
668 PathWithPosition {
669 path: PathBuf::from("ab\ncd"),
670 row: None,
671 column: None
672 }
673 );
674
675 assert_eq!(
676 PathWithPosition::parse_str("👋\nab"),
677 PathWithPosition {
678 path: PathBuf::from("👋\nab"),
679 row: None,
680 column: None
681 }
682 );
683
684 assert_eq!(
685 PathWithPosition::parse_str("Types.hs:(617,9)-(670,28):"),
686 PathWithPosition {
687 path: PathBuf::from("Types.hs"),
688 row: Some(617),
689 column: Some(9),
690 }
691 );
692 }
693
694 #[test]
695 #[cfg(not(target_os = "windows"))]
696 fn path_with_position_parse_posix_path_with_suffix() {
697 assert_eq!(
698 PathWithPosition::parse_str("foo/bar:34:in"),
699 PathWithPosition {
700 path: PathBuf::from("foo/bar"),
701 row: Some(34),
702 column: None,
703 }
704 );
705 assert_eq!(
706 PathWithPosition::parse_str("foo/bar.rs:1902:::15:"),
707 PathWithPosition {
708 path: PathBuf::from("foo/bar.rs:1902"),
709 row: Some(15),
710 column: None
711 }
712 );
713
714 assert_eq!(
715 PathWithPosition::parse_str("app-editors:zed-0.143.6:20240710-201212.log:34:"),
716 PathWithPosition {
717 path: PathBuf::from("app-editors:zed-0.143.6:20240710-201212.log"),
718 row: Some(34),
719 column: None,
720 }
721 );
722
723 assert_eq!(
724 PathWithPosition::parse_str("crates/file_finder/src/file_finder.rs:1902:13:"),
725 PathWithPosition {
726 path: PathBuf::from("crates/file_finder/src/file_finder.rs"),
727 row: Some(1902),
728 column: Some(13),
729 }
730 );
731
732 assert_eq!(
733 PathWithPosition::parse_str("crate/utils/src/test:today.log:34"),
734 PathWithPosition {
735 path: PathBuf::from("crate/utils/src/test:today.log"),
736 row: Some(34),
737 column: None,
738 }
739 );
740 assert_eq!(
741 PathWithPosition::parse_str("/testing/out/src/file_finder.odin(7:15)"),
742 PathWithPosition {
743 path: PathBuf::from("/testing/out/src/file_finder.odin"),
744 row: Some(7),
745 column: Some(15),
746 }
747 );
748 }
749
750 #[test]
751 #[cfg(target_os = "windows")]
752 fn path_with_position_parse_windows_path() {
753 assert_eq!(
754 PathWithPosition::parse_str("crates\\utils\\paths.rs"),
755 PathWithPosition {
756 path: PathBuf::from("crates\\utils\\paths.rs"),
757 row: None,
758 column: None
759 }
760 );
761
762 assert_eq!(
763 PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs"),
764 PathWithPosition {
765 path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
766 row: None,
767 column: None
768 }
769 );
770 }
771
772 #[test]
773 #[cfg(target_os = "windows")]
774 fn path_with_position_parse_windows_path_with_suffix() {
775 assert_eq!(
776 PathWithPosition::parse_str("crates\\utils\\paths.rs:101"),
777 PathWithPosition {
778 path: PathBuf::from("crates\\utils\\paths.rs"),
779 row: Some(101),
780 column: None
781 }
782 );
783
784 assert_eq!(
785 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1:20"),
786 PathWithPosition {
787 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
788 row: Some(1),
789 column: Some(20)
790 }
791 );
792
793 assert_eq!(
794 PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902,13)"),
795 PathWithPosition {
796 path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
797 row: Some(1902),
798 column: Some(13)
799 }
800 );
801
802 // Trim off trailing `:`s for otherwise valid input.
803 assert_eq!(
804 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:"),
805 PathWithPosition {
806 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
807 row: Some(1902),
808 column: Some(13)
809 }
810 );
811
812 assert_eq!(
813 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:15:"),
814 PathWithPosition {
815 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs:1902"),
816 row: Some(13),
817 column: Some(15)
818 }
819 );
820
821 assert_eq!(
822 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:::15:"),
823 PathWithPosition {
824 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs:1902"),
825 row: Some(15),
826 column: None
827 }
828 );
829
830 assert_eq!(
831 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs(1902,13):"),
832 PathWithPosition {
833 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
834 row: Some(1902),
835 column: Some(13),
836 }
837 );
838
839 assert_eq!(
840 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs(1902):"),
841 PathWithPosition {
842 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
843 row: Some(1902),
844 column: None,
845 }
846 );
847
848 assert_eq!(
849 PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs:1902:13:"),
850 PathWithPosition {
851 path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
852 row: Some(1902),
853 column: Some(13),
854 }
855 );
856
857 assert_eq!(
858 PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902,13):"),
859 PathWithPosition {
860 path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
861 row: Some(1902),
862 column: Some(13),
863 }
864 );
865
866 assert_eq!(
867 PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902):"),
868 PathWithPosition {
869 path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
870 row: Some(1902),
871 column: None,
872 }
873 );
874
875 assert_eq!(
876 PathWithPosition::parse_str("crates/utils/paths.rs:101"),
877 PathWithPosition {
878 path: PathBuf::from("crates\\utils\\paths.rs"),
879 row: Some(101),
880 column: None,
881 }
882 );
883 }
884
885 #[test]
886 fn test_path_compact() {
887 let path: PathBuf = [
888 home_dir().to_string_lossy().to_string(),
889 "some_file.txt".to_string(),
890 ]
891 .iter()
892 .collect();
893 if cfg!(any(target_os = "linux", target_os = "freebsd")) || cfg!(target_os = "macos") {
894 assert_eq!(path.compact().to_str(), Some("~/some_file.txt"));
895 } else {
896 assert_eq!(path.compact().to_str(), path.to_str());
897 }
898 }
899
900 #[test]
901 fn test_extension_or_hidden_file_name() {
902 // No dots in name
903 let path = Path::new("/a/b/c/file_name.rs");
904 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
905
906 // Single dot in name
907 let path = Path::new("/a/b/c/file.name.rs");
908 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
909
910 // Multiple dots in name
911 let path = Path::new("/a/b/c/long.file.name.rs");
912 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
913
914 // Hidden file, no extension
915 let path = Path::new("/a/b/c/.gitignore");
916 assert_eq!(path.extension_or_hidden_file_name(), Some("gitignore"));
917
918 // Hidden file, with extension
919 let path = Path::new("/a/b/c/.eslintrc.js");
920 assert_eq!(path.extension_or_hidden_file_name(), Some("eslintrc.js"));
921 }
922
923 #[test]
924 fn edge_of_glob() {
925 let path = Path::new("/work/node_modules");
926 let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
927 assert!(
928 path_matcher.is_match(path),
929 "Path matcher should match {path:?}"
930 );
931 }
932
933 #[test]
934 fn project_search() {
935 let path = Path::new("/Users/someonetoignore/work/zed/zed.dev/node_modules");
936 let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
937 assert!(
938 path_matcher.is_match(path),
939 "Path matcher should match {path:?}"
940 );
941 }
942
943 #[test]
944 #[cfg(target_os = "windows")]
945 fn test_sanitized_path() {
946 let path = Path::new("C:\\Users\\someone\\test_file.rs");
947 let sanitized_path = SanitizedPath::from(path);
948 assert_eq!(
949 sanitized_path.to_string(),
950 "C:\\Users\\someone\\test_file.rs"
951 );
952
953 let path = Path::new("\\\\?\\C:\\Users\\someone\\test_file.rs");
954 let sanitized_path = SanitizedPath::from(path);
955 assert_eq!(
956 sanitized_path.to_string(),
957 "C:\\Users\\someone\\test_file.rs"
958 );
959 }
960}