|
| 1 | +//! The glob matcher behind a target's `include` / `exclude` patterns. |
| 2 | +//! |
| 3 | +//! **Real globs, via [`globset`].** This used to be a hand-written cascade over three shapes — a |
| 4 | +//! `**/*.ext` suffix, a `dir/**` subtree, and a literal — whose last line fell through to |
| 5 | +//! `path.contains(pattern.trim_matches('*'))`. Substring containment is not glob matching: `*.rs` |
| 6 | +//! became `contains(".rs")` and claimed `notes.rs.bak` and `src/lib.rs.orig`, and `*` (which trims |
| 7 | +//! to the empty string, contained by every path) claimed the whole tree. Those are wrong answers |
| 8 | +//! about which files are indexed, and they fail silently. [`globset`] is `ignore`'s own matching |
| 9 | +//! layer — already compiled on every build, and the matcher ripgrep uses for this exact job — so |
| 10 | +//! `**`, `*`, `?`, `[…]` and `{…}` now mean what a glob says they mean. |
| 11 | +//! |
| 12 | +//! Three options are pinned explicitly rather than taken from the defaults, because each default is |
| 13 | +//! wrong for a repo-relative config pattern: |
| 14 | +//! |
| 15 | +//! 1. **`literal_separator(true)`.** `globset` defaults it to *false*, where `*` and `?` match `/` |
| 16 | +//! as well — so `src/*.rs` would claim `src/a/b/deep.rs`. A config pattern names path |
| 17 | +//! components, so a wildcard must stop at a separator; only `**` crosses one. |
| 18 | +//! 2. **`backslash_escape(true)`.** `globset` defaults this to `!is_separator('\\')` — *true* on |
| 19 | +//! Unix, *false* on Windows. Left unpinned, one `rag-rat.toml` would claim different files on |
| 20 | +//! different platforms. Pinned on, `\` escapes the next character everywhere, so a pattern |
| 21 | +//! reaches a Unix file whose NAME contains a backslash by spelling it `\\`. |
| 22 | +//! 3. **The candidate is built from the rendered BYTES**, never re-derived through a [`Path`]. The |
| 23 | +//! caller already holds the `/`-separated rendering `files.path` is stored with |
| 24 | +//! ([`crate::paths::path_string`]); round-tripping it through `Path` would re-run |
| 25 | +//! platform-specific separator handling over a string that is already canonical. |
| 26 | +//! |
| 27 | +//! One shape is normalized before compiling rather than pinned as an option: a trailing `/` names a |
| 28 | +//! subtree, so `src/` compiles as `src/**` (see [`compile`]). |
| 29 | +//! |
| 30 | +//! [`Path`]: std::path::Path |
| 31 | +
|
| 32 | +use std::collections::HashMap; |
| 33 | +use std::sync::{LazyLock, RwLock}; |
| 34 | + |
| 35 | +use globset::{Candidate, GlobBuilder, GlobMatcher}; |
| 36 | + |
| 37 | +/// Compiled matchers keyed by the pattern text, so a pattern is turned into a regex once per |
| 38 | +/// process instead of once per file. |
| 39 | +/// |
| 40 | +/// [`super::ResolvedTarget::globs_claim`] is the per-file gate of the indexing walk and is also |
| 41 | +/// asked once per path (across every target) by the incremental resolver, so pattern compilation |
| 42 | +/// must not sit on that path. The pattern set is bounded by the config file, so the map needs no |
| 43 | +/// eviction. A pattern that fails to compile is memoized as [`None`] — the compile error is |
| 44 | +/// reported once, not once per file. |
| 45 | +static COMPILED: LazyLock<RwLock<HashMap<String, Option<GlobMatcher>>>> = |
| 46 | + LazyLock::new(|| RwLock::new(HashMap::new())); |
| 47 | + |
| 48 | +/// Whether one config glob claims `path`, a `/`-separated repo-relative rendering. |
| 49 | +/// |
| 50 | +/// A pattern that is not a legal glob (an unclosed `[`, a dangling `\`) claims NOTHING and is |
| 51 | +/// reported once. Config load does not reject such a pattern today, so the matcher has to answer |
| 52 | +/// something; claiming nothing keeps a typo from silently widening an `include`. |
| 53 | +pub(crate) fn pattern_claims(path: &str, pattern: &str) -> bool { |
| 54 | + let candidate = Candidate::from_bytes(path.as_bytes()); |
| 55 | + let cached = COMPILED.read().ok().and_then(|compiled| { |
| 56 | + compiled |
| 57 | + .get(pattern) |
| 58 | + .map(|matcher| matcher.as_ref().is_some_and(|m| m.is_match_candidate(&candidate))) |
| 59 | + }); |
| 60 | + if let Some(claims) = cached { |
| 61 | + return claims; |
| 62 | + } |
| 63 | + let matcher = compile(pattern); |
| 64 | + let claims = matcher.as_ref().is_some_and(|m| m.is_match_candidate(&candidate)); |
| 65 | + if let Ok(mut compiled) = COMPILED.write() { |
| 66 | + compiled.entry(pattern.to_string()).or_insert(matcher); |
| 67 | + } |
| 68 | + claims |
| 69 | +} |
| 70 | + |
| 71 | +/// Compile one pattern, or [`None`] (plus a warning) when it is not a legal glob. |
| 72 | +/// |
| 73 | +/// A trailing `/` names a ROOT-ANCHORED SUBTREE, so `src/` compiles as `src/**`. As a bare glob it |
| 74 | +/// would name a single path ending in a separator and claim nothing at all — and a target whose |
| 75 | +/// `include` silently claims nothing indexes zero files, a worse and quieter failure than the |
| 76 | +/// over-claiming this replacement removes. This is rag-rat's own target dialect: close to but not |
| 77 | +/// identical to gitignore's trailing slash, which matches unanchored (a `src/` there would also |
| 78 | +/// match `a/src/`). Here `src/` is the ROOT `src/`, matching what the old substring fallback got |
| 79 | +/// *approximately* right — it matched `src/` by containment, but also claimed `a/src/lib.rs`. |
| 80 | +/// Normalizing to `src/**` keeps the intent and adds the anchor the fallback lacked. |
| 81 | +fn compile(pattern: &str) -> Option<GlobMatcher> { |
| 82 | + let subtree = pattern.strip_suffix('/').map(|dir| format!("{dir}/**")); |
| 83 | + let pattern = subtree.as_deref().unwrap_or(pattern); |
| 84 | + match GlobBuilder::new(pattern).literal_separator(true).backslash_escape(true).build() { |
| 85 | + Ok(glob) => Some(glob.compile_matcher()), |
| 86 | + Err(error) => { |
| 87 | + tracing::warn!( |
| 88 | + pattern, |
| 89 | + %error, |
| 90 | + "target include/exclude pattern is not a valid glob; it claims no files", |
| 91 | + ); |
| 92 | + None |
| 93 | + }, |
| 94 | + } |
| 95 | +} |
| 96 | + |
| 97 | +#[cfg(test)] |
| 98 | +mod tests { |
| 99 | + use super::*; |
| 100 | + |
| 101 | + #[test] |
| 102 | + fn a_wildcard_does_not_cross_a_separator() { |
| 103 | + // `literal_separator` is OFF by default in globset, which would make `*` match `/`. |
| 104 | + assert!(pattern_claims("src/lib.rs", "src/*.rs")); |
| 105 | + assert!(!pattern_claims("src/a/deep.rs", "src/*.rs")); |
| 106 | + assert!(pattern_claims("src/a/deep.rs", "src/**/*.rs")); |
| 107 | + } |
| 108 | + |
| 109 | + #[test] |
| 110 | + fn a_backslash_escape_is_pinned_on_every_platform() { |
| 111 | + // globset's default here is platform-dependent (`!is_separator('\\')`), so one config would |
| 112 | + // otherwise claim different files on Unix and Windows. Pinned ON: `\\` is one literal `\`. |
| 113 | + assert!(pattern_claims("drafts\\secret.md", "drafts\\\\secret.md")); |
| 114 | + assert!(!pattern_claims("drafts/secret.md", "drafts\\\\secret.md")); |
| 115 | + } |
| 116 | + |
| 117 | + #[test] |
| 118 | + fn a_trailing_slash_names_the_subtree_and_anchors_it() { |
| 119 | + assert!(pattern_claims("src/lib.rs", "src/")); |
| 120 | + assert!(pattern_claims("src/a/b/deep.rs", "src/")); |
| 121 | + // The anchor the substring fallback lacked: `src/` is the ROOT `src/`, not any `src/`. |
| 122 | + assert!(!pattern_claims("a/src/lib.rs", "src/")); |
| 123 | + assert!(!pattern_claims("srcx/lib.rs", "src/")); |
| 124 | + // …and the directory itself is not one of its own children. |
| 125 | + assert!(!pattern_claims("src", "src/")); |
| 126 | + } |
| 127 | + |
| 128 | + #[test] |
| 129 | + fn an_illegal_glob_claims_nothing_instead_of_everything() { |
| 130 | + // A dangling escape is a compile error; the matcher must not fall back to "matches all". |
| 131 | + assert!(!pattern_claims("src/lib.rs", "src/lib.rs\\")); |
| 132 | + // The memoized failure answers the same way on the second call. |
| 133 | + assert!(!pattern_claims("src/lib.rs", "src/lib.rs\\")); |
| 134 | + } |
| 135 | + |
| 136 | + #[test] |
| 137 | + fn a_compiled_pattern_is_reused_across_calls() { |
| 138 | + // Second call must come off the memo and agree with the first — the property the per-file |
| 139 | + // hot path depends on. |
| 140 | + let pattern = "**/*.reused-in-a-test"; |
| 141 | + assert!(pattern_claims("a/b.reused-in-a-test", pattern)); |
| 142 | + assert!(pattern_claims("a/b.reused-in-a-test", pattern)); |
| 143 | + assert!( |
| 144 | + COMPILED.read().expect("cache lock").contains_key(pattern), |
| 145 | + "the pattern must be compiled once and memoized", |
| 146 | + ); |
| 147 | + } |
| 148 | +} |
0 commit comments