UNPKG

globby

Version:
681 lines (537 loc) 21 kB
import process from 'node:process'; import fs from 'node:fs'; import nodePath from 'node:path'; import {Readable} from 'node:stream'; import mergeStreams from '@sindresorhus/merge-streams'; import fastGlob from 'fast-glob'; import {toPath} from 'unicorn-magic/node'; import { GITIGNORE_FILES_PATTERN, getIgnorePatternsAndPredicate, getIgnorePatternsAndPredicateSync, getGlobalGitignoreFile, getGlobalGitignoreFileAsync, buildGlobalMatcher, } from './ignore.js'; import { bindFsMethod, promisifyFsMethod, isNegativePattern, getStaticAbsolutePathPrefix, normalizeNegativePattern, normalizeDirectoryPatternForFastGlob, adjustIgnorePatternsForParentDirectories, convertPatternsForFastGlob, findGitRoot, findGitRootSync, } from './utilities.js'; const assertPatternsInput = patterns => { if (patterns.some(pattern => typeof pattern !== 'string')) { throw new TypeError('Patterns must be a string or an array of strings'); } }; const getStatMethod = fsImplementation => { if (fsImplementation) { return bindFsMethod(fsImplementation.promises, 'stat') ?? promisifyFsMethod(fsImplementation, 'stat'); } return bindFsMethod(fs.promises, 'stat'); }; const getStatSyncMethod = fsImplementation => bindFsMethod(fsImplementation, 'statSync') ?? bindFsMethod(fs, 'statSync'); const isDirectory = async (path, fsImplementation) => { try { const stats = await getStatMethod(fsImplementation)(path); return stats.isDirectory(); } catch { return false; } }; const isDirectorySync = (path, fsImplementation) => { try { const stats = getStatSyncMethod(fsImplementation)(path); return stats.isDirectory(); } catch { return false; } }; const normalizePathForDirectoryGlob = (filePath, cwd) => { const path = isNegativePattern(filePath) ? filePath.slice(1) : filePath; return nodePath.isAbsolute(path) ? path : nodePath.join(cwd, path); }; const shouldExpandGlobstarDirectory = pattern => { const match = pattern?.match(/\*\*\/([^/]+)$/); if (!match) { return false; } const dirname = match[1]; const hasWildcards = /[*?[\]{}]/.test(dirname); const hasExtension = nodePath.extname(dirname) && !dirname.startsWith('.'); return !hasWildcards && !hasExtension; }; const getDirectoryGlob = ({directoryPath, files, extensions}) => { const extensionGlob = extensions?.length > 0 ? `.${extensions.length > 1 ? `{${extensions.join(',')}}` : extensions[0]}` : ''; return files ? files.map(file => nodePath.posix.join(directoryPath, `**/${nodePath.extname(file) ? file : `${file}${extensionGlob}`}`)) : [nodePath.posix.join(directoryPath, `**${extensionGlob ? `/*${extensionGlob}` : ''}`)]; }; const directoryToGlob = async (directoryPaths, { cwd = process.cwd(), files, extensions, fs: fsImplementation, } = {}) => { const globs = await Promise.all(directoryPaths.map(async directoryPath => { // Check pattern without negative prefix const checkPattern = isNegativePattern(directoryPath) ? directoryPath.slice(1) : directoryPath; // Expand globstar directory patterns like **/dirname to **/dirname/** if (shouldExpandGlobstarDirectory(checkPattern)) { return getDirectoryGlob({directoryPath, files, extensions}); } // Original logic for checking actual directories const pathToCheck = normalizePathForDirectoryGlob(directoryPath, cwd); return (await isDirectory(pathToCheck, fsImplementation)) ? getDirectoryGlob({directoryPath, files, extensions}) : directoryPath; })); return globs.flat(); }; const directoryToGlobSync = (directoryPaths, { cwd = process.cwd(), files, extensions, fs: fsImplementation, } = {}) => directoryPaths.flatMap(directoryPath => { // Check pattern without negative prefix const checkPattern = isNegativePattern(directoryPath) ? directoryPath.slice(1) : directoryPath; // Expand globstar directory patterns like **/dirname to **/dirname/** if (shouldExpandGlobstarDirectory(checkPattern)) { return getDirectoryGlob({directoryPath, files, extensions}); } // Original logic for checking actual directories const pathToCheck = normalizePathForDirectoryGlob(directoryPath, cwd); return isDirectorySync(pathToCheck, fsImplementation) ? getDirectoryGlob({directoryPath, files, extensions}) : directoryPath; }); const toPatternsArray = patterns => { patterns = [...new Set([patterns].flat())]; assertPatternsInput(patterns); return patterns; }; const checkCwdOption = (cwd, fsImplementation = fs) => { if (!cwd || !fsImplementation.statSync) { return; } let stats; try { stats = fsImplementation.statSync(cwd); } catch { // If stat fails (e.g., path doesn't exist), let fast-glob handle it return; } if (!stats.isDirectory()) { throw new Error(`The \`cwd\` option must be a path to a directory, got: ${cwd}`); } }; const normalizeOptions = (options = {}) => { // Normalize ignore to an array (fast-glob accepts string but we need array internally) const ignore = options.ignore ? (Array.isArray(options.ignore) ? options.ignore : [options.ignore]) : []; options = { ...options, ignore, expandDirectories: options.expandDirectories ?? true, cwd: toPath(options.cwd), }; checkCwdOption(options.cwd, options.fs); return options; }; const normalizeArguments = function_ => async (patterns, options) => function_(toPatternsArray(patterns), normalizeOptions(options)); const normalizeArgumentsSync = function_ => (patterns, options) => function_(toPatternsArray(patterns), normalizeOptions(options)); const getIgnoreFilesPatterns = options => { const {ignoreFiles, gitignore} = options; const patterns = ignoreFiles ? toPatternsArray(ignoreFiles) : []; if (gitignore) { patterns.push(GITIGNORE_FILES_PATTERN); } return patterns; }; const isPathIgnored = (matcher, globalMatcher, path) => { const globalResult = globalMatcher ? globalMatcher(path) : undefined; const result = matcher ? matcher(path) : undefined; if (result?.unignored) { return false; } return Boolean(result?.ignored || globalResult?.ignored); }; const hasIgnoredAncestorDirectory = (matcher, globalMatcher, file) => { let currentPath = file; while (true) { const parentDirectory = nodePath.dirname(currentPath); if (parentDirectory === currentPath) { return false; } if (isPathIgnored(matcher, globalMatcher, `${parentDirectory}${nodePath.sep}`)) { return true; } currentPath = parentDirectory; } }; const combinePredicate = (matcher, globalMatcher) => { if (!matcher && !globalMatcher) { return false; } return file => { const result = matcher ? matcher(file) : undefined; // A local negation (e.g. `!file`) re-includes the file, unless // a parent directory is ignored by either matcher. if (result?.unignored) { const globalResult = globalMatcher ? globalMatcher(file) : undefined; return globalResult?.ignored && hasIgnoredAncestorDirectory(matcher, globalMatcher, file); } return isPathIgnored(matcher, globalMatcher, file); }; }; const buildIgnoreFilterResult = (options, cwd, {patterns, matcher, usingGitRoot}, globalMatcher, createFilter) => { const finalPredicate = combinePredicate(matcher, globalMatcher); // Convert patterns to fast-glob format (may return empty array if predicate should handle everything) const patternsForFastGlob = convertPatternsForFastGlob(patterns, usingGitRoot, normalizeDirectoryPatternForFastGlob); return { options: { ...options, ignore: [...options.ignore, ...patternsForFastGlob], }, filter: createFilter(finalPredicate, cwd, options.fs), }; }; /** Apply gitignore patterns to options and return filter predicate. When negation patterns are present (e.g., '!important.log'), we cannot pass positive patterns to fast-glob because it would filter out files before our predicate can re-include them. In this case, we rely entirely on the predicate for filtering, which handles negations correctly. When there are no negations, we optimize by passing patterns to fast-glob's ignore option to skip directories during traversal (performance optimization). All patterns (including negated) are always used in the filter predicate to ensure correct Git-compatible behavior. @returns {Promise<{options: Object, filter: Function}>} */ const applyIgnoreFilesAndGetFilter = async options => { const cwd = options.cwd ?? process.cwd(); const ignoreFilesPatterns = getIgnoreFilesPatterns(options); const globalIgnoreFile = options.globalGitignore ? await getGlobalGitignoreFileAsync(options) : undefined; if (ignoreFilesPatterns.length === 0 && !globalIgnoreFile) { return { options, filter: createFilterFunctionAsync(false, cwd, options.fs), }; } // Read ignore files once and get both patterns and predicate // Enable parent .gitignore search when using gitignore option const includeParentIgnoreFiles = options.gitignore === true; const ignoreResult = ignoreFilesPatterns.length > 0 ? await getIgnorePatternsAndPredicate(ignoreFilesPatterns, options, includeParentIgnoreFiles) : {patterns: [], matcher: false, usingGitRoot: false}; const globalGitRoot = globalIgnoreFile ? await findGitRoot(cwd, options.fs) : undefined; const globalMatcher = globalIgnoreFile ? buildGlobalMatcher(globalIgnoreFile, cwd, globalGitRoot ?? cwd) : undefined; return buildIgnoreFilterResult(options, cwd, ignoreResult, globalMatcher, createFilterFunctionAsync); }; /** Apply gitignore patterns to options and return filter predicate (sync version). @returns {{options: Object, filter: Function}} */ const applyIgnoreFilesAndGetFilterSync = options => { const cwd = options.cwd ?? process.cwd(); const ignoreFilesPatterns = getIgnoreFilesPatterns(options); const globalIgnoreFile = options.globalGitignore ? getGlobalGitignoreFile(options) : undefined; if (ignoreFilesPatterns.length === 0 && !globalIgnoreFile) { return { options, filter: createFilterFunction(false, cwd, options.fs), }; } // Read ignore files once and get both patterns and predicate // Enable parent .gitignore search when using gitignore option const includeParentIgnoreFiles = options.gitignore === true; const ignoreResult = ignoreFilesPatterns.length > 0 ? getIgnorePatternsAndPredicateSync(ignoreFilesPatterns, options, includeParentIgnoreFiles) : {patterns: [], matcher: false, usingGitRoot: false}; const globalGitRoot = globalIgnoreFile ? findGitRootSync(cwd, options.fs) : undefined; const globalMatcher = globalIgnoreFile ? buildGlobalMatcher(globalIgnoreFile, cwd, globalGitRoot ?? cwd) : undefined; return buildIgnoreFilterResult(options, cwd, ignoreResult, globalMatcher, createFilterFunction); }; const assertGlobalGitignoreSyncSupport = options => { if (options.globalGitignore && options.fs && !options.fs.statSync) { throw new Error('The `globalGitignore` option in `globbySync()` requires `fs.statSync` when a custom `fs` is provided.'); } }; const globalGitignoreAsyncStatErrorMessage = 'The `globalGitignore` option in `globby()` and `globbyStream()` requires `fs.promises.stat` or `fs.stat` when a custom `fs` is provided.'; const assertGlobalGitignoreAsyncSupport = options => { if (!options.globalGitignore || !options.fs) { return; } if (!options.fs.promises?.stat && !options.fs.stat) { throw new Error(globalGitignoreAsyncStatErrorMessage); } }; const createPathResolver = cwd => { const basePath = cwd || process.cwd(); const pathCache = new Map(); return pathKey => { let absolutePath = pathCache.get(pathKey); if (absolutePath === undefined) { if (pathCache.size > 10_000) { pathCache.clear(); } absolutePath = nodePath.isAbsolute(pathKey) ? pathKey : nodePath.resolve(basePath, pathKey); pathCache.set(pathKey, absolutePath); } return absolutePath; }; }; const createAsyncDirectoryCheck = fsMethod => { const directoryCache = new Map(); return async absolutePath => { let isDirectory = directoryCache.get(absolutePath); if (isDirectory !== undefined) { return isDirectory; } try { const stats = await fsMethod?.(absolutePath); isDirectory = Boolean(stats?.isDirectory()); } catch { isDirectory = false; } if (directoryCache.size > 10_000) { directoryCache.clear(); } directoryCache.set(absolutePath, isDirectory); return isDirectory; }; }; const createDirectoryCheck = fsMethod => { const directoryCache = new Map(); return absolutePath => { let isDirectory = directoryCache.get(absolutePath); if (isDirectory !== undefined) { return isDirectory; } try { isDirectory = Boolean(fsMethod?.(absolutePath)?.isDirectory()); } catch { isDirectory = false; } if (directoryCache.size > 10_000) { directoryCache.clear(); } directoryCache.set(absolutePath, isDirectory); return isDirectory; }; }; const createFilterFunctionAsync = (isIgnored, cwd, fsImplementation) => { const resolveAbsolutePath = createPathResolver(cwd); const isDirectoryEntry = createAsyncDirectoryCheck(getStatMethod(fsImplementation)); return async fastGlobResult => { if (!isIgnored) { return true; } const absolutePath = resolveAbsolutePath(nodePath.normalize(fastGlobResult.path ?? fastGlobResult)); if (isIgnored(absolutePath)) { return false; } return !(await isDirectoryEntry(absolutePath) && isIgnored(`${absolutePath}${nodePath.sep}`)); }; }; const createFilterFunction = (isIgnored, cwd, fsImplementation) => { const seen = new Set(); const resolveAbsolutePath = createPathResolver(cwd); const isDirectoryEntry = createDirectoryCheck(getStatSyncMethod(fsImplementation)); return fastGlobResult => { const pathKey = nodePath.normalize(fastGlobResult.path ?? fastGlobResult); if (seen.has(pathKey)) { return false; } if (isIgnored) { const absolutePath = resolveAbsolutePath(pathKey); if (isIgnored(absolutePath)) { return false; } if (isDirectoryEntry(absolutePath) && isIgnored(`${absolutePath}${nodePath.sep}`)) { return false; } } seen.add(pathKey); return true; }; }; const unionFastGlobResults = (results, filter) => results.flat().filter(fastGlobResult => filter(fastGlobResult)); const unionFastGlobResultsAsync = async (results, filter) => { results = results.flat(); const matches = await Promise.all(results.map(fastGlobResult => filter(fastGlobResult))); const seen = new Set(); return results.filter((fastGlobResult, index) => { if (!matches[index]) { return false; } const pathKey = nodePath.normalize(fastGlobResult.path ?? fastGlobResult); if (seen.has(pathKey)) { return false; } seen.add(pathKey); return true; }); }; const convertNegativePatterns = (patterns, options) => { // If all patterns are negative and expandNegationOnlyPatterns is enabled (default), // prepend a positive catch-all pattern to make negation-only patterns work intuitively // (e.g., '!*.json' matches all files except JSON) if (patterns.length > 0 && patterns.every(pattern => isNegativePattern(pattern))) { if (options.expandNegationOnlyPatterns === false) { return []; } patterns = ['**/*', ...patterns]; } const positiveAbsolutePathPrefixes = []; let hasRelativePositivePattern = false; const normalizedPatterns = []; for (const pattern of patterns) { if (isNegativePattern(pattern)) { normalizedPatterns.push(`!${normalizeNegativePattern(pattern.slice(1), positiveAbsolutePathPrefixes, hasRelativePositivePattern)}`); continue; } normalizedPatterns.push(pattern); const staticAbsolutePathPrefix = getStaticAbsolutePathPrefix(pattern); if (staticAbsolutePathPrefix === undefined) { hasRelativePositivePattern = true; continue; } positiveAbsolutePathPrefixes.push(staticAbsolutePathPrefix); } patterns = normalizedPatterns; const tasks = []; while (patterns.length > 0) { const index = patterns.findIndex(pattern => isNegativePattern(pattern)); if (index === -1) { tasks.push({patterns, options}); break; } const ignorePattern = patterns[index].slice(1); for (const task of tasks) { task.options.ignore.push(ignorePattern); } if (index !== 0) { tasks.push({ patterns: patterns.slice(0, index), options: { ...options, ignore: [ ...options.ignore, ignorePattern, ], }, }); } patterns = patterns.slice(index + 1); } return tasks; }; const applyParentDirectoryIgnoreAdjustments = tasks => tasks.map(task => ({ patterns: task.patterns, options: { ...task.options, ignore: adjustIgnorePatternsForParentDirectories(task.patterns, task.options.ignore), }, })); const normalizeExpandDirectoriesOption = (options, cwd) => ({ ...(cwd ? {cwd} : {}), ...(Array.isArray(options) ? {files: options} : options), }); const generateTasks = async (patterns, options) => { const globTasks = convertNegativePatterns(patterns, options); const {cwd, expandDirectories, fs: fsImplementation} = options; if (!expandDirectories) { return applyParentDirectoryIgnoreAdjustments(globTasks); } const directoryToGlobOptions = { ...normalizeExpandDirectoriesOption(expandDirectories, cwd), fs: fsImplementation, }; return Promise.all(globTasks.map(async task => { let {patterns, options} = task; [ patterns, options.ignore, ] = await Promise.all([ directoryToGlob(patterns, directoryToGlobOptions), directoryToGlob(options.ignore, {cwd, fs: fsImplementation}), ]); // Adjust ignore patterns for parent directory references options.ignore = adjustIgnorePatternsForParentDirectories(patterns, options.ignore); return {patterns, options}; })); }; const generateTasksSync = (patterns, options) => { const globTasks = convertNegativePatterns(patterns, options); const {cwd, expandDirectories, fs: fsImplementation} = options; if (!expandDirectories) { return applyParentDirectoryIgnoreAdjustments(globTasks); } const directoryToGlobSyncOptions = { ...normalizeExpandDirectoriesOption(expandDirectories, cwd), fs: fsImplementation, }; return globTasks.map(task => { let {patterns, options} = task; patterns = directoryToGlobSync(patterns, directoryToGlobSyncOptions); options.ignore = directoryToGlobSync(options.ignore, {cwd, fs: fsImplementation}); // Adjust ignore patterns for parent directory references options.ignore = adjustIgnorePatternsForParentDirectories(patterns, options.ignore); return {patterns, options}; }); }; export const globby = normalizeArguments(async (patterns, options) => { assertGlobalGitignoreAsyncSupport(options); // Apply ignore files and get filter (reads .gitignore files once) const {options: modifiedOptions, filter} = await applyIgnoreFilesAndGetFilter(options); // Generate tasks with modified options (includes gitignore patterns in ignore option) const tasks = await generateTasks(patterns, modifiedOptions); const results = await Promise.all(tasks.map(task => fastGlob(task.patterns, task.options))); return unionFastGlobResultsAsync(results, filter); }); export const globbySync = normalizeArgumentsSync((patterns, options) => { assertGlobalGitignoreSyncSupport(options); // Apply ignore files and get filter (reads .gitignore files once) const {options: modifiedOptions, filter} = applyIgnoreFilesAndGetFilterSync(options); // Generate tasks with modified options (includes gitignore patterns in ignore option) const tasks = generateTasksSync(patterns, modifiedOptions); const results = tasks.map(task => fastGlob.sync(task.patterns, task.options)); return unionFastGlobResults(results, filter); }); export const globbyStream = normalizeArgumentsSync((patterns, options) => { assertGlobalGitignoreAsyncSupport(options); const seen = new Set(); const stream = Readable.from((async function * () { // Apply ignore files and get filter (reads .gitignore files once) const {options: modifiedOptions, filter} = await applyIgnoreFilesAndGetFilter(options); // Generate tasks with modified options (includes gitignore patterns in ignore option) const tasks = await generateTasks(patterns, modifiedOptions); if (tasks.length === 0) { return; } const streams = tasks.map(task => fastGlob.stream(task.patterns, task.options)); for await (const fastGlobResult of mergeStreams(streams)) { const pathKey = nodePath.normalize(fastGlobResult.path ?? fastGlobResult); if (!seen.has(pathKey) && await filter(fastGlobResult)) { seen.add(pathKey); yield fastGlobResult; } } })()); // Returning a web stream will require revisiting once Readable.toWeb integration is viable. // return Readable.toWeb(stream); return stream; }); export const isDynamicPattern = normalizeArgumentsSync((patterns, options) => patterns.some(pattern => fastGlob.isDynamicPattern(pattern, options))); export const generateGlobTasks = normalizeArguments(generateTasks); export const generateGlobTasksSync = normalizeArgumentsSync(generateTasksSync); export { isGitIgnored, isGitIgnoredSync, isIgnoredByIgnoreFiles, isIgnoredByIgnoreFilesSync, } from './ignore.js'; export const {convertPathToPattern} = fastGlob;