Skip to content

Commit 08e3a8f

Browse files
committed
feat(fmt): support Prettier plugins
1 parent 9b7ff77 commit 08e3a8f

11 files changed

Lines changed: 293 additions & 144 deletions

File tree

packages/rstack/src/fmt/discovery.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,17 @@
1-
import { getFileInfo, type FileInfoOptions } from 'prettier';
21
import { resolveFmtOptions } from './config.ts';
32
import { discoverFmtPaths } from './discoverPaths.ts';
43
import { createFmtIgnoreMatcher } from './ignore.ts';
4+
import { resolveFmtParser } from './parser.ts';
5+
import { createFmtPluginResolver, type FmtPluginResolver } from './plugins.ts';
56
import type { DiscoverFmtFilesOptions, FmtFileRequest, ResolvedFmtConfig } from './types.ts';
67

7-
const fileInfoOptions = {
8-
ignorePath: [],
9-
resolveConfig: false,
10-
withNodeModules: true,
11-
} satisfies FileInfoOptions;
12-
138
const resolveFileRequest = async (
149
filePath: string,
1510
config: ResolvedFmtConfig,
11+
resolvePlugins: FmtPluginResolver,
1612
): Promise<FmtFileRequest | undefined> => {
17-
const options = resolveFmtOptions(filePath, config);
18-
19-
if (options.plugins?.length) {
20-
throw new Error('Prettier plugins are not supported yet.');
21-
}
22-
23-
const parser = options.parser ?? (await getFileInfo(filePath, fileInfoOptions)).inferredParser;
13+
const options = resolvePlugins(resolveFmtOptions(filePath, config));
14+
const parser = await resolveFmtParser(filePath, options);
2415
if (!parser) {
2516
return;
2617
}
@@ -50,8 +41,9 @@ const discoverFmtFiles = async ({
5041
const filePaths = isFmtIgnored
5142
? candidates.filter((filePath) => !isFmtIgnored(filePath))
5243
: candidates;
44+
const resolvePlugins = createFmtPluginResolver(config.rootPath);
5345
const files = await Promise.all(
54-
filePaths.map((filePath) => resolveFileRequest(filePath, config)),
46+
filePaths.map((filePath) => resolveFileRequest(filePath, config, resolvePlugins)),
5547
);
5648

5749
return files.filter((file): file is FmtFileRequest => file !== undefined);

packages/rstack/src/fmt/format.ts

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,16 @@
1-
import { format, formatWithCursor, getFileInfo } from 'prettier';
1+
import { format, formatWithCursor } from 'prettier';
22
import { resolveFmtOptions } from './config.ts';
3+
import { resolveFmtParser } from './parser.ts';
4+
import { createFmtPluginResolver } from './plugins.ts';
35
import type { FormatTextOptions, FormatTextResult } from './types.ts';
46

57
/** Formats source text without reading formatter config or ignore files. */
68
const formatText = async (
79
source: string,
810
{ filePath, cursorOffset, config }: FormatTextOptions,
911
): Promise<FormatTextResult> => {
10-
const options = resolveFmtOptions(filePath, config);
11-
12-
if (options.plugins?.length) {
13-
throw new Error('Prettier plugins are not supported yet.');
14-
}
15-
16-
const parser =
17-
options.parser ??
18-
(
19-
await getFileInfo(filePath, {
20-
ignorePath: [],
21-
resolveConfig: false,
22-
withNodeModules: true,
23-
})
24-
).inferredParser;
12+
const options = createFmtPluginResolver(config.rootPath)(resolveFmtOptions(filePath, config));
13+
const parser = await resolveFmtParser(filePath, options);
2514

2615
if (!parser) {
2716
return {

packages/rstack/src/fmt/parser.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { getFileInfo, type FileInfoOptions, type Options as PrettierOptions } from 'prettier';
2+
3+
const fileInfoOptions = {
4+
ignorePath: [],
5+
resolveConfig: false,
6+
withNodeModules: true,
7+
} satisfies FileInfoOptions;
8+
9+
/** Uses the configured parser or infers one without loading Prettier config. */
10+
const resolveFmtParser = async (
11+
filePath: string,
12+
options: PrettierOptions,
13+
): Promise<PrettierOptions['parser'] | null> =>
14+
options.parser ??
15+
(
16+
await getFileInfo(filePath, {
17+
...fileInfoOptions,
18+
plugins: options.plugins,
19+
})
20+
).inferredParser;
21+
22+
export { resolveFmtParser };

packages/rstack/src/fmt/plugins.ts

Lines changed: 49 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,76 +2,68 @@ import { isAbsolute, join, resolve as resolvePath } from 'node:path';
22
import { pathToFileURL } from 'node:url';
33
import { moduleResolve } from 'import-meta-resolve';
44
import type { Options as PrettierOptions } from 'prettier';
5-
import type { ResolvedFmtConfig } from './types.ts';
5+
import type { FmtPluginSpecifier } from './types.ts';
66

77
type FmtPlugin = NonNullable<PrettierOptions['plugins']>[number];
8+
type FmtPluginResolver = (options: PrettierOptions) => PrettierOptions;
89

910
const resolveModuleUrl = (specifier: string, parentUrl: URL): string =>
1011
moduleResolve(specifier, parentUrl).href;
1112

12-
const resolvePlugin = (plugin: FmtPlugin, rootPath: string, parentUrl: URL): FmtPlugin => {
13-
if (plugin instanceof URL) {
14-
return resolveModuleUrl(plugin.href, parentUrl);
15-
}
16-
if (typeof plugin !== 'string') {
17-
return plugin;
18-
}
19-
if (isAbsolute(plugin)) {
20-
return resolveModuleUrl(pathToFileURL(plugin).href, parentUrl);
21-
}
22-
if (URL.canParse(plugin)) {
23-
return resolveModuleUrl(plugin, parentUrl);
24-
}
13+
const isFmtPluginSpecifier = (plugin: FmtPlugin): plugin is FmtPluginSpecifier =>
14+
typeof plugin === 'string' || plugin instanceof URL;
2515

26-
try {
27-
return resolveModuleUrl(pathToFileURL(resolvePath(rootPath, plugin)).href, parentUrl);
28-
} catch {
29-
return resolveModuleUrl(plugin, parentUrl);
30-
}
31-
};
32-
33-
const resolveOptionsPlugins = (
34-
options: PrettierOptions,
35-
rootPath: string,
36-
parentUrl: URL,
37-
): PrettierOptions => {
38-
const { plugins } = options;
39-
if (!plugins?.some((plugin) => typeof plugin === 'string' || plugin instanceof URL)) {
40-
return options;
41-
}
42-
43-
const resolvedPlugins = plugins.map((plugin) => resolvePlugin(plugin, rootPath, parentUrl));
44-
45-
return resolvedPlugins.every((plugin, index) => plugin === plugins[index])
46-
? options
47-
: { ...options, plugins: resolvedPlugins };
48-
};
49-
50-
/** Resolves plugin specifiers from the Rstack config root. */
51-
const resolveFmtConfigPlugins = (config: ResolvedFmtConfig): ResolvedFmtConfig => {
52-
const { rootPath } = config;
16+
/** Creates a project-root resolver for plugins in final per-file options. */
17+
const createFmtPluginResolver = (rootPath: string): FmtPluginResolver => {
5318
const parentUrl = pathToFileURL(join(rootPath, 'index.js'));
54-
const baseOptions = resolveOptionsPlugins(config.baseOptions, rootPath, parentUrl);
55-
let overrides = config.overrides;
19+
const cache = new Map<string, string>();
5620

57-
for (let index = 0; index < overrides.length; index++) {
58-
const override = overrides[index];
59-
if (!override.options) {
60-
continue;
21+
const resolvePlugin = (plugin: FmtPluginSpecifier): string => {
22+
const specifier = plugin instanceof URL ? plugin.href : plugin;
23+
const cached = cache.get(specifier);
24+
if (cached !== undefined) {
25+
return cached;
6126
}
6227

63-
const options = resolveOptionsPlugins(override.options, rootPath, parentUrl);
64-
if (options !== override.options) {
65-
if (overrides === config.overrides) {
66-
overrides = [...overrides];
28+
let resolved: string;
29+
if (isAbsolute(specifier)) {
30+
resolved = resolveModuleUrl(pathToFileURL(specifier).href, parentUrl);
31+
} else if (URL.canParse(specifier)) {
32+
resolved = resolveModuleUrl(specifier, parentUrl);
33+
} else {
34+
try {
35+
resolved = resolveModuleUrl(
36+
pathToFileURL(resolvePath(rootPath, specifier)).href,
37+
parentUrl,
38+
);
39+
} catch {
40+
resolved = resolveModuleUrl(specifier, parentUrl);
6741
}
68-
overrides[index] = { ...override, options };
6942
}
70-
}
7143

72-
return baseOptions === config.baseOptions && overrides === config.overrides
73-
? config
74-
: { ...config, baseOptions, overrides };
44+
cache.set(specifier, resolved);
45+
return resolved;
46+
};
47+
48+
return (options) => {
49+
const { plugins } = options;
50+
if (!plugins?.length) {
51+
return options;
52+
}
53+
if (!plugins.every(isFmtPluginSpecifier)) {
54+
// Imported plugin objects are not planned for support.
55+
throw new TypeError(
56+
'Prettier plugin objects are not supported. Use a package name, path, or URL instead.',
57+
);
58+
}
59+
60+
const resolvedPlugins = plugins.map(resolvePlugin);
61+
62+
return resolvedPlugins.every((plugin, index) => plugin === plugins[index])
63+
? options
64+
: { ...options, plugins: resolvedPlugins };
65+
};
7566
};
7667

77-
export { resolveFmtConfigPlugins };
68+
export { createFmtPluginResolver };
69+
export type { FmtPluginResolver };

packages/rstack/src/fmt/runner.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ const runFmtFilesParallel = async (
6666
}
6767
};
6868

69-
/** Checks every worker payload before any formatting can begin. */
69+
/** Checks whether every request can be cloned for a worker. */
7070
const canRunFmtFilesParallel = (files: FmtFileRequest[]): boolean => {
7171
try {
7272
structuredClone(files);

packages/rstack/src/fmt/types.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import type { Config as PrettierConfig, Options as PrettierOptions } from 'prettier';
22

3-
interface FmtConfig extends PrettierConfig {
3+
/** Plugin objects cannot cross worker boundaries and are not planned for support. */
4+
type FmtPluginSpecifier = string | URL;
5+
6+
type FmtOptions = Omit<PrettierOptions, 'plugins'> & {
7+
plugins?: FmtPluginSpecifier[];
8+
};
9+
10+
type PrettierOverride = NonNullable<PrettierConfig['overrides']>[number];
11+
12+
type FmtOverride = Omit<PrettierOverride, 'options'> & {
13+
options?: FmtOptions;
14+
};
15+
16+
interface FmtConfig extends Omit<PrettierConfig, 'plugins' | 'overrides'> {
17+
plugins?: FmtPluginSpecifier[];
18+
overrides?: FmtOverride[];
419
/** Gitignore-compatible patterns relative to the Rstack config root. */
520
ignorePatterns?: string[];
621
}
@@ -103,6 +118,7 @@ export type {
103118
FmtFileResult,
104119
FmtFileRequest,
105120
FmtMode,
121+
FmtPluginSpecifier,
106122
FmtRunResult,
107123
FormatTextOptions,
108124
FormatTextResult,

packages/rstack/tests/cli/fmt/index.test.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,20 @@ const writeProjectFile = (filePath: string, content: string): void => {
1515
const readProjectFile = (filePath: string): string =>
1616
readFileSync(path.join(projectPath, filePath), 'utf8');
1717

18+
const writeFixturePlugin = (): void => {
19+
writeProjectFile(
20+
'node_modules/prettier-plugin-fixture/package.json',
21+
JSON.stringify({ name: 'prettier-plugin-fixture', exports: './index.mjs' }),
22+
);
23+
writeProjectFile(
24+
'node_modules/prettier-plugin-fixture/index.mjs',
25+
`export default {
26+
languages: [{ name: 'Fixture JSON', parsers: ['json'], extensions: ['.fixture'] }],
27+
};
28+
`,
29+
);
30+
};
31+
1832
const runCLI = (args: string[]) => {
1933
const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: '1' };
2034
delete env.FORCE_COLOR;
@@ -216,23 +230,79 @@ test('returns exit code 2 for config errors', () => {
216230
expect(result.stderr).toContain('invalid fmt config');
217231
});
218232

219-
test('returns exit code 2 for unsupported plugins', () => {
233+
test.each([
234+
['parallel execution', []],
235+
['serial execution', ['--no-parallel']],
236+
] as const)('formats with a project-local plugin using %s', (_, options) => {
220237
writeProjectFile(
221238
'rstack.config.ts',
222239
`import { define } from 'rstack';
223240
224241
define.fmt({
225-
plugins: ['prettier-plugin-example'],
242+
plugins: ['prettier-plugin-fixture'],
226243
});
227244
`,
228245
);
229-
writeProjectFile('index.ts', 'const message="hello"');
246+
writeFixturePlugin();
247+
writeProjectFile('first.fixture', '{"first":true}');
248+
writeProjectFile('second.fixture', '{"second":true}');
249+
250+
const result = runFmt([...options, '*.fixture']);
251+
252+
expect(result.status).toBe(0);
253+
expect(result.stdout).toBe('first.fixture\nsecond.fixture\n');
254+
expect(result.stderr).toBe('');
255+
expect(readProjectFile('first.fixture')).toBe('{ "first": true }\n');
256+
expect(readProjectFile('second.fixture')).toBe('{ "second": true }\n');
257+
});
258+
259+
test('formats mixed plugin overrides in parallel', () => {
260+
writeProjectFile(
261+
'rstack.config.ts',
262+
`import { define } from 'rstack';
263+
264+
define.fmt({
265+
overrides: [
266+
{
267+
files: '*.fixture',
268+
options: { plugins: ['prettier-plugin-fixture'] },
269+
},
270+
],
271+
});
272+
`,
273+
);
274+
writeFixturePlugin();
275+
writeProjectFile('data.fixture', '{"value":true}');
276+
writeProjectFile('index.ts', 'const value=true');
277+
278+
const result = runFmt(['data.fixture', 'index.ts']);
279+
280+
expect(result.status).toBe(0);
281+
expect(result.stdout).toBe('data.fixture\nindex.ts\n');
282+
expect(result.stderr).toBe('');
283+
expect(readProjectFile('data.fixture')).toBe('{ "value": true }\n');
284+
expect(readProjectFile('index.ts')).toBe('const value = true;\n');
285+
});
286+
287+
test('returns exit code 2 for imported plugin objects', () => {
288+
writeProjectFile(
289+
'rstack.config.ts',
290+
`import { define } from 'rstack';
291+
292+
define.fmt({
293+
plugins: [{ languages: [] }],
294+
});
295+
`,
296+
);
297+
writeProjectFile('index.ts', 'const value=true');
230298

231299
const result = runFmt(['index.ts']);
232300

233301
expect(result.status).toBe(2);
234302
expect(result.stdout).toBe('');
235-
expect(result.stderr).toContain('Prettier plugins are not supported yet.');
303+
expect(result.stderr).toContain(
304+
'Prettier plugin objects are not supported. Use a package name, path, or URL instead.',
305+
);
236306
});
237307

238308
test('returns exit code 2 for formatting errors', () => {

0 commit comments

Comments
 (0)