diff --git a/packages/rstack/src/fmt/discovery.ts b/packages/rstack/src/fmt/discovery.ts index aedb6f58..d8abd251 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 b6e52c4f..b2898316 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 614038db..f841220f 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 4b251e07..a0fa109b 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 b02fbc3e..7be9afa9 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 0b115eb3..ed81301e 100644 --- a/packages/rstack/src/fmt/worker.ts +++ b/packages/rstack/src/fmt/worker.ts @@ -2,8 +2,9 @@ import { readFileSync, writeFileSync } from 'node:fs'; 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'; /** * Use synchronous direct I/O inside the dedicated worker to avoid libuv @@ -12,22 +13,29 @@ import type { FmtFileRequest } from './types.ts'; 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 = readFileSync(path, 'utf8'); const formatted = await format(source, { ...options, - plugins: await getPrettierPlugins(options), + parser, + plugins, }); if (source === formatted) { - return false; + return 'unchanged'; } if (shouldWrite) { writeFileSync(path, formatted, 'utf8'); } - 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 56755011..ec4ed87c 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 edd84ebf..ba8f018d 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 d8a699e5..1d6a99bb 100644 --- a/packages/rstack/tests/fmt/worker.test.ts +++ b/packages/rstack/tests/fmt/worker.test.ts @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; +import path from 'node:path'; import { expect, test } from 'rstack/test'; import { formatFile } from '../../src/fmt/worker.ts'; import { withTempProject, writeProjectFile } from './helpers.ts'; @@ -18,8 +19,43 @@ test('writes formatted files', async () => { }, true, ), - ).resolves.toBe(true); + ).resolves.toBe('changed'); expect(readFileSync(filePath, 'utf8')).toBe('const value = 1;\n'); }); }); + +test('infers the parser before formatting', async () => { + await withTempProject(async (rootPath) => { + const source = 'const value=1'; + const filePath = writeProjectFile(rootPath, 'example.ts', source); + + await expect( + formatFile( + { + path: filePath, + options: { filepath: filePath }, + }, + false, + ), + ).resolves.toBe('changed'); + + expect(readFileSync(filePath, 'utf8')).toBe(source); + }); +}); + +test('skips unsupported files before reading them', async () => { + await withTempProject(async (rootPath) => { + const filePath = path.join(rootPath, 'missing.unknown'); + + await expect( + formatFile( + { + path: filePath, + options: { filepath: filePath }, + }, + true, + ), + ).resolves.toBe('unsupported'); + }); +});