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:
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user