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
58 changes: 45 additions & 13 deletions packages/rstack/src/fmt/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import { loadRstackConfig } from '../config.ts';
import { resolveFmtConfig } from './config.ts';
import { discoverFmtFiles } from './discovery.ts';
import { runFmtFiles } from './runner.ts';
import type { FmtMode, FmtRunResult } from './types.ts';
import { runFmtStdin } from './stdin.ts';
import type { FmtMode, FmtRunResult, ResolvedFmtConfig } from './types.ts';

interface ParsedFmtCLIArgs {
mode: FmtMode;
patterns: string[];
maxWorkers?: number;
help: boolean;
/** Path the stdin content is formatted as; it need not exist on disk. */
stdinFilepath?: string;
}

const fmtHelpMessage: string = `Rstack v${RSTACK_VERSION}
Expand All @@ -27,6 +30,7 @@ ${color.cyan('Options')}:
--check Check whether files are formatted
--list-different Print paths of unformatted files
--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`;

const parseMaxWorkers = (
Expand Down Expand Up @@ -56,6 +60,8 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
listDifferent: { type: 'boolean' },
'parallel-workers': { type: 'string' },
parallelWorkers: { type: 'string' },
'stdin-filepath': { type: 'string' },
stdinFilepath: { type: 'string' },
help: { type: 'boolean', short: 'h' },
},
allowPositionals: true,
Expand All @@ -70,12 +76,26 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {

const mode = values.check ? 'check' : listDifferent ? 'list-different' : 'write';
const maxWorkers = parseMaxWorkers(values['parallel-workers'], values.parallelWorkers);
const stdinFilepath = values['stdin-filepath'] ?? values.stdinFilepath;

if (stdinFilepath !== undefined) {
if (modes.length > 0) {
throw new Error(
'The --stdin-filepath option cannot be used with --write, --check, or --list-different.',
);
}

if (positionals.length > 0) {
throw new Error('The --stdin-filepath option cannot be used with file arguments.');
}
}

return {
mode,
patterns: positionals,
maxWorkers,
help: values.help ?? false,
stdinFilepath,
};
};

Expand Down Expand Up @@ -174,23 +194,35 @@ const logFmtResult = (
}
};

const runFmtCLI = async (args: string[]): Promise<void> => {
const { help, maxWorkers, mode, patterns } = parseFmtCLIArgs(args);
if (help) {
logger.log(fmtHelpMessage);
return;
}
const loadFmtConfig = async (cwd: string): Promise<ResolvedFmtConfig> => {
const { configs, filePath } = await loadRstackConfig();

return resolveFmtConfig({
definition: configs.fmt,
configFilePath: filePath,
cwd,
});
};

const runFmtCLI = async (args: string[]): Promise<void> => {
const cwd = process.cwd();
const startTime = performance.now();

// Argument errors are reported like every other failure so that a single
// exit code identifies "rs fmt refused to run".
try {
const { configs, filePath } = await loadRstackConfig();
const config = await resolveFmtConfig({
definition: configs.fmt,
configFilePath: filePath,
cwd,
});
const { help, maxWorkers, mode, patterns, stdinFilepath } = parseFmtCLIArgs(args);
if (help) {
logger.log(fmtHelpMessage);
return;
}

if (stdinFilepath !== undefined) {
await runFmtStdin({ filepath: stdinFilepath, cwd, loadConfig: () => loadFmtConfig(cwd) });
return;
}

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

if (files.length === 0) {
Expand Down
2 changes: 1 addition & 1 deletion packages/rstack/src/fmt/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ const discoverFmtFiles = async ({
return files.map((file) => ({ ...file, options: resolvePlugins(file.options) }));
};

export { discoverFmtFiles };
export { createFileRequest, discoverFmtFiles };
59 changes: 59 additions & 0 deletions packages/rstack/src/fmt/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Derived from @prettier/cli, see THIRD_PARTY_NOTICES.md

import {
format,
getFileInfo,
type FileInfoOptions,
type Options as PrettierOptions,
} from 'prettier';
import { getPrettierPlugins } from './prettierPlugins.ts';
import type { FmtFileRequest } from './types.ts';

type PrettierPlugins = NonNullable<PrettierOptions['plugins']>;

type FormatFmtSourceResult =
{ status: 'unsupported' } | { status: 'formatted'; source: string; formatted: string };

const fileInfoOptions = {
ignorePath: [],
resolveConfig: false,
withNodeModules: true,
} satisfies FileInfoOptions;

/** Uses the configured parser or infers one without loading Prettier config. */
const resolveFmtParser = async (
filePath: string,
options: PrettierOptions,
plugins: PrettierPlugins,
): Promise<PrettierOptions['parser'] | null> =>
options.parser ??
(
await getFileInfo(filePath, {
...fileInfoOptions,
plugins,
})
).inferredParser;

/** Formats file contents, requesting the source only once a parser is known. */
const formatFmtSource = async (
{ path, options }: FmtFileRequest,
readSource: () => string,
): Promise<FormatFmtSourceResult> => {
const plugins = await getPrettierPlugins(options, path);
const parser = await resolveFmtParser(path, options, plugins);
if (!parser) {
return { status: 'unsupported' };
}

const source = readSource();
const formatted = await format(source, {
...options,
filepath: path,
parser,
plugins,
});

return { status: 'formatted', source, formatted };
};

export { formatFmtSource };
84 changes: 84 additions & 0 deletions packages/rstack/src/fmt/stdin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { resolve } from 'node:path';
import { createFileRequest } from './discovery.ts';
import { formatFmtSource } from './format.ts';
import { createFmtIgnoreMatcher } 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;
/** Loads the project config; its failures surface only after stdin is drained. */
loadConfig: () => Promise<ResolvedFmtConfig>;
}

const readStdin = async (): Promise<string> => {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}

return Buffer.concat(chunks).toString('utf8');
};

/**
* Writes to stdout directly to keep the output byte-exact. A reader that
* closes the pipe early (`| head`) surfaces EPIPE, which is not a failure;
* other stream errors reject so the CLI-level handler reports them.
*/
const writeStdout = (output: string): Promise<void> =>
new Promise((resolvePromise, reject) => {
// The stream also emits 'error' for the same failure; swallow the event so
// only the write callback reports it.
process.stdout.once('error', () => {});
process.stdout.write(output, (error) => {
if (error && (error as NodeJS.ErrnoException).code !== 'EPIPE') {
reject(error);
} else {
resolvePromise();
}
});
});

/**
* 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 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.
configPromise.catch(() => {});
const source = await readStdin();
const config = await configPromise;

const absolutePath = resolve(cwd, filepath);
if (createFmtIgnoreMatcher(config)(absolutePath)) {
await writeStdout(source);
return;
}

if (source === '') {
return;
}

let file = createFileRequest(absolutePath, config);
if (file.options.plugins?.length) {
const { createFmtPluginResolver } = await import(
/* rspackChunkName: 'fmtPlugins' */
'./plugins.ts'
);
file = { ...file, options: createFmtPluginResolver(config.rootPath)(file.options) };
}

const result = await formatFmtSource(file, () => source);

if (result.status === 'unsupported') {
throw new Error(`No parser could be inferred for "${filepath}".`);
}

await writeStdout(result.formatted);
};

export { runFmtStdin };
50 changes: 6 additions & 44 deletions packages/rstack/src/fmt/worker.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,33 @@
// Derived from @prettier/cli, see THIRD_PARTY_NOTICES.md

import { readFileSync, writeFileSync } from 'node:fs';
import {
format,
getFileInfo,
type FileInfoOptions,
type Options as PrettierOptions,
} from 'prettier';
import { getPrettierPlugins } from './prettierPlugins.ts';
import { formatFmtSource } from './format.ts';
import type { FmtFileRequest } from './types.ts';

type PrettierPlugins = NonNullable<PrettierOptions['plugins']>;
type FormatFileResult = 'changed' | 'unchanged' | 'unsupported';

interface FormatFileTask {
file: FmtFileRequest;
shouldWrite: boolean;
}

const fileInfoOptions = {
ignorePath: [],
resolveConfig: false,
withNodeModules: true,
} satisfies FileInfoOptions;

/** Uses the configured parser or infers one without loading Prettier config. */
const resolveFmtParser = async (
filePath: string,
options: PrettierOptions,
plugins: PrettierPlugins,
): Promise<PrettierOptions['parser'] | null> =>
options.parser ??
(
await getFileInfo(filePath, {
...fileInfoOptions,
plugins,
})
).inferredParser;

/**
* Use synchronous direct I/O inside the dedicated worker to avoid libuv
* scheduling overhead. This prioritizes throughput over crash-safe replacement.
*/
const formatFile = async ({
file: { path, options },
shouldWrite,
}: FormatFileTask): Promise<FormatFileResult> => {
const plugins = await getPrettierPlugins(options, path);
const parser = await resolveFmtParser(path, options, plugins);
if (!parser) {
const formatFile = async ({ file, shouldWrite }: FormatFileTask): Promise<FormatFileResult> => {
const result = await formatFmtSource(file, () => readFileSync(file.path, 'utf8'));
if (result.status === 'unsupported') {
return 'unsupported';
}

const source = readFileSync(path, 'utf8');
const formatted = await format(source, {
...options,
filepath: path,
parser,
plugins,
});

const { source, formatted } = result;
if (source === formatted) {
return 'unchanged';
}

if (shouldWrite) {
writeFileSync(path, formatted, 'utf8');
writeFileSync(file.path, formatted, 'utf8');
}

return 'changed';
Expand Down
Loading