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: 18 additions & 4 deletions packages/rstack/src/fmt/cacheIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ declare const RSTACK_VERSION: string;

type CacheKeyResolver = (filePath: string) => string | undefined;
type OptionsHasher = (options: ResolvedFmtOptions) => string | undefined;
type PluginFingerprints = ReadonlyMap<string, string>;

const sha256 = (content: string | Uint8Array): string =>
createHash('sha256').update(content).digest('hex');
Expand All @@ -28,7 +29,7 @@ const createCacheKeyResolver = (rootPath: string): CacheKeyResolver => {
};

/** Hashes final per-file options and memoizes option objects shared by many files. */
const createOptionsHasher = (): OptionsHasher => {
const createOptionsHasher = (pluginFingerprints?: PluginFingerprints): OptionsHasher => {
const hashes = new WeakMap<ResolvedFmtOptions, string | null>();

return (options) => {
Expand All @@ -39,10 +40,23 @@ const createOptionsHasher = (): OptionsHasher => {

let hash: string | undefined;
try {
// A resolved plugin path does not identify the plugin implementation.
if (!options.plugins?.length) {
hash = sha256(stableStringify(options));
const { plugins } = options;
let value = options;
if (plugins?.length) {
const fingerprints: string[] = [];
for (const plugin of plugins) {
const key =
plugin instanceof URL ? plugin.href : typeof plugin === 'string' ? plugin : undefined;
const fingerprint = key === undefined ? undefined : pluginFingerprints?.get(key);
if (fingerprint === undefined) {
hashes.set(options, null);
return undefined;
}
fingerprints.push(fingerprint);
}
value = { ...options, plugins: fingerprints };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Account for plugin configuration inputs in the cache key

For plugins whose output depends on external files, such as prettier-plugin-tailwindcss reading a Tailwind config or stylesheet, changing that file leaves the formatted file content, the option containing its path, and this package fingerprint unchanged. The worker can consequently reuse the prior entry without running the plugin, causing --check to report clean or --write to do nothing even though the expected formatting changed; these plugins need their external inputs included in invalidation or must continue bypassing the cache.

Useful? React with 👍 / 👎.

}
hash = sha256(stableStringify(value));
} catch {
// Circular or unreadable options cannot be cached.
}
Expand Down
46 changes: 44 additions & 2 deletions packages/rstack/src/fmt/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
FmtExitCode,
FmtFileRequest,
FmtFileResult,
FmtPluginSpecifier,
FmtRunResult,
RunFmtFilesOptions,
} from './types.ts';
Expand Down Expand Up @@ -43,6 +44,43 @@ const minPriorityWorkers = 8;
const isMarkdown = (file: FmtFileRequest): boolean =>
file.path.endsWith('.md') || file.path.endsWith('.mdx');

/** Resolves each distinct plugin once before the synchronous per-file cache path. */
const loadPluginFingerprints = async (
files: FmtFileRequest[],
): Promise<Map<string, string> | undefined> => {
const plugins = new Map<string, FmtPluginSpecifier>();
for (const file of files) {
try {
for (const plugin of file.options.plugins ?? []) {
if (typeof plugin === 'string' || plugin instanceof URL) {
plugins.set(plugin instanceof URL ? plugin.href : plugin, plugin);
}
}
} catch {
// Unreadable options cannot be cached by the options hasher.
}
}
if (plugins.size === 0) {
return undefined;
}

const { createFingerprintResolver } = await import(
/* rspackChunkName: 'fmtPlugins' */
'./plugins.ts'
);
const resolveFingerprint = createFingerprintResolver();
const entries = await Promise.all(
Array.from(plugins, async ([key, plugin]) => [key, await resolveFingerprint(plugin)] as const),
);
const fingerprints = new Map<string, string>();
for (const [key, fingerprint] of entries) {
if (fingerprint !== undefined) {
fingerprints.set(key, fingerprint);
}
}
return fingerprints;
};

/** Converts a formatter outcome into the shared per-file result. */
const runFmtFile = async (
file: FmtFileRequest,
Expand Down Expand Up @@ -184,10 +222,14 @@ const runFmtFiles = async ({
const shouldWrite = mode === 'write';
let runCache: RunCache | undefined;
if (files.length > 0 && cache) {
const [store, fingerprints] = await Promise.all([
loadFmtCacheStore(cache.filePath, cacheNamespace),
loadPluginFingerprints(files),
]);
runCache = {
store: await loadFmtCacheStore(cache.filePath, cacheNamespace),
store,
resolveKey: createCacheKeyResolver(cache.rootPath),
hashOptions: createOptionsHasher(),
hashOptions: createOptionsHasher(fingerprints),
};
}

Expand Down
10 changes: 10 additions & 0 deletions packages/rstack/tests/fmt/cacheIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ test('invalidates hashes when final formatter options change', () => {
expect(new Set(hashes).size).toBe(hashes.length);
});

test('includes plugin fingerprints in option hashes', () => {
const plugin = pathToFileURL(path.resolve('plugin.mjs')).href;
const first = createOptionsHasher(new Map([[plugin, 'plugin@1']]));
const second = createOptionsHasher(new Map([[plugin, 'plugin@2']]));

expect(first({ plugins: [plugin] })).toHaveLength(64);
expect(first({ plugins: [new URL(plugin)] })).toBe(first({ plugins: [plugin] }));
expect(first({ plugins: [plugin] })).not.toBe(second({ plugins: [plugin] }));
});

test('bypasses user plugins and unserializable options', () => {
const hashOptions = createOptionsHasher();
const cyclic: Record<string, unknown> = {};
Expand Down
51 changes: 50 additions & 1 deletion packages/rstack/tests/fmt/runnerCache.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFileSync, statSync, utimesSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { expect, test } from 'rstack/test';
import { cacheNamespace, createOptionsHasher, sha256 } from '../../src/fmt/cacheIdentity.ts';
import { loadFmtCacheStore } from '../../src/fmt/cacheStore.ts';
Expand All @@ -10,7 +11,7 @@ import type {
FmtMode,
ResolvedFmtOptions,
} from '../../src/fmt/types.ts';
import { withTempProject } from './helpers.ts';
import { withTempProject, writeProjectFile } from './helpers.ts';

const createRequest = (
filePath: string,
Expand Down Expand Up @@ -120,6 +121,54 @@ test('invalidates entries when final options change', async () => {
});
});

test('caches only plugins with stable fingerprints', async () => {
await withTempProject(async (rootPath) => {
const filePath = writeProjectFile(rootPath, 'data.fixture', '{"value":true}');
const pluginEntry = writeProjectFile(
rootPath,
'node_modules/prettier-plugin-fixture/index.mjs',
`export default {
languages: [{ name: 'Fixture JSON', parsers: ['json'], extensions: ['.fixture'] }],
};
`,
);
const packageJsonPath = 'node_modules/prettier-plugin-fixture/package.json';
const writePackageJson = (version?: string) =>
writeProjectFile(
rootPath,
packageJsonPath,
JSON.stringify({
name: 'prettier-plugin-fixture',
exports: './index.mjs',
...(version ? { version } : {}),
}),
);
const cache = createCache(rootPath);
const file = createRequest(filePath, { plugins: [pathToFileURL(pluginEntry).href] });

writePackageJson();
await run([file], 'check', cache);
expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('data.fixture')).toBe(
undefined,
);

writePackageJson('1.0.0');
await run([file], 'check', cache);
const firstHash = (await loadFmtCacheStore(cache.filePath, cacheNamespace)).get(
'data.fixture',
)?.[1];
expect(firstHash).toHaveLength(64);

writePackageJson('2.0.0');
await run([file], 'check', cache);
const secondHash = (await loadFmtCacheStore(cache.filePath, cacheNamespace)).get(
'data.fixture',
)?.[1];
expect(secondHash).toHaveLength(64);
expect(secondHash).not.toBe(firstHash);
});
});

test('preserves entries outside the formatted subset', async () => {
await withTempProject(async (rootPath) => {
const firstPath = path.join(rootPath, 'first.ts');
Expand Down
2 changes: 1 addition & 1 deletion website/docs/en/guide/formatting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ define.fmt({

## Cache

`rs fmt` uses a persistent cache by default for file-based `--write`, `--check`, and `--list-different` runs. Cache entries use file content and final formatting options, so changing either causes the file to be formatted again. Files that use custom Prettier plugins currently bypass the cache.
`rs fmt` uses a persistent cache by default for file-based `--write`, `--check`, and `--list-different` runs. Cache entries use file content and final formatting options, so changing either causes the file to be formatted again. Installed Prettier plugins are identified by their package name, version, and entry point; local, linked, or unversioned plugins bypass the cache.

The default cache directory is `.rstack/cache/fmt` under the Rstack configuration root. When a command runs from a subdirectory, it continues to use the cache next to the resolved `rstack.config.*` file. Stdin formatting does not use this cache.

Expand Down
2 changes: 1 addition & 1 deletion website/docs/zh/guide/formatting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ define.fmt({

## 缓存 \{#cache}

`rs fmt` 默认会在基于文件的 `--write`、`--check` 和 `--list-different` 调用中使用持久化缓存。缓存条目基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。使用自定义 Prettier 插件的文件目前会绕过缓存
`rs fmt` 默认会在基于文件的 `--write`、`--check` 和 `--list-different` 调用中使用持久化缓存。缓存条目基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。已安装的 Prettier 插件通过包名、版本和入口进行识别;本地插件、链接插件或缺少版本信息的插件会绕过缓存

默认缓存目录位于 Rstack 配置根目录下的 `.rstack/cache/fmt`。从子目录运行命令时,仍会使用解析到的 `rstack.config.*` 文件旁的缓存。stdin 格式化不会使用该缓存。

Expand Down