use std::{fmt::Debug, str::FromStr}; use anyhow::{Result, bail}; use globset::{Candidate, GlobBuilder}; use serde_with::DeserializeFromStr; use strum::EnumIs; use yazi_shared::{auth::Auth, url::AsUrl}; use crate::Mixable; #[derive(Clone, DeserializeFromStr)] pub struct Pattern { inner: globset::GlobMatcher, scheme: PatternScheme, pub is_dir: bool, is_star: bool, #[cfg(windows)] sep_lit: bool, } impl Debug for Pattern { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Pattern") .field("regex", &self.inner.glob().regex()) .field("scheme", &self.scheme) .field("is_dir", &self.is_dir) .field("is_star", &self.is_star) .finish() } } impl Pattern { pub fn match_url(&self, url: impl AsUrl, is_dir: bool) -> bool { let url = url.as_url(); if is_dir != self.is_dir { return false; } else if !self.scheme.matches(url.auth()) { return false; } else if self.is_star { return true; } #[cfg(unix)] { self.inner.is_match_candidate(&Candidate::from_bytes(url.loc().encoded_bytes())) } #[cfg(windows)] if self.sep_lit { use yazi_shared::strand::{AsStrand, StrandLike}; self.inner.is_match_candidate(&Candidate::from_bytes( url.loc().as_strand().backslash_to_slash().encoded_bytes(), )) } else { self.inner.is_match_candidate(&Candidate::from_bytes(url.loc().encoded_bytes())) } } pub fn match_mime(&self, mime: impl AsRef) -> bool { self.is_star || (!mime.as_ref().is_empty() && self.inner.is_match(mime.as_ref())) } } impl FromStr for Pattern { type Err = anyhow::Error; fn from_str(s: &str) -> Result { // Trim leading case-sensitive indicator let a = s.trim_start_matches(r"\s"); // Parse the URL scheme if present let (scheme, skip) = PatternScheme::parse(a)?; let b = &a[skip..]; // Trim the ending slash which indicates a directory let c = b.trim_end_matches('/'); // Check whether it's a filename pattern or a full path pattern let sep_lit = c.contains('/'); let inner = GlobBuilder::new(c) .case_insensitive(a.len() == s.len()) .literal_separator(sep_lit) .backslash_escape(false) .empty_alternates(true) .build()? .compile_matcher(); Ok(Self { inner, scheme, is_dir: c.len() < b.len(), is_star: c == "*", #[cfg(windows)] sep_lit, }) } } impl Mixable for Pattern { fn any_file(&self) -> bool { self.is_star && !self.is_dir && self.scheme.is_any() } fn any_dir(&self) -> bool { self.is_star && self.is_dir && self.scheme.is_any() } } // --- Scheme #[derive(Clone, Debug, EnumIs)] enum PatternScheme { Any, Local, Remote, Virtual, Custom(String), } impl PatternScheme { fn parse(s: &str) -> Result<(Self, usize)> { let Some((s, _)) = s.split_once("://") else { return Ok((Self::Any, 0)); }; let scheme = match s { "*" => Self::Any, "local" => Self::Local, "remote" => Self::Remote, "virtual" => Self::Virtual, "" => bail!("Invalid URL pattern: scheme is empty"), other => Self::Custom(other.to_owned()), }; Ok((scheme, s.len() + 3)) } #[inline] fn matches(&self, auth: &Auth) -> bool { match self { Self::Any => true, Self::Local => auth.kind.is_local(), Self::Remote => auth.kind.is_remote(), Self::Virtual => auth.kind.is_virtual(), Self::Custom(name) => auth.scheme == name, } } } // --- Tests #[cfg(test)] mod tests { use yazi_shared::url::UrlCow; use super::*; fn matches(glob: &str, url: &str) -> bool { Pattern::from_str(glob).unwrap().match_url(UrlCow::try_from(url).unwrap(), false) } #[cfg(unix)] #[test] fn test_unix() { yazi_shared::init_tests(); // 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() { yazi_shared::init_tests(); // 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"#)); } }