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