Skip to main content

sphinx_ultra/
matching.rs

1//! Pattern matching utilities for file filtering.
2//!
3//! This module provides glob-style pattern matching compatible with Sphinx's
4//! include_patterns and exclude_patterns functionality. It implements the same
5//! pattern translation and matching logic as Sphinx's util/matching.py.
6
7use regex::Regex;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::Mutex;
11
12lazy_static::lazy_static! {
13    /// Cache for compiled regex patterns
14    static ref PATTERN_CACHE: Mutex<HashMap<String, Regex>> = Mutex::new(HashMap::new());
15}
16
17/// Translates shell-style glob pattern to regex pattern.
18///
19/// This implements the same logic as Sphinx's _translate_pattern function:
20/// - ** matches everything, including directory separators
21/// - * matches everything except a directory separator
22/// - ? matches any single character except a directory separator
23/// - [seq] matches any character in seq
24/// - [!seq] matches any character not in seq (never a directory separator)
25///
26/// Based on Python's fnmatch.translate but with modifications for path handling.
27pub fn translate_pattern(pattern: &str) -> String {
28    let mut regex_pattern = String::new();
29    let mut i = 0;
30    let chars: Vec<char> = pattern.chars().collect();
31    let n = chars.len();
32
33    while i < n {
34        let c = chars[i];
35        match c {
36            '*' => {
37                if i + 1 < n && chars[i + 1] == '*' {
38                    // ** matches everything, including '/'. Sphinx has no
39                    // directory-boundary special case: a following '/' is an
40                    // ordinary literal, so 'foo/**/bar' requires at least one
41                    // intermediate path component.
42                    regex_pattern.push_str(".*");
43                    i += 2;
44                } else {
45                    // Single * - matches everything except directory separator
46                    regex_pattern.push_str("[^/]*");
47                    i += 1;
48                }
49            }
50            '?' => {
51                // ? matches any single character except directory separator
52                regex_pattern.push_str("[^/]");
53                i += 1;
54            }
55            '[' => {
56                // Character class: scan for the closing ']' like Sphinx,
57                // skipping a leading '!' and then a leading ']' (a ']' in
58                // first position is a literal member)
59                let mut j = i + 1;
60                if j < n && chars[j] == '!' {
61                    j += 1;
62                }
63                if j < n && chars[j] == ']' {
64                    j += 1;
65                }
66                while j < n && chars[j] != ']' {
67                    j += 1;
68                }
69                if j >= n {
70                    // No closing ], treat [ as literal
71                    regex_pattern.push_str("\\[");
72                    i += 1;
73                } else {
74                    // Valid character class.
75                    // Sphinx semantics (sphinx/util/matching.py): backslashes
76                    // in the class body are doubled (so '[\d]' is a literal
77                    // backslash or 'd', never the digit class), only '[!...]'
78                    // negates and never matches '/', and a leading '^' is an
79                    // escaped literal character.
80                    let body: String = chars[i + 1..j].iter().collect();
81                    let mut stuff = body.replace('\\', "\\\\");
82                    if let Some(rest) = stuff.strip_prefix('!') {
83                        stuff = format!("^/{rest}");
84                    } else if stuff.starts_with('^') {
85                        stuff.insert(0, '\\');
86                    }
87
88                    regex_pattern.push('[');
89                    regex_pattern.push_str(&stuff);
90                    regex_pattern.push(']');
91                    i = j + 1;
92                }
93            }
94            _ => {
95                // Escape regex special characters
96                match c {
97                    '\\' | '.' | '^' | '$' | '+' | '{' | '}' | '|' | '(' | ')' => {
98                        regex_pattern.push('\\');
99                        regex_pattern.push(c);
100                    }
101                    _ => {
102                        regex_pattern.push(c);
103                    }
104                }
105                i += 1;
106            }
107        }
108    }
109
110    // Anchor the pattern to match the entire string
111    format!("^{}$", regex_pattern)
112}
113
114/// Compiles a pattern into a regex, using cache for performance.
115pub fn compile_pattern(pattern: &str) -> Result<Regex, regex::Error> {
116    let mut cache = PATTERN_CACHE.lock().unwrap();
117
118    if let Some(regex) = cache.get(pattern) {
119        return Ok(regex.clone());
120    }
121
122    let regex_pattern = translate_pattern(pattern);
123    let regex = Regex::new(&regex_pattern)?;
124    cache.insert(pattern.to_string(), regex.clone());
125
126    Ok(regex)
127}
128
129/// Tests if a name matches a glob pattern.
130pub fn pattern_match(name: &str, pattern: &str) -> Result<bool, regex::Error> {
131    let regex = compile_pattern(pattern)?;
132    Ok(regex.is_match(name))
133}
134
135/// Filters a list of names by a glob pattern.
136pub fn pattern_filter(names: &[String], pattern: &str) -> Result<Vec<String>, regex::Error> {
137    let regex = compile_pattern(pattern)?;
138    Ok(names
139        .iter()
140        .filter(|name| regex.is_match(name))
141        .cloned()
142        .collect())
143}
144
145/// Normalizes a path to use forward slashes for pattern matching.
146/// This ensures consistent behavior across platforms.
147pub fn normalize_path(path: &Path) -> String {
148    path.to_string_lossy().replace('\\', "/")
149}
150
151/// Gets matching files from a directory using include and exclude patterns.
152///
153/// This function implements the same logic as Sphinx's get_matching_files:
154/// - Only files matching some pattern in include_patterns are included
155/// - Exclusions from exclude_patterns take priority over inclusions
156/// - The default include pattern is "**" (all files)
157/// - The default exclude pattern is empty (exclude nothing)
158pub fn get_matching_files<P: AsRef<Path>>(
159    dirname: P,
160    include_patterns: &[String],
161    exclude_patterns: &[String],
162) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
163    let dirname = dirname.as_ref().canonicalize()?;
164    let include_patterns = if include_patterns.is_empty() {
165        vec!["**".to_string()]
166    } else {
167        include_patterns.to_vec()
168    };
169
170    // Compile all patterns
171    let mut include_regexes = Vec::new();
172    for pattern in &include_patterns {
173        include_regexes.push(compile_pattern(pattern)?);
174    }
175
176    let mut exclude_regexes = Vec::new();
177    for pattern in exclude_patterns {
178        exclude_regexes.push(compile_pattern(pattern)?);
179    }
180
181    let mut matched_files = Vec::new();
182
183    // Walk the directory recursively
184    fn walk_dir(
185        dir: &Path,
186        base_dir: &Path,
187        include_regexes: &[Regex],
188        exclude_regexes: &[Regex],
189        matched_files: &mut Vec<PathBuf>,
190    ) -> Result<(), Box<dyn std::error::Error>> {
191        if !dir.is_dir() {
192            return Ok(());
193        }
194
195        for entry in std::fs::read_dir(dir)? {
196            let entry = entry?;
197            let path = entry.path();
198
199            if path.is_dir() {
200                // Prune excluded directories like Sphinx's get_matching_files,
201                // which filters os.walk dirs by matching the bare relative path
202                // against the exclude matchers (a trailing-slash pattern like
203                // "_build/" is inert in Sphinx and must stay inert here).
204                let relative_path = path.strip_prefix(base_dir)?;
205                let normalized_path = normalize_path(relative_path);
206
207                let excluded = exclude_regexes
208                    .iter()
209                    .any(|regex| regex.is_match(&normalized_path));
210
211                if !excluded {
212                    walk_dir(
213                        &path,
214                        base_dir,
215                        include_regexes,
216                        exclude_regexes,
217                        matched_files,
218                    )?;
219                }
220            } else if path.is_file() {
221                // Get relative path from base directory
222                let relative_path = path.strip_prefix(base_dir)?;
223                let normalized_path = normalize_path(relative_path);
224
225                // Check if file matches any include pattern
226                let included = include_regexes
227                    .iter()
228                    .any(|regex| regex.is_match(&normalized_path));
229
230                if included {
231                    // Check if file matches any exclude pattern
232                    let excluded = exclude_regexes
233                        .iter()
234                        .any(|regex| regex.is_match(&normalized_path));
235
236                    if !excluded {
237                        matched_files.push(path);
238                    }
239                }
240            }
241        }
242
243        Ok(())
244    }
245
246    walk_dir(
247        &dirname,
248        &dirname,
249        &include_regexes,
250        &exclude_regexes,
251        &mut matched_files,
252    )?;
253
254    // Sort for consistent results
255    matched_files.sort();
256
257    Ok(matched_files)
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use std::fs;
264    use tempfile::TempDir;
265
266    #[test]
267    fn test_translate_pattern() {
268        // Basic patterns
269        assert_eq!(translate_pattern("*.rst"), "^[^/]*\\.rst$");
270        assert_eq!(translate_pattern("**"), "^.*$");
271        assert_eq!(translate_pattern("**/index.rst"), "^.*/index\\.rst$");
272        assert_eq!(translate_pattern("docs/*.rst"), "^docs/[^/]*\\.rst$");
273        assert_eq!(translate_pattern("***"), "^.*[^/]*$");
274
275        // Character classes
276        assert_eq!(translate_pattern("[abc].rst"), "^[abc]\\.rst$");
277        assert_eq!(translate_pattern("[!abc].rst"), "^[^/abc]\\.rst$");
278        assert_eq!(translate_pattern("[^abc].rst"), "^[\\^abc]\\.rst$");
279        assert_eq!(translate_pattern("[\\d]x"), "^[\\\\d]x$");
280    }
281
282    #[test]
283    fn test_double_star_matches_sphinx() {
284        // Sphinx translates '**' to plain '.*' with no directory-boundary
285        // special case, so 'foo/**/bar' requires at least one intermediate
286        // component and '**/x' never matches a top-level 'x'.
287        assert!(!pattern_match("foo/bar", "foo/**/bar").unwrap());
288        assert!(pattern_match("foo/x/bar", "foo/**/bar").unwrap());
289        assert!(pattern_match("foo/x/y/bar", "foo/**/bar").unwrap());
290        assert!(!pattern_match("bar", "**/bar").unwrap());
291        assert!(pattern_match("x/bar", "**/bar").unwrap());
292        assert!(!pattern_match("foo", "foo/**").unwrap());
293        assert!(pattern_match("foo/x/y", "foo/**").unwrap());
294        assert!(pattern_match("foo/bar.rst", "**").unwrap());
295        assert!(!pattern_match("foo/bar", "*").unwrap());
296        assert!(pattern_match("foo", "*").unwrap());
297        assert!(!pattern_match("a/b.rst", "*.rst").unwrap());
298        assert!(pattern_match("a/b.rst", "**.rst").unwrap());
299        assert!(pattern_match("ab", "a**b").unwrap());
300        assert!(pattern_match("axx/yyb", "a**b").unwrap());
301    }
302
303    #[test]
304    fn test_character_class_matches_sphinx() {
305        assert!(pattern_match("bx", "[!a]x").unwrap());
306        assert!(!pattern_match("/x", "[!a]x").unwrap());
307        // '[^...]' does not negate: the caret is an escaped literal member
308        assert!(pattern_match("^x", "[^a]x").unwrap());
309        assert!(pattern_match("ax", "[^a]x").unwrap());
310        assert!(!pattern_match("bx", "[^a]x").unwrap());
311        // Sphinx doubles in-class backslashes, so '[\d]' is a class of a
312        // literal backslash and 'd', never the regex digit class
313        assert!(pattern_match("dx", "[\\d]x").unwrap());
314        assert!(!pattern_match("5x", "[\\d]x").unwrap());
315        assert!(pattern_match("\\x", "[\\d]x").unwrap());
316        // A ']' first in the class body is a literal member, exactly as
317        // Python's re parses Sphinx's output
318        assert!(pattern_match("]", "[]a]").unwrap());
319        assert!(pattern_match("a", "[]a]").unwrap());
320        // '[!]a]' becomes '[^/]a]': the ']' closes the class early,
321        // leaving a literal 'a]' tail
322        assert!(pattern_match("]a]", "[!]a]").unwrap());
323        assert!(pattern_match("xa]", "[!]a]").unwrap());
324    }
325
326    #[test]
327    fn test_pattern_match() {
328        // Test basic patterns
329        assert!(pattern_match("index.rst", "*.rst").unwrap());
330        assert!(pattern_match("docs/index.rst", "**/*.rst").unwrap());
331        assert!(pattern_match("docs/api/module.rst", "**/api/*.rst").unwrap());
332
333        // Test exclusions
334        assert!(!pattern_match("_build/index.html", "*.rst").unwrap());
335        assert!(pattern_match("_build/index.html", "**").unwrap());
336
337        // Test character classes
338        assert!(pattern_match("a.rst", "[abc].rst").unwrap());
339        assert!(!pattern_match("d.rst", "[abc].rst").unwrap());
340        assert!(!pattern_match("a.rst", "[!abc].rst").unwrap());
341        assert!(pattern_match("d.rst", "[!abc].rst").unwrap());
342        // Sphinx semantics: negated classes never match '/'
343        assert!(!pattern_match("/.rst", "[!abc].rst").unwrap());
344    }
345
346    #[test]
347    fn test_directory_pruning_matches_sphinx() {
348        // Sphinx's get_matching_files prunes directories whose bare relative
349        // path matches an exclude matcher; "_build/**" excludes the files
350        // beneath instead; a trailing-slash pattern like "_build/" matches
351        // neither directories nor files (inert).
352        let make_tree = || {
353            let temp_dir = TempDir::new().unwrap();
354            let base = temp_dir.path().to_path_buf();
355            fs::create_dir_all(base.join("_build/deep")).unwrap();
356            fs::write(base.join("index.rst"), "Index").unwrap();
357            fs::write(base.join("_build/stale.rst"), "Stale").unwrap();
358            fs::write(base.join("_build/deep/stale.rst"), "Stale").unwrap();
359            (temp_dir, base)
360        };
361
362        let include = vec!["**".to_string()];
363
364        // Bare "_build" prunes the whole tree
365        let (_t1, base) = make_tree();
366        let files = get_matching_files(&base, &include, &["_build".to_string()]).unwrap();
367        let names: Vec<String> = files
368            .iter()
369            .map(|f| normalize_path(f.strip_prefix(base.canonicalize().unwrap()).unwrap()))
370            .collect();
371        assert_eq!(names, vec!["index.rst"]);
372
373        // "_build/**" produces the same visible output (files excluded one by one)
374        let (_t2, base) = make_tree();
375        let files = get_matching_files(&base, &include, &["_build/**".to_string()]).unwrap();
376        let names: Vec<String> = files
377            .iter()
378            .map(|f| normalize_path(f.strip_prefix(base.canonicalize().unwrap()).unwrap()))
379            .collect();
380        assert_eq!(names, vec!["index.rst"]);
381
382        // Trailing-slash "_build/" is inert, exactly like Sphinx
383        let (_t3, base) = make_tree();
384        let files = get_matching_files(&base, &include, &["_build/".to_string()]).unwrap();
385        let names: Vec<String> = files
386            .iter()
387            .map(|f| normalize_path(f.strip_prefix(base.canonicalize().unwrap()).unwrap()))
388            .collect();
389        assert_eq!(
390            names,
391            vec!["_build/deep/stale.rst", "_build/stale.rst", "index.rst"]
392        );
393    }
394
395    #[test]
396    fn test_get_matching_files() {
397        let temp_dir = TempDir::new().unwrap();
398        let base_path = temp_dir.path();
399
400        // Create test files
401        fs::create_dir_all(base_path.join("docs")).unwrap();
402        fs::create_dir_all(base_path.join("_build")).unwrap();
403        fs::write(base_path.join("index.rst"), "content").unwrap();
404        fs::write(base_path.join("docs/api.rst"), "content").unwrap();
405        fs::write(base_path.join("_build/index.html"), "content").unwrap();
406        fs::write(base_path.join("README.md"), "content").unwrap();
407
408        // Test include nested RST files: '**/*.rst' requires a directory
409        // component in Sphinx, so top-level index.rst is not matched
410        let files = get_matching_files(base_path, &["**/*.rst".to_string()], &[]).unwrap();
411        assert_eq!(files.len(), 1);
412        assert!(!files.iter().any(|p| p.file_name().unwrap() == "index.rst"));
413        assert!(files.iter().any(|p| p.file_name().unwrap() == "api.rst"));
414
415        // Test exclude _build directory
416        let files =
417            get_matching_files(base_path, &["**".to_string()], &["_build/**".to_string()]).unwrap();
418        assert!(!files.iter().any(|p| p.to_string_lossy().contains("_build")));
419
420        // Test include RST files but exclude docs directory: with Sphinx
421        // '**' semantics the include only matches docs/api.rst, which the
422        // exclusion then removes
423        let files = get_matching_files(
424            base_path,
425            &["**/*.rst".to_string()],
426            &["docs/**".to_string()],
427        )
428        .unwrap();
429        assert!(files.is_empty());
430    }
431}