feat(natives): restricted traversal depth for glob pattern matching

- Added `walk_depth_bound` to calculate the maximum file path depth based on glob pattern segments.
- Configured the glob walker to restrict traversal depth for non-recursive patterns, preventing unnecessary scanning of subtrees that cannot match.
- Improved response times for narrow globs over large directories by pruning the search space earlier.
- Added test cases to verify depth-bounded matching behavior and pattern edge cases.
This commit is contained in:
can1357
2026-07-11 03:17:05 +02:00
parent 2f97b7fe4b
commit 45143e8c75
3 changed files with 94 additions and 1 deletions
+51 -1
View File
@@ -172,6 +172,9 @@ fn run_glob(
ct: task::CancelToken,
) -> Result<GlobResult> {
let walk_glob_pattern = glob_util::build_glob_pattern(&config.pattern, config.recursive);
// Non-recursive patterns bound the walk: `dir/*` must not traverse the
// entire subtree under `dir` to match only direct children.
let walk_depth_limit = glob_util::walk_depth_bound(&walk_glob_pattern);
let walk_glob = pi_walker::CompiledWalkGlob::new([walk_glob_pattern])
.map_err(|err| Error::from_reason(format!("Invalid glob pattern: {err}")))?;
if config.max_results == 0 {
@@ -192,7 +195,7 @@ fn run_glob(
.detail(scan_detail)
.order(pi_walker::WalkOrder::Path)
.emit_root(false)
.depth(1, usize::MAX)
.depth(1, walk_depth_limit)
.directory_errors(pi_walker::DirectoryErrorMode::SkipSkippable)
.cache(config.cache)
.empty_recheck(pi_walker::EmptyRecheck::Configured)
@@ -378,4 +381,51 @@ mod tests {
"gitignored directory should be pruned before matching, got {paths:?}"
);
}
#[test]
fn run_glob_depth_bounded_patterns_still_match_at_their_exact_depth() {
// The walk for non-`**` patterns is depth-bounded (see walk_depth_bound);
// this defends the boundary: matches AT the bound depth must survive,
// deeper entries must not appear, and the mtime-ranked mode (the glob
// tool default) must behave identically to the streaming mode.
let root = TempDirGuard::new();
fs::write(root.path().join("top.txt"), "top").expect("write top file");
fs::create_dir_all(root.path().join("deep/nested")).expect("create nested dirs");
fs::write(root.path().join("deep/child.txt"), "mid").expect("write mid file");
fs::write(root.path().join("deep/nested/leaf.txt"), "leaf").expect("write leaf file");
let run = |pattern: &str| {
super::run_glob(
super::GlobConfig {
root: root.path().to_path_buf(),
pattern: pattern.to_string(),
recursive: false,
include_hidden: true,
file_type_filter: None,
max_results: 100,
use_gitignore: true,
mentions_node_modules: false,
sort_by_mtime: true,
cache: false,
},
None,
crate::task::CancelToken::default(),
)
.expect("glob succeeds")
};
let direct = run("*.txt");
assert_eq!(match_paths(&direct), ["top.txt"]);
let two_deep = run("deep/*.txt");
assert_eq!(match_paths(&two_deep), ["deep/child.txt"]);
let wildcard_dir = run("*/nested/leaf.txt");
assert_eq!(match_paths(&wildcard_dir), ["deep/nested/leaf.txt"]);
let recursive = run("**/*.txt");
let mut recursive_paths = match_paths(&recursive);
recursive_paths.sort_unstable();
assert_eq!(recursive_paths, ["deep/child.txt", "deep/nested/leaf.txt", "top.txt"]);
}
}
+39
View File
@@ -64,6 +64,29 @@ pub fn build_glob_pattern(glob: &str, recursive: bool) -> String {
fix_unclosed_braces(pattern)
}
/// Maximum walk depth (path components) a normalized glob pattern can match,
/// or `usize::MAX` when unbounded.
///
/// Walk-relative globs compile with `literal_separator(true)`, so `*`, `?`,
/// and `[...]` never cross `/` — a pattern with N literal segments can only
/// match entries at most N components deep. Bounding the walk to that depth
/// keeps non-recursive patterns (`*`, `dir/*.json`) from traversing an entire
/// subtree they can never match into (the source of "narrow glob timed out on
/// a populated directory" failures).
///
/// `**` matches any number of components and `{...}` alternations may contain
/// `/`, so both disable the bound.
pub fn walk_depth_bound(pattern: &str) -> usize {
if pattern.contains("**") || pattern.contains('{') {
return usize::MAX;
}
pattern
.split('/')
.filter(|seg| !seg.is_empty())
.count()
.max(1)
}
/// Compile a glob pattern string into a [`CompiledGlob`].
///
/// When `recursive` is true, simple patterns (no path separators, no leading
@@ -220,6 +243,22 @@ mod tests {
assert_eq!(build_glob_pattern("*.ts", false), "*.ts");
}
#[test]
fn walk_depth_bound_counts_segments_for_bounded_patterns() {
assert_eq!(walk_depth_bound("*"), 1);
assert_eq!(walk_depth_bound("*.json"), 1);
assert_eq!(walk_depth_bound("dir/*.ts"), 2);
assert_eq!(walk_depth_bound("a/*/c.txt"), 3);
}
#[test]
fn walk_depth_bound_unbounded_for_recursive_and_brace_patterns() {
assert_eq!(walk_depth_bound("**/*"), usize::MAX);
assert_eq!(walk_depth_bound("src/**/*.ts"), usize::MAX);
// `{}` groups may contain `/`, so segment counting is unsound for them.
assert_eq!(walk_depth_bound("{a/b,c}/d.txt"), usize::MAX);
}
#[test]
fn compiled_non_recursive_extension_glob_matches_only_root_files() {
let glob = compile_glob("*.rs", false).expect("compile non-recursive extension glob");
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed non-recursive glob patterns traversing entire subtrees they could never match into: `dir/*.json` walked everything under `dir` (unbounded depth) because only the match filter — not the walk — knew the pattern was shallow. The walker is now depth-bounded by the pattern's segment count when it contains no `**` or brace alternation (wildcards never cross `/`), so narrow direct-child globs over huge directories (`~/.cache/*`-style) return in milliseconds instead of hitting the 5s timeout with zero partial matches.
## [16.3.13] - 2026-07-09
### Fixed