From a991157a56adcdf1744b502684943d73ecc99355 Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 3 Aug 2026 13:13:58 +0800 Subject: [PATCH] perf(fmt): infer parsers in workers --- packages/rstack/src/fmt/discovery.ts | 17 ++----- packages/rstack/src/fmt/format.ts | 19 ++++---- packages/rstack/src/fmt/parser.ts | 6 ++- packages/rstack/src/fmt/runner.ts | 17 ++++--- packages/rstack/src/fmt/types.ts | 6 ++- packages/rstack/src/fmt/worker.ts | 18 +++++--- packages/rstack/tests/fmt/discovery.test.ts | 13 +++--- packages/rstack/tests/fmt/runner.test.ts | 17 +++++++ packages/rstack/tests/fmt/worker.test.ts | 49 +++++++++++++++++++-- 9 files changed, 117 insertions(+), 45 deletions(-) diff --git a/packages/rstack/src/fmt/discovery.ts b/packages/rstack/src/fmt/discovery.ts index aedb6f5..d8abd25 100644 --- a/packages/rstack/src/fmt/discovery.ts +++ b/packages/rstack/src/fmt/discovery.ts @@ -1,32 +1,26 @@ import { resolveFmtOptions } from './config.ts'; import { discoverFmtPaths } from './discoverPaths.ts'; import { createFmtIgnoreMatcher } from './ignore.ts'; -import { resolveFmtParser } from './parser.ts'; import { createFmtPluginResolver, type FmtPluginResolver } from './plugins.ts'; import type { DiscoverFmtFilesOptions, FmtFileRequest, ResolvedFmtConfig } from './types.ts'; -const resolveFileRequest = async ( +const createFileRequest = ( filePath: string, config: ResolvedFmtConfig, resolvePlugins: FmtPluginResolver, -): Promise => { +): FmtFileRequest => { const options = resolvePlugins(resolveFmtOptions(filePath, config)); - const parser = await resolveFmtParser(filePath, options); - if (!parser) { - return; - } return { path: filePath, options: { ...options, filepath: filePath, - parser, }, }; }; -/** Discovers format-ready files without reading Prettier config files or `.prettierignore`. */ +/** Discovers worker-ready files without reading Prettier config files or `.prettierignore`. */ const discoverFmtFiles = async ({ cwd, patterns, @@ -42,11 +36,8 @@ const discoverFmtFiles = async ({ ? candidates.filter((filePath) => !isFmtIgnored(filePath)) : candidates; const resolvePlugins = createFmtPluginResolver(config.rootPath); - const files = await Promise.all( - filePaths.map((filePath) => resolveFileRequest(filePath, config, resolvePlugins)), - ); - return files.filter((file): file is FmtFileRequest => file !== undefined); + return filePaths.map((filePath) => createFileRequest(filePath, config, resolvePlugins)); }; export { discoverFmtFiles }; diff --git a/packages/rstack/src/fmt/format.ts b/packages/rstack/src/fmt/format.ts index b6e52c4..b289831 100644 --- a/packages/rstack/src/fmt/format.ts +++ b/packages/rstack/src/fmt/format.ts @@ -11,7 +11,12 @@ const formatText = async ( { filePath, cursorOffset, config }: FormatTextOptions, ): Promise => { const options = createFmtPluginResolver(config.rootPath)(resolveFmtOptions(filePath, config)); - const parser = await resolveFmtParser(filePath, options); + const formatOptions = { + ...options, + filepath: filePath, + }; + const plugins = await getPrettierPlugins(formatOptions); + const parser = await resolveFmtParser(filePath, formatOptions, plugins); if (!parser) { return { @@ -20,24 +25,18 @@ const formatText = async ( }; } - const formatOptions = { - ...options, - filepath: filePath, - parser, - }; - const plugins = await getPrettierPlugins(formatOptions); + const resolvedOptions = { ...formatOptions, parser, plugins }; if (cursorOffset === undefined) { return { status: 'formatted', - formatted: await format(source, { ...formatOptions, plugins }), + formatted: await format(source, resolvedOptions), }; } const result = await formatWithCursor(source, { - ...formatOptions, + ...resolvedOptions, cursorOffset, - plugins, }); return { diff --git a/packages/rstack/src/fmt/parser.ts b/packages/rstack/src/fmt/parser.ts index 614038d..f841220 100644 --- a/packages/rstack/src/fmt/parser.ts +++ b/packages/rstack/src/fmt/parser.ts @@ -1,5 +1,6 @@ import { getFileInfo, type FileInfoOptions, type Options as PrettierOptions } from 'prettier'; -import { getPrettierPlugins } from './prettierPlugins.ts'; + +type PrettierPlugins = NonNullable; const fileInfoOptions = { ignorePath: [], @@ -11,12 +12,13 @@ const fileInfoOptions = { const resolveFmtParser = async ( filePath: string, options: PrettierOptions, + plugins: PrettierPlugins, ): Promise => options.parser ?? ( await getFileInfo(filePath, { ...fileInfoOptions, - plugins: await getPrettierPlugins(options), + plugins, }) ).inferredParser; diff --git a/packages/rstack/src/fmt/runner.ts b/packages/rstack/src/fmt/runner.ts index 4b251e0..a0fa109 100644 --- a/packages/rstack/src/fmt/runner.ts +++ b/packages/rstack/src/fmt/runner.ts @@ -3,26 +3,30 @@ import type { FmtFileRequest, FmtFileResult, FmtRunResult, + FmtWorkerFileResult, RunFmtFilesOptions, } from './types.ts'; /** Formats one file and reports whether its contents differ. */ -type FormatFile = (file: FmtFileRequest, shouldWrite: boolean) => Promise; +type FormatFile = (file: FmtFileRequest, shouldWrite: boolean) => Promise; /** Converts a formatter outcome into the shared per-file result. */ const runFmtFile = async ( file: FmtFileRequest, shouldWrite: boolean, formatFile: FormatFile, -): Promise => { +): Promise => { const startTime = performance.now(); try { - const changed = await formatFile(file, shouldWrite); + const result = await formatFile(file, shouldWrite); + if (result === 'unsupported') { + return; + } return { path: file.path, - status: changed ? (shouldWrite ? 'written' : 'different') : 'unchanged', + status: result === 'changed' ? (shouldWrite ? 'written' : 'different') : 'unchanged', durationMs: performance.now() - startTime, }; } catch (error) { @@ -45,7 +49,10 @@ const runFmtFilesWithWorkers = async ( const worker = await createFmtWorker(files.length, maxWorkers); try { - return await Promise.all(files.map((file) => runFmtFile(file, shouldWrite, worker.formatFile))); + const results = await Promise.all( + files.map((file) => runFmtFile(file, shouldWrite, worker.formatFile)), + ); + return results.filter((result): result is FmtFileResult => result !== undefined); } finally { worker.terminate(); } diff --git a/packages/rstack/src/fmt/types.ts b/packages/rstack/src/fmt/types.ts index b02fbc3..7be9afa 100644 --- a/packages/rstack/src/fmt/types.ts +++ b/packages/rstack/src/fmt/types.ts @@ -66,10 +66,11 @@ interface DiscoverFmtFilesOptions { interface FmtFileRequest { /** Absolute path to the file. */ path: string; - /** Final Prettier options with the parser and file path resolved. */ - options: ResolvedFmtOptions & Required>; + /** Final per-file options with project plugins and the file path resolved. */ + options: ResolvedFmtOptions & Required>; } +type FmtWorkerFileResult = 'changed' | 'unchanged' | 'unsupported'; type FmtMode = 'write' | 'check' | 'list-different'; type FmtExitCode = 0 | 1 | 2; @@ -129,6 +130,7 @@ export type { FmtMode, FmtPluginSpecifier, FmtRunResult, + FmtWorkerFileResult, FormatTextOptions, FormatTextResult, ResolvedFmtConfig, diff --git a/packages/rstack/src/fmt/worker.ts b/packages/rstack/src/fmt/worker.ts index 7e50c74..7c10625 100644 --- a/packages/rstack/src/fmt/worker.ts +++ b/packages/rstack/src/fmt/worker.ts @@ -2,8 +2,9 @@ import { readFile, writeFile } from 'atomically'; import { format } from 'prettier'; +import { resolveFmtParser } from './parser.ts'; import { getPrettierPlugins } from './prettierPlugins.ts'; -import type { FmtFileRequest } from './types.ts'; +import type { FmtFileRequest, FmtWorkerFileResult } from './types.ts'; /** * Formatting output can be regenerated, so avoid waiting for a durability sync @@ -18,22 +19,29 @@ const atomicWriteOptions = { const formatFile = async ( { path, options }: FmtFileRequest, shouldWrite: boolean, -): Promise => { +): Promise => { + const plugins = await getPrettierPlugins(options); + const parser = await resolveFmtParser(path, options, plugins); + if (!parser) { + return 'unsupported'; + } + const source = await readFile(path, 'utf8'); const formatted = await format(source, { ...options, - plugins: await getPrettierPlugins(options), + parser, + plugins, }); if (source === formatted) { - return false; + return 'unchanged'; } if (shouldWrite) { await writeFile(path, formatted, atomicWriteOptions); } - return true; + return 'changed'; }; /** Confirms that the worker module and its runtime dependencies are ready. */ diff --git a/packages/rstack/tests/fmt/discovery.test.ts b/packages/rstack/tests/fmt/discovery.test.ts index 5675501..ec4ed87 100644 --- a/packages/rstack/tests/fmt/discovery.test.ts +++ b/packages/rstack/tests/fmt/discovery.test.ts @@ -47,7 +47,7 @@ test('applies config ignore patterns outside the config root', async () => { }); }); -test('uses Yuku parsers by default and accepts an explicit parser', async () => { +test('defers parser inference to workers and preserves an explicit parser', async () => { await withTempProject(async (rootPath) => { writeProjectFile(rootPath, 'index.js'); writeProjectFile(rootPath, 'index.ts'); @@ -57,8 +57,13 @@ test('uses Yuku parsers by default and accepts an explicit parser', async () => const inferredFiles = await discover(rootPath); const configuredFiles = await discover(rootPath, ['source.custom'], { parser: 'babel' }); - expect(relativePaths(rootPath, inferredFiles)).toEqual(['index.js', 'index.ts']); - expect(inferredFiles.map((file) => file.options.parser)).toEqual(['yuku', 'yuku-ts']); + expect(relativePaths(rootPath, inferredFiles)).toEqual([ + 'index.js', + 'index.ts', + 'source.custom', + 'unknown.extension', + ]); + expect(inferredFiles.every((file) => file.options.parser === undefined)).toBe(true); expect(configuredFiles[0].options).toMatchObject({ filepath: path.join(rootPath, 'source.custom'), parser: 'babel', @@ -108,13 +113,11 @@ test('resolves plugins after applying matching overrides', async () => { expect(files).toHaveLength(2); expect(files[0]).toMatchObject({ options: { - parser: 'json', plugins: [pathToFileURL(pluginEntry).href], }, }); expect(files[1]).toMatchObject({ options: { - parser: 'babel', plugins: [pathToFileURL(pluginEntry).href], }, }); diff --git a/packages/rstack/tests/fmt/runner.test.ts b/packages/rstack/tests/fmt/runner.test.ts index edd84eb..ba8f018 100644 --- a/packages/rstack/tests/fmt/runner.test.ts +++ b/packages/rstack/tests/fmt/runner.test.ts @@ -104,3 +104,20 @@ test('continues after a file fails and gives errors exit-code precedence', async expect(readFileSync(validPath, 'utf8')).toBe('const value=1'); }); }); + +test('omits unsupported files from the result', async () => { + await withTempProject(async (rootPath) => { + const filePath = path.join(rootPath, 'example.unknown'); + writeFileSync(filePath, 'plain text'); + + const result = await run([ + { + path: filePath, + options: { filepath: filePath }, + }, + ]); + + expect(result).toMatchObject({ exitCode: 0, files: [] }); + expect(readFileSync(filePath, 'utf8')).toBe('plain text'); + }); +}); diff --git a/packages/rstack/tests/fmt/worker.test.ts b/packages/rstack/tests/fmt/worker.test.ts index 6adaf89..c9a2b3a 100644 --- a/packages/rstack/tests/fmt/worker.test.ts +++ b/packages/rstack/tests/fmt/worker.test.ts @@ -1,18 +1,27 @@ -import { expect, rs, test } from 'rstack/test'; +import { beforeEach, expect, rs, test } from 'rstack/test'; import { formatFile } from '../../src/fmt/worker.ts'; const mocks = rs.hoisted(() => ({ + readFileCalls: [] as string[], writeFileCalls: [] as [string, string, unknown][], })); rs.mock('atomically', () => ({ - readFile: () => Promise.resolve('const value=1'), + readFile: (path: string) => { + mocks.readFileCalls.push(path); + return Promise.resolve('const value=1'); + }, writeFile: (path: string, data: string, options: unknown) => { mocks.writeFileCalls.push([path, data, options]); return Promise.resolve(); }, })); +beforeEach(() => { + mocks.readFileCalls.length = 0; + mocks.writeFileCalls.length = 0; +}); + test('disables fsync for atomic writes', async () => { const filePath = '/virtual/example.ts'; @@ -27,9 +36,43 @@ test('disables fsync for atomic writes', async () => { }, true, ), - ).resolves.toBe(true); + ).resolves.toBe('changed'); expect(mocks.writeFileCalls).toEqual([ [filePath, 'const value = 1;\n', { encoding: 'utf8', fsync: false }], ]); }); + +test('infers the parser before formatting', async () => { + const filePath = '/virtual/example.ts'; + + await expect( + formatFile( + { + path: filePath, + options: { filepath: filePath }, + }, + false, + ), + ).resolves.toBe('changed'); + + expect(mocks.readFileCalls).toEqual([filePath]); + expect(mocks.writeFileCalls).toEqual([]); +}); + +test('skips unsupported files before reading them', async () => { + const filePath = '/virtual/example.unknown'; + + await expect( + formatFile( + { + path: filePath, + options: { filepath: filePath }, + }, + true, + ), + ).resolves.toBe('unsupported'); + + expect(mocks.readFileCalls).toEqual([]); + expect(mocks.writeFileCalls).toEqual([]); +});