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