Skip to content

Commit 565734c

Browse files
authored
perf(fmt): cache unsupported extensionless files by content (#244)
1 parent 3c0604d commit 565734c

10 files changed

Lines changed: 135 additions & 17 deletions

File tree

packages/rstack/src/fmt/cacheStore.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const fmtCacheVersion = 1;
88
type FmtCacheState = 'clean' | 'dirty' | 'unsupported';
99
type FmtCacheEntry =
1010
| readonly [contentHash: string, optionsHash: string, state: 'clean' | 'dirty']
11-
| readonly [contentHash: null, optionsHash: string, state: 'unsupported'];
11+
| readonly [contentHash: string | null, optionsHash: string, state: 'unsupported'];
1212

1313
interface FmtCacheFile {
1414
version: typeof fmtCacheVersion;
@@ -35,7 +35,9 @@ const parseCacheEntry = (value: unknown): FmtCacheEntry | undefined => {
3535
}
3636

3737
if (value[2] === 'unsupported') {
38-
return value[0] === null ? [null, value[1], value[2]] : undefined;
38+
return value[0] === null || typeof value[0] === 'string'
39+
? [value[0], value[1], value[2]]
40+
: undefined;
3941
}
4042
if (typeof value[0] !== 'string' || (value[2] !== 'clean' && value[2] !== 'dirty')) {
4143
return;
@@ -124,7 +126,9 @@ class FmtCacheStoreImpl implements FmtCacheStore {
124126
}
125127

126128
this.#cache.files[filePath] =
127-
entry[2] === 'unsupported' ? [null, entry[1], entry[2]] : [entry[0], entry[1], entry[2]];
129+
entry[2] === 'unsupported'
130+
? [entry[0], entry[1], 'unsupported']
131+
: [entry[0], entry[1], entry[2]];
128132
this.#changed = true;
129133
}
130134

packages/rstack/src/fmt/pathHelpers.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,8 @@ const createRelativePathResolver = (rootPath: string): RelativePathResolver => {
1616
: path.relative(rootPath, filePath);
1717
};
1818

19-
export { createRelativePathResolver, toPosixPath };
19+
/** Prettier only inspects a file's shebang when its basename contains no dot. */
20+
const hasDottedBasename = (filePath: string): boolean => path.basename(filePath).includes('.');
21+
22+
export { createRelativePathResolver, hasDottedBasename, toPosixPath };
2023
export type { RelativePathResolver };

packages/rstack/src/fmt/runner.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { cacheNamespace, createCacheKeyResolver, createOptionsHasher } from './cacheIdentity.ts';
22
import { loadFmtCacheStore } from './cacheStore.ts';
33
import type { FmtCacheEntry, FmtCacheStore } from './cacheStore.ts';
4+
import { hasDottedBasename } from './pathHelpers.ts';
45
import type {
56
FmtFileCache,
67
FmtExitCode,
@@ -110,11 +111,16 @@ const createFmtFileRunTask = (file: FmtFileRequest, cache?: RunCache): FmtFileRu
110111
return { file, key, cache: fileCache };
111112
};
112113

113-
const isCachedUnsupported = ({ cache }: FmtFileRunTask): boolean => {
114+
const isCachedUnsupported = ({ file, cache }: FmtFileRunTask): boolean => {
114115
if (!cache?.entry) {
115116
return false;
116117
}
117-
return cache.entry[1] === cache.optionsHash && cache.entry[2] === 'unsupported';
118+
return (
119+
cache.entry[0] === null &&
120+
cache.entry[1] === cache.optionsHash &&
121+
cache.entry[2] === 'unsupported' &&
122+
hasDottedBasename(file.path)
123+
);
118124
};
119125

120126
/** Converts a formatter outcome into the shared per-file result. */

packages/rstack/src/fmt/worker.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { hash } from 'node:crypto';
44
import { readFileSync, writeFileSync } from 'node:fs';
55
import type { FmtCacheEntry } from './cacheStore.ts';
6+
import { hasDottedBasename } from './pathHelpers.ts';
67
import type { FmtFileCache, FmtFileRequest, FmtWorkerResult } from './types.ts';
78

89
interface FormatFileTask {
@@ -43,13 +44,23 @@ const formatFile = async ({
4344
if (cache?.entry && cache.entry[1] === cache.optionsHash) {
4445
const { entry } = cache;
4546
if (entry[2] === 'unsupported') {
46-
return { status: 'unsupported' };
47-
}
48-
49-
sourceBuffer = readFileSync(file.path);
50-
contentHash = hashContent(sourceBuffer);
51-
if (entry[0] === contentHash && (!shouldWrite || entry[2] === 'clean')) {
52-
return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' };
47+
if (entry[0] === null) {
48+
if (hasDottedBasename(file.path)) {
49+
return { status: 'unsupported' };
50+
}
51+
} else {
52+
sourceBuffer = readFileSync(file.path);
53+
contentHash = hashContent(sourceBuffer);
54+
if (entry[0] === contentHash) {
55+
return { status: 'unsupported' };
56+
}
57+
}
58+
} else {
59+
sourceBuffer = readFileSync(file.path);
60+
contentHash = hashContent(sourceBuffer);
61+
if (entry[0] === contentHash && (!shouldWrite || entry[2] === 'clean')) {
62+
return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' };
63+
}
5364
}
5465
}
5566

@@ -59,7 +70,13 @@ const formatFile = async ({
5970
return cache
6071
? {
6172
status: 'unsupported',
62-
cacheEntry: [null, cache.optionsHash, 'unsupported'],
73+
cacheEntry: [
74+
hasDottedBasename(file.path)
75+
? null
76+
: (contentHash ?? hashContent(sourceBuffer ?? readFileSync(file.path))),
77+
cache.optionsHash,
78+
'unsupported',
79+
],
6380
}
6481
: { status: 'unsupported' };
6582
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const namespace = 'test-namespace';
88
const firstEntry = ['content-a', 'options-a', 'clean'] as const;
99
const secondEntry = ['content-b', 'options-b', 'dirty'] as const;
1010
const unsupportedEntry = [null, 'options-c', 'unsupported'] as const;
11+
const hashedUnsupportedEntry = ['content-c', 'options-c', 'unsupported'] as const;
1112

1213
const readCache = (filePath: string): FmtCacheFile =>
1314
JSON.parse(readFileSync(filePath, 'utf8')) as FmtCacheFile;
@@ -22,12 +23,14 @@ test('writes entries that can be loaded by another store', async () => {
2223

2324
store.set('src/a.ts', firstEntry);
2425
store.set('src/unknown.fixture', unsupportedEntry);
26+
store.set('script', hashedUnsupportedEntry);
2527
expect(await store.save()).toBe(true);
2628
expect(await store.save()).toBe(false);
2729

2830
const loaded = await loadFmtCacheStore(cachePath, namespace);
2931
expect(loaded.get('src/a.ts')).toEqual(firstEntry);
3032
expect(loaded.get('src/unknown.fixture')).toEqual(unsupportedEntry);
33+
expect(loaded.get('script')).toEqual(hashedUnsupportedEntry);
3134
});
3235
});
3336

@@ -74,7 +77,7 @@ test('discards invalid data and entries from another namespace', async () => {
7477
JSON.stringify({
7578
version: fmtCacheVersion,
7679
namespace,
77-
files: { 'src/a.ts': ['content', 'options', 'unsupported'] },
80+
files: { 'src/a.ts': [42, 'options', 'unsupported'] },
7881
}),
7982
JSON.stringify({
8083
version: fmtCacheVersion,

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,40 @@ test('caches unsupported parser results until final options change', async () =>
155155
});
156156
});
157157

158+
test('invalidates cached unsupported parser results when content changes without an extension', async () => {
159+
await withTempProject(async (rootPath) => {
160+
const filePath = writeProjectFile(rootPath, 'script', 'plain text\n');
161+
const cache = createCache(rootPath);
162+
const file = createRequest(filePath, {});
163+
164+
const first = await run([file], 'check', cache);
165+
expect(first).toEqual({
166+
exitCode: 2,
167+
files: [],
168+
processedFileCount: 0,
169+
});
170+
expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('script')).toEqual([
171+
sha256(readFileSync(filePath)),
172+
createOptionsHasher()(file.options),
173+
'unsupported',
174+
]);
175+
176+
await expect(run([file], 'check', cache)).resolves.toEqual(first);
177+
178+
writeFileSync(filePath, '#!/usr/bin/env node\nconst value=1');
179+
await expect(run([file], 'check', cache)).resolves.toMatchObject({
180+
exitCode: 1,
181+
files: [{ path: filePath, status: 'different' }],
182+
processedFileCount: 1,
183+
});
184+
expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('script')).toEqual([
185+
sha256(readFileSync(filePath)),
186+
createOptionsHasher()(file.options),
187+
'dirty',
188+
]);
189+
});
190+
});
191+
158192
test('caches only plugins with stable fingerprints', async () => {
159193
await withTempProject(async (rootPath) => {
160194
const filePath = writeProjectFile(rootPath, 'data.fixture', '{"value":true}');

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,28 @@ test('does not start the worker pool when every parser result is cached as unsup
8383
expect(mocks.createFmtWorkerPoolCalls).toEqual([]);
8484
});
8585
});
86+
87+
test('starts the worker pool for a path-only unsupported entry without an extension', async () => {
88+
await withTempProject(async (rootPath) => {
89+
const filePath = writeProjectFile(rootPath, 'script', 'plain text');
90+
const cachePath = path.join(rootPath, 'cache', 'fmt-v1.json');
91+
const file: FmtFileRequest = { path: filePath, options: {} };
92+
const optionsHash = createOptionsHasher()(file.options);
93+
if (optionsHash === undefined) {
94+
throw new Error('Expected cacheable formatter options.');
95+
}
96+
97+
const store = await loadFmtCacheStore(cachePath, cacheNamespace);
98+
store.set('script', [null, optionsHash, 'unsupported']);
99+
await expect(store.save()).resolves.toBe(true);
100+
101+
await expect(
102+
runFmtFiles({
103+
files: [file],
104+
mode: 'check',
105+
cache: { filePath: cachePath, rootPath },
106+
}),
107+
).rejects.toThrow('worker startup failed');
108+
expect(mocks.createFmtWorkerPoolCalls).toEqual([[1, undefined]]);
109+
});
110+
});

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ 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 noExtensionPath = writeProjectFile(rootPath, 'script', source);
5152
const missingPath = path.join(rootPath, 'missing.unknown');
5253
const contentHash = sha256(source);
5354
const optionsHash = 'options';
@@ -56,6 +57,8 @@ test('returns cached states before resolving the parser', async () => {
5657
[[contentHash, optionsHash, 'clean'], filePath, false, 'unchanged'],
5758
[[contentHash, optionsHash, 'dirty'], filePath, false, 'changed'],
5859
[[contentHash, optionsHash, 'clean'], filePath, true, 'unchanged'],
60+
[[contentHash, optionsHash, 'unsupported'], noExtensionPath, false, 'unsupported'],
61+
[[contentHash, optionsHash, 'unsupported'], noExtensionPath, true, 'unsupported'],
5962
[[null, optionsHash, 'unsupported'], missingPath, false, 'unsupported'],
6063
[[null, optionsHash, 'unsupported'], missingPath, true, 'unsupported'],
6164
] as const) {
@@ -78,6 +81,29 @@ test('returns cached states before resolving the parser', async () => {
7881
});
7982
});
8083

84+
test('does not trust path-only unsupported entries for files without extensions', async () => {
85+
await withTempProject(async (rootPath) => {
86+
const filePath = writeProjectFile(rootPath, 'script', '#!/usr/bin/env node\nconst value=1');
87+
88+
await expect(
89+
formatFile({
90+
file: {
91+
path: filePath,
92+
options: {},
93+
},
94+
shouldWrite: false,
95+
cache: {
96+
entry: [null, 'options', 'unsupported'],
97+
optionsHash: 'options',
98+
},
99+
}),
100+
).resolves.toEqual({
101+
status: 'changed',
102+
cacheEntry: [sha256(readFileSync(filePath)), 'options', 'dirty'],
103+
});
104+
});
105+
});
106+
81107
test('resolves parser support before reading on a cache miss', async () => {
82108
await withTempProject(async (rootPath) => {
83109
await expect(

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. 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.
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 normally use the file path and final options. For filenames without an extension, they also use file content because Prettier may infer a parser from the shebang. 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` 调用中使用持久化缓存。格式化结果基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。由于 parser 推断不会读取文件内容,不支持的 parser 查询结果仅基于文件路径和最终选项。已安装的 Prettier 插件通过包名、版本和入口进行识别;本地插件、链接插件或缺少版本信息的插件会绕过缓存。
179+
`rs fmt` 默认会在基于文件的 `--write``--check``--list-different` 调用中使用持久化缓存。格式化结果基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。不支持的 parser 查询结果通常基于文件路径和最终选项。对于没有扩展名的文件,还会基于文件内容,因为 Prettier 可能从 shebang 推断 parser。已安装的 Prettier 插件通过包名、版本和入口进行识别;本地插件、链接插件或缺少版本信息的插件会绕过缓存。
180180

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

0 commit comments

Comments
 (0)