Skip to content

Commit 38ad68e

Browse files
authored
feat(fmt): enable parallel formatting (#132)
1 parent f594ea8 commit 38ad68e

15 files changed

Lines changed: 421 additions & 23 deletions

packages/rstack/THIRD_PARTY_NOTICES.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,63 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
171171
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
172172
SOFTWARE.
173173

174+
## isoconcurrency
175+
176+
This package includes bundled code from
177+
[isoconcurrency](https://github.com/fabiospampinato/isoconcurrency).
178+
179+
License: MIT
180+
181+
The MIT License (MIT)
182+
183+
Copyright (c) 2025-present Fabio Spampinato
184+
185+
Permission is hereby granted, free of charge, to any person obtaining a
186+
copy of this software and associated documentation files (the "Software"),
187+
to deal in the Software without restriction, including without limitation
188+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
189+
and/or sell copies of the Software, and to permit persons to whom the
190+
Software is furnished to do so, subject to the following conditions:
191+
192+
The above copyright notice and this permission notice shall be included in
193+
all copies or substantial portions of the Software.
194+
195+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
196+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
197+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
198+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
199+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
200+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
201+
DEALINGS IN THE SOFTWARE.
202+
203+
## isotimer
204+
205+
This package includes bundled code from [isotimer](https://github.com/fabiospampinato/isotimer).
206+
207+
License: MIT
208+
209+
The MIT License (MIT)
210+
211+
Copyright (c) 2025-present Fabio Spampinato
212+
213+
Permission is hereby granted, free of charge, to any person obtaining a
214+
copy of this software and associated documentation files (the "Software"),
215+
to deal in the Software without restriction, including without limitation
216+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
217+
and/or sell copies of the Software, and to permit persons to whom the
218+
Software is furnished to do so, subject to the following conditions:
219+
220+
The above copyright notice and this permission notice shall be included in
221+
all copies or substantial portions of the Software.
222+
223+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
224+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
225+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
226+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
227+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
228+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
229+
DEALINGS IN THE SOFTWARE.
230+
174231
## micromatch
175232

176233
This package includes bundled code from [micromatch](https://github.com/micromatch/micromatch).
@@ -283,6 +340,35 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
283340
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
284341
THE SOFTWARE.
285342

343+
## promise-make-naked
344+
345+
This package includes bundled code from
346+
[promise-make-naked](https://github.com/fabiospampinato/promise-make-naked).
347+
348+
License: MIT
349+
350+
The MIT License (MIT)
351+
352+
Copyright (c) 2021-present Fabio Spampinato
353+
354+
Permission is hereby granted, free of charge, to any person obtaining a
355+
copy of this software and associated documentation files (the "Software"),
356+
to deal in the Software without restriction, including without limitation
357+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
358+
and/or sell copies of the Software, and to permit persons to whom the
359+
Software is furnished to do so, subject to the following conditions:
360+
361+
The above copyright notice and this permission notice shall be included in
362+
all copies or substantial portions of the Software.
363+
364+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
365+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
366+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
367+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
368+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
369+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
370+
DEALINGS IN THE SOFTWARE.
371+
286372
## tinyexec
287373

288374
This package includes bundled code from [tinyexec](https://github.com/tinylibs/tinyexec).
@@ -338,3 +424,60 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
338424
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
339425
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
340426
DEALINGS IN THE SOFTWARE.
427+
428+
## webworker-shim
429+
430+
This package includes bundled code from
431+
[webworker-shim](https://github.com/fabiospampinato/webworker-shim).
432+
433+
License: MIT
434+
435+
The MIT License (MIT)
436+
437+
Copyright (c) 2022-present Fabio Spampinato
438+
439+
Permission is hereby granted, free of charge, to any person obtaining a
440+
copy of this software and associated documentation files (the "Software"),
441+
to deal in the Software without restriction, including without limitation
442+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
443+
and/or sell copies of the Software, and to permit persons to whom the
444+
Software is furnished to do so, subject to the following conditions:
445+
446+
The above copyright notice and this permission notice shall be included in
447+
all copies or substantial portions of the Software.
448+
449+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
450+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
451+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
452+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
453+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
454+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
455+
DEALINGS IN THE SOFTWARE.
456+
457+
## worktank
458+
459+
This package includes bundled code from [worktank](https://github.com/fabiospampinato/worktank).
460+
461+
License: MIT
462+
463+
The MIT License (MIT)
464+
465+
Copyright (c) 2021-present Fabio Spampinato
466+
467+
Permission is hereby granted, free of charge, to any person obtaining a
468+
copy of this software and associated documentation files (the "Software"),
469+
to deal in the Software without restriction, including without limitation
470+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
471+
and/or sell copies of the Software, and to permit persons to whom the
472+
Software is furnished to do so, subject to the following conditions:
473+
474+
The above copyright notice and this permission notice shall be included in
475+
all copies or substantial portions of the Software.
476+
477+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
478+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
479+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
480+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
481+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
482+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
483+
DEALINGS IN THE SOFTWARE.

packages/rstack/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@
7575
"micromatch": "catalog:",
7676
"rslog": "catalog:",
7777
"tiny-readdir": "catalog:",
78-
"typescript": "catalog:"
78+
"typescript": "catalog:",
79+
"worktank": "catalog:"
7980
},
8081
"peerDependencies": {
8182
"@rspress/core": "^2.0.17"

packages/rstack/src/fmt/cli.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { FmtMode, FmtRunResult } from './types.ts';
1010
interface ParsedFmtCLIArgs {
1111
mode: FmtMode;
1212
patterns: string[];
13+
parallel: boolean;
1314
help: boolean;
1415
}
1516

@@ -24,6 +25,7 @@ ${color.cyan('Options')}:
2425
--write Write formatted files in place (default)
2526
--check Check whether files are formatted
2627
--list-different Print paths of unformatted files
28+
--no-parallel Disable worker parallelism
2729
-h, --help Display this help message`;
2830

2931
const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
@@ -34,6 +36,8 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
3436
check: { type: 'boolean' },
3537
'list-different': { type: 'boolean' },
3638
listDifferent: { type: 'boolean' },
39+
'no-parallel': { type: 'boolean' },
40+
noParallel: { type: 'boolean' },
3741
help: { type: 'boolean', short: 'h' },
3842
},
3943
allowPositionals: true,
@@ -51,6 +55,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
5155
return {
5256
mode,
5357
patterns: positionals,
58+
parallel: !(values['no-parallel'] || values.noParallel),
5459
help: values.help ?? false,
5560
};
5661
};
@@ -93,7 +98,7 @@ const logFmtResult = (result: FmtRunResult, mode: FmtMode, cwd: string): void =>
9398
};
9499

95100
const runFmtCLI = async (args: string[]): Promise<void> => {
96-
const { help, mode, patterns } = parseFmtCLIArgs(args);
101+
const { help, mode, parallel, patterns } = parseFmtCLIArgs(args);
97102
if (help) {
98103
console.log(fmtHelpMessage);
99104
return;
@@ -118,7 +123,7 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
118123
files,
119124
mode,
120125
cache: false,
121-
parallel: false,
126+
parallel,
122127
});
123128

124129
logFmtResult(result, mode, cwd);
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Derived from @prettier/cli, see THIRD_PARTY_NOTICES.md
2+
3+
import { availableParallelism } from 'node:os';
4+
import WorkTank from 'worktank';
5+
6+
type FmtWorkerMethods = typeof import('./worker.ts');
7+
8+
interface FmtWorker {
9+
formatFile: FmtWorkerMethods['formatFileSerial'];
10+
terminate: () => void;
11+
}
12+
13+
const getFmtWorkerCount = (fileCount: number): number =>
14+
Math.min(fileCount, Math.max(1, availableParallelism() - 1));
15+
16+
const getFmtWorkerUrl = (): URL => {
17+
// Source tests run after build and exercise the same worker artifact as the CLI.
18+
const workerPath = new URL(import.meta.url).pathname.endsWith('.ts')
19+
? '../../dist/fmtWorker.js'
20+
: './fmtWorker.js';
21+
return new URL(workerPath, import.meta.url);
22+
};
23+
24+
/** Creates and starts every worker before formatting can begin. */
25+
const createFmtWorker = async (fileCount: number): Promise<FmtWorker> => {
26+
const workerCount = getFmtWorkerCount(fileCount);
27+
const pool = new WorkTank<FmtWorkerMethods>({
28+
pool: {
29+
name: 'rstack-fmt',
30+
size: workerCount,
31+
},
32+
worker: {
33+
autoInstantiate: true,
34+
methods: getFmtWorkerUrl(),
35+
},
36+
});
37+
38+
try {
39+
// Concurrent handshakes make WorkTank assign one task to every worker.
40+
await Promise.all(
41+
Array.from({ length: workerCount }, () => pool.exec('initializeFmtWorker', [])),
42+
);
43+
} catch (error) {
44+
pool.terminate();
45+
throw error;
46+
}
47+
48+
return {
49+
formatFile: (file, shouldWrite) => pool.exec('formatFileSerial', [file, shouldWrite]),
50+
terminate: pool.terminate,
51+
};
52+
};
53+
54+
export { createFmtWorker };

packages/rstack/src/fmt/runner.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,31 @@ const runFmtFilesSerial = async (
5050
return results;
5151
};
5252

53+
/** Processes files concurrently while preserving input order. */
54+
const runFmtFilesParallel = async (
55+
files: FmtFileRequest[],
56+
shouldWrite: boolean,
57+
): Promise<FmtFileResult[]> => {
58+
const { createFmtWorker } = await import('./parallel.ts');
59+
const worker = await createFmtWorker(files.length);
60+
61+
try {
62+
return await Promise.all(files.map((file) => runFmtFile(file, shouldWrite, worker.formatFile)));
63+
} finally {
64+
worker.terminate();
65+
}
66+
};
67+
68+
/** Checks every worker payload before any formatting can begin. */
69+
const canRunFmtFilesParallel = (files: FmtFileRequest[]): boolean => {
70+
try {
71+
structuredClone(files);
72+
return true;
73+
} catch {
74+
return false;
75+
}
76+
};
77+
5378
/** Maps file results to the Prettier-compatible CLI exit code. */
5479
const getFmtExitCode = (files: FmtFileResult[]): FmtExitCode => {
5580
let exitCode: FmtExitCode = 0;
@@ -67,9 +92,17 @@ const getFmtExitCode = (files: FmtFileResult[]): FmtExitCode => {
6792
};
6893

6994
/** Runs resolved files and summarizes their outcomes for the CLI. */
70-
const runFmtFiles = async ({ files, mode }: RunFmtFilesOptions): Promise<FmtRunResult> => {
95+
const runFmtFiles = async ({
96+
files,
97+
mode,
98+
parallel,
99+
}: RunFmtFilesOptions): Promise<FmtRunResult> => {
71100
const startTime = performance.now();
72-
const results = await runFmtFilesSerial(files, mode === 'write');
101+
const shouldWrite = mode === 'write';
102+
const results =
103+
parallel && files.length > 1 && canRunFmtFilesParallel(files)
104+
? await runFmtFilesParallel(files, shouldWrite)
105+
: await runFmtFilesSerial(files, shouldWrite);
73106

74107
return {
75108
files: results,

packages/rstack/src/fmt/serial.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
/**
2-
* Derived from @prettier/cli v0.12.0.
3-
* SPDX-License-Identifier: MIT
4-
* Modified by Rstack contributors.
5-
*/
1+
// Derived from @prettier/cli, see THIRD_PARTY_NOTICES.md
62

73
import { readFile, writeFile } from 'atomically';
84
import { format } from 'prettier';

packages/rstack/src/fmt/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ interface RunFmtFilesOptions {
5454
mode: FmtMode;
5555
/** Persistent cache support is added in a later implementation step. */
5656
cache: false;
57-
/** Parallel execution support is added in a later implementation step. */
58-
parallel: false;
57+
/** Whether cloneable file requests should run in worker threads. */
58+
parallel: boolean;
5959
}
6060

6161
interface SuccessfulFmtFileResult {

packages/rstack/src/fmt/worker.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
/**
2-
* Internal build entry reserved for the fmt worker.
3-
*
4-
* The worker implementation is intentionally added by a later PR. Keeping a
5-
* dedicated entry now establishes a stable published asset boundary.
6-
*/
7-
export {};
1+
import { formatFileSerial } from './serial.ts';
2+
3+
/** Confirms that the worker module and its runtime dependencies are ready. */
4+
const initializeFmtWorker = (): true => true;
5+
6+
export { formatFileSerial, initializeFmtWorker };

packages/rstack/tests/cli/fmt/index.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,19 @@ test('formats the current directory with Prettier defaults', () => {
8484
expect(readProjectFile('index.ts')).toBe('const message = "hello";\n');
8585
});
8686

87+
test('supports disabling parallel execution', () => {
88+
writeProjectFile('first.ts', 'const first="first"');
89+
writeProjectFile('second.ts', 'const second="second"');
90+
91+
const result = runFmt(['--no-parallel', 'first.ts', 'second.ts']);
92+
93+
expect(result.status).toBe(0);
94+
expect(result.stdout).toBe('first.ts\nsecond.ts\n');
95+
expect(result.stderr).toBe('');
96+
expect(readProjectFile('first.ts')).toBe('const first = "first";\n');
97+
expect(readProjectFile('second.ts')).toBe('const second = "second";\n');
98+
});
99+
87100
test('does not load Prettier config or ignore files', () => {
88101
writeProjectFile('.prettierrc.json', '{ "singleQuote": true, "semi": false }\n');
89102
writeProjectFile('.prettierignore', 'index.ts\n');

0 commit comments

Comments
 (0)