From bf4391810e4ef9a95f24da10dec7ce0d73842d60 Mon Sep 17 00:00:00 2001 From: JayOfTheKeyboard <140487034+JayOfTheKeyboard@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:00:19 +1100 Subject: [PATCH] fix(rust): match managed rg globs under ./-prefixed search paths Managed rg resolved relative search paths with root.join(path), so `.`, `./` and `./src` walked `/./...`. The ignore crate strips the root from candidates as a byte prefix and matched `./src/a.rs`, so rules with a slash never matched: `-g 'src/**' needle .` returned nothing, and `-g '!docs/**'` or an --ignore-file pattern such as `docs/*.rs` excluded nothing. Drop `.` segments from relative walk roots, matching what ripgrep sees from the workspace root. --- rust/crates/zg-engine/src/lexical/mod.rs | 54 +++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/rust/crates/zg-engine/src/lexical/mod.rs b/rust/crates/zg-engine/src/lexical/mod.rs index b4302b3be..41f520451 100644 --- a/rust/crates/zg-engine/src/lexical/mod.rs +++ b/rust/crates/zg-engine/src/lexical/mod.rs @@ -455,7 +455,7 @@ fn build_walker( checked_paths .existing .iter() - .map(|path| resolve_path(root, path)) + .map(|path| resolve_match_path(root, path)) .collect() }; let mut walker = WalkBuilder::from_iter(paths); @@ -832,6 +832,19 @@ fn resolve_path(root: &Path, path: &Path) -> PathBuf { } } +/// Resolves a search path whose descendants are matched against globs and ignore files. +/// +/// The `ignore` crate strips the workspace root from candidates as a byte +/// prefix, so `/./src/a.rs` would be matched as `./src/a.rs` and miss +/// `src/**`. Absolute paths are kept as given, as ripgrep keeps them. +fn resolve_match_path(root: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path).components().collect() + } +} + fn is_hard_ignored_path(path: &Path) -> bool { path.components().any(|component| { let Component::Normal(name) = component else { @@ -1548,6 +1561,45 @@ mod tests { let reply = search(&LexicalSearchService::new(), root.path(), &unfiltered).await; assert_eq!(reply.matches.len(), 3); } + + #[tokio::test] + async fn path_rules_match_search_paths_with_a_current_directory_prefix() { + let root = TempDir::new().expect("temp dir"); + fs::create_dir(root.path().join("src")).expect("src dir"); + fs::create_dir(root.path().join("docs")).expect("docs dir"); + fs::write(root.path().join("src/keep.rs"), "needle\n").expect("src fixture"); + fs::write(root.path().join("docs/drop.rs"), "needle\n").expect("docs fixture"); + fs::write(root.path().join("custom.ignore"), "docs/*.rs\n").expect("ignore fixture"); + + let mut failures = Vec::new(); + for (paths, rule) in [ + (&[][..], "src/**"), + (&["."][..], "src/**"), + (&["./"][..], "src/**"), + (&["./src"][..], "src/*.rs"), + (&["."][..], "!docs/**"), + (&["./docs", "./src"][..], "!docs/**"), + (&[][..], "--ignore-file custom.ignore"), + (&["."][..], "--ignore-file custom.ignore"), + ] { + let mut filtered = request("needle"); + filtered.paths = paths.iter().map(PathBuf::from).collect(); + match rule.strip_prefix("--ignore-file ") { + Some(file) => filtered.options.ignore_files = vec![PathBuf::from(file)], + None => filtered.options.globs = vec![rule.to_owned()], + } + let reply = search(&LexicalSearchService::new(), root.path(), &filtered).await; + let found = reply + .matches + .iter() + .map(|item| item.relative_path.clone()) + .collect::>(); + if found != [Path::new("src/keep.rs")] { + failures.push(format!("paths {paths:?} {rule}: {found:?}")); + } + } + assert!(failures.is_empty(), "{failures:#?}"); + } } #[cfg(test)]