diff --git a/packages/rstack/src/fmt/discovery.ts b/packages/rstack/src/fmt/discovery.ts new file mode 100644 index 0000000..ebfed7e --- /dev/null +++ b/packages/rstack/src/fmt/discovery.ts @@ -0,0 +1,60 @@ +import { getFileInfo, type FileInfoOptions } from 'prettier'; +import { resolveFmtOptions } from './config.ts'; +import { discoverFmtPaths } from './discoverPaths.ts'; +import { createFmtIgnoreMatcher } from './ignore.ts'; +import type { DiscoverFmtFilesOptions, FmtFileRequest, ResolvedFmtConfig } from './types.ts'; + +const fileInfoOptions = { + ignorePath: [], + resolveConfig: false, + withNodeModules: true, +} satisfies FileInfoOptions; + +const resolveFileRequest = async ( + filePath: string, + config: ResolvedFmtConfig, +): Promise => { + const options = resolveFmtOptions(filePath, config); + + if (options.plugins?.length) { + throw new Error('Prettier plugins are not supported yet.'); + } + + const parser = options.parser ?? (await getFileInfo(filePath, fileInfoOptions)).inferredParser; + if (!parser) { + return; + } + + return { + path: filePath, + options: { + ...options, + filepath: filePath, + parser, + }, + }; +}; + +/** Discovers format-ready files without reading Prettier config files or `.prettierignore`. */ +const discoverFmtFiles = async ({ + cwd, + patterns, + config, +}: DiscoverFmtFilesOptions): Promise => { + const candidates = await discoverFmtPaths({ cwd, patterns }); + if (candidates.length === 0) { + return []; + } + + const isFmtIgnored = config.ignorePatterns.length ? createFmtIgnoreMatcher(config) : undefined; + const filePaths = isFmtIgnored + ? candidates.filter((filePath) => !isFmtIgnored(filePath)) + : candidates; + const files = await Promise.all( + filePaths.map((filePath) => resolveFileRequest(filePath, config)), + ); + + return files.filter((file): file is FmtFileRequest => file !== undefined); +}; + +export { discoverFmtFiles }; diff --git a/packages/rstack/src/fmt/types.ts b/packages/rstack/src/fmt/types.ts index 8944223..16400e1 100644 --- a/packages/rstack/src/fmt/types.ts +++ b/packages/rstack/src/fmt/types.ts @@ -28,6 +28,22 @@ interface FormatTextOptions { config: ResolvedFmtConfig; } +interface DiscoverFmtFilesOptions { + /** Absolute directory used to resolve input paths. */ + cwd: string; + /** Files, directories, and positive or negative globs. Defaults to the current directory. */ + patterns?: string[]; + /** Resolved project config applied to discovered files. */ + config: ResolvedFmtConfig; +} + +interface FmtFileRequest { + /** Absolute path to the file. */ + path: string; + /** Final Prettier options with the parser and file path resolved. */ + options: PrettierOptions & Required>; +} + interface FormattedTextResult { status: 'formatted'; formatted: string; @@ -42,8 +58,10 @@ interface SkippedTextResult { type FormatTextResult = FormattedTextResult | SkippedTextResult; export type { + DiscoverFmtFilesOptions, FmtConfig, FmtConfigDefinition, + FmtFileRequest, FormatTextOptions, FormatTextResult, ResolvedFmtConfig, diff --git a/packages/rstack/tests/fmt/discovery.test.ts b/packages/rstack/tests/fmt/discovery.test.ts new file mode 100644 index 0000000..4b8e866 --- /dev/null +++ b/packages/rstack/tests/fmt/discovery.test.ts @@ -0,0 +1,82 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { expect, test } from 'rstack/test'; +import { normalizeFmtConfig } from '../../src/fmt/config.ts'; +import { discoverFmtFiles } from '../../src/fmt/discovery.ts'; +import type { FmtConfig } from '../../src/fmt/types.ts'; + +const withProject = async (callback: (rootPath: string) => Promise): Promise => { + const rootPath = mkdtempSync(path.join(tmpdir(), 'rstack fmt ')); + + try { + await callback(rootPath); + } finally { + rmSync(rootPath, { force: true, recursive: true }); + } +}; + +const writeProjectFile = (rootPath: string, filePath: string, content = ''): string => { + const absolutePath = path.join(rootPath, filePath); + mkdirSync(path.dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, content); + return absolutePath; +}; + +const discover = async (cwd: string, patterns?: string[], config?: FmtConfig, configRoot = cwd) => + discoverFmtFiles({ + cwd, + patterns, + config: normalizeFmtConfig(config, configRoot), + }); + +const relativePaths = (rootPath: string, files: Awaited>): string[] => + files.map((file) => path.relative(rootPath, file.path)); + +test('applies config ignore patterns to discovered and explicit files', async () => { + await withProject(async (rootPath) => { + const keepPath = writeProjectFile(rootPath, 'generated/keep.ts'); + const blockedPath = writeProjectFile(rootPath, 'generated/blocked.ts'); + writeProjectFile(rootPath, 'src/index.ts'); + const config = { ignorePatterns: ['generated/blocked.ts'] }; + + const discoveredFiles = await discover(rootPath, undefined, config); + const explicitFiles = await discover(rootPath, [keepPath, blockedPath], config); + + expect(relativePaths(rootPath, discoveredFiles)).toEqual([ + path.join('generated', 'keep.ts'), + path.join('src', 'index.ts'), + ]); + expect(relativePaths(rootPath, explicitFiles)).toEqual([path.join('generated', 'keep.ts')]); + }); +}); + +test('applies config ignore patterns outside the config root', async () => { + await withProject(async (rootPath) => { + const configRoot = path.join(rootPath, 'project'); + const filePath = writeProjectFile(rootPath, 'shared/index.ts'); + mkdirSync(configRoot); + + await expect( + discover(configRoot, [filePath], { ignorePatterns: ['../shared/*.ts'] }, configRoot), + ).resolves.toEqual([]); + }); +}); + +test('resolves parsers and accepts unknown extensions with an explicit parser', async () => { + await withProject(async (rootPath) => { + writeProjectFile(rootPath, 'index.ts'); + writeProjectFile(rootPath, 'source.custom'); + writeProjectFile(rootPath, 'unknown.extension'); + + const inferredFiles = await discover(rootPath); + const configuredFiles = await discover(rootPath, ['source.custom'], { parser: 'babel' }); + + expect(relativePaths(rootPath, inferredFiles)).toEqual(['index.ts']); + expect(inferredFiles[0].options.parser).toBe('typescript'); + expect(configuredFiles[0].options).toMatchObject({ + filepath: path.join(rootPath, 'source.custom'), + parser: 'babel', + }); + }); +});