Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions packages/rust/migrations.d/2026-10-11-faster-glob-matching.md
Original file line number Diff line number Diff line change
@@ -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.
79 changes: 70 additions & 9 deletions packages/rust/src/matcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Regex>,
}

impl Pattern {
/// `*` and `?` stop at `/`; `**` crosses it.
pub(crate) fn new(glob: &str) -> Result<Self, GlobError> {
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<Regex, GlobError> {
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.
Expand Down Expand Up @@ -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();
Expand Down
18 changes: 18 additions & 0 deletions packages/rust/src/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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();
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "pnpm test:e2e",
"ran_at": 1791771870,
"exit_code": 0,
"commit": "d8c9441c314abcf613ba761c0d84ffa5b953d6d6",
"branch": "bot/speed-walk-components-1011"
}
Loading