|
| 1 | +import { lstat } from 'node:fs/promises'; |
| 2 | +import path from 'node:path'; |
| 3 | +import isBinaryPath from 'is-binary-path'; |
| 4 | +import micromatch from 'micromatch'; |
| 5 | +import readdir, { type Dirent } from 'tiny-readdir'; |
| 6 | + |
| 7 | +const alwaysIgnoredNames = new Set(['.git', '.sl', '.svn', '.hg', '.jj', 'node_modules']); |
| 8 | + |
| 9 | +interface DiscoverFmtPathsOptions { |
| 10 | + /** Absolute directory used to resolve input paths. */ |
| 11 | + cwd: string; |
| 12 | + patterns?: string[]; |
| 13 | +} |
| 14 | + |
| 15 | +const isErrnoException = (error: unknown): error is NodeJS.ErrnoException => |
| 16 | + error instanceof Error && 'code' in error; |
| 17 | + |
| 18 | +const lstatSafe = async (filePath: string) => { |
| 19 | + try { |
| 20 | + return await lstat(filePath); |
| 21 | + } catch (error) { |
| 22 | + if (!isErrnoException(error) || error.code !== 'ENOENT') { |
| 23 | + throw error; |
| 24 | + } |
| 25 | + } |
| 26 | +}; |
| 27 | + |
| 28 | +const isRelativePathInside = (relativePath: string): boolean => |
| 29 | + relativePath !== '..' && |
| 30 | + !relativePath.startsWith(`..${path.sep}`) && |
| 31 | + !path.isAbsolute(relativePath); |
| 32 | + |
| 33 | +const isPathInside = (rootPath: string, filePath: string): boolean => |
| 34 | + isRelativePathInside(path.relative(rootPath, filePath)); |
| 35 | + |
| 36 | +const toPosixPath = (filePath: string): string => |
| 37 | + path.sep === '\\' ? filePath.replaceAll('\\', '/') : filePath; |
| 38 | + |
| 39 | +/** Supports both the legacy tiny-readdir type and Node.js 24 Dirent. */ |
| 40 | +const getDirentParentPath = (dirent: Dirent): string => |
| 41 | + (dirent as Dirent & { parentPath?: string }).parentPath ?? dirent.path; |
| 42 | + |
| 43 | +/** Mirrors the path passed to tiny-readdir's ignore callback. */ |
| 44 | +const getDirentPath = (dirent: Dirent, parentPath: string): string => |
| 45 | + `${parentPath}${parentPath === path.sep ? '' : path.sep}${dirent.name}`; |
| 46 | + |
| 47 | +const hasAlwaysIgnoredSegment = (cwd: string, filePath: string): boolean => |
| 48 | + path |
| 49 | + .relative(cwd, filePath) |
| 50 | + .split(path.sep) |
| 51 | + .some((segment) => alwaysIgnoredNames.has(segment)); |
| 52 | + |
| 53 | +const createTraversalOptions = (isIncluded?: (filePath: string) => boolean) => { |
| 54 | + // tiny-readdir passes only a path to `ignore`, so retain the dirent type briefly. |
| 55 | + const directories = new Set<string>(); |
| 56 | + |
| 57 | + return { |
| 58 | + followSymlinks: false, |
| 59 | + ignore: (targetPath: string) => { |
| 60 | + const isDirectory = directories.delete(targetPath); |
| 61 | + if (alwaysIgnoredNames.has(path.basename(targetPath))) { |
| 62 | + return true; |
| 63 | + } |
| 64 | + |
| 65 | + return ( |
| 66 | + !isDirectory && |
| 67 | + (isBinaryPath(targetPath) || (isIncluded !== undefined && !isIncluded(targetPath))) |
| 68 | + ); |
| 69 | + }, |
| 70 | + onDirents: (dirents: Dirent[]) => { |
| 71 | + const parentPath = getDirentParentPath(dirents[0]); |
| 72 | + |
| 73 | + for (const dirent of dirents) { |
| 74 | + if (dirent.isDirectory()) { |
| 75 | + directories.add(getDirentPath(dirent, parentPath)); |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + return undefined; |
| 80 | + }, |
| 81 | + }; |
| 82 | +}; |
| 83 | + |
| 84 | +const normalizeGlob = (cwd: string, pattern: string): string => { |
| 85 | + const relativePattern = path.isAbsolute(pattern) ? path.relative(cwd, pattern) : pattern; |
| 86 | + return toPosixPath(relativePattern); |
| 87 | +}; |
| 88 | + |
| 89 | +type PatternEntry = { |
| 90 | + kind: 'file' | 'directory' | 'glob' | 'negative-glob'; |
| 91 | + value: string; |
| 92 | +}; |
| 93 | + |
| 94 | +type ClassifiedPatterns = { |
| 95 | + files: string[]; |
| 96 | + directories: string[]; |
| 97 | + globs: string[]; |
| 98 | + negativeGlobs: string[]; |
| 99 | +}; |
| 100 | + |
| 101 | +const classifyPatterns = async (cwd: string, patterns: string[]): Promise<ClassifiedPatterns> => { |
| 102 | + const entries = await Promise.all( |
| 103 | + patterns.map(async (pattern): Promise<PatternEntry | undefined> => { |
| 104 | + if (pattern.startsWith('!')) { |
| 105 | + return { kind: 'negative-glob', value: normalizeGlob(cwd, pattern.slice(1)) }; |
| 106 | + } |
| 107 | + |
| 108 | + const filePath = path.resolve(cwd, pattern); |
| 109 | + if (hasAlwaysIgnoredSegment(cwd, filePath)) { |
| 110 | + return; |
| 111 | + } |
| 112 | + |
| 113 | + const stats = await lstatSafe(filePath); |
| 114 | + if (stats?.isFile()) { |
| 115 | + return { kind: 'file', value: filePath }; |
| 116 | + } |
| 117 | + if (stats?.isDirectory()) { |
| 118 | + return { kind: 'directory', value: filePath }; |
| 119 | + } |
| 120 | + if (stats) { |
| 121 | + return; |
| 122 | + } |
| 123 | + |
| 124 | + return { kind: 'glob', value: normalizeGlob(cwd, pattern) }; |
| 125 | + }), |
| 126 | + ); |
| 127 | + |
| 128 | + const result: ClassifiedPatterns = { |
| 129 | + files: [], |
| 130 | + directories: [], |
| 131 | + globs: [], |
| 132 | + negativeGlobs: [], |
| 133 | + }; |
| 134 | + |
| 135 | + for (const entry of entries) { |
| 136 | + if (!entry) { |
| 137 | + continue; |
| 138 | + } |
| 139 | + |
| 140 | + switch (entry.kind) { |
| 141 | + case 'file': |
| 142 | + result.files.push(entry.value); |
| 143 | + break; |
| 144 | + case 'directory': |
| 145 | + result.directories.push(entry.value); |
| 146 | + break; |
| 147 | + case 'glob': |
| 148 | + result.globs.push(entry.value); |
| 149 | + break; |
| 150 | + case 'negative-glob': |
| 151 | + result.negativeGlobs.push(entry.value); |
| 152 | + break; |
| 153 | + } |
| 154 | + } |
| 155 | + |
| 156 | + return result; |
| 157 | +}; |
| 158 | + |
| 159 | +const getOutermostPaths = (paths: string[]): string[] => { |
| 160 | + const sortedPaths = [...new Set(paths)].sort((left, right) => left.length - right.length); |
| 161 | + const outermostPaths: string[] = []; |
| 162 | + |
| 163 | + for (const filePath of sortedPaths) { |
| 164 | + if (!outermostPaths.some((parentPath) => isPathInside(parentPath, filePath))) { |
| 165 | + outermostPaths.push(filePath); |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + return outermostPaths; |
| 170 | +}; |
| 171 | + |
| 172 | +/** Merges overlapping roots; micromatch remains responsible for glob syntax. */ |
| 173 | +const getTraversalRoots = (cwd: string, directories: string[], globs: string[]): string[] => { |
| 174 | + const globRoots = globs.map((pattern) => path.resolve(cwd, micromatch.scan(pattern).base || '.')); |
| 175 | + |
| 176 | + return getOutermostPaths([...directories, ...globRoots]); |
| 177 | +}; |
| 178 | + |
| 179 | +const discoverFmtPaths = async ({ |
| 180 | + cwd, |
| 181 | + patterns: inputPatterns, |
| 182 | +}: DiscoverFmtPathsOptions): Promise<string[]> => { |
| 183 | + const patterns = inputPatterns?.length ? inputPatterns : ['.']; |
| 184 | + const { |
| 185 | + files: explicitFiles, |
| 186 | + directories, |
| 187 | + globs, |
| 188 | + negativeGlobs, |
| 189 | + } = await classifyPatterns(cwd, patterns); |
| 190 | + const directoryRoots = getOutermostPaths(directories); |
| 191 | + const globMatchers = globs.map((pattern) => micromatch.matcher(pattern, { dot: true })); |
| 192 | + const candidates = new Set(explicitFiles); |
| 193 | + const traversalRoots = getTraversalRoots(cwd, directoryRoots, globs); |
| 194 | + |
| 195 | + const results = await Promise.all( |
| 196 | + traversalRoots.map(async (rootPath) => { |
| 197 | + const stats = await lstatSafe(rootPath); |
| 198 | + if (!stats?.isDirectory()) { |
| 199 | + return []; |
| 200 | + } |
| 201 | + |
| 202 | + const includesAll = directoryRoots.some((directoryPath) => |
| 203 | + isPathInside(directoryPath, rootPath), |
| 204 | + ); |
| 205 | + const isIncluded = includesAll |
| 206 | + ? undefined |
| 207 | + : (filePath: string): boolean => { |
| 208 | + if (directoryRoots.some((directoryPath) => isPathInside(directoryPath, filePath))) { |
| 209 | + return true; |
| 210 | + } |
| 211 | + |
| 212 | + const relativePath = toPosixPath(path.relative(cwd, filePath)); |
| 213 | + return globMatchers.some((matches) => matches(relativePath)); |
| 214 | + }; |
| 215 | + |
| 216 | + return (await readdir(rootPath, createTraversalOptions(isIncluded))).files; |
| 217 | + }), |
| 218 | + ); |
| 219 | + |
| 220 | + for (const files of results) { |
| 221 | + for (const filePath of files) { |
| 222 | + candidates.add(filePath); |
| 223 | + } |
| 224 | + } |
| 225 | + |
| 226 | + const negativeGlobMatchers = negativeGlobs.map((pattern) => |
| 227 | + micromatch.matcher(pattern, { dot: true }), |
| 228 | + ); |
| 229 | + const filePaths: string[] = []; |
| 230 | + |
| 231 | + for (const filePath of candidates) { |
| 232 | + if (isBinaryPath(filePath)) { |
| 233 | + continue; |
| 234 | + } |
| 235 | + |
| 236 | + if (negativeGlobMatchers.length) { |
| 237 | + const relativePath = toPosixPath(path.relative(cwd, filePath)); |
| 238 | + if (negativeGlobMatchers.some((matches) => matches(relativePath))) { |
| 239 | + continue; |
| 240 | + } |
| 241 | + } |
| 242 | + |
| 243 | + filePaths.push(filePath); |
| 244 | + } |
| 245 | + |
| 246 | + return filePaths.sort(); |
| 247 | +}; |
| 248 | + |
| 249 | +export { discoverFmtPaths }; |
0 commit comments