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