Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions packages/rstack/src/fmt/discovery.ts
Original file line number Diff line number Diff line change
@@ -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<FmtFileRequest | undefined> => {
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<FmtFileRequest[]> => {
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 };
18 changes: 18 additions & 0 deletions packages/rstack/src/fmt/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pick<PrettierOptions, 'filepath' | 'parser'>>;
}

interface FormattedTextResult {
status: 'formatted';
formatted: string;
Expand All @@ -42,8 +58,10 @@ interface SkippedTextResult {
type FormatTextResult = FormattedTextResult | SkippedTextResult;

export type {
DiscoverFmtFilesOptions,
FmtConfig,
FmtConfigDefinition,
FmtFileRequest,
FormatTextOptions,
FormatTextResult,
ResolvedFmtConfig,
Expand Down
82 changes: 82 additions & 0 deletions packages/rstack/tests/fmt/discovery.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>): Promise<void> => {
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<ReturnType<typeof discover>>): 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',
});
});
});