1use std::{
7 borrow::Cow,
8 cmp::Ordering,
9 fmt,
10 hash::{Hash, Hasher},
11 path::PathBuf,
12};
13
14use gpui_kit::SharedString;
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17enum Kind {
18 Posix,
19 Windows,
20}
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub struct PathStyle {
43 kind: Kind,
44 display_separator: char,
45 case_sensitive: bool,
46 computer_root: bool,
47}
48
49impl PathStyle {
50 pub fn posix() -> Self {
52 Self {
53 kind: Kind::Posix,
54 display_separator: '/',
55 case_sensitive: true,
56 computer_root: false,
57 }
58 }
59
60 pub fn windows() -> Self {
63 Self {
64 kind: Kind::Windows,
65 display_separator: '\\',
66 case_sensitive: false,
67 computer_root: false,
68 }
69 }
70
71 pub fn host() -> Self {
73 if cfg!(windows) {
74 Self::windows()
75 } else {
76 Self::posix()
77 }
78 }
79
80 pub fn with_display_separator(mut self, separator: char) -> Self {
83 if self.kind == Kind::Windows && (separator == '/' || separator == '\\') {
84 self.display_separator = separator;
85 }
86 self
87 }
88
89 pub fn with_case_sensitive(mut self, case_sensitive: bool) -> Self {
91 self.case_sensitive = case_sensitive;
92 self
93 }
94
95 pub fn with_computer_root(mut self, computer_root: bool) -> Self {
100 self.computer_root = self.kind == Kind::Windows && computer_root;
101 self
102 }
103
104 pub fn is_windows(&self) -> bool {
106 self.kind == Kind::Windows
107 }
108
109 pub fn separator(&self) -> char {
111 self.display_separator
112 }
113
114 pub fn is_separator(&self, c: char) -> bool {
116 c == '/' || (self.is_windows() && c == '\\')
117 }
118
119 pub fn is_case_sensitive(&self) -> bool {
121 self.case_sensitive
122 }
123
124 pub fn has_computer_root(&self) -> bool {
126 self.computer_root
127 }
128
129 pub fn is_hidden_name(&self, name: &str) -> bool {
133 !self.is_windows() && name.starts_with('.')
134 }
135
136 pub fn fold<'a>(&self, name: &'a str) -> Cow<'a, str> {
139 if self.case_sensitive {
140 Cow::Borrowed(name)
141 } else {
142 Cow::Owned(name.to_lowercase())
143 }
144 }
145
146 pub fn compare_names(&self, a: &str, b: &str) -> Ordering {
149 a.to_lowercase()
150 .cmp(&b.to_lowercase())
151 .then_with(|| a.cmp(b))
152 }
153
154 pub fn path(&self, text: impl AsRef<str>) -> SourcePath {
156 SourcePath::new(text.as_ref(), *self)
157 }
158
159 pub fn normalize_pasted(&self, text: &str, env: &dyn Fn(&str) -> Option<String>) -> String {
167 let unquoted = text.trim().trim_matches(|c| c == '"' || c == '\'').trim();
168 let text = match file_url_path(unquoted, self) {
169 Some(path) => path,
170 None if unquoted.starts_with("file://") => return text.to_string(),
171 None if self.is_windows() => text.trim().to_string(),
172 None => return text.to_string(),
173 };
174 if !self.is_windows() {
175 return text;
176 }
177 let text = text
178 .trim_matches(|c| c == '"' || c == '\'' || char::is_whitespace(c))
179 .to_string();
180 let text = expand_variables(&text, env);
181 let mut out: String = text
182 .chars()
183 .map(|c| {
184 if c == '/' || c == '\\' {
185 self.display_separator
186 } else {
187 c
188 }
189 })
190 .collect();
191 if is_drive_only(&out) {
192 out.push(self.display_separator);
193 }
194 out
195 }
196
197 pub(crate) fn normalize_fragment(&self, text: &str) -> String {
200 if !self.is_windows() {
201 return text.to_string();
202 }
203 text.chars()
204 .map(|c| {
205 if self.is_separator(c) {
206 self.display_separator
207 } else {
208 c
209 }
210 })
211 .collect()
212 }
213
214 pub(crate) fn parse(&self, text: &str) -> Parsed {
218 let sep = self.display_separator;
219 if !self.is_windows() {
220 let rooted = text.starts_with('/');
221 return Parsed {
222 prefix: if rooted { "/".into() } else { String::new() },
223 parts: fold_dots(text.split('/').map(str::to_string).collect()),
224 rooted,
225 computer: false,
226 };
227 }
228 let mut chars = text.chars();
229 let first = chars.next();
230 let second = chars.next();
231 let split = |rest: &str| -> Vec<String> {
232 fold_dots(
233 rest.split(|c| self.is_separator(c))
234 .map(str::to_string)
235 .collect(),
236 )
237 };
238 if let (Some(a), Some(':')) = (first, second)
239 && a.is_ascii_alphabetic()
240 {
241 return Parsed {
242 prefix: format!("{a}:{sep}"),
243 parts: split(&text[2..]),
244 rooted: true,
245 computer: false,
246 };
247 }
248 if first.is_some_and(|c| self.is_separator(c))
249 && second.is_some_and(|c| self.is_separator(c))
250 {
251 let mut parts: Vec<String> = text
252 .split(|c| self.is_separator(c))
253 .filter(|part| !part.is_empty())
254 .map(str::to_string)
255 .collect();
256 if parts.len() == 1 {
257 return Parsed {
258 prefix: format!("{sep}{sep}{}{sep}", parts[0]),
259 parts: Vec::new(),
260 rooted: true,
261 computer: false,
262 };
263 }
264 if parts.is_empty() {
265 return Parsed {
266 prefix: format!("{sep}{sep}"),
267 parts,
268 rooted: true,
269 computer: false,
270 };
271 }
272 if parts.len() >= 2 {
273 let server = parts.remove(0);
274 let share = parts.remove(0);
275 return Parsed {
276 prefix: format!("{sep}{sep}{server}{sep}{share}{sep}"),
277 parts: fold_dots(parts),
278 rooted: true,
279 computer: false,
280 };
281 }
282 }
283 if first.is_some_and(|c| self.is_separator(c)) {
284 let parts = split(text);
285 if parts.is_empty() {
286 return Parsed {
287 prefix: sep.to_string(),
288 parts,
289 rooted: true,
290 computer: self.computer_root,
291 };
292 }
293 return Parsed {
294 prefix: sep.to_string(),
295 parts,
296 rooted: true,
297 computer: false,
298 };
299 }
300 Parsed {
301 prefix: String::new(),
302 parts: split(text),
303 rooted: false,
304 computer: false,
305 }
306 }
307
308 pub(crate) fn display(&self, parsed: &Parsed) -> String {
311 let sep = self.display_separator.to_string();
312 format!("{}{}", parsed.prefix, parsed.parts.join(&sep))
313 }
314
315 pub(crate) fn key(&self, text: &str) -> String {
319 let parsed = self.parse(text);
320 let mut key = parsed.prefix.replace('\\', "/");
321 key.push_str(&parsed.parts.join("/"));
322 if self.case_sensitive {
323 key
324 } else {
325 key.to_lowercase()
326 }
327 }
328}
329
330impl Default for PathStyle {
331 fn default() -> Self {
332 Self::host()
333 }
334}
335
336#[derive(Clone, Debug, PartialEq, Eq)]
338pub(crate) struct Parsed {
339 pub(crate) prefix: String,
342 pub(crate) parts: Vec<String>,
343 pub(crate) rooted: bool,
344 pub(crate) computer: bool,
346}
347
348impl Parsed {
349 pub(crate) fn is_root(&self) -> bool {
351 self.parts.is_empty()
352 }
353}
354
355fn fold_dots(parts: Vec<String>) -> Vec<String> {
359 let mut folded: Vec<String> = Vec::new();
360 for part in parts {
361 match part.as_str() {
362 "" | "." => {}
363 ".." => {
364 folded.pop();
365 }
366 _ => folded.push(part),
367 }
368 }
369 folded
370}
371
372fn is_drive_only(text: &str) -> bool {
373 let mut chars = text.chars();
374 matches!(
375 (chars.next(), chars.next(), chars.next()),
376 (Some(a), Some(':'), None) if a.is_ascii_alphabetic()
377 )
378}
379
380fn expand_variables(text: &str, env: &dyn Fn(&str) -> Option<String>) -> String {
383 let mut out = String::new();
384 let mut rest = text;
385 while let Some(start) = rest.find('%') {
386 out.push_str(&rest[..start]);
387 let after = &rest[start + 1..];
388 match after.find('%') {
389 Some(end) if end > 0 => match env(&after[..end]) {
390 Some(value) => {
391 out.push_str(&value);
392 rest = &after[end + 1..];
393 }
394 None => {
395 out.push('%');
396 rest = after;
397 }
398 },
399 _ => {
400 out.push('%');
401 rest = after;
402 }
403 }
404 }
405 out.push_str(rest);
406 out
407}
408
409fn file_url_path(text: &str, style: &PathStyle) -> Option<String> {
412 let rest = text.strip_prefix("file://")?;
413 let (host, path) = match rest.find('/') {
414 Some(at) => (&rest[..at], &rest[at..]),
415 None => (rest, ""),
416 };
417 let path = percent_decode(path);
418 if path.chars().any(char::is_control) {
419 return None;
420 }
421 if style.is_windows() {
422 let bytes = path.as_bytes();
423 let drive = bytes.len() >= 3
424 && bytes[0] == b'/'
425 && bytes[1].is_ascii_alphabetic()
426 && bytes[2] == b':';
427 if drive {
428 return Some(path[1..].to_string());
429 }
430 if !host.is_empty() && host != "localhost" {
431 return Some(format!("//{host}{path}"));
432 }
433 }
434 Some(path)
435}
436
437fn percent_decode(text: &str) -> String {
438 let bytes = text.as_bytes();
439 let mut out = Vec::with_capacity(bytes.len());
440 let mut at = 0;
441 while at < bytes.len() {
442 if bytes[at] == b'%'
443 && at + 2 < bytes.len()
444 && let (Some(high), Some(low)) = (hex(bytes[at + 1]), hex(bytes[at + 2]))
445 {
446 out.push(high * 16 + low);
447 at += 3;
448 } else {
449 out.push(bytes[at]);
450 at += 1;
451 }
452 }
453 String::from_utf8_lossy(&out).into_owned()
454}
455
456fn hex(byte: u8) -> Option<u8> {
457 (byte as char).to_digit(16).map(|digit| digit as u8)
458}
459
460#[derive(Clone, Debug)]
469pub struct SourcePath {
470 text: SharedString,
471 key: SharedString,
472 style: PathStyle,
473}
474
475impl SourcePath {
476 pub fn new(text: &str, style: PathStyle) -> Self {
479 let parsed = style.parse(text);
480 Self {
481 text: style.display(&parsed).into(),
482 key: style.key(text).into(),
483 style,
484 }
485 }
486
487 pub fn as_str(&self) -> &str {
489 &self.text
490 }
491
492 pub fn style(&self) -> PathStyle {
494 self.style
495 }
496
497 pub fn file_name(&self) -> Option<&str> {
499 let parsed = self.style.parse(&self.text);
500 let last = parsed.parts.last()?;
501 let at = self.text.len() - last.len();
502 Some(&self.text[at..])
503 }
504
505 pub fn parent(&self) -> Option<SourcePath> {
508 let mut parsed = self.style.parse(&self.text);
509 if parsed.parts.is_empty() {
510 if parsed.computer || !self.style.has_computer_root() {
511 return None;
512 }
513 let is_drive = parsed.prefix.len() == 3 && parsed.prefix.as_bytes()[1] == b':';
514 return is_drive.then(|| self.style.path(self.style.separator().to_string()));
515 }
516 parsed.parts.pop();
517 Some(Self::new(&self.style.display(&parsed), self.style))
518 }
519
520 pub fn join(&self, name: &str) -> SourcePath {
522 let sep = self.style.separator();
523 let mut text = self.text.to_string();
524 if !text.ends_with(|c| self.style.is_separator(c)) {
525 text.push(sep);
526 }
527 text.push_str(name);
528 Self::new(&text, self.style)
529 }
530
531 pub fn is_root(&self) -> bool {
534 self.style.parse(&self.text).is_root()
535 }
536
537 pub fn to_path_buf(&self) -> PathBuf {
540 PathBuf::from(self.text.as_ref() as &str)
541 }
542}
543
544impl PartialEq for SourcePath {
545 fn eq(&self, other: &Self) -> bool {
546 self.key == other.key
547 }
548}
549
550impl Eq for SourcePath {}
551
552impl Hash for SourcePath {
553 fn hash<H: Hasher>(&self, state: &mut H) {
554 self.key.hash(state);
555 }
556}
557
558impl fmt::Display for SourcePath {
559 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
560 f.write_str(&self.text)
561 }
562}
563
564#[derive(Clone, Debug, PartialEq, Eq)]
572#[non_exhaustive]
573pub enum ListError {
574 NotFound,
576 PermissionDenied,
578 AuthRequired(SharedString),
581 Disconnected(SharedString),
584 Other(SharedString),
586}
587
588impl ListError {
589 pub fn message(&self) -> SharedString {
591 match self {
592 Self::NotFound => "That folder does not exist".into(),
593 Self::PermissionDenied => "You cannot open that folder".into(),
594 Self::AuthRequired(message) | Self::Disconnected(message) | Self::Other(message) => {
595 message.clone()
596 }
597 }
598 }
599
600 pub fn is_retryable(&self) -> bool {
603 matches!(self, Self::Disconnected(_) | Self::Other(_))
604 }
605
606 pub fn needs_sign_in(&self) -> bool {
609 matches!(self, Self::AuthRequired(_))
610 }
611}
612
613impl From<std::io::Error> for ListError {
614 fn from(error: std::io::Error) -> Self {
615 use std::io::ErrorKind;
616 match error.kind() {
617 ErrorKind::NotFound => Self::NotFound,
618 ErrorKind::PermissionDenied => Self::PermissionDenied,
619 ErrorKind::ConnectionAborted
620 | ErrorKind::ConnectionReset
621 | ErrorKind::ConnectionRefused
622 | ErrorKind::NotConnected
623 | ErrorKind::BrokenPipe
624 | ErrorKind::TimedOut => Self::Disconnected(error.to_string().into()),
625 _ => Self::Other(error.to_string().into()),
626 }
627 }
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633
634 fn no_env(_: &str) -> Option<String> {
635 None
636 }
637
638 fn pasted(style: PathStyle, text: &str) -> String {
639 style.normalize_pasted(text, &no_env)
640 }
641
642 #[test]
643 fn a_windows_path_takes_either_separator_and_writes_its_own() {
644 let windows = PathStyle::windows();
645 for text in [
646 "C:/Users/me",
647 "C:\\Users\\me",
648 "C:/Users\\me/",
649 "C:\\Users/me\\",
650 ] {
651 assert_eq!(windows.path(text).as_str(), "C:\\Users\\me", "{text}");
652 }
653 let slashes = PathStyle::windows().with_display_separator('/');
654 assert_eq!(slashes.path("C:\\Users\\me").as_str(), "C:/Users/me");
655 }
656
657 #[test]
658 fn a_posix_path_keeps_a_backslash_as_a_name_character() {
659 let posix = PathStyle::posix();
660 assert_eq!(posix.path("/home/a\\b/").as_str(), "/home/a\\b");
661 assert_eq!(posix.parse("/home/a\\b").parts, ["home", "a\\b"]);
662 assert!(!posix.is_separator('\\'));
663 }
664
665 #[test]
666 fn paths_that_name_one_place_are_equal_in_their_style() {
667 let windows = PathStyle::windows();
668 assert_eq!(windows.path("C:/Users/Me"), windows.path("c:\\users\\me\\"));
669 assert_ne!(windows.path("C:/Users/Me"), windows.path("D:/Users/Me"));
670 let posix = PathStyle::posix();
671 assert_ne!(posix.path("/Users/Me"), posix.path("/users/me"));
672 assert_eq!(
673 PathStyle::posix().with_case_sensitive(false).path("/A"),
674 PathStyle::posix().with_case_sensitive(false).path("/a")
675 );
676 }
677
678 #[test]
679 fn dots_fold_and_never_climb_above_the_root() {
680 let posix = PathStyle::posix();
681 assert_eq!(posix.path("/home/me/..").as_str(), "/home");
682 assert_eq!(posix.path("/home/./me/../you").as_str(), "/home/you");
683 assert_eq!(posix.path("/../..").as_str(), "/");
684 assert_eq!(posix.path("/a/../../b").as_str(), "/b");
685 let windows = PathStyle::windows();
686 assert_eq!(windows.path("C:\\a\\..\\b").as_str(), "C:\\b");
687 assert_eq!(windows.path("C:/a/../../..").as_str(), "C:\\");
688 assert_eq!(
689 windows.path("\\\\srv\\share\\..\\x").as_str(),
690 "\\\\srv\\share\\x"
691 );
692 assert_eq!(posix.path("/home/me/..").join("..").as_str(), "/");
693 assert_eq!(posix.path("/a").join("..").as_str(), "/");
694 }
695
696 #[test]
697 fn a_partial_unc_path_is_a_root_not_a_rooted_name() {
698 let windows = PathStyle::windows();
699 let server = windows.path("\\\\srv\\");
700 assert_eq!(server.as_str(), "\\\\srv\\");
701 assert!(server.is_root());
702 assert_eq!(server.parent(), None);
703 assert_eq!(windows.path("\\\\").parent(), None);
704 assert_eq!(windows.path("//srv").as_str(), "\\\\srv\\");
705 }
706
707 #[test]
708 fn a_quoted_file_url_is_unquoted_first_and_a_control_character_refuses_it() {
709 let posix = PathStyle::posix();
710 let windows = PathStyle::windows();
711 assert_eq!(pasted(posix, "\"file:///home/me\""), "/home/me");
712 assert_eq!(pasted(windows, "'file:///C:/Users/me'"), "C:\\Users\\me");
713 assert_eq!(pasted(posix, "file:///home/a%00b"), "file:///home/a%00b");
714 assert_eq!(pasted(windows, "file:///C:/a%0Ab"), "file:///C:/a%0Ab");
715 }
716
717 #[test]
718 fn roots_have_no_parent_and_parents_keep_the_root() {
719 let windows = PathStyle::windows();
720 assert_eq!(
721 windows.path("C:\\Users\\me").parent().unwrap().as_str(),
722 "C:\\Users"
723 );
724 assert_eq!(windows.path("C:\\Users").parent().unwrap().as_str(), "C:\\");
725 assert_eq!(windows.path("C:\\").parent(), None);
726 assert_eq!(
727 windows
728 .path("\\\\srv\\share\\dir")
729 .parent()
730 .unwrap()
731 .as_str(),
732 "\\\\srv\\share\\"
733 );
734 assert_eq!(
735 windows.path("\\\\srv\\share").parent(),
736 None,
737 "a share root stays"
738 );
739 let posix = PathStyle::posix();
740 assert_eq!(posix.path("/a/b").parent().unwrap().as_str(), "/a");
741 assert_eq!(posix.path("/a").parent().unwrap().as_str(), "/");
742 assert_eq!(posix.path("/").parent(), None);
743 }
744
745 #[test]
746 fn a_computer_root_sits_above_the_drives_only_when_the_source_has_one() {
747 let pc = PathStyle::windows().with_computer_root(true);
748 let drives = pc.path("C:\\").parent().unwrap();
749 assert_eq!(drives.as_str(), "\\");
750 assert!(drives.is_root());
751 assert_eq!(drives.parent(), None);
752 assert_eq!(pc.path("\\\\srv\\share").parent(), None);
753 assert_eq!(PathStyle::windows().path("C:\\").parent(), None);
754 }
755
756 #[test]
757 fn join_and_file_name_follow_the_style() {
758 let windows = PathStyle::windows();
759 assert_eq!(windows.path("C:\\").join("Users").as_str(), "C:\\Users");
760 assert_eq!(windows.path("C:\\Users").join("me").file_name(), Some("me"));
761 assert_eq!(windows.path("C:\\").file_name(), None);
762 assert_eq!(
763 PathStyle::posix().path("/").join(".config").as_str(),
764 "/.config"
765 );
766 }
767
768 #[test]
769 fn a_pasted_windows_path_is_cleaned_and_written_in_the_style() {
770 let windows = PathStyle::windows();
771 assert_eq!(pasted(windows, "\"C:\\Users\\me\""), "C:\\Users\\me");
772 assert_eq!(pasted(windows, " C:/Users/me "), "C:\\Users\\me");
773 assert_eq!(pasted(windows, "C:/Users\\me/docs"), "C:\\Users\\me\\docs");
774 assert_eq!(pasted(windows, "C:"), "C:\\", "a lone drive is its root");
775 assert_eq!(pasted(windows, "~/code"), "~\\code");
776 assert_eq!(
777 pasted(windows, "\\\\srv\\share\\dir"),
778 "\\\\srv\\share\\dir"
779 );
780 assert_eq!(pasted(windows, "//srv/share/dir"), "\\\\srv\\share\\dir");
781 assert_eq!(
782 pasted(
783 PathStyle::windows().with_display_separator('/'),
784 "C:\\Users\\me"
785 ),
786 "C:/Users/me"
787 );
788 }
789
790 #[test]
791 fn variables_expand_only_through_the_sources_lookup() {
792 let windows = PathStyle::windows();
793 let env = |name: &str| (name == "USERPROFILE").then(|| "C:\\Users\\me".to_string());
794 assert_eq!(
795 windows.normalize_pasted("%USERPROFILE%\\code", &env),
796 "C:\\Users\\me\\code"
797 );
798 assert_eq!(
799 windows.normalize_pasted("%OTHER%\\code", &env),
800 "%OTHER%\\code"
801 );
802 assert_eq!(
803 pasted(windows, "%USERPROFILE%\\code"),
804 "%USERPROFILE%\\code"
805 );
806 }
807
808 #[test]
809 fn a_file_url_becomes_a_path_in_the_style() {
810 let windows = PathStyle::windows();
811 assert_eq!(
812 pasted(windows, "file:///C:/Users/me%20x"),
813 "C:\\Users\\me x"
814 );
815 assert_eq!(
816 pasted(windows, "file://srv/share/dir"),
817 "\\\\srv\\share\\dir"
818 );
819 let posix = PathStyle::posix();
820 assert_eq!(pasted(posix, "file:///home/me"), "/home/me");
821 assert_eq!(pasted(posix, "file:///home/me%20x/a%2Fb"), "/home/me x/a/b");
822 }
823
824 #[test]
825 fn a_pasted_path_in_a_posix_style_is_left_alone() {
826 let posix = PathStyle::posix();
827 assert_eq!(pasted(posix, "C:\\Users\\me"), "C:\\Users\\me");
828 assert_eq!(pasted(posix, "\"/home/me\""), "\"/home/me\"");
829 assert_eq!(pasted(posix, "/home/a\\b"), "/home/a\\b");
830 }
831
832 #[test]
833 fn io_errors_map_to_the_matching_failure() {
834 use std::io::{Error, ErrorKind};
835 assert_eq!(
836 ListError::from(Error::from(ErrorKind::NotFound)),
837 ListError::NotFound
838 );
839 assert_eq!(
840 ListError::from(Error::from(ErrorKind::PermissionDenied)),
841 ListError::PermissionDenied
842 );
843 assert!(matches!(
844 ListError::from(Error::from(ErrorKind::ConnectionReset)),
845 ListError::Disconnected(_)
846 ));
847 assert!(ListError::from(Error::other("x")).is_retryable());
848 assert!(!ListError::NotFound.is_retryable());
849 assert!(ListError::AuthRequired("Sign in".into()).needs_sign_in());
850 }
851
852 #[test]
853 fn names_sort_without_case_and_fold_by_the_styles_case_rule() {
854 let windows = PathStyle::windows();
855 let mut names = vec!["b", "A", "a", "B"];
856 names.sort_by(|a, b| windows.compare_names(a, b));
857 assert_eq!(names, ["A", "a", "B", "b"]);
858 names.reverse();
859 names.sort_by(|a, b| PathStyle::posix().compare_names(a, b));
860 assert_eq!(names, ["A", "a", "B", "b"]);
861 assert_eq!(windows.fold("ABC"), "abc");
862 assert_eq!(PathStyle::posix().fold("ABC"), "ABC");
863 }
864}