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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ doc_build

# Temp files
test-temp-*
TODO.md
TODO-*.md

# IDE
.vscode/*
Expand Down
20 changes: 17 additions & 3 deletions packages/rstack/src/fmt/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { FmtMode, FmtRunResult, ResolvedFmtConfig } from './types.ts';
interface ParsedFmtCLIArgs {
mode: FmtMode;
patterns: string[];
ignorePaths: string[];
maxWorkers?: number;
help: boolean;
/** Path the stdin content is formatted as; it need not exist on disk. */
Expand All @@ -28,6 +29,7 @@ ${color.cyan('Options')}:
--write Write formatted files in place (default)
--check Check whether files are formatted
--list-different Print paths of unformatted files
--ignore-path <path> Path to an additional ignore file (repeatable)
--parallel-workers <count> Number of parallel workers
--stdin-filepath <path> Format stdin as if it were saved at <path>
-h, --help Display this help message`;
Expand Down Expand Up @@ -57,6 +59,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
check: { type: 'boolean' },
'list-different': { type: 'boolean' },
listDifferent: { type: 'boolean' },
'ignore-path': { type: 'string', multiple: true },
'parallel-workers': { type: 'string' },
parallelWorkers: { type: 'string' },
'stdin-filepath': { type: 'string' },
Expand Down Expand Up @@ -92,6 +95,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
return {
mode,
patterns: positionals,
ignorePaths: values['ignore-path'] ?? [],
maxWorkers,
help: values.help ?? false,
stdinFilepath,
Expand Down Expand Up @@ -210,7 +214,7 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
// Argument errors are reported like every other failure so that a single
// exit code identifies "rs fmt refused to run".
try {
const { help, maxWorkers, mode, patterns, stdinFilepath } = parseFmtCLIArgs(args);
const { help, ignorePaths, maxWorkers, mode, patterns, stdinFilepath } = parseFmtCLIArgs(args);
if (help) {
logger.log(fmtHelpMessage);
return;
Expand All @@ -221,12 +225,22 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
/* rspackChunkName: 'fmtStdin' */
'./stdin.ts'
);
await runFmtStdin({ filepath: stdinFilepath, cwd, loadConfig: () => loadFmtConfig(cwd) });
await runFmtStdin({
filepath: stdinFilepath,
cwd,
ignorePaths,
loadConfig: () => loadFmtConfig(cwd),
});
return;
}

const config = await loadFmtConfig(cwd);
const files = await discoverFmtFiles({ cwd, patterns, config });
const files = await discoverFmtFiles({
cwd,
patterns,
config,
ignorePaths,
});

if (files.length === 0) {
if (mode !== 'list-different') {
Expand Down
16 changes: 11 additions & 5 deletions packages/rstack/src/fmt/discovery.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { resolveFmtOptions } from './config.ts';
import { discoverFmtPaths } from './discoverPaths.ts';
import { createFmtIgnoreMatcher } from './ignore.ts';
import { createIgnoreMatcher } from './ignore.ts';
import type { DiscoverFmtFilesOptions, FmtFileRequest, ResolvedFmtConfig } from './types.ts';

const createFileRequest = (filePath: string, config: ResolvedFmtConfig): FmtFileRequest => ({
Expand All @@ -12,15 +12,18 @@ const createFileRequest = (filePath: string, config: ResolvedFmtConfig): FmtFile
const discoverFmtFiles = async ({
cwd,
patterns,
ignorePaths,
config,
}: DiscoverFmtFilesOptions): Promise<FmtFileRequest[]> => {
const candidates = await discoverFmtPaths({ cwd, patterns });
const [candidates, isIgnored] = await Promise.all([
discoverFmtPaths({ cwd, patterns }),
createIgnoreMatcher({ config, cwd, ignorePaths }),
]);
if (candidates.length === 0) {
return [];
}

const isFmtIgnored = createFmtIgnoreMatcher(config);
const filePaths = candidates.filter((filePath) => !isFmtIgnored(filePath));
const filePaths = candidates.filter((filePath) => !isIgnored(filePath));
const files = filePaths.map((filePath) => createFileRequest(filePath, config));
if (!files.some((file) => file.options.plugins?.length)) {
return files;
Expand All @@ -32,7 +35,10 @@ const discoverFmtFiles = async ({
);
const resolvePlugins = createFmtPluginResolver(config.rootPath);

return files.map((file) => ({ ...file, options: resolvePlugins(file.options) }));
return files.map((file) => ({
...file,
options: resolvePlugins(file.options),
}));
};

export { createFileRequest, discoverFmtFiles };
54 changes: 48 additions & 6 deletions packages/rstack/src/fmt/ignore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { relative } from 'node:path';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import fastIgnore from 'fast-ignore';
import type { ResolvedFmtConfig } from './types.ts';

Expand All @@ -10,11 +11,52 @@ import type { ResolvedFmtConfig } from './types.ts';
*/
const defaultIgnorePatterns = ['package-lock.json', 'pnpm-lock.yaml'];

/** Creates a reusable matcher for default and config-level ignore patterns. */
const createFmtIgnoreMatcher = (config: ResolvedFmtConfig): ((filePath: string) => boolean) => {
const matches = fastIgnore([...defaultIgnorePatterns, ...config.ignorePatterns].join('\n'));
type IgnoreMatcher = (filePath: string) => boolean;

return (filePath) => matches(relative(config.rootPath, filePath));
interface CreateIgnoreMatcherOptions {
config: ResolvedFmtConfig;
/** Base directory for relative ignore paths. */
cwd: string;
ignorePaths?: string[];
}

const createPatternMatcher = (rootPath: string, patterns: string): IgnoreMatcher => {
const matches = fastIgnore(patterns);

return (filePath) => matches(path.relative(rootPath, filePath));
};

const loadIgnoreMatcher = async (cwd: string, ignorePath: string): Promise<IgnoreMatcher> => {
const filePath = path.resolve(cwd, ignorePath);
let patterns: string;

try {
patterns = await readFile(filePath, 'utf8');
} catch (error) {
throw new Error(`Failed to read ignore file "${ignorePath}".`, {
cause: error,
});
}

return createPatternMatcher(path.dirname(filePath), patterns);
};

/** Creates a reusable matcher for default, config-level, and CLI-provided ignore patterns. */
const createIgnoreMatcher = async ({
config,
cwd,
ignorePaths = [],
}: CreateIgnoreMatcherOptions): Promise<IgnoreMatcher> => {
const configMatcher = createPatternMatcher(
config.rootPath,
[...defaultIgnorePatterns, ...config.ignorePatterns].join('\n'),
);
const ignoreMatchers = await Promise.all(
ignorePaths.map((ignorePath) => loadIgnoreMatcher(cwd, ignorePath)),
);

return (filePath) =>
configMatcher(filePath) || ignoreMatchers.some((matches) => matches(filePath));
Comment thread
chenjiahan marked this conversation as resolved.
};

export { createFmtIgnoreMatcher };
export { createIgnoreMatcher };
23 changes: 19 additions & 4 deletions packages/rstack/src/fmt/stdin.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { resolve } from 'node:path';
import { createFileRequest } from './discovery.ts';
import { formatFmtSource } from './format.ts';
import { createFmtIgnoreMatcher } from './ignore.ts';
import { createIgnoreMatcher } from './ignore.ts';
import type { ResolvedFmtConfig } from './types.ts';

interface RunFmtStdinOptions {
/** Path used for per-file options and parser inference; it need not exist on disk. */
filepath: string;
/** Absolute directory used to resolve the path. */
cwd: string;
/** Ignore files resolved from `cwd`. */
ignorePaths?: string[];
/** Loads the project config; its failures surface only after stdin is drained. */
loadConfig: () => Promise<ResolvedFmtConfig>;
}
Expand Down Expand Up @@ -45,7 +47,12 @@ const writeStdout = (output: string): Promise<void> =>
* Formats stdin on the main thread and writes the result to stdout.
* Nothing but the formatted output may reach stdout in this mode.
*/
const runFmtStdin = async ({ filepath, cwd, loadConfig }: RunFmtStdinOptions): Promise<void> => {
const runFmtStdin = async ({
filepath,
cwd,
ignorePaths,
loadConfig,
}: RunFmtStdinOptions): Promise<void> => {
const configPromise = loadConfig();
// Drain stdin before surfacing any failure, otherwise a writer that already
// queued more than the pipe buffer sees EPIPE instead of the real error.
Expand All @@ -54,7 +61,12 @@ const runFmtStdin = async ({ filepath, cwd, loadConfig }: RunFmtStdinOptions): P
const config = await configPromise;

const absolutePath = resolve(cwd, filepath);
if (createFmtIgnoreMatcher(config)(absolutePath)) {
const isIgnored = await createIgnoreMatcher({
config,
cwd,
ignorePaths,
});
if (isIgnored(absolutePath)) {
await writeStdout(source);
return;
}
Expand All @@ -69,7 +81,10 @@ const runFmtStdin = async ({ filepath, cwd, loadConfig }: RunFmtStdinOptions): P
/* rspackChunkName: 'fmtPlugins' */
'./plugins.ts'
);
file = { ...file, options: createFmtPluginResolver(config.rootPath)(file.options) };
file = {
...file,
options: createFmtPluginResolver(config.rootPath)(file.options),
};
}

const result = await formatFmtSource(file, () => source);
Expand Down
2 changes: 2 additions & 0 deletions packages/rstack/src/fmt/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ interface DiscoverFmtFilesOptions {
cwd: string;
/** Files, directories, and positive or negative globs. Defaults to the current directory. */
patterns?: string[];
/** Ignore files resolved from `cwd`; each file's patterns are relative to its own directory. */
ignorePaths?: string[];
/** Resolved project config applied to discovered files. */
config: ResolvedFmtConfig;
}
Expand Down
49 changes: 49 additions & 0 deletions packages/rstack/tests/cli/fmt/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,41 @@ test('does not load Prettier config or ignore files', () => {
expect(readProjectFile('index.ts')).toBe('function getMessage() {\n return "hello";\n}\n');
});

test('applies repeated ignore paths to explicit files', () => {
writeProjectFile('.prettierignore', 'src/ignored-by-root.ts\n');
writeProjectFile('config/extra.ignore', '../src/ignored-by-extra.ts\n');
writeProjectFile('src/ignored-by-root.ts', 'const root="ignored"');
writeProjectFile('src/ignored-by-extra.ts', 'const extra="ignored"');
writeProjectFile('src/index.ts', 'const index="formatted"');

const result = runFmt([
'--ignore-path',
'.prettierignore',
'--ignore-path=config/extra.ignore',
'src/ignored-by-root.ts',
'src/ignored-by-extra.ts',
'src/index.ts',
]);

expect(result.status).toBe(0);
expectWriteSummary(result.stdout, 1, 1);
expect(result.stderr).toBe('');
expect(readProjectFile('src/ignored-by-root.ts')).toBe('const root="ignored"');
expect(readProjectFile('src/ignored-by-extra.ts')).toBe('const extra="ignored"');
expect(readProjectFile('src/index.ts')).toBe('const index = "formatted";\n');
});

test('returns exit code 2 for an unreadable ignore path', () => {
writeProjectFile('index.ts', 'const value=true');

const result = runFmt(['--ignore-path', 'missing.ignore', 'index.ts']);

expect(result.status).toBe(2);
expect(result.stdout).toBe('');
expect(result.stderr).toContain('Failed to read ignore file "missing.ignore".');
expect(readProjectFile('index.ts')).toBe('const value=true');
});

test('applies define.fmt options, overrides, ignore patterns, and globs', () => {
writeProjectFile(
'rstack.config.ts',
Expand Down Expand Up @@ -469,6 +504,20 @@ define.fmt({ ignorePatterns: ['src/ignored.ts'] });
expect(result.stderr).toBe('');
});

test('echoes stdin paths ignored by --ignore-path', () => {
writeProjectFile('.prettierignore', 'src/ignored.ts\n');

const source = 'const ignored="ignored"';
const result = runFmtStdin(
['--ignore-path', '.prettierignore', '--stdin-filepath', 'src/ignored.ts'],
source,
);

expect(result.status).toBe(0);
expect(result.stdout).toBe(source);
expect(result.stderr).toBe('');
});

test('echoes stdin for default ignored lock files', () => {
const source = 'lockfileVersion: "9.0"\n';
const result = runFmtStdin(['--stdin-filepath', 'pnpm-lock.yaml'], source);
Expand Down
15 changes: 15 additions & 0 deletions packages/rstack/tests/fmt/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ test('uses write mode by default', () => {
expect(parseFmtCLIArgs([])).toEqual({
mode: 'write',
patterns: [],
ignorePaths: [],
maxWorkers: undefined,
help: false,
});
Expand All @@ -35,6 +36,7 @@ test.each([
expect(parseFmtCLIArgs([option])).toEqual({
mode,
patterns: [],
ignorePaths: [],
maxWorkers: undefined,
help: false,
});
Expand All @@ -46,6 +48,7 @@ test.each(['--parallel-workers', '--parallelWorkers'])(
expect(parseFmtCLIArgs([option, '3'])).toEqual({
mode: 'write',
patterns: [],
ignorePaths: [],
maxWorkers: 3,
help: false,
});
Expand All @@ -71,6 +74,7 @@ test('preserves file paths and globs', () => {
expect(parseFmtCLIArgs([patterns[0], '--check', ...patterns.slice(1)])).toEqual({
mode: 'check',
patterns,
ignorePaths: [],
maxWorkers: undefined,
help: false,
});
Expand All @@ -80,6 +84,7 @@ test('treats arguments after the terminator as paths', () => {
expect(parseFmtCLIArgs(['--check', '--', '--write', '--help'])).toEqual({
mode: 'check',
patterns: ['--write', '--help'],
ignorePaths: [],
maxWorkers: undefined,
help: false,
});
Expand All @@ -89,10 +94,18 @@ test.each(['--help', '-h'])('parses %s', (option) => {
expect(parseFmtCLIArgs([option]).help).toBe(true);
});

test('collects repeated ignore paths', () => {
expect(
parseFmtCLIArgs(['--ignore-path', '.prettierignore', '--ignore-path=config/format.ignore'])
.ignorePaths,
).toEqual(['.prettierignore', 'config/format.ignore']);
});

test.each(['--stdin-filepath', '--stdinFilepath'])('parses %s', (option) => {
expect(parseFmtCLIArgs([option, 'src/index.ts'])).toEqual({
mode: 'write',
patterns: [],
ignorePaths: [],
maxWorkers: undefined,
help: false,
stdinFilepath: 'src/index.ts',
Expand All @@ -103,6 +116,7 @@ test('accepts a worker count with --stdin-filepath', () => {
expect(parseFmtCLIArgs(['--stdin-filepath', 'index.ts', '--parallel-workers', '2'])).toEqual({
mode: 'write',
patterns: [],
ignorePaths: [],
maxWorkers: 2,
help: false,
stdinFilepath: 'index.ts',
Expand All @@ -129,6 +143,7 @@ test('provides command help', () => {
expect(fmtHelpMessage).toContain('--write');
expect(fmtHelpMessage).toContain('--check');
expect(fmtHelpMessage).toContain('--list-different');
expect(fmtHelpMessage).toContain('--ignore-path <path>');
expect(fmtHelpMessage).toContain('--parallel-workers <count>');
expect(fmtHelpMessage).toContain('--stdin-filepath <path>');
expect(fmtHelpMessage).toContain('-h, --help');
Expand Down
Loading