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::anyhow;
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 .ok_or_else(|| anyhow!("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
181 |
182 \:+(\d+)\:*()$ # filename:row
183 |
184 \:*()()$ # filename:
185 )";
186
187/// A representation of a path-like string with optional row and column numbers.
188/// Matching values example: `te`, `test.rs:22`, `te:22:5`, `test.c(22)`, `test.c(22,5)`etc.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
190pub struct PathWithPosition {
191 pub path: PathBuf,
192 pub row: Option<u32>,
193 // Absent if row is absent.
194 pub column: Option<u32>,
195}
196
197impl PathWithPosition {
198 /// Returns a PathWithPosition from a path.
199 pub fn from_path(path: PathBuf) -> Self {
200 Self {
201 path,
202 row: None,
203 column: None,
204 }
205 }
206
207 /// Parses a string that possibly has `:row:column` or `(row, column)` suffix.
208 /// Parenthesis format is used by [MSBuild](https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-diagnostic-format-for-tasks) compatible tools
209 /// Ignores trailing `:`s, so `test.rs:22:` is parsed as `test.rs:22`.
210 /// If the suffix parsing fails, the whole string is parsed as a path.
211 ///
212 /// Be mindful that `test_file:10:1:` is a valid posix filename.
213 /// `PathWithPosition` class assumes that the ending position-like suffix is **not** part of the filename.
214 ///
215 /// # Examples
216 ///
217 /// ```
218 /// # use util::paths::PathWithPosition;
219 /// # use std::path::PathBuf;
220 /// assert_eq!(PathWithPosition::parse_str("test_file"), PathWithPosition {
221 /// path: PathBuf::from("test_file"),
222 /// row: None,
223 /// column: None,
224 /// });
225 /// assert_eq!(PathWithPosition::parse_str("test_file:10"), PathWithPosition {
226 /// path: PathBuf::from("test_file"),
227 /// row: Some(10),
228 /// column: None,
229 /// });
230 /// assert_eq!(PathWithPosition::parse_str("test_file.rs"), PathWithPosition {
231 /// path: PathBuf::from("test_file.rs"),
232 /// row: None,
233 /// column: None,
234 /// });
235 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1"), PathWithPosition {
236 /// path: PathBuf::from("test_file.rs"),
237 /// row: Some(1),
238 /// column: None,
239 /// });
240 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1:2"), PathWithPosition {
241 /// path: PathBuf::from("test_file.rs"),
242 /// row: Some(1),
243 /// column: Some(2),
244 /// });
245 /// ```
246 ///
247 /// # Expected parsing results when encounter ill-formatted inputs.
248 /// ```
249 /// # use util::paths::PathWithPosition;
250 /// # use std::path::PathBuf;
251 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:a"), PathWithPosition {
252 /// path: PathBuf::from("test_file.rs:a"),
253 /// row: None,
254 /// column: None,
255 /// });
256 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:a:b"), PathWithPosition {
257 /// path: PathBuf::from("test_file.rs:a:b"),
258 /// row: None,
259 /// column: None,
260 /// });
261 /// assert_eq!(PathWithPosition::parse_str("test_file.rs::"), PathWithPosition {
262 /// path: PathBuf::from("test_file.rs"),
263 /// row: None,
264 /// column: None,
265 /// });
266 /// assert_eq!(PathWithPosition::parse_str("test_file.rs::1"), PathWithPosition {
267 /// path: PathBuf::from("test_file.rs"),
268 /// row: Some(1),
269 /// column: None,
270 /// });
271 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1::"), PathWithPosition {
272 /// path: PathBuf::from("test_file.rs"),
273 /// row: Some(1),
274 /// column: None,
275 /// });
276 /// assert_eq!(PathWithPosition::parse_str("test_file.rs::1:2"), PathWithPosition {
277 /// path: PathBuf::from("test_file.rs"),
278 /// row: Some(1),
279 /// column: Some(2),
280 /// });
281 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1::2"), PathWithPosition {
282 /// path: PathBuf::from("test_file.rs:1"),
283 /// row: Some(2),
284 /// column: None,
285 /// });
286 /// assert_eq!(PathWithPosition::parse_str("test_file.rs:1:2:3"), PathWithPosition {
287 /// path: PathBuf::from("test_file.rs:1"),
288 /// row: Some(2),
289 /// column: Some(3),
290 /// });
291 /// ```
292 pub fn parse_str(s: &str) -> Self {
293 let trimmed = s.trim();
294 let path = Path::new(trimmed);
295 let maybe_file_name_with_row_col = path.file_name().unwrap_or_default().to_string_lossy();
296 if maybe_file_name_with_row_col.is_empty() {
297 return Self {
298 path: Path::new(s).to_path_buf(),
299 row: None,
300 column: None,
301 };
302 }
303
304 // Let's avoid repeated init cost on this. It is subject to thread contention, but
305 // so far this code isn't called from multiple hot paths. Getting contention here
306 // in the future seems unlikely.
307 static SUFFIX_RE: LazyLock<Regex> =
308 LazyLock::new(|| Regex::new(ROW_COL_CAPTURE_REGEX).unwrap());
309 match SUFFIX_RE
310 .captures(&maybe_file_name_with_row_col)
311 .map(|caps| caps.extract())
312 {
313 Some((_, [file_name, maybe_row, maybe_column])) => {
314 let row = maybe_row.parse::<u32>().ok();
315 let column = maybe_column.parse::<u32>().ok();
316
317 let suffix_length = maybe_file_name_with_row_col.len() - file_name.len();
318 let path_without_suffix = &trimmed[..trimmed.len() - suffix_length];
319
320 Self {
321 path: Path::new(path_without_suffix).to_path_buf(),
322 row,
323 column,
324 }
325 }
326 None => Self {
327 path: Path::new(s).to_path_buf(),
328 row: None,
329 column: None,
330 },
331 }
332 }
333
334 pub fn map_path<E>(
335 self,
336 mapping: impl FnOnce(PathBuf) -> Result<PathBuf, E>,
337 ) -> Result<PathWithPosition, E> {
338 Ok(PathWithPosition {
339 path: mapping(self.path)?,
340 row: self.row,
341 column: self.column,
342 })
343 }
344
345 pub fn to_string(&self, path_to_string: impl Fn(&PathBuf) -> String) -> String {
346 let path_string = path_to_string(&self.path);
347 if let Some(row) = self.row {
348 if let Some(column) = self.column {
349 format!("{path_string}:{row}:{column}")
350 } else {
351 format!("{path_string}:{row}")
352 }
353 } else {
354 path_string
355 }
356 }
357}
358
359#[derive(Clone, Debug, Default)]
360pub struct PathMatcher {
361 sources: Vec<String>,
362 glob: GlobSet,
363}
364
365// impl std::fmt::Display for PathMatcher {
366// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367// self.sources.fmt(f)
368// }
369// }
370
371impl PartialEq for PathMatcher {
372 fn eq(&self, other: &Self) -> bool {
373 self.sources.eq(&other.sources)
374 }
375}
376
377impl Eq for PathMatcher {}
378
379impl PathMatcher {
380 pub fn new(globs: impl IntoIterator<Item = impl AsRef<str>>) -> Result<Self, globset::Error> {
381 let globs = globs
382 .into_iter()
383 .map(|as_str| Glob::new(as_str.as_ref()))
384 .collect::<Result<Vec<_>, _>>()?;
385 let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect();
386 let mut glob_builder = GlobSetBuilder::new();
387 for single_glob in globs {
388 glob_builder.add(single_glob);
389 }
390 let glob = glob_builder.build()?;
391 Ok(PathMatcher { glob, sources })
392 }
393
394 pub fn sources(&self) -> &[String] {
395 &self.sources
396 }
397
398 pub fn is_match<P: AsRef<Path>>(&self, other: P) -> bool {
399 let other_path = other.as_ref();
400 self.sources.iter().any(|source| {
401 let as_bytes = other_path.as_os_str().as_encoded_bytes();
402 as_bytes.starts_with(source.as_bytes()) || as_bytes.ends_with(source.as_bytes())
403 }) || self.glob.is_match(other_path)
404 || self.check_with_end_separator(other_path)
405 }
406
407 fn check_with_end_separator(&self, path: &Path) -> bool {
408 let path_str = path.to_string_lossy();
409 let separator = std::path::MAIN_SEPARATOR_STR;
410 if path_str.ends_with(separator) {
411 false
412 } else {
413 self.glob.is_match(path_str.to_string() + separator)
414 }
415 }
416}
417
418pub fn compare_paths(
419 (path_a, a_is_file): (&Path, bool),
420 (path_b, b_is_file): (&Path, bool),
421) -> cmp::Ordering {
422 let mut components_a = path_a.components().peekable();
423 let mut components_b = path_b.components().peekable();
424 loop {
425 match (components_a.next(), components_b.next()) {
426 (Some(component_a), Some(component_b)) => {
427 let a_is_file = components_a.peek().is_none() && a_is_file;
428 let b_is_file = components_b.peek().is_none() && b_is_file;
429 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
430 let path_a = Path::new(component_a.as_os_str());
431 let path_string_a = if a_is_file {
432 path_a.file_stem()
433 } else {
434 path_a.file_name()
435 }
436 .map(|s| s.to_string_lossy());
437 let num_and_remainder_a = path_string_a
438 .as_deref()
439 .map(NumericPrefixWithSuffix::from_numeric_prefixed_str);
440
441 let path_b = Path::new(component_b.as_os_str());
442 let path_string_b = if b_is_file {
443 path_b.file_stem()
444 } else {
445 path_b.file_name()
446 }
447 .map(|s| s.to_string_lossy());
448 let num_and_remainder_b = path_string_b
449 .as_deref()
450 .map(NumericPrefixWithSuffix::from_numeric_prefixed_str);
451
452 num_and_remainder_a.cmp(&num_and_remainder_b).then_with(|| {
453 if a_is_file && b_is_file {
454 let ext_a = path_a.extension().unwrap_or_default();
455 let ext_b = path_b.extension().unwrap_or_default();
456 ext_a.cmp(ext_b)
457 } else {
458 cmp::Ordering::Equal
459 }
460 })
461 });
462 if !ordering.is_eq() {
463 return ordering;
464 }
465 }
466 (Some(_), None) => break cmp::Ordering::Greater,
467 (None, Some(_)) => break cmp::Ordering::Less,
468 (None, None) => break cmp::Ordering::Equal,
469 }
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn compare_paths_with_dots() {
479 let mut paths = vec![
480 (Path::new("test_dirs"), false),
481 (Path::new("test_dirs/1.46"), false),
482 (Path::new("test_dirs/1.46/bar_1"), true),
483 (Path::new("test_dirs/1.46/bar_2"), true),
484 (Path::new("test_dirs/1.45"), false),
485 (Path::new("test_dirs/1.45/foo_2"), true),
486 (Path::new("test_dirs/1.45/foo_1"), true),
487 ];
488 paths.sort_by(|&a, &b| compare_paths(a, b));
489 assert_eq!(
490 paths,
491 vec![
492 (Path::new("test_dirs"), false),
493 (Path::new("test_dirs/1.45"), false),
494 (Path::new("test_dirs/1.45/foo_1"), true),
495 (Path::new("test_dirs/1.45/foo_2"), true),
496 (Path::new("test_dirs/1.46"), false),
497 (Path::new("test_dirs/1.46/bar_1"), true),
498 (Path::new("test_dirs/1.46/bar_2"), true),
499 ]
500 );
501 let mut paths = vec![
502 (Path::new("root1/one.txt"), true),
503 (Path::new("root1/one.two.txt"), true),
504 ];
505 paths.sort_by(|&a, &b| compare_paths(a, b));
506 assert_eq!(
507 paths,
508 vec![
509 (Path::new("root1/one.txt"), true),
510 (Path::new("root1/one.two.txt"), true),
511 ]
512 );
513 }
514
515 #[test]
516 fn compare_paths_with_same_name_different_extensions() {
517 let mut paths = vec![
518 (Path::new("test_dirs/file.rs"), true),
519 (Path::new("test_dirs/file.txt"), true),
520 (Path::new("test_dirs/file.md"), true),
521 (Path::new("test_dirs/file"), true),
522 (Path::new("test_dirs/file.a"), true),
523 ];
524 paths.sort_by(|&a, &b| compare_paths(a, b));
525 assert_eq!(
526 paths,
527 vec![
528 (Path::new("test_dirs/file"), true),
529 (Path::new("test_dirs/file.a"), true),
530 (Path::new("test_dirs/file.md"), true),
531 (Path::new("test_dirs/file.rs"), true),
532 (Path::new("test_dirs/file.txt"), true),
533 ]
534 );
535 }
536
537 #[test]
538 fn compare_paths_case_semi_sensitive() {
539 let mut paths = vec![
540 (Path::new("test_DIRS"), false),
541 (Path::new("test_DIRS/foo_1"), true),
542 (Path::new("test_DIRS/foo_2"), true),
543 (Path::new("test_DIRS/bar"), true),
544 (Path::new("test_DIRS/BAR"), true),
545 (Path::new("test_dirs"), false),
546 (Path::new("test_dirs/foo_1"), true),
547 (Path::new("test_dirs/foo_2"), true),
548 (Path::new("test_dirs/bar"), true),
549 (Path::new("test_dirs/BAR"), true),
550 ];
551 paths.sort_by(|&a, &b| compare_paths(a, b));
552 assert_eq!(
553 paths,
554 vec![
555 (Path::new("test_dirs"), false),
556 (Path::new("test_dirs/bar"), true),
557 (Path::new("test_dirs/BAR"), true),
558 (Path::new("test_dirs/foo_1"), true),
559 (Path::new("test_dirs/foo_2"), true),
560 (Path::new("test_DIRS"), false),
561 (Path::new("test_DIRS/bar"), true),
562 (Path::new("test_DIRS/BAR"), true),
563 (Path::new("test_DIRS/foo_1"), true),
564 (Path::new("test_DIRS/foo_2"), true),
565 ]
566 );
567 }
568
569 #[test]
570 fn path_with_position_parse_posix_path() {
571 // Test POSIX filename edge cases
572 // Read more at https://en.wikipedia.org/wiki/Filename
573 assert_eq!(
574 PathWithPosition::parse_str(" test_file"),
575 PathWithPosition {
576 path: PathBuf::from("test_file"),
577 row: None,
578 column: None
579 }
580 );
581
582 assert_eq!(
583 PathWithPosition::parse_str("a:bc:.zip:1"),
584 PathWithPosition {
585 path: PathBuf::from("a:bc:.zip"),
586 row: Some(1),
587 column: None
588 }
589 );
590
591 assert_eq!(
592 PathWithPosition::parse_str("one.second.zip:1"),
593 PathWithPosition {
594 path: PathBuf::from("one.second.zip"),
595 row: Some(1),
596 column: None
597 }
598 );
599
600 // Trim off trailing `:`s for otherwise valid input.
601 assert_eq!(
602 PathWithPosition::parse_str("test_file:10:1:"),
603 PathWithPosition {
604 path: PathBuf::from("test_file"),
605 row: Some(10),
606 column: Some(1)
607 }
608 );
609
610 assert_eq!(
611 PathWithPosition::parse_str("test_file.rs:"),
612 PathWithPosition {
613 path: PathBuf::from("test_file.rs"),
614 row: None,
615 column: None
616 }
617 );
618
619 assert_eq!(
620 PathWithPosition::parse_str("test_file.rs:1:"),
621 PathWithPosition {
622 path: PathBuf::from("test_file.rs"),
623 row: Some(1),
624 column: None
625 }
626 );
627
628 assert_eq!(
629 PathWithPosition::parse_str("ab\ncd"),
630 PathWithPosition {
631 path: PathBuf::from("ab\ncd"),
632 row: None,
633 column: None
634 }
635 );
636
637 assert_eq!(
638 PathWithPosition::parse_str("👋\nab"),
639 PathWithPosition {
640 path: PathBuf::from("👋\nab"),
641 row: None,
642 column: None
643 }
644 );
645 }
646
647 #[test]
648 #[cfg(not(target_os = "windows"))]
649 fn path_with_position_parse_posix_path_with_suffix() {
650 assert_eq!(
651 PathWithPosition::parse_str("app-editors:zed-0.143.6:20240710-201212.log:34:"),
652 PathWithPosition {
653 path: PathBuf::from("app-editors:zed-0.143.6:20240710-201212.log"),
654 row: Some(34),
655 column: None,
656 }
657 );
658
659 assert_eq!(
660 PathWithPosition::parse_str("crates/file_finder/src/file_finder.rs:1902:13:"),
661 PathWithPosition {
662 path: PathBuf::from("crates/file_finder/src/file_finder.rs"),
663 row: Some(1902),
664 column: Some(13),
665 }
666 );
667
668 assert_eq!(
669 PathWithPosition::parse_str("crate/utils/src/test:today.log:34"),
670 PathWithPosition {
671 path: PathBuf::from("crate/utils/src/test:today.log"),
672 row: Some(34),
673 column: None,
674 }
675 );
676 assert_eq!(
677 PathWithPosition::parse_str("/testing/out/src/file_finder.odin(7:15)"),
678 PathWithPosition {
679 path: PathBuf::from("/testing/out/src/file_finder.odin"),
680 row: Some(7),
681 column: Some(15),
682 }
683 );
684 }
685
686 #[test]
687 #[cfg(target_os = "windows")]
688 fn path_with_position_parse_windows_path() {
689 assert_eq!(
690 PathWithPosition::parse_str("crates\\utils\\paths.rs"),
691 PathWithPosition {
692 path: PathBuf::from("crates\\utils\\paths.rs"),
693 row: None,
694 column: None
695 }
696 );
697
698 assert_eq!(
699 PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs"),
700 PathWithPosition {
701 path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
702 row: None,
703 column: None
704 }
705 );
706 }
707
708 #[test]
709 #[cfg(target_os = "windows")]
710 fn path_with_position_parse_windows_path_with_suffix() {
711 assert_eq!(
712 PathWithPosition::parse_str("crates\\utils\\paths.rs:101"),
713 PathWithPosition {
714 path: PathBuf::from("crates\\utils\\paths.rs"),
715 row: Some(101),
716 column: None
717 }
718 );
719
720 assert_eq!(
721 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1:20"),
722 PathWithPosition {
723 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
724 row: Some(1),
725 column: Some(20)
726 }
727 );
728
729 assert_eq!(
730 PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902,13)"),
731 PathWithPosition {
732 path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
733 row: Some(1902),
734 column: Some(13)
735 }
736 );
737
738 // Trim off trailing `:`s for otherwise valid input.
739 assert_eq!(
740 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:"),
741 PathWithPosition {
742 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
743 row: Some(1902),
744 column: Some(13)
745 }
746 );
747
748 assert_eq!(
749 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:13:15:"),
750 PathWithPosition {
751 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs:1902"),
752 row: Some(13),
753 column: Some(15)
754 }
755 );
756
757 assert_eq!(
758 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs:1902:::15:"),
759 PathWithPosition {
760 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs:1902"),
761 row: Some(15),
762 column: None
763 }
764 );
765
766 assert_eq!(
767 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs(1902,13):"),
768 PathWithPosition {
769 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
770 row: Some(1902),
771 column: Some(13),
772 }
773 );
774
775 assert_eq!(
776 PathWithPosition::parse_str("\\\\?\\C:\\Users\\someone\\test_file.rs(1902):"),
777 PathWithPosition {
778 path: PathBuf::from("\\\\?\\C:\\Users\\someone\\test_file.rs"),
779 row: Some(1902),
780 column: None,
781 }
782 );
783
784 assert_eq!(
785 PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs:1902:13:"),
786 PathWithPosition {
787 path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
788 row: Some(1902),
789 column: Some(13),
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 assert_eq!(
803 PathWithPosition::parse_str("C:\\Users\\someone\\test_file.rs(1902):"),
804 PathWithPosition {
805 path: PathBuf::from("C:\\Users\\someone\\test_file.rs"),
806 row: Some(1902),
807 column: None,
808 }
809 );
810
811 assert_eq!(
812 PathWithPosition::parse_str("crates/utils/paths.rs:101"),
813 PathWithPosition {
814 path: PathBuf::from("crates\\utils\\paths.rs"),
815 row: Some(101),
816 column: None,
817 }
818 );
819 }
820
821 #[test]
822 fn test_path_compact() {
823 let path: PathBuf = [
824 home_dir().to_string_lossy().to_string(),
825 "some_file.txt".to_string(),
826 ]
827 .iter()
828 .collect();
829 if cfg!(any(target_os = "linux", target_os = "freebsd")) || cfg!(target_os = "macos") {
830 assert_eq!(path.compact().to_str(), Some("~/some_file.txt"));
831 } else {
832 assert_eq!(path.compact().to_str(), path.to_str());
833 }
834 }
835
836 #[test]
837 fn test_extension_or_hidden_file_name() {
838 // No dots in name
839 let path = Path::new("/a/b/c/file_name.rs");
840 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
841
842 // Single dot in name
843 let path = Path::new("/a/b/c/file.name.rs");
844 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
845
846 // Multiple dots in name
847 let path = Path::new("/a/b/c/long.file.name.rs");
848 assert_eq!(path.extension_or_hidden_file_name(), Some("rs"));
849
850 // Hidden file, no extension
851 let path = Path::new("/a/b/c/.gitignore");
852 assert_eq!(path.extension_or_hidden_file_name(), Some("gitignore"));
853
854 // Hidden file, with extension
855 let path = Path::new("/a/b/c/.eslintrc.js");
856 assert_eq!(path.extension_or_hidden_file_name(), Some("eslintrc.js"));
857 }
858
859 #[test]
860 fn edge_of_glob() {
861 let path = Path::new("/work/node_modules");
862 let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
863 assert!(
864 path_matcher.is_match(path),
865 "Path matcher should match {path:?}"
866 );
867 }
868
869 #[test]
870 fn project_search() {
871 let path = Path::new("/Users/someonetoignore/work/zed/zed.dev/node_modules");
872 let path_matcher = PathMatcher::new(&["**/node_modules/**".to_owned()]).unwrap();
873 assert!(
874 path_matcher.is_match(path),
875 "Path matcher should match {path:?}"
876 );
877 }
878
879 #[test]
880 #[cfg(target_os = "windows")]
881 fn test_sanitized_path() {
882 let path = Path::new("C:\\Users\\someone\\test_file.rs");
883 let sanitized_path = SanitizedPath::from(path);
884 assert_eq!(
885 sanitized_path.to_string(),
886 "C:\\Users\\someone\\test_file.rs"
887 );
888
889 let path = Path::new("\\\\?\\C:\\Users\\someone\\test_file.rs");
890 let sanitized_path = SanitizedPath::from(path);
891 assert_eq!(
892 sanitized_path.to_string(),
893 "C:\\Users\\someone\\test_file.rs"
894 );
895 }
896}