Skip to main content

ty_module_resolver/
module_glob.rs

1//! Module glob patterns for matching Python module names.
2//!
3//! This module provides glob-like pattern matching for Python module names,
4//! allowing configuration of which modules should be included or excluded
5//! from certain behaviors (e.g., ignoring import resolution errors).
6//!
7//! # Examples
8//!
9//! ```
10//! use ty_module_resolver::{ModuleGlobSet, ModuleName};
11//!
12//! let set = ModuleGlobSet::from_patterns(["test.*", "!test.internal"]).unwrap();
13//!
14//! assert!(set.matches(&ModuleName::new("test.foo").unwrap()).is_include());
15//! assert!(set.matches(&ModuleName::new("test.internal").unwrap()).is_exclude());
16//! ```
17//!
18//! # Pattern Syntax
19//!
20//! - `test` matches the module `test` exactly (but not `test.foo`).
21//!
22//! - `*` matches zero or more characters, but not `.`.
23//!
24//! - `**` matches zero or more module components. This sequence **must** form
25//!   a single component, so both `**foo` and `foo**` are invalid and will
26//!   result in an error. A sequence of more than two consecutive `*` characters
27//!   is also invalid.
28//!
29//! - Patterns starting with `!` are negated and will exclude matching modules.
30//!   When multiple patterns match, the last match wins (like gitignore).
31//!
32//! # Pattern Examples
33//!
34//! | Pattern | Matches | Does not match |
35//! |---------|---------|----------------|
36//! | `test` | `test` | `test.foo`, `testing` |
37//! | `test.*` | `test.foo`, `test.bar` | `test`, `test.foo.bar` |
38//! | `*.test` | `foo.test`, `bar.test` | `test`, `foo.bar.test` |
39//! | `test.**` | `test`, `test.foo`, `test.foo.bar` | `testing` |
40//! | `**.test` | `test`, `foo.test`, `foo.bar.test` | `test.foo` |
41//! | `test.**.bar` | `test.bar`, `test.foo.bar`, `test.a.b.bar` | `test`, `test.bar.foo` |
42//! | `**` | (any module) | |
43
44use std::fmt;
45
46use regex::RegexSet;
47
48use crate::ModuleName;
49
50/// A compiled set of module glob patterns.
51///
52/// This allows efficient matching of module names against multiple glob patterns,
53/// with support for negated patterns.
54#[derive(Clone, Debug, get_size2::GetSize)]
55pub struct ModuleGlobSet {
56    #[get_size(ignore)]
57    regex_set: RegexSet,
58    /// Parsed glob metadata.
59    globs: Box<[ModuleGlob]>,
60}
61
62impl ModuleGlobSet {
63    pub fn empty() -> Self {
64        Self {
65            regex_set: RegexSet::empty(),
66            globs: Box::default(),
67        }
68    }
69
70    /// Creates a new [`ModuleGlobSet`] from an iterator of patterns.
71    ///
72    /// This is a convenience method that creates a builder, adds all patterns,
73    /// and builds the set.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error if any pattern is invalid or if the regex set fails to compile.
78    pub fn from_patterns<I, S>(patterns: I) -> Result<Self, ModuleGlobError>
79    where
80        I: IntoIterator<Item = S>,
81        S: AsRef<str>,
82    {
83        let mut builder = ModuleGlobSetBuilder::new();
84        for pattern in patterns {
85            builder.add(pattern.as_ref())?;
86        }
87        builder.build()
88    }
89
90    /// Returns whether the given module name matches any pattern in this set.
91    ///
92    /// Uses "last match wins" semantics (like gitignore):
93    /// - Returns [`ModuleNameMatch::Include`] if the last matching pattern is a positive pattern.
94    /// - Returns [`ModuleNameMatch::Exclude`] if the last matching pattern is a negative pattern.
95    /// - Returns [`ModuleNameMatch::None`] if no pattern matches.
96    pub fn matches(&self, module: &ModuleName) -> ModuleNameMatch {
97        if self.globs.is_empty() {
98            return ModuleNameMatch::None;
99        }
100
101        // Find the last matching pattern (by index order, which is the order patterns were added).
102        let Some(last_match_index) = self.regex_set.matches(module.as_str()).iter().next_back()
103        else {
104            return ModuleNameMatch::None;
105        };
106
107        if self.globs[last_match_index].negated {
108            ModuleNameMatch::Exclude
109        } else {
110            ModuleNameMatch::Include
111        }
112    }
113}
114
115impl PartialEq for ModuleGlobSet {
116    fn eq(&self, other: &Self) -> bool {
117        self.globs == other.globs
118    }
119}
120
121impl Eq for ModuleGlobSet {}
122
123impl fmt::Display for ModuleGlobSet {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        f.debug_list()
126            .entries(self.globs.iter().map(|g| &g.original))
127            .finish()
128    }
129}
130
131/// Builder for constructing a [`ModuleGlobSet`].
132///
133/// For simple cases, prefer [`ModuleGlobSet::from_patterns`] instead.
134#[derive(Debug, Default)]
135pub struct ModuleGlobSetBuilder {
136    /// Regex patterns converted from globs.
137    patterns: Vec<Box<str>>,
138    /// Parsed glob metadata.
139    globs: Vec<ModuleGlob>,
140}
141
142impl ModuleGlobSetBuilder {
143    /// Creates a new empty builder.
144    pub fn new() -> Self {
145        Self::default()
146    }
147
148    /// Adds a glob pattern to the builder.
149    ///
150    /// Patterns starting with `!` are treated as negated patterns.
151    ///
152    /// # Errors
153    ///
154    /// Returns an error if the pattern is invalid.
155    pub fn add(&mut self, pattern: &str) -> Result<&mut Self, ModuleGlobError> {
156        if pattern.is_empty() {
157            return Err(ModuleGlobError::EmptyPattern);
158        }
159
160        // Handle negation prefix.
161        let (negated, pattern_without_negation) = if let Some(rest) = pattern.strip_prefix('!') {
162            (true, rest)
163        } else {
164            (false, pattern)
165        };
166
167        if pattern_without_negation.is_empty() {
168            return Err(ModuleGlobError::EmptyPattern);
169        }
170
171        let regex_pattern = glob_to_regex(pattern_without_negation)?;
172
173        self.patterns.push(regex_pattern);
174        self.globs.push(ModuleGlob {
175            original: pattern.into(),
176            negated,
177        });
178
179        Ok(self)
180    }
181
182    /// Builds the [`ModuleGlobSet`] from the added patterns.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if the regex set fails to compile.
187    pub fn build(self) -> Result<ModuleGlobSet, ModuleGlobError> {
188        let regex_set = RegexSet::new(&self.patterns)?;
189
190        Ok(ModuleGlobSet {
191            regex_set,
192            globs: self.globs.into_boxed_slice(),
193        })
194    }
195}
196
197/// The result of matching a module name against a [`ModuleGlobSet`].
198#[derive(Copy, Clone, Debug, PartialEq, Eq)]
199pub enum ModuleNameMatch {
200    /// The module name matches no pattern.
201    None,
202
203    /// The module name matches an include pattern (a positive pattern).
204    Include,
205
206    /// The module name matches an exclude pattern (a negative pattern starting with `!`).
207    Exclude,
208}
209
210impl ModuleNameMatch {
211    /// Returns `true` if the match result is [`ModuleNameMatch::Include`].
212    pub const fn is_include(self) -> bool {
213        matches!(self, ModuleNameMatch::Include)
214    }
215
216    /// Returns `true` if the match result is [`ModuleNameMatch::Exclude`].
217    pub const fn is_exclude(self) -> bool {
218        matches!(self, ModuleNameMatch::Exclude)
219    }
220
221    /// Returns `true` if the match result is [`ModuleNameMatch::None`].
222    pub const fn is_none(self) -> bool {
223        matches!(self, ModuleNameMatch::None)
224    }
225}
226
227/// Error type for module glob pattern parsing.
228#[derive(Debug, thiserror::Error)]
229pub enum ModuleGlobError {
230    /// The pattern is empty (e.g., `""` or `"!"`).
231    #[error("module glob pattern cannot be empty")]
232    EmptyPattern,
233
234    /// The pattern starts with a dot (e.g., `.foo`).
235    #[error("module glob pattern cannot start with a dot")]
236    LeadingDot,
237
238    /// The pattern ends with a dot (e.g., `foo.`).
239    #[error("module glob pattern cannot end with a dot")]
240    TrailingDot,
241
242    /// The pattern contains consecutive dots (e.g., `foo..bar`).
243    #[error("module glob pattern cannot contain consecutive dots")]
244    ConsecutiveDots,
245
246    /// The pattern contains an invalid `**` usage (e.g., `foo**` or `**bar`).
247    #[error(
248        "`**` can only appear as a complete component (e.g., `foo.**` or `**.bar`), not combined with other text like `{0}`"
249    )]
250    InvalidDoubleStarUsage(Box<str>),
251
252    /// The underlying regex failed to compile (e.g., DFA size limit exceeded).
253    #[error("failed to compile module glob pattern")]
254    Regex(#[from] regex::Error),
255}
256
257/// A parsed module glob pattern.
258#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)]
259struct ModuleGlob {
260    /// The original glob pattern string (including `!` prefix if negated).
261    original: Box<str>,
262    /// Whether this is a negated pattern (starts with `!`).
263    negated: bool,
264}
265
266/// Converts a module glob pattern to a regex pattern, validating during parsing.
267fn glob_to_regex(pattern: &str) -> Result<Box<str>, ModuleGlobError> {
268    if pattern.is_empty() {
269        return Err(ModuleGlobError::EmptyPattern);
270    }
271
272    // Check for leading or trailing dots.
273    if pattern.starts_with('.') {
274        return Err(ModuleGlobError::LeadingDot);
275    }
276    if pattern.ends_with('.') {
277        return Err(ModuleGlobError::TrailingDot);
278    }
279
280    let mut regex = String::with_capacity(pattern.len());
281    regex.push('^');
282
283    let mut components = pattern.split('.').peekable();
284
285    let mut is_first = true;
286    let mut prev_was_double_star_at_start = false;
287
288    while let Some(component) = components.next() {
289        if component.is_empty() {
290            return Err(ModuleGlobError::ConsecutiveDots);
291        }
292
293        // Check for `**` mixed with other characters.
294        if component.contains("**") && component != "**" {
295            return Err(ModuleGlobError::InvalidDoubleStarUsage(Box::from(
296                component,
297            )));
298        }
299
300        let is_last = components.peek().is_none();
301
302        if component == "**" {
303            if is_first {
304                // Pattern is just "**" - matches everything.
305                if is_last {
306                    regex.push_str(".*");
307                } else {
308                    // "**.foo" - matches zero or more components at the start.
309                    // Matches: "foo", "x.foo", "x.y.foo", etc.
310                    // The pattern includes the trailing dot if there are prefix components.
311                    regex.push_str("(?:[^.]+\\.)*");
312                    prev_was_double_star_at_start = true;
313                }
314            } else {
315                // "foo.**" or "foo.**.bar" - matches zero or more components.
316                // Matches: "foo", "foo.x", "foo.x.y", "foo.bar", "foo.x.bar", etc.
317                regex.push_str("(?:\\.[^.]+)*");
318            }
319        } else {
320            // Add dot separator if not at the beginning and not after `**` at the start.
321            // When `**` is at position 0, it already includes the trailing dot in its pattern.
322            if !is_first && !prev_was_double_star_at_start {
323                regex.push_str("\\.");
324            }
325            prev_was_double_star_at_start = false;
326
327            // Handle `*` as a complete component vs `*` mixed with text differently.
328            if component == "*" {
329                // `*` as a complete component matches exactly one non-empty component.
330                regex.push_str("[^.]+");
331            } else {
332                // Convert the component, handling `*` wildcards mixed with text.
333                for c in component.chars() {
334                    if c == '*' {
335                        // `*` mixed with text matches zero or more characters except `.`.
336                        regex.push_str("[^.]*");
337                    } else if regex_syntax::is_meta_character(c) {
338                        // Escape regex special characters.
339                        regex.push('\\');
340                        regex.push(c);
341                    } else {
342                        regex.push(c);
343                    }
344                }
345            }
346        }
347
348        is_first = false;
349    }
350
351    regex.push('$');
352    Ok(regex.into_boxed_str())
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[track_caller]
360    fn assert_include(set: &ModuleGlobSet, name: &str) {
361        let module = ModuleName::new(name).unwrap();
362        assert_eq!(
363            set.matches(&module),
364            ModuleNameMatch::Include,
365            "expected `{name}` to be included"
366        );
367    }
368
369    #[track_caller]
370    fn assert_excludes(set: &ModuleGlobSet, name: &str) {
371        let module = ModuleName::new(name).unwrap();
372        assert_eq!(
373            set.matches(&module),
374            ModuleNameMatch::Exclude,
375            "expected `{name}` to be excluded"
376        );
377    }
378
379    #[track_caller]
380    fn assert_no_match(set: &ModuleGlobSet, name: &str) {
381        let module = ModuleName::new(name).unwrap();
382        assert_eq!(
383            set.matches(&module),
384            ModuleNameMatch::None,
385            "expected `{name}` not to match"
386        );
387    }
388
389    #[test]
390    fn test_exact_match() {
391        let set = ModuleGlobSet::from_patterns(["test"]).unwrap();
392
393        assert_include(&set, "test");
394        assert_no_match(&set, "test2");
395        assert_no_match(&set, "test_foo");
396        assert_no_match(&set, "foo");
397        assert_no_match(&set, "test.foo");
398    }
399
400    #[test]
401    fn test_single_star_direct_submodule() {
402        let set = ModuleGlobSet::from_patterns(["test.*"]).unwrap();
403
404        assert_include(&set, "test.foo");
405        assert_include(&set, "test.bar");
406        assert_no_match(&set, "test");
407        assert_no_match(&set, "test.foo.bar");
408    }
409
410    #[test]
411    fn test_single_star_prefix() {
412        let set = ModuleGlobSet::from_patterns(["*.test"]).unwrap();
413
414        assert_include(&set, "foo.test");
415        assert_include(&set, "bar.test");
416        assert_no_match(&set, "test");
417        assert_no_match(&set, "foo.bar.test");
418    }
419
420    #[test]
421    fn test_single_star_middle() {
422        let set = ModuleGlobSet::from_patterns(["foo.*.bar"]).unwrap();
423
424        assert_include(&set, "foo.x.bar");
425        assert_include(&set, "foo.y.bar");
426        assert_no_match(&set, "foo.bar");
427        assert_no_match(&set, "foo.x.y.bar");
428    }
429
430    #[test]
431    fn test_star_with_literal_text() {
432        let set = ModuleGlobSet::from_patterns(["*test.bar"]).unwrap();
433
434        assert_include(&set, "test.bar");
435        assert_include(&set, "mytest.bar");
436        assert_no_match(&set, "foobar.bar");
437    }
438
439    #[test]
440    fn test_double_star_end() {
441        let set = ModuleGlobSet::from_patterns(["test.**"]).unwrap();
442
443        assert_include(&set, "test");
444        assert_include(&set, "test.foo");
445        assert_include(&set, "test.foo.bar");
446        assert_include(&set, "test.foo.bar.baz");
447        assert_no_match(&set, "testing");
448    }
449
450    #[test]
451    fn test_double_star_start() {
452        let set = ModuleGlobSet::from_patterns(["**.bar"]).unwrap();
453
454        assert_include(&set, "bar");
455        assert_include(&set, "foo.bar");
456        assert_include(&set, "foo.baz.bar");
457        assert_include(&set, "foo.baz.qux.bar");
458        assert_no_match(&set, "bar.foo");
459    }
460
461    #[test]
462    fn test_double_star_middle() {
463        let set = ModuleGlobSet::from_patterns(["test.**.bar"]).unwrap();
464
465        assert_include(&set, "test.bar");
466        assert_include(&set, "test.foo.bar");
467        assert_include(&set, "test.foo.baz.bar");
468        assert_include(&set, "test.foo.baz.qux.bar");
469        assert_no_match(&set, "test");
470        assert_no_match(&set, "test.bar.foo");
471    }
472
473    #[test]
474    fn test_just_double_star() {
475        let set = ModuleGlobSet::from_patterns(["**"]).unwrap();
476
477        assert_include(&set, "foo");
478        assert_include(&set, "foo.bar");
479        assert_include(&set, "foo.bar.baz");
480    }
481
482    #[test]
483    fn test_negated_pattern() {
484        let set = ModuleGlobSet::from_patterns(["test.*", "!test.internal"]).unwrap();
485
486        assert_include(&set, "test.foo");
487        assert_include(&set, "test.bar");
488        // Last match wins - !test.internal matches last, so it excludes.
489        assert_excludes(&set, "test.internal");
490    }
491
492    #[test]
493    fn test_negated_pattern_override() {
494        // The negation comes first, but test.* comes last and overrides it.
495        let set = ModuleGlobSet::from_patterns(["!test.internal", "test.*"]).unwrap();
496
497        assert_include(&set, "test.foo");
498        assert_include(&set, "test.bar");
499        // test.* matches last, so test.internal is included.
500        assert_include(&set, "test.internal");
501    }
502
503    #[test]
504    fn test_negated_only() {
505        let set = ModuleGlobSet::from_patterns(["!test"]).unwrap();
506
507        assert_excludes(&set, "test");
508        assert_no_match(&set, "other");
509    }
510
511    #[test]
512    fn test_empty_set() {
513        let set = ModuleGlobSet::from_patterns::<[&str; 0], _>([]).unwrap();
514
515        assert_no_match(&set, "test");
516    }
517
518    #[test]
519    fn test_display() {
520        let set = ModuleGlobSet::from_patterns(["test.*", "!test.internal"]).unwrap();
521
522        let display = format!("{set}");
523        assert!(display.contains("test.*"));
524        assert!(display.contains("!test.internal"));
525    }
526
527    #[test]
528    fn test_invalid_empty_pattern() {
529        let result = ModuleGlobSet::from_patterns([""]);
530        assert!(matches!(result, Err(ModuleGlobError::EmptyPattern)));
531    }
532
533    #[test]
534    fn test_invalid_just_negation() {
535        let result = ModuleGlobSet::from_patterns(["!"]);
536        assert!(matches!(result, Err(ModuleGlobError::EmptyPattern)));
537    }
538
539    #[test]
540    fn test_invalid_double_star_combined() {
541        let result = ModuleGlobSet::from_patterns(["foo**"]);
542        assert!(matches!(
543            result,
544            Err(ModuleGlobError::InvalidDoubleStarUsage(_))
545        ));
546
547        let result = ModuleGlobSet::from_patterns(["**foo"]);
548        assert!(matches!(
549            result,
550            Err(ModuleGlobError::InvalidDoubleStarUsage(_))
551        ));
552
553        let result = ModuleGlobSet::from_patterns(["foo.bar**"]);
554        assert!(matches!(
555            result,
556            Err(ModuleGlobError::InvalidDoubleStarUsage(_))
557        ));
558    }
559
560    #[test]
561    fn test_invalid_consecutive_dots() {
562        let result = ModuleGlobSet::from_patterns(["foo..bar"]);
563        assert!(matches!(result, Err(ModuleGlobError::ConsecutiveDots)));
564    }
565
566    #[test]
567    fn test_invalid_leading_dot() {
568        let result = ModuleGlobSet::from_patterns([".foo"]);
569        assert!(matches!(result, Err(ModuleGlobError::LeadingDot)));
570    }
571
572    #[test]
573    fn test_invalid_trailing_dot() {
574        let result = ModuleGlobSet::from_patterns(["foo."]);
575        assert!(matches!(result, Err(ModuleGlobError::TrailingDot)));
576    }
577
578    #[test]
579    fn test_underscore_in_module_name() {
580        let set = ModuleGlobSet::from_patterns(["foo_bar.*"]).unwrap();
581
582        assert_include(&set, "foo_bar.baz");
583    }
584
585    #[test]
586    fn test_numbers_in_module_name() {
587        let set = ModuleGlobSet::from_patterns(["foo123.*"]).unwrap();
588
589        assert_include(&set, "foo123.bar");
590    }
591
592    #[test]
593    fn test_multiple_patterns() {
594        let set = ModuleGlobSet::from_patterns(["alpha.*", "beta.*", "gamma"]).unwrap();
595
596        assert_include(&set, "alpha.one");
597        assert_include(&set, "beta.two");
598        assert_include(&set, "gamma");
599        assert_no_match(&set, "delta");
600    }
601}