diff --git a/bench/glob/scan.mjs b/bench/glob/scan.mjs index b5292eba1e4d..c05efdda0686 100644 --- a/bench/glob/scan.mjs +++ b/bench/glob/scan.mjs @@ -5,6 +5,7 @@ import { bench, group, run } from "../runner.mjs"; const normalPattern = "*.ts"; const recursivePattern = "**/*.ts"; const nodeModulesPattern = "**/node_modules/**/*.js"; +const multiLevelPattern = "node_modules/*/lib/*.js"; const benchFdir = false; const cwd = undefined; @@ -104,6 +105,38 @@ group({ name: `node_modules pattern="${nodeModulesPattern}"`, summary: true }, ( }); }); +group({ name: `multi-level pattern="${multiLevelPattern}"`, summary: true }, () => { + bench("fast-glob", async () => { + const entries = await fg.glob([multiLevelPattern], fgOpts); + }); + + if (Glob) + bench("Bun.Glob", async () => { + const entries = await Array.fromAsync(new Glob(multiLevelPattern).scan(bunOpts)); + }); + + if (benchFdir) + bench("fdir", async () => { + const entries = await new fdir().withFullPaths().glob(multiLevelPattern).crawl(process.cwd()).withPromise(); + }); +}); + +group({ name: `sync multi-level pattern="${multiLevelPattern}"`, summary: true }, () => { + bench("fast-glob", () => { + const entries = fg.globSync([multiLevelPattern], fgOpts); + }); + + if (Glob) + bench("Bun.Glob", () => { + const entries = [...new Glob(multiLevelPattern).scanSync(bunOpts)]; + }); + + if (benchFdir) + bench("fdir", () => { + const entries = new fdir().withFullPaths().glob(multiLevelPattern).crawl(process.cwd()).sync(); + }); +}); + await run({ avg: true, colors: false, diff --git a/src/bun.js/node/dir_iterator.zig b/src/bun.js/node/dir_iterator.zig index 8e5493e76f58..2f928b6b4cc9 100644 --- a/src/bun.js/node/dir_iterator.zig +++ b/src/bun.js/node/dir_iterator.zig @@ -201,6 +201,10 @@ pub fn NewIterator(comptime use_windows_ospath: bool) type { end_index: usize, first: bool, name_data: if (use_windows_ospath) [257]u16 else [513]u8, + /// Optional kernel-side wildcard filter passed to NtQueryDirectoryFile. + /// Evaluated by FsRtlIsNameInExpression (case-insensitive, supports `*` and `?`). + /// Only honored on the first call (RestartScan=TRUE); sticky for the handle lifetime. + name_filter: ?[]const u16 = null, const Self = @This(); @@ -220,6 +224,16 @@ pub fn NewIterator(comptime use_windows_ospath: bool) type { @memset(&self.buf, 0); } + var filter_us: w.UNICODE_STRING = undefined; + const filter_ptr: ?*w.UNICODE_STRING = if (self.name_filter) |f| blk: { + filter_us = .{ + .Length = @intCast(f.len * 2), + .MaximumLength = @intCast(f.len * 2), + .Buffer = @constCast(f.ptr), + }; + break :blk &filter_us; + } else null; + const rc = w.ntdll.NtQueryDirectoryFile( self.dir.cast(), null, @@ -230,7 +244,7 @@ pub fn NewIterator(comptime use_windows_ospath: bool) type { self.buf.len, .FileDirectoryInformation, w.FALSE, - null, + filter_ptr, if (self.first) @as(w.BOOLEAN, w.TRUE) else @as(w.BOOLEAN, w.FALSE), ); @@ -252,7 +266,9 @@ pub fn NewIterator(comptime use_windows_ospath: bool) type { }; } - if (rc == .NO_MORE_FILES) { + // NO_SUCH_FILE is returned on the first call when a FileName filter + // matches nothing; NO_MORE_FILES on subsequent calls. Both mean "done". + if (rc == .NO_MORE_FILES or rc == .NO_SUCH_FILE) { bun.sys.syslog("NtQueryDirectoryFile({f}) = {s}", .{ self.dir, @tagName(rc) }); self.end_index = self.index; return .{ .result = null }; @@ -449,6 +465,11 @@ pub fn NewWrappedIterator(comptime path_type: PathType) type { }, }; } + + pub fn setNameFilter(self: *Self, filter: ?[]const u16) void { + if (comptime !bun.Environment.isWindows) return; + self.iter.name_filter = filter; + } }; } diff --git a/src/glob/GlobWalker.zig b/src/glob/GlobWalker.zig index 2d275a3faab9..fd5e75d9a85b 100644 --- a/src/glob/GlobWalker.zig +++ b/src/glob/GlobWalker.zig @@ -137,6 +137,10 @@ pub const SyscallAccessor = struct { pub inline fn iterate(dir: Handle) DirIter { return .{ .value = DirIterator.WrappedIterator.init(dir.value) }; } + + pub inline fn setNameFilter(self: *DirIter, filter: ?[]const u16) void { + self.value.setNameFilter(filter); + } }; pub fn open(path: [:0]const u8) !Maybe(Handle) { @@ -435,6 +439,8 @@ pub fn GlobWalker_( /// directory being iterated on. fds_open: if (count_fds) usize else u0 = 0, + nt_filter_buf: if (isWindows) [256]u16 else void = if (isWindows) undefined else {}, + pub fn init(this: *Iterator) !Maybe(void) { log("Iterator init pattern={s}", .{this.walker.pattern}); var was_absolute = false; @@ -702,13 +708,49 @@ pub fn GlobWalker_( log("Transition(dirpath={s}, component_idx={d})", .{ dir_path, component_idx }); this.iter_state.directory.fd = fd; - const iterator = Accessor.DirIter.iterate(fd); + var iterator = Accessor.DirIter.iterate(fd); + if (comptime isWindows) { + if (@hasDecl(Accessor.DirIter, "setNameFilter")) { + iterator.setNameFilter(this.computeNtFilter(component_idx)); + } + } this.iter_state.directory.iter = iterator; this.iter_state.directory.iter_closed = false; return .success; } + /// Compute an optional NtQueryDirectoryFile FileName filter for the current + /// pattern component. The kernel filter is used purely as a pre-filter; + /// matchPatternImpl still runs on every returned entry for correctness + /// (case sensitivity, 8.3 aliases, etc). We only emit a filter when the + /// NT match is guaranteed to be a superset of the glob match. + fn computeNtFilter(this: *Iterator, component_idx: u32) ?[]const u16 { + if (comptime !isWindows) return null; + + const comp = &this.walker.patternComponents.items[component_idx]; + switch (comp.syntax_hint) { + // `*` and `**` match everything; a filter gains nothing and for `**` + // would incorrectly hide subdirectories we need to recurse into. + .Single, .Double, .Dot, .DotBack => return null, + else => {}, + } + + const slice = comp.patternSlice(this.walker.pattern); + if (slice.len == 0 or slice.len > this.nt_filter_buf.len) return null; + + // Only `*` and literals are safe to lower. Reject anything NT cannot + // express (`[` `{` `\` `!`) or where NT semantics under-match glob + // (`?` matches one UTF-16 code unit, glob matches one codepoint). + // `<` `>` `"` are NT wildcards; treating them as literals would over-match, + // but they are invalid in Windows filenames so such a pattern never matches + // anyway. + if (bun.strings.indexOfAny(slice, "?[{\\!<>\"") != null) return null; + + const wide = bun.strings.convertUTF8toUTF16InBuffer(&this.nt_filter_buf, slice); + return wide; + } + pub fn next(this: *Iterator) !Maybe(?MatchedPath) { while (true) { switch (this.iter_state) {