From ebf0e7acdd3474a282e8c8e1b6a0cfcc006eed16 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:14:54 +0100 Subject: [PATCH 1/4] feat: update readdir/readdir-glob Both have new major versions so we need a bit more code to adopt them. Basically, the "found files" (i.e. those we traversed but haven't yet checked if we ignore) no longer come from `tiny-readdir` as a result. Instead, we have to listen to `Dirent` emit and build our own set. --- package-lock.json | 85 +++++++++++++++++++++++------------------------ package.json | 4 +-- src/utils.ts | 52 ++++++++++++++++++++++++++--- 3 files changed, 91 insertions(+), 50 deletions(-) diff --git a/package-lock.json b/package-lock.json index 742f692..33e8967 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,8 +27,8 @@ "smol-toml": "^1.7.0", "specialist": "^2.0.0", "tiny-editorconfig": "^1.0.2", - "tiny-readdir": "^2.7.4", - "tiny-readdir-glob": "^1.23.2", + "tiny-readdir": "^3.1.1", + "tiny-readdir-glob": "^2.0.0", "tiny-spinner": "^2.0.5", "worktank": "^3.0.2", "zeptomatch": "^2.1.0", @@ -4583,6 +4583,16 @@ "tiny-readdir": "^2.2.0" } }, + "node_modules/json-archive/node_modules/tiny-readdir": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/tiny-readdir/-/tiny-readdir-2.7.4.tgz", + "integrity": "sha512-721U+zsYwDirjr8IM6jqpesD/McpZooeFi3Zc6mcjy1pse2C+v19eHPFRqz4chGXZFw7C3KITDjAtHETc2wj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "promise-make-counter": "^1.0.2" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -6381,23 +6391,22 @@ "integrity": "sha512-RnIDHQ+r9zMuslQWVoRxfKVOumteeheQqbwNYJyQxzM2vzx/vdN5xAeL64F3rQOpfbVdxFkhM4zPDyfq7SxsBQ==" }, "node_modules/tiny-readdir": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/tiny-readdir/-/tiny-readdir-2.7.4.tgz", - "integrity": "sha512-721U+zsYwDirjr8IM6jqpesD/McpZooeFi3Zc6mcjy1pse2C+v19eHPFRqz4chGXZFw7C3KITDjAtHETc2wj7Q==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tiny-readdir/-/tiny-readdir-3.1.1.tgz", + "integrity": "sha512-SiNW3Kp5xshlA3McA2C/tjU7K8+VJc9ZNe09foB8ut8lMY7bR9NKPo5ARHDOj24mV69/utNy8gqDjF93FcFlFg==", + "license": "MIT", "dependencies": { "promise-make-counter": "^1.0.2" } }, "node_modules/tiny-readdir-glob": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/tiny-readdir-glob/-/tiny-readdir-glob-1.23.2.tgz", - "integrity": "sha512-+47FIdgzEtZj03mOyq9iAljlZZNleqSEwe3i6Uzkzec5axbMg32Vp78U2fLo4TiCMv9gzjnno7yJn34z5pXECw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tiny-readdir-glob/-/tiny-readdir-glob-2.0.0.tgz", + "integrity": "sha512-1zAGi76CspOE4MjMrsx2OeLpvjiHyprFTuJ1O3k5C1qU09GpltOL+zx5rw9CaVQ/qkpkmCfc09TS2fV52GJrrw==", + "license": "MIT", "dependencies": { - "tiny-readdir": "^2.7.0", - "zeptomatch": "^2.0.1", - "zeptomatch-explode": "^1.0.1", - "zeptomatch-is-static": "^1.0.1", - "zeptomatch-unescape": "^1.0.1" + "tiny-readdir": "^3.1.1", + "zeptomatch": "^2.1.0" } }, "node_modules/tiny-spinner": { @@ -7004,20 +7013,10 @@ "resolved": "https://registry.npmjs.org/zeptomatch-escape/-/zeptomatch-escape-1.0.1.tgz", "integrity": "sha512-kAc5HzvnF66djCYDqpsS46Y/FKi+4pe/KJRmTmm/hwmoaNYzmm6bBY07cdkxmJCdY018S5UeQn4yP+9X2x1MbQ==" }, - "node_modules/zeptomatch-explode": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/zeptomatch-explode/-/zeptomatch-explode-1.0.1.tgz", - "integrity": "sha512-7cUQASLLRGZ20+zEQcEgQ9z/gH1+jSfrNg4KfRJSxF1QU2fpymAwWvnAxl69GD5pr3IV0V9vo3ke2np//Nh4tQ==" - }, "node_modules/zeptomatch-is-static": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/zeptomatch-is-static/-/zeptomatch-is-static-1.0.1.tgz", "integrity": "sha512-bN9q7H/UdXhkub01WE7b7Grg07jLldNnIWG2T1IpBq5NtvcQ4DwFbNiGGapnbKHUdWiCNjg/bIvixV88nj9gog==" - }, - "node_modules/zeptomatch-unescape": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/zeptomatch-unescape/-/zeptomatch-unescape-1.0.1.tgz", - "integrity": "sha512-xhSFkKV0aQ03e/eiN4VhOTwJhcqfH7SMiGHrWKw9gXi+0EVJAxJ8Gt4ehozYsYLhUXL1JFbP1g3EE6ZmkStB0g==" } }, "dependencies": { @@ -10243,6 +10242,17 @@ "dev": true, "requires": { "tiny-readdir": "^2.2.0" + }, + "dependencies": { + "tiny-readdir": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/tiny-readdir/-/tiny-readdir-2.7.4.tgz", + "integrity": "sha512-721U+zsYwDirjr8IM6jqpesD/McpZooeFi3Zc6mcjy1pse2C+v19eHPFRqz4chGXZFw7C3KITDjAtHETc2wj7Q==", + "dev": true, + "requires": { + "promise-make-counter": "^1.0.2" + } + } } }, "json-parse-even-better-errors": { @@ -11569,23 +11579,20 @@ "integrity": "sha512-RnIDHQ+r9zMuslQWVoRxfKVOumteeheQqbwNYJyQxzM2vzx/vdN5xAeL64F3rQOpfbVdxFkhM4zPDyfq7SxsBQ==" }, "tiny-readdir": { - "version": "2.7.4", - "resolved": "https://registry.npmjs.org/tiny-readdir/-/tiny-readdir-2.7.4.tgz", - "integrity": "sha512-721U+zsYwDirjr8IM6jqpesD/McpZooeFi3Zc6mcjy1pse2C+v19eHPFRqz4chGXZFw7C3KITDjAtHETc2wj7Q==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tiny-readdir/-/tiny-readdir-3.1.1.tgz", + "integrity": "sha512-SiNW3Kp5xshlA3McA2C/tjU7K8+VJc9ZNe09foB8ut8lMY7bR9NKPo5ARHDOj24mV69/utNy8gqDjF93FcFlFg==", "requires": { "promise-make-counter": "^1.0.2" } }, "tiny-readdir-glob": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/tiny-readdir-glob/-/tiny-readdir-glob-1.23.2.tgz", - "integrity": "sha512-+47FIdgzEtZj03mOyq9iAljlZZNleqSEwe3i6Uzkzec5axbMg32Vp78U2fLo4TiCMv9gzjnno7yJn34z5pXECw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tiny-readdir-glob/-/tiny-readdir-glob-2.0.0.tgz", + "integrity": "sha512-1zAGi76CspOE4MjMrsx2OeLpvjiHyprFTuJ1O3k5C1qU09GpltOL+zx5rw9CaVQ/qkpkmCfc09TS2fV52GJrrw==", "requires": { - "tiny-readdir": "^2.7.0", - "zeptomatch": "^2.0.1", - "zeptomatch-explode": "^1.0.1", - "zeptomatch-is-static": "^1.0.1", - "zeptomatch-unescape": "^1.0.1" + "tiny-readdir": "^3.1.1", + "zeptomatch": "^2.1.0" } }, "tiny-spinner": { @@ -12037,20 +12044,10 @@ "resolved": "https://registry.npmjs.org/zeptomatch-escape/-/zeptomatch-escape-1.0.1.tgz", "integrity": "sha512-kAc5HzvnF66djCYDqpsS46Y/FKi+4pe/KJRmTmm/hwmoaNYzmm6bBY07cdkxmJCdY018S5UeQn4yP+9X2x1MbQ==" }, - "zeptomatch-explode": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/zeptomatch-explode/-/zeptomatch-explode-1.0.1.tgz", - "integrity": "sha512-7cUQASLLRGZ20+zEQcEgQ9z/gH1+jSfrNg4KfRJSxF1QU2fpymAwWvnAxl69GD5pr3IV0V9vo3ke2np//Nh4tQ==" - }, "zeptomatch-is-static": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/zeptomatch-is-static/-/zeptomatch-is-static-1.0.1.tgz", "integrity": "sha512-bN9q7H/UdXhkub01WE7b7Grg07jLldNnIWG2T1IpBq5NtvcQ4DwFbNiGGapnbKHUdWiCNjg/bIvixV88nj9gog==" - }, - "zeptomatch-unescape": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/zeptomatch-unescape/-/zeptomatch-unescape-1.0.1.tgz", - "integrity": "sha512-xhSFkKV0aQ03e/eiN4VhOTwJhcqfH7SMiGHrWKw9gXi+0EVJAxJ8Gt4ehozYsYLhUXL1JFbP1g3EE6ZmkStB0g==" } } } diff --git a/package.json b/package.json index dc43720..a764597 100644 --- a/package.json +++ b/package.json @@ -54,8 +54,8 @@ "smol-toml": "^1.7.0", "specialist": "^2.0.0", "tiny-editorconfig": "^1.0.2", - "tiny-readdir": "^2.7.4", - "tiny-readdir-glob": "^1.23.2", + "tiny-readdir": "^3.1.1", + "tiny-readdir-glob": "^2.0.0", "tiny-spinner": "^2.0.5", "worktank": "^3.0.2", "zeptomatch": "^2.1.0", diff --git a/src/utils.ts b/src/utils.ts index 65fcd63..9bdcca6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -11,6 +11,7 @@ import url from "node:url"; import resolveTimeout from "promise-resolve-timeout"; import { exit } from "specialist"; import readdir from "tiny-readdir"; +import type { Dirent } from "tiny-readdir"; import readdirGlob from "tiny-readdir-glob"; import zeptomatch from "zeptomatch"; import zeptomatchEscape from "zeptomatch-escape"; @@ -18,6 +19,8 @@ import zeptomatchIsStatic from "zeptomatch-is-static"; import type { ContextOptions, FormatOptions, FunctionMaybe, Key, LogLevel, Options, PrettierConfigWithOverrides, PrettierPlugin } from "./types.js"; import type { PluginsOptions } from "./types.js"; +type DirentWithParentPath = Dirent & { parentPath: string }; + function castArray(value: T | T[]): T[] { return isArray(value) ? value : [value]; } @@ -106,12 +109,53 @@ async function getFoldersChildrenPaths(foldersPaths: string[]): Promise { + return ignoreRe.test(path.relative(rootPath, targetPath)); + }; + + // These are the files and directories that were found during glob traversal. + // They haven't yet been filtered by the `ignore` function so may not + // equal the result files. + const filesFound: string[] = []; + const filesFoundNames = new Set(); + const filesFoundNamesToPaths: Record = {}; + const directoriesFound: string[] = []; + + const onDirents = (dirents: Dirent[]): undefined => { + for (const dirent of dirents) { + const direntName = dirent.name; + // TODO (jg): remove this cast once tiny-readdir knows about + // the `parentPath` property on Dirent objects + const direntPath = fastJoinedPath((dirent as DirentWithParentPath).parentPath, direntName); + if (ignore(direntPath)) continue; + if (dirent.isFile()) { + filesFound.push(direntPath); + filesFoundNames.add(direntName); + if (!Object.hasOwn(filesFoundNamesToPaths, direntName)) { + filesFoundNamesToPaths[direntName] = []; + } + filesFoundNamesToPaths[direntName].push(direntPath); + } else if (dirent.isDirectory()) { + directoriesFound.push(direntPath); + } + } + }; + + // Globs are matched against paths relative to the root, which are never prefixed with "./" + const globsNormalized = globs.map((glob) => glob.replace(/^(!*)(?:\.\/)+/, "$1")); + + const result = await readdirGlob(globsNormalized, { cwd: rootPath, followSymlinks: false, - ignore: `**/{.git,.sl,.svn,.hg,.DS_Store,Thumbs.db${withNodeModules ? "" : ",node_modules"}}`, + ignore, + onDirents, }); + + return { ...result, filesFound, filesFoundNames, filesFoundNamesToPaths, directoriesFound }; } async function getModule(modulePath: string): Promise { @@ -263,7 +307,7 @@ async function getTargetsPaths( const directoriesResults = await Promise.all(targetDirectories.map((targetPath) => getDirectoryPaths(targetPath, withNodeModules))); const directoriesResultsFiles = directoriesResults.map((result) => result.files); - const directoriesResultsFilesFoundNames = directoriesResults.map((result) => [...result.filesNames]); + const directoriesResultsFilesFoundNames = directoriesResults.map((result) => uniq(result.files.map((filePath) => path.basename(filePath)))); const foundFiles = uniqChunks(globResultFiles, ...directoriesResultsFiles); const foundFilesNames = uniqChunks(globResultFilesFoundNames, ...directoriesResultsFilesFoundNames); From 940968973cbb719bcd2872d84e5d7e3c3aff8f2b Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:48:31 +0100 Subject: [PATCH 2/4] fix: only consume parallel workers when > 0 --- src/index.ts | 6 +++++- src/utils.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index bebb6d5..688e14e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -181,7 +181,11 @@ async function runGlobs(options: Options, pluginsDefaultOptions: PluginsOptions, const cache = shouldCache ? new Cache(cacheVersion, projectPath, getCacheRootPath(rootPath), options, stdout) : undefined; const prettier = await makePrettier(options, cache); - const concurrency = options.parallel ? (options.parallelWorkers ?? Math.max(1, (os.availableParallelism?.() ?? os.cpus().length) - 1)) : 1; + const concurrency = options.parallel + ? options.parallelWorkers > 0 + ? options.parallelWorkers + : Math.max(1, (os.availableParallelism?.() ?? os.cpus().length) - 1) + : 1; const filesResults = await mapSettledWithConcurrency(filesPathsTargets, concurrency, async (filePath) => { const isIgnored = () => (ignoreManual ? ignoreManual(filePath) : getIgnoreResolved(filePath, ignoreNames)); const isCacheable = () => cache?.has(filePath, isIgnored); diff --git a/src/utils.ts b/src/utils.ts index 728cbb7..edf1b91 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -109,8 +109,11 @@ async function getFoldersChildrenPaths(foldersPaths: string[]): Promise { From 57a9b6c1976de5394c9249f07ac95d727d8846fe Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:00:31 +0100 Subject: [PATCH 3/4] fix: switcheroo the globs --- src/utils.ts | 6 +++--- test/__tests__/parallel.js | 35 +++++++++++++++++++++++++++++++++++ test/utils.js | 2 +- 3 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 test/__tests__/parallel.js diff --git a/src/utils.ts b/src/utils.ts index edf1b91..f2c1aae 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -109,11 +109,11 @@ async function getFoldersChildrenPaths(foldersPaths: string[]): Promise { diff --git a/test/__tests__/parallel.js b/test/__tests__/parallel.js new file mode 100644 index 0000000..6caf213 --- /dev/null +++ b/test/__tests__/parallel.js @@ -0,0 +1,35 @@ +import { runCli } from "../utils"; + +describe("files are processed when parallelism is left at its defaults", () => { + runCli("write", [ + "--list-different", + "formatted.js", + "unformatted.js", + "unformatted2.js", + ], { + parallel: true, + }).test({ + stdout: "unformatted.js\nunformatted2.js", + stderr: "", + status: 1, + write: [], + }); +}); + +describe("files are processed when an explicit worker count is given", () => { + runCli("write", [ + "--parallel-workers", + "2", + "--list-different", + "formatted.js", + "unformatted.js", + "unformatted2.js", + ], { + parallel: true, + }).test({ + stdout: "unformatted.js\nunformatted2.js", + stderr: "", + status: 1, + write: [], + }); +}); diff --git a/test/utils.js b/test/utils.js index 9ee7e30..81116e8 100644 --- a/test/utils.js +++ b/test/utils.js @@ -122,7 +122,7 @@ async function runCommand(dir, args, options) { const fixtures = dir ? await getIsolatedFixtures(dir) : undefined; const archive = fixtures ? await getArchive(fixtures.path) : undefined; const cwd = fixtures ? fixtures.path : TESTS_PATH; - const argsForTesting = ["--no-parallel"]; + const argsForTesting = options.parallel ? [] : ["--no-parallel"]; const argsWithReplacements = args.map((arg) => arg.replaceAll("$CWD", cwd)); const result = exec("node", [BIN_PATH, ...argsForTesting, ...argsWithReplacements], { cwd, stdio: "pipe" }); From 35d945263a1f47c8bee61ce93c1c1dc0cbb02c1a Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:04:45 +0100 Subject: [PATCH 4/4] chore: some tidying --- src/utils.ts | 22 +++++++++++----------- test/__tests__/patterns.js | 3 +-- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index f2c1aae..b3679cd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -110,22 +110,20 @@ async function getFoldersChildrenPaths(foldersPaths: string[]): Promise { return ignoreRe.test(path.relative(rootPath, targetPath)); }; // These are the files and directories that were found during glob traversal. - // They haven't yet been filtered by the `ignore` function so may not + // They haven't yet been filtered by the user globs so may not // equal the result files. const filesFound: string[] = []; - const filesFoundNames = new Set(); - const filesFoundNamesToPaths: Record = {}; + const filesFoundNamesToPaths: Record = Object.create(null); const directoriesFound: string[] = []; const onDirents = (dirents: Dirent[]): undefined => { @@ -137,7 +135,6 @@ async function getGlobPaths(rootPath: string, globs: string[], withNodeModules: if (ignore(direntPath)) continue; if (dirent.isFile()) { filesFound.push(direntPath); - filesFoundNames.add(direntName); if (!Object.hasOwn(filesFoundNamesToPaths, direntName)) { filesFoundNamesToPaths[direntName] = []; } @@ -149,16 +146,17 @@ async function getGlobPaths(rootPath: string, globs: string[], withNodeModules: }; // Globs are matched against paths relative to the root, which are never prefixed with "./" + // TODO (jg): handle '../' in globs const globsNormalized = globs.map((glob) => glob.replace(/^(!*)(?:\.\/)+/, "$1")); const result = await readdirGlob(globsNormalized, { cwd: rootPath, followSymlinks: false, - ignore, + ignore: ignoreGlob, onDirents, }); - return { ...result, filesFound, filesFoundNames, filesFoundNamesToPaths, directoriesFound }; + return { files: result.files, filesFound, filesFoundNamesToPaths, directoriesFound }; } async function getModule(modulePath: string): Promise { @@ -295,7 +293,9 @@ async function getTargetsPaths( const fileName = path.basename(filePath); targetFiles.push(filePath); targetFilesNames.push(fileName); - targetFilesNamesToPaths.propertyIsEnumerable(fileName) || (targetFilesNamesToPaths[fileName] = []); + if (!Object.hasOwn(targetFilesNamesToPaths, fileName)) { + targetFilesNamesToPaths[fileName] = []; + } targetFilesNamesToPaths[fileName].push(filePath); } else if (fileStats?.isDirectory()) { targetDirectories.push(filePath); @@ -306,7 +306,7 @@ async function getTargetsPaths( const globResult = await getGlobPaths(rootPath, targetGlobs, withNodeModules); const globResultFiles = globResult.files; - const globResultFilesFoundNames = [...globResult.filesFoundNames]; + const globResultFilesFoundNames = Object.keys(globResult.filesFoundNamesToPaths); const directoriesResults = await Promise.all(targetDirectories.map((targetPath) => getDirectoryPaths(targetPath, withNodeModules))); const directoriesResultsFiles = directoriesResults.map((result) => result.files); diff --git a/test/__tests__/patterns.js b/test/__tests__/patterns.js index db1243e..31f1420 100644 --- a/test/__tests__/patterns.js +++ b/test/__tests__/patterns.js @@ -40,8 +40,7 @@ describe("multiple patterns with a negated pattern, ignores node_modules by defa }); }); -// TODO: Handle leading `./` and `../` in patterns. -describe.skip("multiple patterns with a negated pattern and leading `./`, ignores node_modules by default", () => { +describe("multiple patterns with a negated pattern and leading `./`, ignores node_modules by default", () => { runCli("patterns", [ "./**/*.js", "!./directory/**",