Skip to main content

picomatch_rs/
matcher.rs

1use fancy_regex::Regex;
2
3use crate::{make_re, CompileOptions};
4
5#[derive(Debug)]
6pub enum MatchError {
7    EmptyPattern,
8    UnsupportedPattern(String),
9    InvalidRegex(String),
10}
11
12pub struct Matcher {
13    glob: String,
14    options: CompileOptions,
15    regex: Regex,
16}
17
18impl Matcher {
19    pub fn is_match(&self, input: &str) -> Result<bool, MatchError> {
20        if input.is_empty() {
21            return Ok(false);
22        }
23
24        let output = normalize_input(input, &self.options);
25
26        if input == self.glob || output == self.glob {
27            return Ok(true);
28        }
29
30        let candidate = if self.options.match_base || self.options.basename {
31            basename(input, self.options.windows)
32        } else {
33            output
34        };
35
36        self.regex
37            .is_match(&candidate)
38            .map_err(|err| MatchError::InvalidRegex(err.to_string()))
39    }
40}
41
42pub fn compile_matcher(pattern: &str, options: &CompileOptions) -> Result<Matcher, MatchError> {
43    if pattern.is_empty() {
44        return Err(MatchError::EmptyPattern);
45    }
46
47    let descriptor = make_re(pattern, options, false)
48        .ok_or_else(|| MatchError::UnsupportedPattern(pattern.to_string()))?;
49    let regex = Regex::new(&regex_source(&descriptor.source, &descriptor.flags))
50        .map_err(|err| MatchError::InvalidRegex(err.to_string()))?;
51
52    Ok(Matcher {
53        glob: pattern.to_string(),
54        options: options.clone(),
55        regex,
56    })
57}
58
59fn regex_source(source: &str, flags: &str) -> String {
60    if flags.is_empty() {
61        return source.to_string();
62    }
63
64    let mut inline = String::new();
65    if flags.contains('i') {
66        inline.push('i');
67    }
68
69    if inline.is_empty() {
70        source.to_string()
71    } else {
72        format!("(?{inline}){source}")
73    }
74}
75
76pub fn is_match(input: &str, pattern: &str, options: &CompileOptions) -> Result<bool, MatchError> {
77    compile_matcher(pattern, options)?.is_match(input)
78}
79
80pub fn is_match_any<'a, I>(
81    input: &str,
82    patterns: I,
83    options: &CompileOptions,
84) -> Result<bool, MatchError>
85where
86    I: IntoIterator<Item = &'a str>,
87{
88    for pattern in patterns {
89        if is_match(input, pattern, options)? {
90            return Ok(true);
91        }
92    }
93
94    Ok(false)
95}
96
97fn normalize_input(input: &str, options: &CompileOptions) -> String {
98    let _ = options;
99    input.to_string()
100}
101
102fn basename(input: &str, windows: bool) -> String {
103    let parts: Vec<&str> = if windows {
104        input.split(['/', '\\']).collect()
105    } else {
106        input.split('/').collect()
107    };
108
109    match parts.last().copied() {
110        Some("") => parts
111            .get(parts.len().saturating_sub(2))
112            .copied()
113            .unwrap_or_default()
114            .to_string(),
115        Some(value) => value.to_string(),
116        None => String::new(),
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::{basename, is_match, is_match_any};
123    use crate::CompileOptions;
124
125    #[test]
126    fn matches_windows_literals() {
127        let options = CompileOptions {
128            windows: true,
129            ..CompileOptions::default()
130        };
131
132        assert!(is_match("aaa\\bbb", "aaa/bbb", &options).unwrap());
133        assert!(is_match("aaa/bbb", "aaa/bbb", &options).unwrap());
134    }
135
136    #[test]
137    fn matches_against_any_pattern() {
138        assert!(is_match_any("ab", ["*b", "foo"], &CompileOptions::default()).unwrap());
139        assert!(!is_match_any("ab", ["foo", "bar"], &CompileOptions::default()).unwrap());
140    }
141
142    #[test]
143    fn extracts_basename() {
144        assert_eq!(basename("a/b/c.md", false), "c.md");
145        assert_eq!(basename("a\\b\\c.md", true), "c.md");
146        assert_eq!(basename("a/b/", false), "b");
147    }
148
149    // Covers the "should basename paths" test from test/regex-features.js
150    // (tests/regex_features.rs references this via comment since basename is private)
151    #[test]
152    fn should_basename_paths() {
153        assert_eq!(basename("/a/b/c", false), "c");
154        assert_eq!(basename("/a/b/c/", false), "c");
155        assert_eq!(basename("/a\\b/c", true), "c");
156        assert_eq!(basename("/a\\b/c\\", true), "c");
157        assert_eq!(basename("\\a/b\\c", true), "c");
158        assert_eq!(basename("\\a/b\\c/", true), "c");
159    }
160
161    #[test]
162    fn honors_case_insensitive_flag() {
163        let options = CompileOptions {
164            flags: "i".to_string(),
165            ..CompileOptions::default()
166        };
167
168        assert!(is_match("A/B/C.MD", "a/b/*.md", &options).unwrap());
169    }
170}