diff --git a/src/node/internal/internal_fs_callback.ts b/src/node/internal/internal_fs_callback.ts index d4c76fe116e..3153e7444b2 100644 --- a/src/node/internal/internal_fs_callback.ts +++ b/src/node/internal/internal_fs_callback.ts @@ -73,7 +73,7 @@ import { ERR_INVALID_ARG_VALUE, ERR_UNSUPPORTED_OPERATION, } from 'node-internal:internal_errors'; -import { type Dir } from 'node-internal:internal_fs'; +import { type Dir, Dirent } from 'node-internal:internal_fs'; import { Buffer } from 'node-internal:internal_buffer'; import { isArrayBufferView } from 'node-internal:internal_types'; import { @@ -1416,18 +1416,26 @@ export function watchFile(): void { } export function glob( - _pattern: string | readonly string[], - _options: + pattern: string | readonly string[], + optionsOrCallback: | GlobOptions | GlobOptionsWithFileTypes - | GlobOptionsWithoutFileTypes, - _callback: ErrorOnlyCallback + | GlobOptionsWithoutFileTypes + | SingleArgCallback, + callback?: SingleArgCallback ): void { - // We do not yet implement the globSync function. In Node.js, this - // function depends heavily on the third party minimatch library - // which is not yet available in the workers runtime. This will be - // explored for implementation separately in the future. - throw new ERR_UNSUPPORTED_OPERATION(); + let options: + | GlobOptions + | GlobOptionsWithFileTypes + | GlobOptionsWithoutFileTypes; + if (typeof optionsOrCallback === 'function') { + callback = optionsOrCallback; + options = {}; + } else { + options = optionsOrCallback; + } + if (callback === undefined) return; + callWithSingleArgCallback(() => fssync.globSync(pattern, options), callback); } // An API is considered stubbed if it is not implemented by the function @@ -1497,5 +1505,5 @@ export function glob( // [ ][ ][ ][ ] fs.createReadStream(path[, options]) // [ ][ ][ ][ ] fs.createWriteStream(path[, options]) // -// [ ][ ][ ][ ] fs.glob(pattern[, options], callback) +// [x][x][x][x] fs.glob(pattern[, options], callback) // [ ][ ][ ][ ] fs.openAsBlob(path[, options]) diff --git a/src/node/internal/internal_fs_glob.ts b/src/node/internal/internal_fs_glob.ts new file mode 100644 index 00000000000..778f852c41c --- /dev/null +++ b/src/node/internal/internal_fs_glob.ts @@ -0,0 +1,523 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import { default as cffs } from 'cloudflare-internal:filesystem'; +import type { DirEntryHandle } from 'cloudflare-internal:filesystem'; +import { normalizePath } from 'node-internal:internal_fs_utils'; + +// UV_DIRENT_DIR constant from internal_fs_constants +const UV_DIRENT_DIR = 2; + +// Maximum recursion depth to prevent stack overflow on deeply nested VFS +const MAX_WALK_DEPTH = 256; + +// ============================================================================ +// Brace Expansion +// ============================================================================ + +// Splits a string by top-level occurrences of a separator character, +// respecting nested braces/parens and backslash escapes. +function splitTopLevel(str: string, sep: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ''; + + for (const c of str) { + if (c === '{' || c === '(') { + depth++; + current += c; + } else if (c === '}' || c === ')') { + depth--; + current += c; + } else if (c === sep && depth === 0) { + parts.push(current); + current = ''; + } else { + current += c; + } + } + + parts.push(current); + return parts; +} + +// Expands brace expressions in a glob pattern into multiple patterns. +export function expandBraces(pattern: string): string[] { + let depth = 0; + let braceStart = -1; + + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === '\\' && i + 1 < pattern.length) { + i++; + continue; + } + if (c === '{') { + if (depth === 0) braceStart = i; + depth++; + } else if (c === '}') { + depth--; + if (depth === 0 && braceStart !== -1) { + const prefix = pattern.slice(0, braceStart); + const body = pattern.slice(braceStart + 1, i); + const suffix = pattern.slice(i + 1); + + const alternatives = splitTopLevel(body, ','); + + if (alternatives.length === 1) { + return [pattern]; + } + + const results: string[] = []; + for (const alt of alternatives) { + for (const expanded of expandBraces(prefix + alt + suffix)) { + results.push(expanded); + } + } + return results; + } + } + } + + return [pattern]; +} + +// ============================================================================ +// Pattern Normalization +// ============================================================================ + +export function normalizePattern(pattern: string): string { + // Strip leading ./ + if (pattern.startsWith('./')) { + pattern = pattern.slice(2); + } + // Collapse multiple slashes to single + pattern = pattern.replace(/\/\/+/g, '/'); + // Strip trailing slash + if (pattern.endsWith('/') && pattern.length > 1) { + pattern = pattern.slice(0, -1); + } + return pattern; +} + +// ============================================================================ +// Segment-Level Regex (with extglob support) +// ============================================================================ + +// Converts a single path segment pattern to a RegExp. +// Supports: *, ?, [...], [!...], @(...), *(...), +(...), ?(...), !(...) +export function segmentToRegex(segment: string): RegExp { + const regex = segmentToRegexStr(segment); + return new RegExp('^' + regex + '$'); +} + +function segmentToRegexStr(segment: string): string { + let regex = ''; + let i = 0; + let inCharClass = false; + + while (i < segment.length) { + const c = segment[i] ?? ''; + + // Handle escape sequences + if (c === '\\' && i + 1 < segment.length) { + regex += '\\' + escapeRegexChar(segment[i + 1] ?? ''); + i += 2; + continue; + } + + // Inside character classes, most chars are literal + if (inCharClass) { + if (c === ']') { + regex += ']'; + inCharClass = false; + } else { + regex += c; + } + i++; + continue; + } + + // Check for extglob: @(...), *(...), +(...), ?(...), !(...) + if ( + (c === '@' || c === '*' || c === '+' || c === '?' || c === '!') && + i + 1 < segment.length && + segment[i + 1] === '(' + ) { + const closeIdx = findMatchingParen(segment, i + 1); + if (closeIdx !== -1) { + const inner = segment.slice(i + 2, closeIdx); + // Convert pipe-separated alternatives, each may contain glob chars + const alts = splitTopLevel(inner, '|'); + const altRegexes = alts.map((a) => segmentToRegexStr(a)); + const group = altRegexes.join('|'); + + switch (c) { + case '@': // exactly one + regex += '(?:' + group + ')'; + break; + case '*': // zero or more + regex += '(?:' + group + ')*'; + break; + case '+': // one or more + regex += '(?:' + group + ')+'; + break; + case '?': // zero or one + regex += '(?:' + group + ')?'; + break; + case '!': // none of (negative lookahead) + regex += '(?!(?:' + group + ')$)[^/]*'; + break; + } + + i = closeIdx + 1; + continue; + } + } + + switch (c) { + case '[': + inCharClass = true; + regex += '['; + if (i + 1 < segment.length && segment[i + 1] === '!') { + regex += '^'; + i++; + } + break; + + case '*': + regex += '[^/]*'; + break; + + case '?': + regex += '[^/]'; + break; + + case '.': + case '+': + case '^': + case '$': + case '|': + case '(': + case ')': + regex += '\\' + c; + break; + + default: + regex += c; + break; + } + i++; + } + + return regex; +} + +function findMatchingParen(str: string, openIdx: number): number { + let depth = 0; + for (let i = openIdx; i < str.length; i++) { + if (str[i] === '\\' && i + 1 < str.length) { + i++; + continue; + } + if (str[i] === '(') depth++; + else if (str[i] === ')') { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +function escapeRegexChar(c: string): string { + if ('.+*?^$|()[]{}\\'.includes(c)) { + return '\\' + c; + } + return c; +} + +// ============================================================================ +// Full-Path Regex (used for exclude pattern matching) +// ============================================================================ + +// Converts a full glob pattern (with /) to a single RegExp for exclude matching. +export function globToRegex(pattern: string): RegExp { + const normalized = normalizePattern(pattern); + const segments = normalized.split('/').filter((s) => s !== ''); + const parts: string[] = []; + + for (const seg of segments) { + if (seg === '**') { + // ** matches zero or more path segments + // We handle this by inserting a special marker + parts.push('**'); + } else if (seg === '.') { + // skip + } else if (seg === '..') { + parts.pop(); + } else { + parts.push(segmentToRegexStr(seg)); + } + } + + // Now build regex from parts, handling ** markers + let regex = ''; + for (let i = 0; i < parts.length; i++) { + const part = parts[i] ?? ''; + const prevPart = i > 0 ? (parts[i - 1] ?? '') : ''; + if (part === '**') { + if (parts.length === 1) { + // ** alone: match everything + regex = '.*'; + } else if (i === 0) { + // ** at start: match zero or more leading segments + regex += '(?:.*\\/)?'; + } else if (i === parts.length - 1) { + // ** at end: match zero or more trailing segments + regex += '(?:\\/.*)?'; + } else { + // ** in middle: match zero or more middle segments + regex += '(?:\\/[^/]+)*(?:\\/)?'; + } + } else { + if (i > 0 && prevPart !== '**') { + regex += '\\/'; + } + regex += part; + } + } + + return new RegExp('^' + regex + '$'); +} + +// ============================================================================ +// Exclude Compiler +// ============================================================================ + +// Compiles an array of glob patterns into an exclude function. +// Only accepts string arrays — user-provided functions are handled +// separately in globSync to preserve their (string | Dirent) signature. +export function compileExcludePatterns( + exclude: readonly string[] +): (path: string) => boolean { + const regexes: RegExp[] = []; + for (const pat of exclude) { + for (const expanded of expandBraces(pat)) { + regexes.push(globToRegex(expanded)); + } + } + + return (path: string): boolean => { + for (const re of regexes) { + if (re.test(path)) return true; + } + return false; + }; +} + +// ============================================================================ +// Directory Entry Cache +// ============================================================================ + +type EntryCache = Map; + +function getDirectoryEntries( + absPath: string, + cache: EntryCache +): DirEntryHandle[] { + const cached = cache.get(absPath); + if (cached !== undefined) return cached; + + try { + const entries = cffs.readdir(normalizePath(absPath), { recursive: false }); + cache.set(absPath, entries); + return entries; + } catch { + const empty: DirEntryHandle[] = []; + cache.set(absPath, empty); + return empty; + } +} + +function isDirectory(entry: DirEntryHandle): boolean { + return entry.type === UV_DIRENT_DIR; +} + +// ============================================================================ +// Pattern-Driven Directory Walk +// ============================================================================ + +export interface GlobResult { + relativePath: string; + handle: DirEntryHandle | null; +} + +export function walkGlob( + cwd: string, + segments: string[], + segIdx: number, + currentAbsPath: string, + relativePath: string, + results: Map, + cache: EntryCache, + visitedGlobstar?: Set, + depth: number = 0 +): void { + // Guard against excessive recursion depth + if (depth >= MAX_WALK_DEPTH) return; + + // All segments consumed: this path is a match + if (segIdx >= segments.length) { + if (!results.has(relativePath)) { + // Resolve handle for the matched path by reading the parent directory + const lastSlash = currentAbsPath.lastIndexOf('/'); + const parentDir = + lastSlash > 0 ? currentAbsPath.slice(0, lastSlash) : currentAbsPath; + const basename = relativePath.split('/').pop() ?? ''; + const entries = getDirectoryEntries(parentDir, cache); + const handle = entries.find((e) => e.name === basename) ?? null; + results.set(relativePath, { relativePath, handle }); + } + return; + } + + const seg = segments[segIdx] ?? ''; + + // Handle '.' — stay in current directory + if (seg === '.') { + walkGlob( + cwd, + segments, + segIdx + 1, + currentAbsPath, + relativePath, + results, + cache, + visitedGlobstar, + depth + ); + return; + } + + // Handle '..' — go up one directory (but don't escape cwd) + if (seg === '..') { + if (currentAbsPath === cwd || currentAbsPath.length <= cwd.length) { + return; + } + + const lastSlash = currentAbsPath.lastIndexOf('/'); + const newAbs = lastSlash > 0 ? currentAbsPath.slice(0, lastSlash) : '/'; + + const relParts = relativePath.split('/').filter(Boolean); + relParts.pop(); + const newRel = relParts.join('/'); + + walkGlob( + cwd, + segments, + segIdx + 1, + newAbs, + newRel, + results, + cache, + visitedGlobstar, + depth + ); + return; + } + + // Handle '**' — match zero or more directory levels + if (seg === '**') { + const gsKey = `${currentAbsPath}:${String(segIdx)}`; + if (visitedGlobstar === undefined) { + visitedGlobstar = new Set(); + } + if (visitedGlobstar.has(gsKey)) return; + visitedGlobstar.add(gsKey); + + // Zero levels: advance to next segment at current path + walkGlob( + cwd, + segments, + segIdx + 1, + currentAbsPath, + relativePath, + results, + cache, + visitedGlobstar, + depth + ); + + // One or more levels: enumerate children + const entries = getDirectoryEntries(currentAbsPath, cache); + for (const entry of entries) { + const childAbs = currentAbsPath + '/' + entry.name; + const childRel = relativePath + ? relativePath + '/' + entry.name + : entry.name; + + // Try matching next segment against this child + walkGlob( + cwd, + segments, + segIdx + 1, + childAbs, + childRel, + results, + cache, + visitedGlobstar, + depth + 1 + ); + + // If directory, recurse ** deeper + if (isDirectory(entry)) { + walkGlob( + cwd, + segments, + segIdx, + childAbs, + childRel, + results, + cache, + visitedGlobstar, + depth + 1 + ); + } + } + return; + } + + // Regular segment: match against directory entries + const segRegex = segmentToRegex(seg); + const entries = getDirectoryEntries(currentAbsPath, cache); + + for (const entry of entries) { + if (segRegex.test(entry.name)) { + const childAbs = currentAbsPath + '/' + entry.name; + const childRel = relativePath + ? relativePath + '/' + entry.name + : entry.name; + + if (segIdx + 1 >= segments.length) { + // This is the last segment — record match + if (!results.has(childRel)) { + results.set(childRel, { relativePath: childRel, handle: entry }); + } + } else { + // More segments to match — recurse + walkGlob( + cwd, + segments, + segIdx + 1, + childAbs, + childRel, + results, + cache, + visitedGlobstar, + depth + 1 + ); + } + } + } +} diff --git a/src/node/internal/internal_fs_promises.ts b/src/node/internal/internal_fs_promises.ts index e192430b717..1e27242baa6 100644 --- a/src/node/internal/internal_fs_promises.ts +++ b/src/node/internal/internal_fs_promises.ts @@ -57,10 +57,7 @@ import { import type { Dirent } from 'node-internal:internal_fs'; import { Buffer } from 'node-internal:internal_buffer'; import { type Dir } from 'node-internal:internal_fs'; -import { - ERR_EBADF, - ERR_UNSUPPORTED_OPERATION, -} from 'node-internal:internal_errors'; +import { ERR_EBADF } from 'node-internal:internal_errors'; import { validateBoolean, validateObject, @@ -664,18 +661,17 @@ export function writeFile( }); } -export function glob( - _pattern: string | readonly string[], - _options: +export function* glob( + pattern: string | readonly string[], + options: | GlobOptions | GlobOptionsWithFileTypes | GlobOptionsWithoutFileTypes = {} -): NodeJS.AsyncIterator { - // We do not yet implement the globSync function. In Node.js, this - // function depends heavily on the third party minimatch library - // which is not yet available in the workers runtime. This will be - // explored for implementation separately in the future. - throw new ERR_UNSUPPORTED_OPERATION(); +): Generator { + const results = fssync.globSync(pattern, options); + for (const result of results) { + yield result; + } } function getReadableWebStream( diff --git a/src/node/internal/internal_fs_sync.ts b/src/node/internal/internal_fs_sync.ts index b5419a15378..af983d2ffa8 100644 --- a/src/node/internal/internal_fs_sync.ts +++ b/src/node/internal/internal_fs_sync.ts @@ -87,6 +87,14 @@ import { Dir, Dirent } from 'node-internal:internal_fs'; import { default as cffs } from 'cloudflare-internal:filesystem'; import { Buffer } from 'node-internal:internal_buffer'; +import { + expandBraces, + normalizePattern, + walkGlob, + compileExcludePatterns, + type GlobResult, +} from 'node-internal:internal_fs_glob'; +import processImpl from 'node-internal:process'; import type { BigIntStatsFs, CopySyncOptions, @@ -839,17 +847,84 @@ export function writevSync( } export function globSync( - _pattern: string | readonly string[], - _options: + pattern: string | readonly string[], + options: | GlobOptions | GlobOptionsWithFileTypes | GlobOptionsWithoutFileTypes = {} -): string[] { - // We do not yet implement the globSync function. In Node.js, this - // function depends heavily on the third party minimatch library - // which is not yet available in the workers runtime. This will be - // explored for implementation separately in the future. - throw new ERR_UNSUPPORTED_OPERATION(); +): string[] | Dirent[] { + // Normalize pattern to array + const patterns: string[] = + typeof pattern === 'string' ? [pattern] : [...pattern]; + for (const p of patterns) { + validateString(p, 'pattern'); + } + + if (typeof options !== 'object' || options === null) { + validateObject(options, 'options'); + } + + const cwdOption = (options as GlobOptions).cwd; + const cwd: string = + cwdOption instanceof URL + ? cwdOption.pathname + : ((cwdOption as string | undefined) ?? processImpl.getCwd()); + validateString(cwd, 'options.cwd'); + + const withFileTypes: boolean = + (options as GlobOptionsWithFileTypes).withFileTypes ?? false; + + // Exclude can be a user function or an array of glob patterns. + // Keep them as separate variables to preserve proper type signatures. + const excludeOption = (options as GlobOptions).exclude; + let excludeUserFn: ((path: string | Dirent) => boolean) | undefined; + let excludePatternFn: ((path: string) => boolean) | undefined; + if (typeof excludeOption === 'function') { + excludeUserFn = excludeOption as (path: string | Dirent) => boolean; + } else if (Array.isArray(excludeOption)) { + excludePatternFn = compileExcludePatterns(excludeOption as string[]); + } + + // Pattern-driven directory walk + const results = new Map(); + const dirCache = new Map< + string, + import('cloudflare-internal:filesystem').DirEntryHandle[] + >(); + + for (const p of patterns) { + for (const expanded of expandBraces(p)) { + const normalized = normalizePattern(expanded); + const segments = normalized.split('/').filter((s) => s !== ''); + if (segments.length === 0) continue; + walkGlob(cwd, segments, 0, cwd, '', results, dirCache); + } + } + + // Build final results, applying exclude filter + const stringResults: string[] = []; + const direntResults: Dirent[] = []; + + for (const [relPath, entry] of results) { + if (!relPath) continue; // skip empty paths + + if (excludePatternFn && excludePatternFn(relPath)) continue; + + if (withFileTypes) { + const parts = relPath.split('/'); + const name = parts.pop() ?? ''; + const parentPath = cwd + (parts.length ? '/' + parts.join('/') : ''); + const type = entry.handle?.type ?? 0; + const dirent = new Dirent(name, type, parentPath); + if (excludeUserFn && excludeUserFn(dirent)) continue; + direntResults.push(dirent); + } else { + if (excludeUserFn && excludeUserFn(relPath)) continue; + stringResults.push(relPath); + } + } + + return withFileTypes ? direntResults : stringResults; } export interface OpenAsBlobOptions { @@ -930,4 +1005,4 @@ export function openAsBlob( // [x][x][2][x][x] fs.copyFileSync(src, dest[, mode]) // [x][x][2][x][x] fs.opendirSync(path[, options]) // [x][x][2][x][x] fs.cpSync(src, dest[, options]) -// [ ][ ][ ][ ][ ] fs.globSync(pattern[, options]) +// [x][x][2][x][x] fs.globSync(pattern[, options]) diff --git a/src/workerd/api/node/tests/fs-glob-test.js b/src/workerd/api/node/tests/fs-glob-test.js index e4a26460a98..126ea1d01c8 100644 --- a/src/workerd/api/node/tests/fs-glob-test.js +++ b/src/workerd/api/node/tests/fs-glob-test.js @@ -1,25 +1,355 @@ -import { throws } from 'node:assert'; -import { glob, globSync, promises } from 'node:fs'; +// Ported from Node.js test/parallel/test-fs-glob.mjs +import { deepStrictEqual, ok, strictEqual } from 'node:assert'; +import { glob, globSync, mkdirSync, writeFileSync, promises } from 'node:fs'; -function mustNotCall() { - throw new Error('This function should not be called'); +// Node.js test fixture structure (minus symlinks — not supported in workerd VFS tests) +function setupFixtures() { + const base = '/tmp/gt'; + mkdirSync(base + '/a/.abcdef/x/y/z', { recursive: true }); + mkdirSync(base + '/a/abcdef/g', { recursive: true }); + mkdirSync(base + '/a/abcfed/g', { recursive: true }); + mkdirSync(base + '/a/b/c', { recursive: true }); + mkdirSync(base + '/a/bc/e', { recursive: true }); + mkdirSync(base + '/a/c/d/c', { recursive: true }); + mkdirSync(base + '/a/cb/e', { recursive: true }); + mkdirSync(base + '/a/x/.y', { recursive: true }); + mkdirSync(base + '/a/z/.y', { recursive: true }); + + writeFileSync(base + '/a/.abcdef/x/y/z/a', 'x'); + writeFileSync(base + '/a/abcdef/g/h', 'x'); + writeFileSync(base + '/a/abcfed/g/h', 'x'); + writeFileSync(base + '/a/b/c/d', 'x'); + writeFileSync(base + '/a/bc/e/f', 'x'); + writeFileSync(base + '/a/c/d/c/b', 'x'); + writeFileSync(base + '/a/cb/e/f', 'x'); + writeFileSync(base + '/a/x/.y/b', 'x'); + writeFileSync(base + '/a/z/.y/b', 'x'); + writeFileSync(base + '/a/.b', 'x'); + writeFileSync(base + '/a/b/.b', 'x'); + + return base; } -export const globTest = { +const cwd = '/tmp/gt'; + +// ============================================================================ +// Pattern Matching Tests (ported from Node.js) +// ============================================================================ + +export const singleWildcardInSubdir = { + test() { + setupFixtures(); + const result = globSync('a/c/d/*/b', { cwd }); + deepStrictEqual(result.sort(), ['a/c/d/c/b']); + }, +}; + +export const doubleSlashNormalization = { + test() { + setupFixtures(); + // Double slashes should be normalized + const result = globSync('a//c//d//*//b', { cwd }); + deepStrictEqual(result.sort(), ['a/c/d/c/b']); + }, +}; + +export const multipleWildcards = { + test() { + setupFixtures(); + const result = globSync('a/*/d/*/b', { cwd }); + deepStrictEqual(result.sort(), ['a/c/d/c/b']); + }, +}; + +export const extglobPlus = { + test() { + setupFixtures(); + // +(c|g) should match one or more occurrences of c or g + // a/*/+(c|g)/./d → matches a/b/c/./d which resolves to a/b/c/d + const result = globSync('a/*/+(c|g)/./d', { cwd }); + deepStrictEqual(result.sort(), ['a/b/c/d']); + }, +}; + +export const braceExpansion = { + test() { + setupFixtures(); + const result = globSync('a/abc{fed,def}/g/h', { cwd }); + deepStrictEqual(result.sort(), ['a/abcdef/g/h', 'a/abcfed/g/h']); + }, +}; + +export const braceExpansionNoMatch = { + test() { + setupFixtures(); + // None of b,c,d,e,f directories have a **/g path + const result = globSync('a/{b,c,d,e,f}/**/g', { cwd }); + deepStrictEqual(result, []); + }, +}; + +export const globstarMatchesAll = { + test() { + setupFixtures(); + // a/b/** should match a/b itself and everything under it + const result = globSync('a/b/**', { cwd }); + deepStrictEqual(result.sort(), ['a/b', 'a/b/.b', 'a/b/c', 'a/b/c/d']); + }, +}; + +export const dotSlashGlobstar = { + test() { + setupFixtures(); + // ./**/g should find all 'g' entries + const result = globSync('./**/g', { cwd }); + deepStrictEqual(result.sort(), ['a/abcdef/g', 'a/abcfed/g']); + }, +}; + +export const globstarA = { + test() { + setupFixtures(); + // **/a — find all entries named 'a' + const result = globSync('**/a', { cwd }); + ok(result.includes('a')); + ok(result.includes('a/.abcdef/x/y/z/a')); + }, +}; + +export const threeWildcardLevelsF = { + test() { + setupFixtures(); + const result = globSync('*/*/*/f', { cwd }); + deepStrictEqual(result.sort(), ['a/bc/e/f', 'a/cb/e/f']); + }, +}; + +export const dotSlashGlobstarF = { + test() { + setupFixtures(); + const result = globSync('./**/f', { cwd }); + deepStrictEqual(result.sort(), ['a/bc/e/f', 'a/cb/e/f']); + }, +}; + +export const dotFileGlobstar = { test() { - // Glob is unsupported currently in workerd. - // Verify that we're throwing an error as expected. + setupFixtures(); + // **/.b should match dot files named .b + const result = globSync('**/.b', { cwd }); + deepStrictEqual(result.sort(), ['a/.b', 'a/b/.b']); + }, +}; - throws(() => glob('*.js', {}, mustNotCall), { - code: 'ERR_UNSUPPORTED_OPERATION', +export const globstarCharClass = { + test() { + setupFixtures(); + // a/**/[cg] — match entries named 'c' or 'g' at any depth under a/ + const result = globSync('a/**/[cg]', { cwd }); + ok(result.includes('a/abcdef/g')); + ok(result.includes('a/abcfed/g')); + ok(result.includes('a/b/c')); + ok(result.includes('a/c')); + ok(result.includes('a/c/d/c')); + }, +}; + +export const parentTraversal = { + test() { + setupFixtures(); + // a/**/[cg]/../[cg] — go into [cg] dir, go up, match [cg] again + const result = globSync('a/**/[cg]/../[cg]', { cwd }); + ok(result.length > 0); + // Should find paths where a directory matching [cg] has a sibling matching [cg] + ok(result.includes('a/c')); + }, +}; + +export const extglobNegation = { + test() { + setupFixtures(); + // a/!(doesnotexist)/** — match all under a/ except entries named 'doesnotexist' + const result = globSync('a/!(doesnotexist)/**', { cwd }); + ok(result.length > 0); + ok(result.includes('a/b')); + ok(result.includes('a/b/c')); + ok(result.includes('a/b/c/d')); + }, +}; + +// ============================================================================ +// Exclude Tests +// ============================================================================ + +export const excludeFunction = { + test() { + setupFixtures(); + // Exclude everything — should return empty + const result = globSync('a/**', { cwd, exclude: () => true }); + deepStrictEqual(result, []); + }, +}; + +export const excludeFunctionSelective = { + test() { + setupFixtures(); + // Exclude paths containing 'abcdef' + const result = globSync('a/abc*/g/h', { + cwd, + exclude: (p) => p.includes('abcdef'), }); + deepStrictEqual(result, ['a/abcfed/g/h']); + }, +}; - throws(() => globSync('*.js', {}), { - code: 'ERR_UNSUPPORTED_OPERATION', +export const excludeArrayPattern = { + test() { + setupFixtures(); + // Exclude using array of glob patterns + const result = globSync('a/abc*/g/h', { + cwd, + exclude: ['**/abcdef/**'], }); + deepStrictEqual(result, ['a/abcfed/g/h']); + }, +}; + +export const excludeArrayMatchesAll = { + test() { + setupFixtures(); + // Exclude all with wildcard + const result = globSync('a/**', { cwd, exclude: ['**'] }); + deepStrictEqual(result, []); + }, +}; + +// ============================================================================ +// withFileTypes Tests +// ============================================================================ + +export const withFileTypesTest = { + test() { + setupFixtures(); + const result = globSync('a/b/c/d', { cwd, withFileTypes: true }); + strictEqual(result.length, 1); + const dirent = result[0]; + strictEqual(dirent.name, 'd'); + ok(dirent.isFile()); + ok(!dirent.isDirectory()); + }, +}; - throws(() => promises.glob('*.js', {}), { - code: 'ERR_UNSUPPORTED_OPERATION', +export const withFileTypesDirTest = { + test() { + setupFixtures(); + const result = globSync('a/b/c', { cwd, withFileTypes: true }); + strictEqual(result.length, 1); + const dirent = result[0]; + strictEqual(dirent.name, 'c'); + ok(dirent.isDirectory()); + ok(!dirent.isFile()); + }, +}; + +// ============================================================================ +// Callback API Tests +// ============================================================================ + +export const callbackGlobTest = { + async test() { + setupFixtures(); + const result = await new Promise((resolve, reject) => { + glob('a/abc{fed,def}/g/h', { cwd }, (err, matches) => { + if (err) reject(err); + else resolve(matches); + }); }); + deepStrictEqual([...result].sort(), ['a/abcdef/g/h', 'a/abcfed/g/h']); + }, +}; + +export const callbackGlobNoOptions = { + async test() { + const result = await new Promise((resolve, reject) => { + glob('*.nonexistent', (err, matches) => { + if (err) reject(err); + else resolve(matches); + }); + }); + deepStrictEqual(result, []); + }, +}; + +// ============================================================================ +// Promises API Tests +// ============================================================================ + +export const promisesGlobTest = { + async test() { + setupFixtures(); + const results = []; + for await (const entry of promises.glob('a/abc{fed,def}/g/h', { cwd })) { + results.push(entry); + } + deepStrictEqual(results.sort(), ['a/abcdef/g/h', 'a/abcfed/g/h']); + }, +}; + +export const promisesGlobstarTest = { + async test() { + setupFixtures(); + const results = []; + for await (const entry of promises.glob('**/.b', { cwd })) { + results.push(entry); + } + deepStrictEqual(results.sort(), ['a/.b', 'a/b/.b']); + }, +}; + +// ============================================================================ +// Edge Cases +// ============================================================================ + +export const noMatchTest = { + test() { + setupFixtures(); + const result = globSync('nonexistent/**', { cwd }); + deepStrictEqual(result, []); + }, +}; + +export const exactPathTest = { + test() { + setupFixtures(); + // Exact path match (no wildcards) + const result = globSync('a/b/c/d', { cwd }); + deepStrictEqual(result, ['a/b/c/d']); + }, +}; + +export const defaultCwdTest = { + test() { + // Without cwd option, should use process.cwd() and not throw + const result = globSync('*.nonexistent'); + deepStrictEqual(result, []); + }, +}; + +export const multiplePatternsTest = { + test() { + setupFixtures(); + const result = globSync(['a/b/c/d', 'a/bc/e/f'], { cwd }); + deepStrictEqual(result.sort(), ['a/b/c/d', 'a/bc/e/f']); + }, +}; + +export const braceWithGlobstar = { + test() { + setupFixtures(); + // a/{b/**,b/c} should match a/b and everything under it, plus a/b/c + const result = globSync('a/{b/**,b/c}', { cwd }); + ok(result.includes('a/b')); + ok(result.includes('a/b/c')); + ok(result.includes('a/b/c/d')); }, };