Skip to content

Commit 29095e9

Browse files
authored
perf(fmt): cache unsupported parser results (#239)
1 parent 2005852 commit 29095e9

9 files changed

Lines changed: 166 additions & 38 deletions

File tree

‎packages/rstack/src/fmt/cacheStore.ts‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import path from 'node:path';
55
const fmtCacheFileName = 'v1.json';
66
const fmtCacheVersion = 1;
77

8-
type FmtCacheState = 'clean' | 'dirty';
9-
type FmtCacheEntry = readonly [contentHash: string, optionsHash: string, state: FmtCacheState];
8+
type FmtCacheState = 'clean' | 'dirty' | 'unsupported';
9+
type FmtCacheEntry =
10+
| readonly [contentHash: string, optionsHash: string, state: 'clean' | 'dirty']
11+
| readonly [contentHash: null, optionsHash: string, state: 'unsupported'];
1012

1113
interface FmtCacheFile {
1214
version: typeof fmtCacheVersion;
@@ -28,13 +30,14 @@ const createEmptyCache = (namespace: string): FmtCacheFile => ({
2830
});
2931

3032
const parseCacheEntry = (value: unknown): FmtCacheEntry | undefined => {
31-
if (
32-
!Array.isArray(value) ||
33-
value.length !== 3 ||
34-
typeof value[0] !== 'string' ||
35-
typeof value[1] !== 'string' ||
36-
(value[2] !== 'clean' && value[2] !== 'dirty')
37-
) {
33+
if (!Array.isArray(value) || value.length !== 3 || typeof value[1] !== 'string') {
34+
return;
35+
}
36+
37+
if (value[2] === 'unsupported') {
38+
return value[0] === null ? [null, value[1], value[2]] : undefined;
39+
}
40+
if (typeof value[0] !== 'string' || (value[2] !== 'clean' && value[2] !== 'dirty')) {
3841
return;
3942
}
4043

@@ -120,7 +123,8 @@ class FmtCacheStoreImpl implements FmtCacheStore {
120123
return;
121124
}
122125

123-
this.#cache.files[filePath] = [entry[0], entry[1], entry[2]];
126+
this.#cache.files[filePath] =
127+
entry[2] === 'unsupported' ? [null, entry[1], entry[2]] : [entry[0], entry[1], entry[2]];
124128
this.#changed = true;
125129
}
126130

‎packages/rstack/src/fmt/runner.ts‎

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ interface FmtFileRun {
2222
entry?: FmtCacheEntry;
2323
}
2424

25+
interface FmtFileRunTask {
26+
file: FmtFileRequest;
27+
key?: string;
28+
cache?: FmtFileCache;
29+
}
30+
2531
interface RunCache {
2632
store: FmtCacheStore;
2733
resolveKey: ReturnType<typeof createCacheKeyResolver>;
@@ -81,13 +87,8 @@ const loadPluginFingerprints = async (
8187
return fingerprints;
8288
};
8389

84-
/** Converts a formatter outcome into the shared per-file result. */
85-
const runFmtFile = async (
86-
file: FmtFileRequest,
87-
shouldWrite: boolean,
88-
formatFile: FormatFile,
89-
cache?: RunCache,
90-
): Promise<FmtFileRun> => {
90+
/** Resolves the portable cache identity before work is dispatched. */
91+
const createFmtFileRunTask = (file: FmtFileRequest, cache?: RunCache): FmtFileRunTask => {
9192
let key: string | undefined;
9293
let fileCache: FmtFileCache | undefined;
9394

@@ -106,8 +107,29 @@ const runFmtFile = async (
106107
}
107108
}
108109

110+
return { file, key, cache: fileCache };
111+
};
112+
113+
const isCachedUnsupported = ({ cache }: FmtFileRunTask): boolean => {
114+
if (!cache?.entry) {
115+
return false;
116+
}
117+
return cache.entry[1] === cache.optionsHash && cache.entry[2] === 'unsupported';
118+
};
119+
120+
/** Converts a formatter outcome into the shared per-file result. */
121+
const runFmtFile = async (
122+
task: FmtFileRunTask,
123+
shouldWrite: boolean,
124+
formatFile: FormatFile,
125+
): Promise<FmtFileRun> => {
126+
if (isCachedUnsupported(task)) {
127+
return { outcome: 'unsupported' };
128+
}
129+
130+
const { file, key, cache } = task;
109131
try {
110-
const result = await formatFile(file, shouldWrite, fileCache);
132+
const result = await formatFile(file, shouldWrite, cache);
111133
const outcome: FmtFileOutcome =
112134
result.status === 'changed'
113135
? {
@@ -133,23 +155,22 @@ const runFmtFile = async (
133155

134156
/** Starts slower Markdown parsers first while preserving order within both priority groups. */
135157
const runPriorityFmtFiles = async (
136-
files: FmtFileRequest[],
158+
tasks: FmtFileRunTask[],
137159
shouldWrite: boolean,
138160
formatFile: FormatFile,
139-
cache?: RunCache,
140161
): Promise<FmtFileRun[]> => {
141162
const priority: number[] = [];
142163
const rest: number[] = [];
143164

144-
for (let index = 0; index < files.length; index++) {
145-
(isMarkdown(files[index]) ? priority : rest).push(index);
165+
for (let index = 0; index < tasks.length; index++) {
166+
(isMarkdown(tasks[index].file) ? priority : rest).push(index);
146167
}
147168

148169
const order = priority.concat(rest);
149170
const outcomes = await Promise.all(
150-
order.map((index) => runFmtFile(files[index], shouldWrite, formatFile, cache)),
171+
order.map((index) => runFmtFile(tasks[index], shouldWrite, formatFile)),
151172
);
152-
const results = new Array<FmtFileRun>(files.length);
173+
const results = new Array<FmtFileRun>(tasks.length);
153174
for (let index = 0; index < order.length; index++) {
154175
results[order[index]] = outcomes[index];
155176
}
@@ -163,15 +184,24 @@ const runFmtFilesInWorkerPool = async (
163184
maxWorkers?: number,
164185
cache?: RunCache,
165186
): Promise<FmtWorkerPoolResult> => {
187+
const tasks = files.map((file) => createFmtFileRunTask(file, cache));
188+
const pendingFileCount = tasks.reduce(
189+
(count, task) => count + (isCachedUnsupported(task) ? 0 : 1),
190+
0,
191+
);
192+
if (pendingFileCount === 0) {
193+
return { files: [], processedFileCount: 0 };
194+
}
195+
166196
const { createFmtWorkerPool } = await import('./workerPool.ts');
167-
const workerPool = await createFmtWorkerPool(files.length, maxWorkers);
197+
const workerPool = await createFmtWorkerPool(pendingFileCount, maxWorkers);
168198

169199
try {
170200
const results =
171201
workerPool.workerCount >= minPriorityWorkers
172-
? await runPriorityFmtFiles(files, shouldWrite, workerPool.formatFile, cache)
202+
? await runPriorityFmtFiles(tasks, shouldWrite, workerPool.formatFile)
173203
: await Promise.all(
174-
files.map((file) => runFmtFile(file, shouldWrite, workerPool.formatFile, cache)),
204+
tasks.map((task) => runFmtFile(task, shouldWrite, workerPool.formatFile)),
175205
);
176206
const processedFiles: FmtFileResult[] = [];
177207
let processedFileCount = 0;

‎packages/rstack/src/fmt/worker.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,13 @@ const formatFile = async ({
4242
};
4343

4444
if (cache?.entry && cache.entry[1] === cache.optionsHash) {
45+
const { entry } = cache;
46+
if (entry[2] === 'unsupported') {
47+
return { status: 'unsupported' };
48+
}
49+
4550
sourceBuffer = readFileSync(file.path);
4651
contentHash = hashContent(sourceBuffer);
47-
const { entry } = cache;
4852
if (entry[0] === contentHash && (!shouldWrite || entry[2] === 'clean')) {
4953
return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' };
5054
}
@@ -53,7 +57,12 @@ const formatFile = async ({
5357
const { formatFmtSource } = await import('./format.ts');
5458
const result = await formatFmtSource(file, () => (source ??= readSource()));
5559
if (result.status === 'unsupported') {
56-
return { status: 'unsupported' };
60+
return cache
61+
? {
62+
status: 'unsupported',
63+
cacheEntry: [null, cache.optionsHash, 'unsupported'],
64+
}
65+
: { status: 'unsupported' };
5766
}
5867

5968
const unchanged = result.source === result.formatted;

‎packages/rstack/tests/fmt/cacheStore.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { withTempProject } from './helpers.ts';
77
const namespace = 'test-namespace';
88
const firstEntry = ['content-a', 'options-a', 'clean'] as const;
99
const secondEntry = ['content-b', 'options-b', 'dirty'] as const;
10+
const unsupportedEntry = [null, 'options-c', 'unsupported'] as const;
1011

1112
const readCache = (filePath: string): FmtCacheFile =>
1213
JSON.parse(readFileSync(filePath, 'utf8')) as FmtCacheFile;
@@ -20,11 +21,13 @@ test('writes entries that can be loaded by another store', async () => {
2021
expect(existsSync(cachePath)).toBe(false);
2122

2223
store.set('src/a.ts', firstEntry);
24+
store.set('src/unknown.fixture', unsupportedEntry);
2325
expect(await store.save()).toBe(true);
2426
expect(await store.save()).toBe(false);
2527

2628
const loaded = await loadFmtCacheStore(cachePath, namespace);
2729
expect(loaded.get('src/a.ts')).toEqual(firstEntry);
30+
expect(loaded.get('src/unknown.fixture')).toEqual(unsupportedEntry);
2831
});
2932
});
3033

@@ -68,6 +71,16 @@ test('discards invalid data and entries from another namespace', async () => {
6871
namespace,
6972
files: { 'src/a.ts': ['content', 'options', 'unknown'] },
7073
}),
74+
JSON.stringify({
75+
version: fmtCacheVersion,
76+
namespace,
77+
files: { 'src/a.ts': ['content', 'options', 'unsupported'] },
78+
}),
79+
JSON.stringify({
80+
version: fmtCacheVersion,
81+
namespace,
82+
files: { 'src/a.ts': [null, 'options', 'clean'] },
83+
}),
7184
];
7285

7386
for (const content of invalidContents) {

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,40 @@ test('invalidates entries when final options change', async () => {
121121
});
122122
});
123123

124+
test('caches unsupported parser results until final options change', async () => {
125+
await withTempProject(async (rootPath) => {
126+
const filePath = writeProjectFile(rootPath, 'data.unknown', '{"value":true}');
127+
const cache = createCache(rootPath);
128+
const unsupported = createRequest(filePath, {});
129+
130+
const first = await run([unsupported], 'check', cache);
131+
expect(first).toEqual({
132+
exitCode: 2,
133+
files: [],
134+
processedFileCount: 0,
135+
});
136+
expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('data.unknown')).toEqual([
137+
null,
138+
createOptionsHasher()(unsupported.options),
139+
'unsupported',
140+
]);
141+
142+
await expect(run([unsupported], 'check', cache)).resolves.toEqual(first);
143+
144+
const supported = createRequest(filePath, { parser: 'json' });
145+
await expect(run([supported], 'check', cache)).resolves.toMatchObject({
146+
exitCode: 1,
147+
files: [{ path: filePath, status: 'different' }],
148+
processedFileCount: 1,
149+
});
150+
expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('data.unknown')).toEqual([
151+
sha256(readFileSync(filePath)),
152+
createOptionsHasher()(supported.options),
153+
'dirty',
154+
]);
155+
});
156+
});
157+
124158
test('caches only plugins with stable fingerprints', async () => {
125159
await withTempProject(async (rootPath) => {
126160
const filePath = writeProjectFile(rootPath, 'data.fixture', '{"value":true}');

‎packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { readFileSync } from 'node:fs';
2+
import path from 'node:path';
23
import { beforeEach, expect, rs, test } from 'rstack/test';
4+
import { cacheNamespace, createOptionsHasher } from '../../src/fmt/cacheIdentity.ts';
5+
import { loadFmtCacheStore } from '../../src/fmt/cacheStore.ts';
36
import { runFmtFiles } from '../../src/fmt/runner.ts';
47
import type { FmtFileRequest } from '../../src/fmt/types.ts';
58
import { withTempProject, writeProjectFile } from './helpers.ts';
@@ -51,3 +54,32 @@ test('does not start the worker pool when there are no files', async () => {
5154
});
5255
expect(mocks.createFmtWorkerPoolCalls).toEqual([]);
5356
});
57+
58+
test('does not start the worker pool when every parser result is cached as unsupported', async () => {
59+
await withTempProject(async (rootPath) => {
60+
const filePath = writeProjectFile(rootPath, 'example.unknown', 'plain text');
61+
const cachePath = path.join(rootPath, 'cache', 'fmt-v1.json');
62+
const file: FmtFileRequest = { path: filePath, options: {} };
63+
const optionsHash = createOptionsHasher()(file.options);
64+
if (optionsHash === undefined) {
65+
throw new Error('Expected cacheable formatter options.');
66+
}
67+
68+
const store = await loadFmtCacheStore(cachePath, cacheNamespace);
69+
store.set('example.unknown', [null, optionsHash, 'unsupported']);
70+
await expect(store.save()).resolves.toBe(true);
71+
72+
await expect(
73+
runFmtFiles({
74+
files: [file],
75+
mode: 'check',
76+
cache: { filePath: cachePath, rootPath },
77+
}),
78+
).resolves.toEqual({
79+
exitCode: 2,
80+
files: [],
81+
processedFileCount: 0,
82+
});
83+
expect(mocks.createFmtWorkerPoolCalls).toEqual([]);
84+
});
85+
});

‎packages/rstack/tests/fmt/worker.test.ts‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,25 +48,28 @@ test('returns cached states before resolving the parser', async () => {
4848
await withTempProject(async (rootPath) => {
4949
const source = 'const value=1';
5050
const filePath = writeProjectFile(rootPath, 'example.ts', source);
51+
const missingPath = path.join(rootPath, 'missing.unknown');
5152
const contentHash = sha256(source);
5253
const optionsHash = 'options';
5354

54-
for (const [state, shouldWrite, status] of [
55-
['clean', false, 'unchanged'],
56-
['dirty', false, 'changed'],
57-
['clean', true, 'unchanged'],
55+
for (const [entry, targetPath, shouldWrite, status] of [
56+
[[contentHash, optionsHash, 'clean'], filePath, false, 'unchanged'],
57+
[[contentHash, optionsHash, 'dirty'], filePath, false, 'changed'],
58+
[[contentHash, optionsHash, 'clean'], filePath, true, 'unchanged'],
59+
[[null, optionsHash, 'unsupported'], missingPath, false, 'unsupported'],
60+
[[null, optionsHash, 'unsupported'], missingPath, true, 'unsupported'],
5861
] as const) {
5962
await expect(
6063
formatFile({
6164
file: {
62-
path: filePath,
65+
path: targetPath,
6366
options: {
6467
parser: 'unknown-parser',
6568
},
6669
},
6770
shouldWrite,
6871
cache: {
69-
entry: [contentHash, optionsHash, state],
72+
entry,
7073
optionsHash,
7174
},
7275
}),
@@ -89,6 +92,9 @@ test('resolves parser support before reading on a cache miss', async () => {
8992
optionsHash: 'options',
9093
},
9194
}),
92-
).resolves.toEqual({ status: 'unsupported' });
95+
).resolves.toEqual({
96+
status: 'unsupported',
97+
cacheEntry: [null, 'options', 'unsupported'],
98+
});
9399
});
94100
});

‎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. Installed Prettier plugins are identified by their package name, version, and entry point; local, linked, or unversioned plugins bypass the cache.
179+
`rs fmt` uses a persistent cache by default for file-based `--write`, `--check`, and `--list-different` runs. Formatting results use file content and final formatting options, so changing either causes the file to be formatted again. Unsupported parser lookups use the file path and final options because parser inference does not inspect file content. 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` 调用中使用持久化缓存。格式化结果基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。由于 parser 推断不会读取文件内容,不支持的 parser 查询结果仅基于文件路径和最终选项。已安装的 Prettier 插件通过包名、版本和入口进行识别;本地插件、链接插件或缺少版本信息的插件会绕过缓存。
180180

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

0 commit comments

Comments
 (0)