Skip to content

Commit 50522a6

Browse files
committed
feat(fmt): cache files using package plugins
1 parent 3c652be commit 50522a6

6 files changed

Lines changed: 124 additions & 9 deletions

File tree

packages/rstack/src/fmt/cacheIdentity.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ declare const RSTACK_VERSION: string;
1010

1111
type CacheKeyResolver = (filePath: string) => string | undefined;
1212
type OptionsHasher = (options: ResolvedFmtOptions) => string | undefined;
13+
type PluginFingerprints = ReadonlyMap<string, string>;
1314

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

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

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

4041
let hash: string | undefined;
4142
try {
42-
// A resolved plugin path does not identify the plugin implementation.
43-
if (!options.plugins?.length) {
44-
hash = sha256(stableStringify(options));
43+
const { plugins } = options;
44+
let value = options;
45+
if (plugins?.length) {
46+
const fingerprints: string[] = [];
47+
for (const plugin of plugins) {
48+
const key =
49+
plugin instanceof URL ? plugin.href : typeof plugin === 'string' ? plugin : undefined;
50+
const fingerprint = key === undefined ? undefined : pluginFingerprints?.get(key);
51+
if (fingerprint === undefined) {
52+
hashes.set(options, null);
53+
return undefined;
54+
}
55+
fingerprints.push(fingerprint);
56+
}
57+
value = { ...options, plugins: fingerprints };
4558
}
59+
hash = sha256(stableStringify(value));
4660
} catch {
4761
// Circular or unreadable options cannot be cached.
4862
}

packages/rstack/src/fmt/runner.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
FmtExitCode,
77
FmtFileRequest,
88
FmtFileResult,
9+
FmtPluginSpecifier,
910
FmtRunResult,
1011
RunFmtFilesOptions,
1112
} from './types.ts';
@@ -43,6 +44,43 @@ const minPriorityWorkers = 8;
4344
const isMarkdown = (file: FmtFileRequest): boolean =>
4445
file.path.endsWith('.md') || file.path.endsWith('.mdx');
4546

47+
/** Resolves each distinct plugin once before the synchronous per-file cache path. */
48+
const loadPluginFingerprints = async (
49+
files: FmtFileRequest[],
50+
): Promise<Map<string, string> | undefined> => {
51+
const plugins = new Map<string, FmtPluginSpecifier>();
52+
for (const file of files) {
53+
try {
54+
for (const plugin of file.options.plugins ?? []) {
55+
if (typeof plugin === 'string' || plugin instanceof URL) {
56+
plugins.set(plugin instanceof URL ? plugin.href : plugin, plugin);
57+
}
58+
}
59+
} catch {
60+
// Unreadable options cannot be cached by the options hasher.
61+
}
62+
}
63+
if (plugins.size === 0) {
64+
return undefined;
65+
}
66+
67+
const { createFingerprintResolver } = await import(
68+
/* rspackChunkName: 'fmtPlugins' */
69+
'./plugins.ts'
70+
);
71+
const resolveFingerprint = createFingerprintResolver();
72+
const entries = await Promise.all(
73+
Array.from(plugins, async ([key, plugin]) => [key, await resolveFingerprint(plugin)] as const),
74+
);
75+
const fingerprints = new Map<string, string>();
76+
for (const [key, fingerprint] of entries) {
77+
if (fingerprint !== undefined) {
78+
fingerprints.set(key, fingerprint);
79+
}
80+
}
81+
return fingerprints;
82+
};
83+
4684
/** Converts a formatter outcome into the shared per-file result. */
4785
const runFmtFile = async (
4886
file: FmtFileRequest,
@@ -184,10 +222,14 @@ const runFmtFiles = async ({
184222
const shouldWrite = mode === 'write';
185223
let runCache: RunCache | undefined;
186224
if (files.length > 0 && cache) {
225+
const [store, fingerprints] = await Promise.all([
226+
loadFmtCacheStore(cache.filePath, cacheNamespace),
227+
loadPluginFingerprints(files),
228+
]);
187229
runCache = {
188-
store: await loadFmtCacheStore(cache.filePath, cacheNamespace),
230+
store,
189231
resolveKey: createCacheKeyResolver(cache.rootPath),
190-
hashOptions: createOptionsHasher(),
232+
hashOptions: createOptionsHasher(fingerprints),
191233
};
192234
}
193235

packages/rstack/tests/fmt/cacheIdentity.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ test('invalidates hashes when final formatter options change', () => {
4747
expect(new Set(hashes).size).toBe(hashes.length);
4848
});
4949

50+
test('includes plugin fingerprints in option hashes', () => {
51+
const plugin = pathToFileURL(path.resolve('plugin.mjs')).href;
52+
const first = createOptionsHasher(new Map([[plugin, 'plugin@1']]));
53+
const second = createOptionsHasher(new Map([[plugin, 'plugin@2']]));
54+
55+
expect(first({ plugins: [plugin] })).toHaveLength(64);
56+
expect(first({ plugins: [new URL(plugin)] })).toBe(first({ plugins: [plugin] }));
57+
expect(first({ plugins: [plugin] })).not.toBe(second({ plugins: [plugin] }));
58+
});
59+
5060
test('bypasses user plugins and unserializable options', () => {
5161
const hashOptions = createOptionsHasher();
5262
const cyclic: Record<string, unknown> = {};

packages/rstack/tests/fmt/runnerCache.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { readFileSync, statSync, utimesSync, writeFileSync } from 'node:fs';
22
import path from 'node:path';
3+
import { pathToFileURL } from 'node:url';
34
import { expect, test } from 'rstack/test';
45
import { cacheNamespace, createOptionsHasher, sha256 } from '../../src/fmt/cacheIdentity.ts';
56
import { loadFmtCacheStore } from '../../src/fmt/cacheStore.ts';
@@ -10,7 +11,7 @@ import type {
1011
FmtMode,
1112
ResolvedFmtOptions,
1213
} from '../../src/fmt/types.ts';
13-
import { withTempProject } from './helpers.ts';
14+
import { withTempProject, writeProjectFile } from './helpers.ts';
1415

1516
const createRequest = (
1617
filePath: string,
@@ -120,6 +121,54 @@ test('invalidates entries when final options change', async () => {
120121
});
121122
});
122123

124+
test('caches only plugins with stable fingerprints', async () => {
125+
await withTempProject(async (rootPath) => {
126+
const filePath = writeProjectFile(rootPath, 'data.fixture', '{"value":true}');
127+
const pluginEntry = writeProjectFile(
128+
rootPath,
129+
'node_modules/prettier-plugin-fixture/index.mjs',
130+
`export default {
131+
languages: [{ name: 'Fixture JSON', parsers: ['json'], extensions: ['.fixture'] }],
132+
};
133+
`,
134+
);
135+
const packageJsonPath = 'node_modules/prettier-plugin-fixture/package.json';
136+
const writePackageJson = (version?: string) =>
137+
writeProjectFile(
138+
rootPath,
139+
packageJsonPath,
140+
JSON.stringify({
141+
name: 'prettier-plugin-fixture',
142+
exports: './index.mjs',
143+
...(version ? { version } : {}),
144+
}),
145+
);
146+
const cache = createCache(rootPath);
147+
const file = createRequest(filePath, { plugins: [pathToFileURL(pluginEntry).href] });
148+
149+
writePackageJson();
150+
await run([file], 'check', cache);
151+
expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('data.fixture')).toBe(
152+
undefined,
153+
);
154+
155+
writePackageJson('1.0.0');
156+
await run([file], 'check', cache);
157+
const firstHash = (await loadFmtCacheStore(cache.filePath, cacheNamespace)).get(
158+
'data.fixture',
159+
)?.[1];
160+
expect(firstHash).toHaveLength(64);
161+
162+
writePackageJson('2.0.0');
163+
await run([file], 'check', cache);
164+
const secondHash = (await loadFmtCacheStore(cache.filePath, cacheNamespace)).get(
165+
'data.fixture',
166+
)?.[1];
167+
expect(secondHash).toHaveLength(64);
168+
expect(secondHash).not.toBe(firstHash);
169+
});
170+
});
171+
123172
test('preserves entries outside the formatted subset', async () => {
124173
await withTempProject(async (rootPath) => {
125174
const firstPath = path.join(rootPath, 'first.ts');

website/docs/en/guide/formatting.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ define.fmt({
176176

177177
## Cache
178178

179-
`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.
179+
`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.
180180

181181
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.
182182

website/docs/zh/guide/formatting.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ define.fmt({
176176

177177
## 缓存 \{#cache}
178178

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

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

0 commit comments

Comments
 (0)