Skip to content

Commit d30c190

Browse files
authored
feat(fmt): support --stdin-filepath (#167)
1 parent 26c6c5a commit d30c190

13 files changed

Lines changed: 482 additions & 85 deletions

File tree

packages/rstack/src/fmt/cli.ts

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ import { loadRstackConfig } from '../config.ts';
66
import { resolveFmtConfig } from './config.ts';
77
import { discoverFmtFiles } from './discovery.ts';
88
import { runFmtFiles } from './runner.ts';
9-
import type { FmtMode, FmtRunResult } from './types.ts';
9+
import { runFmtStdin } from './stdin.ts';
10+
import type { FmtMode, FmtRunResult, ResolvedFmtConfig } from './types.ts';
1011

1112
interface ParsedFmtCLIArgs {
1213
mode: FmtMode;
1314
patterns: string[];
1415
maxWorkers?: number;
1516
help: boolean;
17+
/** Path the stdin content is formatted as; it need not exist on disk. */
18+
stdinFilepath?: string;
1619
}
1720

1821
const fmtHelpMessage: string = `Rstack v${RSTACK_VERSION}
@@ -27,6 +30,7 @@ ${color.cyan('Options')}:
2730
--check Check whether files are formatted
2831
--list-different Print paths of unformatted files
2932
--parallel-workers <count> Number of parallel workers
33+
--stdin-filepath <path> Format stdin as if it were saved at <path>
3034
-h, --help Display this help message`;
3135

3236
const parseMaxWorkers = (
@@ -56,6 +60,8 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
5660
listDifferent: { type: 'boolean' },
5761
'parallel-workers': { type: 'string' },
5862
parallelWorkers: { type: 'string' },
63+
'stdin-filepath': { type: 'string' },
64+
stdinFilepath: { type: 'string' },
5965
help: { type: 'boolean', short: 'h' },
6066
},
6167
allowPositionals: true,
@@ -70,12 +76,26 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
7076

7177
const mode = values.check ? 'check' : listDifferent ? 'list-different' : 'write';
7278
const maxWorkers = parseMaxWorkers(values['parallel-workers'], values.parallelWorkers);
79+
const stdinFilepath = values['stdin-filepath'] ?? values.stdinFilepath;
80+
81+
if (stdinFilepath !== undefined) {
82+
if (modes.length > 0) {
83+
throw new Error(
84+
'The --stdin-filepath option cannot be used with --write, --check, or --list-different.',
85+
);
86+
}
87+
88+
if (positionals.length > 0) {
89+
throw new Error('The --stdin-filepath option cannot be used with file arguments.');
90+
}
91+
}
7392

7493
return {
7594
mode,
7695
patterns: positionals,
7796
maxWorkers,
7897
help: values.help ?? false,
98+
stdinFilepath,
7999
};
80100
};
81101

@@ -174,23 +194,35 @@ const logFmtResult = (
174194
}
175195
};
176196

177-
const runFmtCLI = async (args: string[]): Promise<void> => {
178-
const { help, maxWorkers, mode, patterns } = parseFmtCLIArgs(args);
179-
if (help) {
180-
logger.log(fmtHelpMessage);
181-
return;
182-
}
197+
const loadFmtConfig = async (cwd: string): Promise<ResolvedFmtConfig> => {
198+
const { configs, filePath } = await loadRstackConfig();
183199

200+
return resolveFmtConfig({
201+
definition: configs.fmt,
202+
configFilePath: filePath,
203+
cwd,
204+
});
205+
};
206+
207+
const runFmtCLI = async (args: string[]): Promise<void> => {
184208
const cwd = process.cwd();
185209
const startTime = performance.now();
186210

211+
// Argument errors are reported like every other failure so that a single
212+
// exit code identifies "rs fmt refused to run".
187213
try {
188-
const { configs, filePath } = await loadRstackConfig();
189-
const config = await resolveFmtConfig({
190-
definition: configs.fmt,
191-
configFilePath: filePath,
192-
cwd,
193-
});
214+
const { help, maxWorkers, mode, patterns, stdinFilepath } = parseFmtCLIArgs(args);
215+
if (help) {
216+
logger.log(fmtHelpMessage);
217+
return;
218+
}
219+
220+
if (stdinFilepath !== undefined) {
221+
await runFmtStdin({ filepath: stdinFilepath, cwd, loadConfig: () => loadFmtConfig(cwd) });
222+
return;
223+
}
224+
225+
const config = await loadFmtConfig(cwd);
194226
const files = await discoverFmtFiles({ cwd, patterns, config });
195227

196228
if (files.length === 0) {

packages/rstack/src/fmt/discovery.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,4 @@ const discoverFmtFiles = async ({
3535
return files.map((file) => ({ ...file, options: resolvePlugins(file.options) }));
3636
};
3737

38-
export { discoverFmtFiles };
38+
export { createFileRequest, discoverFmtFiles };

packages/rstack/src/fmt/format.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Derived from @prettier/cli, see THIRD_PARTY_NOTICES.md
2+
3+
import {
4+
format,
5+
getFileInfo,
6+
type FileInfoOptions,
7+
type Options as PrettierOptions,
8+
} from 'prettier';
9+
import { getPrettierPlugins } from './prettierPlugins.ts';
10+
import type { FmtFileRequest } from './types.ts';
11+
12+
type PrettierPlugins = NonNullable<PrettierOptions['plugins']>;
13+
14+
type FormatFmtSourceResult =
15+
{ status: 'unsupported' } | { status: 'formatted'; source: string; formatted: string };
16+
17+
const fileInfoOptions = {
18+
ignorePath: [],
19+
resolveConfig: false,
20+
withNodeModules: true,
21+
} satisfies FileInfoOptions;
22+
23+
/** Uses the configured parser or infers one without loading Prettier config. */
24+
const resolveFmtParser = async (
25+
filePath: string,
26+
options: PrettierOptions,
27+
plugins: PrettierPlugins,
28+
): Promise<PrettierOptions['parser'] | null> =>
29+
options.parser ??
30+
(
31+
await getFileInfo(filePath, {
32+
...fileInfoOptions,
33+
plugins,
34+
})
35+
).inferredParser;
36+
37+
/** Formats file contents, requesting the source only once a parser is known. */
38+
const formatFmtSource = async (
39+
{ path, options }: FmtFileRequest,
40+
readSource: () => string,
41+
): Promise<FormatFmtSourceResult> => {
42+
const plugins = await getPrettierPlugins(options, path);
43+
const parser = await resolveFmtParser(path, options, plugins);
44+
if (!parser) {
45+
return { status: 'unsupported' };
46+
}
47+
48+
const source = readSource();
49+
const formatted = await format(source, {
50+
...options,
51+
filepath: path,
52+
parser,
53+
plugins,
54+
});
55+
56+
return { status: 'formatted', source, formatted };
57+
};
58+
59+
export { formatFmtSource };

packages/rstack/src/fmt/stdin.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { resolve } from 'node:path';
2+
import { createFileRequest } from './discovery.ts';
3+
import { formatFmtSource } from './format.ts';
4+
import { createFmtIgnoreMatcher } from './ignore.ts';
5+
import type { ResolvedFmtConfig } from './types.ts';
6+
7+
interface RunFmtStdinOptions {
8+
/** Path used for per-file options and parser inference; it need not exist on disk. */
9+
filepath: string;
10+
/** Absolute directory used to resolve the path. */
11+
cwd: string;
12+
/** Loads the project config; its failures surface only after stdin is drained. */
13+
loadConfig: () => Promise<ResolvedFmtConfig>;
14+
}
15+
16+
const readStdin = async (): Promise<string> => {
17+
const chunks: Buffer[] = [];
18+
for await (const chunk of process.stdin) {
19+
chunks.push(chunk as Buffer);
20+
}
21+
22+
return Buffer.concat(chunks).toString('utf8');
23+
};
24+
25+
/**
26+
* Writes to stdout directly to keep the output byte-exact. A reader that
27+
* closes the pipe early (`| head`) surfaces EPIPE, which is not a failure;
28+
* other stream errors reject so the CLI-level handler reports them.
29+
*/
30+
const writeStdout = (output: string): Promise<void> =>
31+
new Promise((resolvePromise, reject) => {
32+
// The stream also emits 'error' for the same failure; swallow the event so
33+
// only the write callback reports it.
34+
process.stdout.once('error', () => {});
35+
process.stdout.write(output, (error) => {
36+
if (error && (error as NodeJS.ErrnoException).code !== 'EPIPE') {
37+
reject(error);
38+
} else {
39+
resolvePromise();
40+
}
41+
});
42+
});
43+
44+
/**
45+
* Formats stdin on the main thread and writes the result to stdout.
46+
* Nothing but the formatted output may reach stdout in this mode.
47+
*/
48+
const runFmtStdin = async ({ filepath, cwd, loadConfig }: RunFmtStdinOptions): Promise<void> => {
49+
const configPromise = loadConfig();
50+
// Drain stdin before surfacing any failure, otherwise a writer that already
51+
// queued more than the pipe buffer sees EPIPE instead of the real error.
52+
configPromise.catch(() => {});
53+
const source = await readStdin();
54+
const config = await configPromise;
55+
56+
const absolutePath = resolve(cwd, filepath);
57+
if (createFmtIgnoreMatcher(config)(absolutePath)) {
58+
await writeStdout(source);
59+
return;
60+
}
61+
62+
if (source === '') {
63+
return;
64+
}
65+
66+
let file = createFileRequest(absolutePath, config);
67+
if (file.options.plugins?.length) {
68+
const { createFmtPluginResolver } = await import(
69+
/* rspackChunkName: 'fmtPlugins' */
70+
'./plugins.ts'
71+
);
72+
file = { ...file, options: createFmtPluginResolver(config.rootPath)(file.options) };
73+
}
74+
75+
const result = await formatFmtSource(file, () => source);
76+
77+
if (result.status === 'unsupported') {
78+
throw new Error(`No parser could be inferred for "${filepath}".`);
79+
}
80+
81+
await writeStdout(result.formatted);
82+
};
83+
84+
export { runFmtStdin };

packages/rstack/src/fmt/worker.ts

Lines changed: 6 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,33 @@
11
// Derived from @prettier/cli, see THIRD_PARTY_NOTICES.md
22

33
import { readFileSync, writeFileSync } from 'node:fs';
4-
import {
5-
format,
6-
getFileInfo,
7-
type FileInfoOptions,
8-
type Options as PrettierOptions,
9-
} from 'prettier';
10-
import { getPrettierPlugins } from './prettierPlugins.ts';
4+
import { formatFmtSource } from './format.ts';
115
import type { FmtFileRequest } from './types.ts';
126

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

169
interface FormatFileTask {
1710
file: FmtFileRequest;
1811
shouldWrite: boolean;
1912
}
2013

21-
const fileInfoOptions = {
22-
ignorePath: [],
23-
resolveConfig: false,
24-
withNodeModules: true,
25-
} satisfies FileInfoOptions;
26-
27-
/** Uses the configured parser or infers one without loading Prettier config. */
28-
const resolveFmtParser = async (
29-
filePath: string,
30-
options: PrettierOptions,
31-
plugins: PrettierPlugins,
32-
): Promise<PrettierOptions['parser'] | null> =>
33-
options.parser ??
34-
(
35-
await getFileInfo(filePath, {
36-
...fileInfoOptions,
37-
plugins,
38-
})
39-
).inferredParser;
40-
4114
/**
4215
* Use synchronous direct I/O inside the dedicated worker to avoid libuv
4316
* scheduling overhead. This prioritizes throughput over crash-safe replacement.
4417
*/
45-
const formatFile = async ({
46-
file: { path, options },
47-
shouldWrite,
48-
}: FormatFileTask): Promise<FormatFileResult> => {
49-
const plugins = await getPrettierPlugins(options, path);
50-
const parser = await resolveFmtParser(path, options, plugins);
51-
if (!parser) {
18+
const formatFile = async ({ file, shouldWrite }: FormatFileTask): Promise<FormatFileResult> => {
19+
const result = await formatFmtSource(file, () => readFileSync(file.path, 'utf8'));
20+
if (result.status === 'unsupported') {
5221
return 'unsupported';
5322
}
5423

55-
const source = readFileSync(path, 'utf8');
56-
const formatted = await format(source, {
57-
...options,
58-
filepath: path,
59-
parser,
60-
plugins,
61-
});
62-
24+
const { source, formatted } = result;
6325
if (source === formatted) {
6426
return 'unchanged';
6527
}
6628

6729
if (shouldWrite) {
68-
writeFileSync(path, formatted, 'utf8');
30+
writeFileSync(file.path, formatted, 'utf8');
6931
}
7032

7133
return 'changed';

0 commit comments

Comments
 (0)