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
22 changes: 7 additions & 15 deletions packages/rstack/src/fmt/discovery.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,17 @@
import { getFileInfo, type FileInfoOptions } from 'prettier';
import { resolveFmtOptions } from './config.ts';
import { discoverFmtPaths } from './discoverPaths.ts';
import { createFmtIgnoreMatcher } from './ignore.ts';
import { resolveFmtParser } from './parser.ts';
import { createFmtPluginResolver, type FmtPluginResolver } from './plugins.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,
resolvePlugins: FmtPluginResolver,
): 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;
const options = resolvePlugins(resolveFmtOptions(filePath, config));
const parser = await resolveFmtParser(filePath, options);
if (!parser) {
return;
}
Expand Down Expand Up @@ -50,8 +41,9 @@ const discoverFmtFiles = async ({
const filePaths = isFmtIgnored
? candidates.filter((filePath) => !isFmtIgnored(filePath))
: candidates;
const resolvePlugins = createFmtPluginResolver(config.rootPath);
const files = await Promise.all(
filePaths.map((filePath) => resolveFileRequest(filePath, config)),
filePaths.map((filePath) => resolveFileRequest(filePath, config, resolvePlugins)),
);

return files.filter((file): file is FmtFileRequest => file !== undefined);
Expand Down
21 changes: 5 additions & 16 deletions packages/rstack/src/fmt/format.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,16 @@
import { format, formatWithCursor, getFileInfo } from 'prettier';
import { format, formatWithCursor } from 'prettier';
import { resolveFmtOptions } from './config.ts';
import { resolveFmtParser } from './parser.ts';
import { createFmtPluginResolver } from './plugins.ts';
import type { FormatTextOptions, FormatTextResult } from './types.ts';

/** Formats source text without reading formatter config or ignore files. */
const formatText = async (
source: string,
{ filePath, cursorOffset, config }: FormatTextOptions,
): Promise<FormatTextResult> => {
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, {
ignorePath: [],
resolveConfig: false,
withNodeModules: true,
})
).inferredParser;
const options = createFmtPluginResolver(config.rootPath)(resolveFmtOptions(filePath, config));
const parser = await resolveFmtParser(filePath, options);

if (!parser) {
return {
Expand Down
22 changes: 22 additions & 0 deletions packages/rstack/src/fmt/parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { getFileInfo, type FileInfoOptions, type Options as PrettierOptions } from 'prettier';

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,
): Promise<PrettierOptions['parser'] | null> =>
options.parser ??
(
await getFileInfo(filePath, {
...fileInfoOptions,
plugins: options.plugins,
})
).inferredParser;

export { resolveFmtParser };
106 changes: 49 additions & 57 deletions packages/rstack/src/fmt/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,76 +2,68 @@ import { isAbsolute, join, resolve as resolvePath } from 'node:path';
import { pathToFileURL } from 'node:url';
import { moduleResolve } from 'import-meta-resolve';
import type { Options as PrettierOptions } from 'prettier';
import type { ResolvedFmtConfig } from './types.ts';
import type { FmtPluginSpecifier } from './types.ts';

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

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

const resolvePlugin = (plugin: FmtPlugin, rootPath: string, parentUrl: URL): FmtPlugin => {
if (plugin instanceof URL) {
return resolveModuleUrl(plugin.href, parentUrl);
}
if (typeof plugin !== 'string') {
return plugin;
}
if (isAbsolute(plugin)) {
return resolveModuleUrl(pathToFileURL(plugin).href, parentUrl);
}
if (URL.canParse(plugin)) {
return resolveModuleUrl(plugin, parentUrl);
}
const isFmtPluginSpecifier = (plugin: FmtPlugin): plugin is FmtPluginSpecifier =>
typeof plugin === 'string' || plugin instanceof URL;

try {
return resolveModuleUrl(pathToFileURL(resolvePath(rootPath, plugin)).href, parentUrl);
} catch {
return resolveModuleUrl(plugin, parentUrl);
}
};

const resolveOptionsPlugins = (
options: PrettierOptions,
rootPath: string,
parentUrl: URL,
): PrettierOptions => {
const { plugins } = options;
if (!plugins?.some((plugin) => typeof plugin === 'string' || plugin instanceof URL)) {
return options;
}

const resolvedPlugins = plugins.map((plugin) => resolvePlugin(plugin, rootPath, parentUrl));

return resolvedPlugins.every((plugin, index) => plugin === plugins[index])
? options
: { ...options, plugins: resolvedPlugins };
};

/** Resolves plugin specifiers from the Rstack config root. */
const resolveFmtConfigPlugins = (config: ResolvedFmtConfig): ResolvedFmtConfig => {
const { rootPath } = config;
/** Creates a project-root resolver for plugins in final per-file options. */
const createFmtPluginResolver = (rootPath: string): FmtPluginResolver => {
const parentUrl = pathToFileURL(join(rootPath, 'index.js'));
const baseOptions = resolveOptionsPlugins(config.baseOptions, rootPath, parentUrl);
let overrides = config.overrides;
const cache = new Map<string, string>();

for (let index = 0; index < overrides.length; index++) {
const override = overrides[index];
if (!override.options) {
continue;
const resolvePlugin = (plugin: FmtPluginSpecifier): string => {
const specifier = plugin instanceof URL ? plugin.href : plugin;
const cached = cache.get(specifier);
if (cached !== undefined) {
return cached;
}

const options = resolveOptionsPlugins(override.options, rootPath, parentUrl);
if (options !== override.options) {
if (overrides === config.overrides) {
overrides = [...overrides];
let resolved: string;
if (isAbsolute(specifier)) {
resolved = resolveModuleUrl(pathToFileURL(specifier).href, parentUrl);
} else if (URL.canParse(specifier)) {
resolved = resolveModuleUrl(specifier, parentUrl);
} else {
try {
resolved = resolveModuleUrl(
pathToFileURL(resolvePath(rootPath, specifier)).href,
parentUrl,
);
} catch {
resolved = resolveModuleUrl(specifier, parentUrl);
}
overrides[index] = { ...override, options };
}
}

return baseOptions === config.baseOptions && overrides === config.overrides
? config
: { ...config, baseOptions, overrides };
cache.set(specifier, resolved);
return resolved;
};

return (options) => {
const { plugins } = options;
if (!plugins?.length) {
return options;
}
if (!plugins.every(isFmtPluginSpecifier)) {
// Imported plugin objects are not planned for support.
throw new TypeError(
'Prettier plugin objects are not supported. Use a package name, path, or URL instead.',
);
}

const resolvedPlugins = plugins.map(resolvePlugin);

return resolvedPlugins.every((plugin, index) => plugin === plugins[index])
? options
: { ...options, plugins: resolvedPlugins };
};
};

export { resolveFmtConfigPlugins };
export { createFmtPluginResolver };
export type { FmtPluginResolver };
2 changes: 1 addition & 1 deletion packages/rstack/src/fmt/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const runFmtFilesParallel = async (
}
};

/** Checks every worker payload before any formatting can begin. */
/** Checks whether every request can be cloned for a worker. */
const canRunFmtFilesParallel = (files: FmtFileRequest[]): boolean => {
try {
structuredClone(files);
Expand Down
18 changes: 17 additions & 1 deletion packages/rstack/src/fmt/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import type { Config as PrettierConfig, Options as PrettierOptions } from 'prettier';

interface FmtConfig extends PrettierConfig {
/** Plugin objects cannot cross worker boundaries and are not planned for support. */
type FmtPluginSpecifier = string | URL;

type FmtOptions = Omit<PrettierOptions, 'plugins'> & {
plugins?: FmtPluginSpecifier[];
};

type PrettierOverride = NonNullable<PrettierConfig['overrides']>[number];

type FmtOverride = Omit<PrettierOverride, 'options'> & {
options?: FmtOptions;
};

interface FmtConfig extends Omit<PrettierConfig, 'plugins' | 'overrides'> {
plugins?: FmtPluginSpecifier[];
overrides?: FmtOverride[];
/** Gitignore-compatible patterns relative to the Rstack config root. */
ignorePatterns?: string[];
}
Expand Down Expand Up @@ -103,6 +118,7 @@ export type {
FmtFileResult,
FmtFileRequest,
FmtMode,
FmtPluginSpecifier,
FmtRunResult,
FormatTextOptions,
FormatTextResult,
Expand Down
78 changes: 74 additions & 4 deletions packages/rstack/tests/cli/fmt/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ const writeProjectFile = (filePath: string, content: string): void => {
const readProjectFile = (filePath: string): string =>
readFileSync(path.join(projectPath, filePath), 'utf8');

const writeFixturePlugin = (): void => {
writeProjectFile(
'node_modules/prettier-plugin-fixture/package.json',
JSON.stringify({ name: 'prettier-plugin-fixture', exports: './index.mjs' }),
);
writeProjectFile(
'node_modules/prettier-plugin-fixture/index.mjs',
`export default {
languages: [{ name: 'Fixture JSON', parsers: ['json'], extensions: ['.fixture'] }],
};
`,
);
};

const runCLI = (args: string[]) => {
const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: '1' };
delete env.FORCE_COLOR;
Expand Down Expand Up @@ -216,23 +230,79 @@ test('returns exit code 2 for config errors', () => {
expect(result.stderr).toContain('invalid fmt config');
});

test('returns exit code 2 for unsupported plugins', () => {
test.each([
['parallel execution', []],
['serial execution', ['--no-parallel']],
] as const)('formats with a project-local plugin using %s', (_, options) => {
writeProjectFile(
'rstack.config.ts',
`import { define } from 'rstack';

define.fmt({
plugins: ['prettier-plugin-example'],
plugins: ['prettier-plugin-fixture'],
});
`,
);
writeProjectFile('index.ts', 'const message="hello"');
writeFixturePlugin();
writeProjectFile('first.fixture', '{"first":true}');
writeProjectFile('second.fixture', '{"second":true}');

const result = runFmt([...options, '*.fixture']);

expect(result.status).toBe(0);
expect(result.stdout).toBe('first.fixture\nsecond.fixture\n');
expect(result.stderr).toBe('');
expect(readProjectFile('first.fixture')).toBe('{ "first": true }\n');
expect(readProjectFile('second.fixture')).toBe('{ "second": true }\n');
});

test('formats mixed plugin overrides in parallel', () => {
writeProjectFile(
'rstack.config.ts',
`import { define } from 'rstack';

define.fmt({
overrides: [
{
files: '*.fixture',
options: { plugins: ['prettier-plugin-fixture'] },
},
],
});
`,
);
writeFixturePlugin();
writeProjectFile('data.fixture', '{"value":true}');
writeProjectFile('index.ts', 'const value=true');

const result = runFmt(['data.fixture', 'index.ts']);

expect(result.status).toBe(0);
expect(result.stdout).toBe('data.fixture\nindex.ts\n');
expect(result.stderr).toBe('');
expect(readProjectFile('data.fixture')).toBe('{ "value": true }\n');
expect(readProjectFile('index.ts')).toBe('const value = true;\n');
});

test('returns exit code 2 for imported plugin objects', () => {
writeProjectFile(
'rstack.config.ts',
`import { define } from 'rstack';

define.fmt({
plugins: [{ languages: [] }],
});
`,
);
writeProjectFile('index.ts', 'const value=true');

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

expect(result.status).toBe(2);
expect(result.stdout).toBe('');
expect(result.stderr).toContain('Prettier plugins are not supported yet.');
expect(result.stderr).toContain(
'Prettier plugin objects are not supported. Use a package name, path, or URL instead.',
);
});

test('returns exit code 2 for formatting errors', () => {
Expand Down
Loading