diff --git a/packages/python/e2e-attestations/bot-speed-walk-components-1011.json b/packages/python/e2e-attestations/bot-speed-walk-components-1011.json new file mode 100644 index 000000000..f317f1f6a --- /dev/null +++ b/packages/python/e2e-attestations/bot-speed-walk-components-1011.json @@ -0,0 +1,7 @@ +{ + "command": "uv run python -m pytest tests/e2e/glob_speed_depth_bound_test.py tests/e2e/glob_speed_one_level_test.py tests/e2e/glob_speed_recursive_test.py tests/e2e/glob_speed_symlink_test.py tests/e2e/glob_speed_dotfiles_test.py tests/e2e/glob_speed_node_modules_test.py tests/e2e/glob_speed_matchers_test.py tests/e2e/glob_speed_gitignore_test.py tests/e2e/stat_speed_test.py tests/e2e/query_contract_test.py -x -q", + "ran_at": 1791771802, + "exit_code": 0, + "commit": "6a704d60f7963656097b921100e1f3aaefba6bbd", + "branch": "bot/speed-walk-components-1011" +} diff --git a/packages/rust/changelog.d/2026-10-11-faster-glob-matching.md b/packages/rust/changelog.d/2026-10-11-faster-glob-matching.md new file mode 100644 index 000000000..26ecc04f4 --- /dev/null +++ b/packages/rust/changelog.d/2026-10-11-faster-glob-matching.md @@ -0,0 +1 @@ +**Changed** Matcher globs under `**` list sooner: a `**/`-led pattern is tested against the file name alone, and a path is no longer copied to be matched. diff --git a/packages/rust/migrations.d/2026-10-11-faster-glob-matching.md b/packages/rust/migrations.d/2026-10-11-faster-glob-matching.md new file mode 100644 index 000000000..11438480f --- /dev/null +++ b/packages/rust/migrations.d/2026-10-11-faster-glob-matching.md @@ -0,0 +1,21 @@ +### Faster glob matching + +#### Summary + +Glob matching under `**` copies fewer paths. Results and ordering are unchanged; nothing breaks. + +#### Required changes + +_None._ + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +_None._ + +#### Verification + +Run `dirsql --help`; the CLI starts as before. diff --git a/packages/rust/src/matcher.rs b/packages/rust/src/matcher.rs index a6a998ccb..28bf17389 100644 --- a/packages/rust/src/matcher.rs +++ b/packages/rust/src/matcher.rs @@ -17,25 +17,48 @@ pub enum GlobError { /// byte-oriented, so it is recompiled in Unicode mode over the `/`-separated /// path text. #[derive(Debug, Clone)] -pub(crate) struct Pattern(Regex); +pub(crate) struct Pattern { + path: Regex, + /// For a `**/`-led glob whose remainder is one component: that remainder, + /// which a path matches exactly when its last component does. + last_component: Option, +} impl Pattern { /// `*` and `?` stop at `/`; `**` crosses it. pub(crate) fn new(glob: &str) -> Result { - let glob = GlobBuilder::new(&expand_posix_classes(glob)) - .literal_separator(true) - .build()?; - let regex = RegexBuilder::new(&unicode_regex(glob.regex())) - .dot_matches_new_line(true) - .build()?; - Ok(Self(regex)) + let last_component = match glob.strip_prefix("**/") { + Some(rest) if !rest.is_empty() && !rest.contains('/') => Some(compile(rest)?), + _ => None, + }; + Ok(Self { + path: compile(glob)?, + last_component, + }) } pub(crate) fn is_match(&self, path: &Path) -> bool { - self.0.is_match(&crate::scanner::to_slash(path)) + if let Some((regex, name)) = self.last_component.as_ref().zip(path.file_name()) { + return regex.is_match(&name.to_string_lossy()); + } + self.path.is_match(&crate::scanner::slashed(path)) + } + + #[cfg(test)] + pub(crate) fn matches_last_component_only(&self) -> bool { + self.last_component.is_some() } } +fn compile(glob: &str) -> Result { + let glob = GlobBuilder::new(&expand_posix_classes(glob)) + .literal_separator(true) + .build()?; + Ok(RegexBuilder::new(&unicode_regex(glob.regex())) + .dot_matches_new_line(true) + .build()?) +} + /// globset spells each non-ASCII character as the `\xNN` escapes of its UTF-8 /// bytes, which in Unicode mode would name code points instead; decode each /// run back into the characters it encodes. @@ -193,6 +216,44 @@ mod tests { .collect() } + #[test] + fn a_globstar_led_single_component_glob_matches_on_the_last_component_only() { + assert!( + Pattern::new("**/*.md") + .unwrap() + .matches_last_component_only() + ); + assert!( + Pattern::new("**/{a,b}*.md") + .unwrap() + .matches_last_component_only() + ); + } + + #[test] + fn a_glob_with_a_directory_part_matches_the_whole_path() { + assert!( + !Pattern::new("a/**/*.md") + .unwrap() + .matches_last_component_only() + ); + assert!( + !Pattern::new("**/a/*.md") + .unwrap() + .matches_last_component_only() + ); + assert!(!Pattern::new("*.md").unwrap().matches_last_component_only()); + } + + #[test] + fn a_last_component_pattern_matches_at_any_depth_including_the_top() { + let pattern = Pattern::new("**/[[:digit:]]*.md").unwrap(); + assert!(pattern.is_match(Path::new("1a.md"))); + assert!(pattern.is_match(Path::new("x/.y/2b.md"))); + assert!(!pattern.is_match(Path::new("1/b.md"))); + assert!(!pattern.is_match(Path::new("1a.md/b.txt"))); + } + #[test] fn match_all_returns_table_for_matching_glob() { let matcher = TableMatcher::new(&[("*.csv", "data")], &[]).unwrap(); diff --git a/packages/rust/src/scanner.rs b/packages/rust/src/scanner.rs index d2f377cbf..56d697663 100644 --- a/packages/rust/src/scanner.rs +++ b/packages/rust/src/scanner.rs @@ -3,6 +3,7 @@ use crate::matcher::{GlobError, Pattern, TableMatcher}; use crate::tree_walk::{Step, walk_in_order}; use ignore::Match; use ignore::gitignore::{Gitignore, GitignoreBuilder}; +use std::borrow::Cow; use std::ffi::OsStr; use std::fs; use std::path::{Path, PathBuf}; @@ -15,6 +16,14 @@ pub(crate) fn to_slash(path: &Path) -> String { with_slashes(&path.to_string_lossy(), std::path::MAIN_SEPARATOR) } +pub(crate) fn slashed(path: &Path) -> Cow<'_, str> { + let text = path.to_string_lossy(); + if std::path::MAIN_SEPARATOR == '/' { + return text; + } + Cow::Owned(with_slashes(&text, std::path::MAIN_SEPARATOR)) +} + fn with_slashes(path: &str, native: char) -> String { if native == '/' { return path.to_owned(); @@ -1222,6 +1231,15 @@ mod tests { assert_eq!(with_slashes(r"dir/a\b.txt", '/'), r"dir/a\b.txt"); } + #[cfg(unix)] + #[test] + fn slashed_borrows_a_native_unix_path() { + assert!(matches!( + slashed(Path::new("docs/nested/a.md")), + Cow::Borrowed("docs/nested/a.md") + )); + } + #[test] fn to_slash_keeps_a_native_unix_path() { assert_eq!(to_slash(Path::new("docs/nested/a.md")), "docs/nested/a.md"); diff --git a/packages/ts/e2e-attestations/bot-speed-walk-components-1011.json b/packages/ts/e2e-attestations/bot-speed-walk-components-1011.json new file mode 100644 index 000000000..11fb07e6d --- /dev/null +++ b/packages/ts/e2e-attestations/bot-speed-walk-components-1011.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1791771870, + "exit_code": 0, + "commit": "d8c9441c314abcf613ba761c0d84ffa5b953d6d6", + "branch": "bot/speed-walk-components-1011" +}