diff --git a/.github/DISCUSSION_TEMPLATE/1-q-a.yml b/.github/DISCUSSION_TEMPLATE/1-q-a.yml index 580387fc2..6d8186154 100644 --- a/.github/DISCUSSION_TEMPLATE/1-q-a.yml +++ b/.github/DISCUSSION_TEMPLATE/1-q-a.yml @@ -48,7 +48,7 @@ body: id: validations attributes: label: Validations - description: Before submitting the issue, please make sure you have completed the following + description: Before submitting the post, please make sure you have completed the following options: - label: I have searched the existing discussions/issues required: true diff --git a/yazi-config/src/pattern.rs b/yazi-config/src/pattern.rs index 44ca368cb..37be5c6af 100644 --- a/yazi-config/src/pattern.rs +++ b/yazi-config/src/pattern.rs @@ -9,6 +9,8 @@ pub struct Pattern { inner: globset::GlobMatcher, is_dir: bool, is_star: bool, + #[cfg(windows)] + sep_lit: bool, } impl Pattern { @@ -19,7 +21,20 @@ impl Pattern { #[inline] pub fn match_path(&self, path: impl AsRef, is_dir: bool) -> bool { - is_dir == self.is_dir && (self.is_star || self.inner.is_match(path)) + if is_dir != self.is_dir { + return false; + } else if self.is_star { + return true; + } + + #[cfg(windows)] + let path = if self.sep_lit { + yazi_shared::fs::backslash_to_slash(path.as_ref()) + } else { + std::borrow::Cow::Borrowed(path.as_ref()) + }; + + self.inner.is_match(path) } #[inline] @@ -35,16 +50,23 @@ impl FromStr for Pattern { fn from_str(s: &str) -> Result { let a = s.trim_start_matches("\\s"); let b = a.trim_end_matches('/'); + let sep_lit = b.contains('/'); let inner = GlobBuilder::new(b) .case_insensitive(a.len() == s.len()) - .literal_separator(true) + .literal_separator(sep_lit) .backslash_escape(false) .empty_alternates(true) .build()? .compile_matcher(); - Ok(Self { inner, is_dir: b.len() < a.len(), is_star: b == "*" }) + Ok(Self { + inner, + is_dir: b.len() < a.len(), + is_star: b == "*", + #[cfg(windows)] + sep_lit, + }) } } @@ -53,3 +75,84 @@ impl TryFrom for Pattern { fn try_from(s: String) -> Result { Self::from_str(s.as_str()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn matches(glob: &str, path: &str) -> bool { + Pattern::from_str(glob).unwrap().match_path(path, false) + } + + #[cfg(unix)] + #[test] + fn test_unix() { + // Wildcard + assert!(matches("*", "/foo")); + assert!(matches("*", "/foo/bar")); + assert!(matches("**", "foo")); + assert!(matches("**", "/foo")); + assert!(matches("**", "/foo/bar")); + + // Filename + assert!(matches("*.md", "foo.md")); + assert!(matches("*.md", "/foo.md")); + assert!(matches("*.md", "/foo/bar.md")); + + // 1-star + assert!(matches("/*", "/foo")); + assert!(matches("/*/*.md", "/foo/bar.md")); + + // 2-star + assert!(matches("/**", "/foo")); + assert!(matches("/**", "/foo/bar")); + assert!(matches("**/**", "/foo")); + assert!(matches("**/**", "/foo/bar")); + assert!(matches("/**/*", "/foo")); + assert!(matches("/**/*", "/foo/bar")); + + // Failures + assert!(!matches("/*/*", "/foo")); + assert!(!matches("/*/*.md", "/foo.md")); + assert!(!matches("/*", "/foo/bar")); + assert!(!matches("/*.md", "/foo/bar.md")); + } + + #[cfg(windows)] + #[test] + fn test_windows() { + // Wildcard + assert!(matches("*", r#"C:\foo"#)); + assert!(matches("*", r#"C:\foo\bar"#)); + assert!(matches("**", r#"foo"#)); + assert!(matches("**", r#"C:\foo"#)); + assert!(matches("**", r#"C:\foo\bar"#)); + + // Filename + assert!(matches("*.md", r#"foo.md"#)); + assert!(matches("*.md", r#"C:\foo.md"#)); + assert!(matches("*.md", r#"C:\foo\bar.md"#)); + + // 1-star + assert!(matches(r#"C:/*"#, r#"C:\foo"#)); + assert!(matches(r#"C:/*/*.md"#, r#"C:\foo\bar.md"#)); + + // 2-star + assert!(matches(r#"C:/**"#, r#"C:\foo"#)); + assert!(matches(r#"C:/**"#, r#"C:\foo\bar"#)); + assert!(matches(r#"**/**"#, r#"C:\foo"#)); + assert!(matches(r#"**/**"#, r#"C:\foo\bar"#)); + assert!(matches(r#"C:/**/*"#, r#"C:\foo"#)); + assert!(matches(r#"C:/**/*"#, r#"C:\foo\bar"#)); + + // Drive letter + assert!(matches(r#"*:/*"#, r#"C:\foo"#)); + assert!(matches(r#"*:/**/*.md"#, r#"C:\foo\bar.md"#)); + + // Failures + assert!(!matches(r#"C:/*/*"#, r#"C:\foo"#)); + assert!(!matches(r#"C:/*/*.md"#, r#"C:\foo.md"#)); + assert!(!matches(r#"C:/*"#, r#"C:\foo\bar"#)); + assert!(!matches(r#"C:/*.md"#, r#"C:\foo\bar.md"#)); + } +} diff --git a/yazi-shared/src/fs/path.rs b/yazi-shared/src/fs/path.rs index 48a7d7cc4..a9681b5bc 100644 --- a/yazi-shared/src/fs/path.rs +++ b/yazi-shared/src/fs/path.rs @@ -170,6 +170,27 @@ pub fn path_relative_to<'a>(path: &'a Path, root: &Path) -> Cow<'a, Path> { Cow::from(buf) } +#[cfg(windows)] +pub fn backslash_to_slash(p: &Path) -> Cow { + let bytes = p.as_os_str().as_encoded_bytes(); + + // Fast path to skip if there are no backslashes + let skip_len = bytes.iter().take_while(|&&b| b != b'\\').count(); + if skip_len >= bytes.len() { + return Cow::Borrowed(p); + } + + let (skip, rest) = bytes.split_at(skip_len); + let mut out = Vec::new(); + out.try_reserve_exact(bytes.len()).unwrap_or_else(|_| panic!()); + out.extend(skip); + + for &b in rest { + out.push(if b == b'\\' { b'/' } else { b }); + } + Cow::Owned(PathBuf::from(unsafe { OsString::from_encoded_bytes_unchecked(out) })) +} + #[cfg(test)] mod tests { use std::{borrow::Cow, path::Path};