Skip to content

Commit ea97af8

Browse files
Nitjsefnieclaude
andauthored
fix(index): match target include/exclude patterns as globs (#1072) (#1118)
`ResolvedTarget::globs_claim` decides whether a file is indexed, and the matcher behind it recognized three shapes — a `**/*.ext` suffix, a `dir/**` subtree, and a literal — then fell through to substring containment: path == pattern || path.contains(pattern.trim_matches('*')) Containment is not glob matching. `*.rs` asked only whether `.rs` appeared anywhere in the path, claiming `notes.rs.bak` and `src/lib.rs.orig` as Rust sources; a bare `*` trims to the empty string, which every path contains, so it claimed the whole tree; and a literal such as `README.md` claimed `docs/README.md`. These are wrong answers about which files get indexed and they fail silently. Replace the cascade with `globset`, already compiled on every build as `ignore`'s own matching layer, so the package graph and the audit/deny gates are unchanged. Three options are pinned rather than defaulted, each because the default is wrong for a repo-relative config pattern: `literal_separator` (off by default, which would let `*` cross a `/`), `backslash_escape` (whose default is platform-dependent, so one config would claim different files on Unix and Windows), and building the candidate from the rendered bytes instead of re-deriving it through a `Path`. Patterns compile once per process; a pattern that is not a legal glob claims no files and is logged at `warn`. A trailing `/` is normalized to `/**`, keeping `include = ["src/"]` meaning the subtree — anchored now, where containment also matched `a/src/lib.rs`. Behaviour change, documented in docs/config/targets.md: every shipped default is a `**/*.ext` suffix and is unaffected, but a user pattern that relied on the containment fallback now means what a glob says it means. The `dir/**` separator boundary and the "one definition of target matching" invariant recorded on `globs_claim` are restated for the new matcher: the boundary is now correct by construction rather than a hand-written prefix check. Also pins the wildcard case of the git-status pathspec. A file NAME may contain `*`, which gix reads as a wildcard, so a clean file was reported dirty because a sibling matching the same pattern had edits — and blame then refused the committed attribution. `:(literal)` already covered this alongside `\` and a leading `:`; only the wildcard lacked a regression test. Co-authored-by: Claude Opus 5 <[email protected]>
1 parent a62d12f commit ea97af8

9 files changed

Lines changed: 344 additions & 23 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ futures-util = { version = "0.3", default-features = false, features = ["alloc"]
9494
# `rand`/`rand_core` pulled in for this), keeping `from_seed` the sole key constructor.
9595
getrandom = "0.4"
9696
gix = { version = "0.86.0", default-features = false, features = ["status", "parallel", "sha1", "sha256", "revision", "blob-diff", "blame"] }
97+
# The glob matcher behind a target's include/exclude patterns. Already compiled on every build as
98+
# `ignore`'s own matching layer (the same one ripgrep uses), so depending on it directly adds
99+
# nothing to the lock file, the audit gate or the deny gate.
100+
globset = "0.4.19"
97101
httpdate = "1.0"
98102
libc = "0.2"
99103
# Win32 bindings for the Lens discovery credential's owner-only DACL. Unix keeps the token private

crates/rag-rat-base/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ unicode-normalization.workspace = true
1717
anyhow.workspace = true
1818
dunce.workspace = true
1919
gix.workspace = true
20+
globset.workspace = true
2021
libc.workspace = true
2122
path-slash.workspace = true
2223
serde.workspace = true
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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+
}

crates/rag-rat-base/src/config/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use thiserror::Error;
55
use crate::language::LanguageError;
66

77
mod discovery;
8+
mod globs;
89
mod load;
910
mod raw;
1011
mod types;

crates/rag-rat-base/src/config/tests.rs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3359,3 +3359,113 @@ fn a_directory_include_glob_does_not_claim_a_backslash_named_sibling() {
33593359
assert!(!target.globs_claim("foo\\bar.rs"), "a file NAMED `foo\\bar.rs` is not under foo/");
33603360
assert!(!target.globs_claim("foobar.rs"), "a prefix of the NAME is not a directory boundary");
33613361
}
3362+
3363+
/// A target carrying exactly the given patterns; the language/kind are irrelevant to
3364+
/// [`ResolvedTarget::globs_claim`], which reads only `include` and `exclude`.
3365+
fn glob_target(include: &[&str], exclude: &[&str]) -> ResolvedTarget {
3366+
ResolvedTarget {
3367+
name: "globs".to_string(),
3368+
language: Language::Rust,
3369+
directories: vec![PathBuf::from(".")],
3370+
include: include.iter().map(|pattern| (*pattern).to_string()).collect(),
3371+
exclude: exclude.iter().map(|pattern| (*pattern).to_string()).collect(),
3372+
kind: TargetKind::Source,
3373+
}
3374+
}
3375+
3376+
/// A `*` is a WILDCARD over one path component, not a substring probe. The matcher used to fall
3377+
/// through to `path.contains(pattern.trim_matches('*'))`, so `*.rs` asked only whether `.rs`
3378+
/// appeared anywhere in the path — claiming a backup (`notes.rs.bak`) and a conflict leftover
3379+
/// (`src/lib.rs.orig`) as Rust sources, and a wrong answer about what gets indexed.
3380+
#[test]
3381+
fn a_star_glob_matches_a_name_instead_of_containing_a_substring() {
3382+
let target = glob_target(&["*.rs"], &[]);
3383+
3384+
assert!(target.globs_claim("lib.rs"), "a root-level `.rs` file is claimed");
3385+
assert!(!target.globs_claim("notes.rs.bak"), "`.rs` inside the NAME is not a `.rs` file");
3386+
assert!(!target.globs_claim("src/lib.rs.orig"), "nor is a conflict leftover");
3387+
// A single `*` stops at a separator, so an unanchored `*.rs` is root-level only. `**/*.rs` (the
3388+
// shipped default) is the pattern that reaches every depth.
3389+
assert!(!target.globs_claim("src/lib.rs"), "one `*` does not cross a separator");
3390+
assert!(glob_target(&["**/*.rs"], &[]).globs_claim("src/lib.rs"), "`**/` does");
3391+
}
3392+
3393+
/// The same fallthrough made a bare `*` claim the entire tree: `"*".trim_matches('*')` is the empty
3394+
/// string, and every path contains it. `*` is one component's worth of wildcard.
3395+
#[test]
3396+
fn a_bare_star_claims_one_component_not_the_whole_tree() {
3397+
let target = glob_target(&["*"], &[]);
3398+
3399+
assert!(target.globs_claim("README.md"), "a root-level file is claimed");
3400+
assert!(!target.globs_claim("src/lib.rs"), "a nested file is not");
3401+
assert!(!target.globs_claim("docs/deep/guide.md"), "nor a deeper one");
3402+
assert!(glob_target(&["**"], &[]).globs_claim("docs/deep/guide.md"), "`**` is the tree");
3403+
}
3404+
3405+
/// A pattern with no wildcard names a PATH, not a substring of one — on both sides. The old
3406+
/// fallthrough let a literal claim any path it appeared inside, so an `exclude` of `vendor` also
3407+
/// excluded `x/vendor/dep.rs` while an `include` of `src/lib.rs` also claimed `src/lib.rs.orig`.
3408+
#[test]
3409+
fn a_literal_pattern_names_a_path_not_a_substring_of_one() {
3410+
let include = glob_target(&["src/lib.rs", "README.md"], &[]);
3411+
assert!(include.globs_claim("src/lib.rs"), "the literal path itself is claimed");
3412+
assert!(!include.globs_claim("src/lib.rs.orig"), "a longer name is not that path");
3413+
assert!(!include.globs_claim("a/src/lib.rs"), "nor is the same tail deeper in the tree");
3414+
assert!(include.globs_claim("README.md"), "a root-level literal is claimed");
3415+
assert!(!include.globs_claim("docs/README.md"), "a same-named file elsewhere is not");
3416+
3417+
let exclude = glob_target(&["**/*.rs"], &["vendor"]);
3418+
assert!(
3419+
exclude.globs_claim("vendor/dep.rs"),
3420+
"`vendor` excludes the FILE `vendor`, not a tree"
3421+
);
3422+
assert!(exclude.globs_claim("x/vendor/dep.rs"), "and certainly not one nested elsewhere");
3423+
assert!(
3424+
!glob_target(&["**/*.rs"], &["vendor/**"]).globs_claim("vendor/dep.rs"),
3425+
"`vendor/**` is how a subtree is excluded",
3426+
);
3427+
}
3428+
3429+
/// The vocabulary is now real glob syntax, not the three hand-recognized shapes. Character classes,
3430+
/// alternates, `?`, and a `**` in the MIDDLE of a pattern all used to fall through to substring
3431+
/// containment over the pattern with its outer `*`s trimmed.
3432+
#[test]
3433+
fn the_full_glob_vocabulary_is_available() {
3434+
assert!(glob_target(&["**/*.[ch]"], &[]).globs_claim("include/lib.h"), "character class");
3435+
assert!(!glob_target(&["**/*.[ch]"], &[]).globs_claim("include/lib.hpp"), "and it is bounded");
3436+
assert!(glob_target(&["{lib,main}.rs"], &[]).globs_claim("main.rs"), "alternates");
3437+
assert!(!glob_target(&["{lib,main}.rs"], &[]).globs_claim("other.rs"), "and they are bounded");
3438+
assert!(glob_target(&["a?c.rs"], &[]).globs_claim("abc.rs"), "single-character wildcard");
3439+
assert!(!glob_target(&["a?c.rs"], &[]).globs_claim("ac.rs"), "which matches exactly one");
3440+
assert!(glob_target(&["src/**/*.rs"], &[]).globs_claim("src/a/b/deep.rs"), "interior `**`");
3441+
assert!(glob_target(&["src/**/*.rs"], &[]).globs_claim("src/lib.rs"), "which spans zero dirs");
3442+
assert!(
3443+
!glob_target(&["**/*.rs"], &["**/generated/**"]).globs_claim("src/generated/api.rs"),
3444+
"an interior `**` on the exclude side excludes a generated subtree at any depth",
3445+
);
3446+
}
3447+
3448+
/// Every shipped default is a `**/*.ext` suffix ([`Language::default_include_globs`]), the one
3449+
/// shape the old cascade got right. Replacing the matcher must not move a single one of them.
3450+
#[test]
3451+
fn the_shipped_default_globs_claim_exactly_what_they_did() {
3452+
let target = glob_target(&["**/*.rs"], &[]);
3453+
3454+
for claimed in ["lib.rs", "src/lib.rs", "src/a/b/deep.rs", "foo\\bar.rs", ".rs"] {
3455+
assert!(target.globs_claim(claimed), "`**/*.rs` claims {claimed}");
3456+
}
3457+
for unclaimed in ["lib.h", "notes.rs.bak", "src/lib.rs.orig", "my.rs.template"] {
3458+
assert!(!target.globs_claim(unclaimed), "`**/*.rs` does not claim {unclaimed}");
3459+
}
3460+
}
3461+
3462+
/// Config load does not reject a malformed pattern, so the matcher has to answer something for one.
3463+
/// It claims NOTHING: a typo that silently widened an `include` to the whole tree, or narrowed an
3464+
/// `exclude` away, is the failure this whole function exists to stop.
3465+
#[test]
3466+
fn a_pattern_that_is_not_a_legal_glob_claims_nothing() {
3467+
// A dangling escape — `\` with nothing after it — is a globset compile error.
3468+
assert!(!glob_target(&["src/lib.rs\\"], &[]).globs_claim("src/lib.rs"));
3469+
// On the exclude side an uncompilable pattern excludes nothing, so the include still stands.
3470+
assert!(glob_target(&["**/*.rs"], &["oops\\"]).globs_claim("src/lib.rs"));
3471+
}

crates/rag-rat-base/src/config/types.rs

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::time::Duration;
44

55
use serde::{Deserialize, Serialize};
66

7-
use super::ConfigError;
7+
use super::{ConfigError, globs};
88
use crate::embedding_models::{
99
Backend, EmbeddingModelSpec, FASTEMBED_MODEL_ID, MODEL2VEC_MODEL_ID, spec,
1010
};
@@ -1098,30 +1098,21 @@ impl ResolvedTarget {
10981098
///
10991099
/// The single definition of that matching, deliberately: the walk decides what to index and
11001100
/// the per-path resolver decides what an already-known path belongs to, and a second copy of
1101-
/// the rules lets those two answers drift.
1101+
/// the rules lets those two answers drift. The patterns are real globs, matched by
1102+
/// [`globs::pattern_claims`] — the only place a target pattern is interpreted, and the only
1103+
/// place the glob dialect (see that module for the three pinned `globset` options) is decided.
1104+
///
1105+
/// The boundary a `dir/**` prefix needs — it claims what is INSIDE `dir/`, so it must end at a
1106+
/// separator, never at a prefix of a NAME (`drafts/**` is not `draftsman.md`, and not the Unix
1107+
/// file named `drafts\secret.md`) — is now correct by construction: `globset` compiles a
1108+
/// trailing `/**` to a regex that requires the separator, and `*` cannot cross one at all.
1109+
/// Pinned by `a_directory_glob_needs_a_separator_after_its_prefix` and
1110+
/// `a_directory_include_glob_does_not_claim_a_backslash_named_sibling`, not by a hand-written
1111+
/// prefix check.
11021112
pub fn globs_claim(&self, relative_path: &str) -> bool {
1103-
!self.exclude.iter().any(|pattern| glob_claims(relative_path, pattern))
1104-
&& self.include.iter().any(|pattern| glob_claims(relative_path, pattern))
1105-
}
1106-
}
1107-
1108-
/// Whether one config glob claims `path` (a `/`-separated repo-relative rendering). Three shapes:
1109-
/// a `**/*.ext` suffix, a `dir/**` subtree, and a literal (exact or substring).
1110-
///
1111-
/// A `dir/**` subtree requires the prefix to END AT A SEPARATOR. A bare `starts_with` reads a
1112-
/// PREFIX OF A NAME as a directory boundary, so `drafts/**` also claimed the Unix file
1113-
/// `drafts\secret.md` (and `draftsman.md`), excluding a file that is not in `drafts/` at all —
1114-
/// which is exactly what preserving a literal backslash in the rendering exists to prevent.
1115-
fn glob_claims(path: &str, pattern: &str) -> bool {
1116-
if let Some(extension) = pattern.strip_prefix("**/*.") {
1117-
return path.ends_with(&format!(".{extension}"));
1118-
}
1119-
if let Some(prefix) = pattern.strip_suffix("/**") {
1120-
return path
1121-
.strip_prefix(prefix)
1122-
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
1113+
!self.exclude.iter().any(|pattern| globs::pattern_claims(relative_path, pattern))
1114+
&& self.include.iter().any(|pattern| globs::pattern_claims(relative_path, pattern))
11231115
}
1124-
path == pattern || path.contains(pattern.trim_matches('*'))
11251116
}
11261117

11271118
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

0 commit comments

Comments
 (0)