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
10 changes: 7 additions & 3 deletions packages/rstack/src/fmt/cacheStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const fmtCacheVersion = 1;
type FmtCacheState = 'clean' | 'dirty' | 'unsupported';
type FmtCacheEntry =
| readonly [contentHash: string, optionsHash: string, state: 'clean' | 'dirty']
| readonly [contentHash: null, optionsHash: string, state: 'unsupported'];
| readonly [contentHash: string | null, optionsHash: string, state: 'unsupported'];

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

if (value[2] === 'unsupported') {
return value[0] === null ? [null, value[1], value[2]] : undefined;
return value[0] === null || typeof value[0] === 'string'
? [value[0], value[1], value[2]]
: undefined;
}
if (typeof value[0] !== 'string' || (value[2] !== 'clean' && value[2] !== 'dirty')) {
return;
Expand Down Expand Up @@ -124,7 +126,9 @@ class FmtCacheStoreImpl implements FmtCacheStore {
}

this.#cache.files[filePath] =
entry[2] === 'unsupported' ? [null, entry[1], entry[2]] : [entry[0], entry[1], entry[2]];
entry[2] === 'unsupported'
? [entry[0], entry[1], 'unsupported']
: [entry[0], entry[1], entry[2]];
this.#changed = true;
}

Expand Down
5 changes: 4 additions & 1 deletion packages/rstack/src/fmt/pathHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,8 @@ const createRelativePathResolver = (rootPath: string): RelativePathResolver => {
: path.relative(rootPath, filePath);
};

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

export { createRelativePathResolver, hasDottedBasename, toPosixPath };
export type { RelativePathResolver };
10 changes: 8 additions & 2 deletions packages/rstack/src/fmt/runner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { cacheNamespace, createCacheKeyResolver, createOptionsHasher } from './cacheIdentity.ts';
import { loadFmtCacheStore } from './cacheStore.ts';
import type { FmtCacheEntry, FmtCacheStore } from './cacheStore.ts';
import { hasDottedBasename } from './pathHelpers.ts';
import type {
FmtFileCache,
FmtExitCode,
Expand Down Expand Up @@ -110,11 +111,16 @@ const createFmtFileRunTask = (file: FmtFileRequest, cache?: RunCache): FmtFileRu
return { file, key, cache: fileCache };
};

const isCachedUnsupported = ({ cache }: FmtFileRunTask): boolean => {
const isCachedUnsupported = ({ file, cache }: FmtFileRunTask): boolean => {
if (!cache?.entry) {
return false;
}
return cache.entry[1] === cache.optionsHash && cache.entry[2] === 'unsupported';
return (
cache.entry[0] === null &&
cache.entry[1] === cache.optionsHash &&
cache.entry[2] === 'unsupported' &&
hasDottedBasename(file.path)
);
};

/** Converts a formatter outcome into the shared per-file result. */
Expand Down
33 changes: 25 additions & 8 deletions packages/rstack/src/fmt/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import type { FmtCacheEntry } from './cacheStore.ts';
import { hasDottedBasename } from './pathHelpers.ts';
import type { FmtFileCache, FmtFileRequest, FmtWorkerResult } from './types.ts';

interface FormatFileTask {
Expand Down Expand Up @@ -44,13 +45,23 @@ const formatFile = async ({
if (cache?.entry && cache.entry[1] === cache.optionsHash) {
const { entry } = cache;
if (entry[2] === 'unsupported') {
return { status: 'unsupported' };
}

sourceBuffer = readFileSync(file.path);
contentHash = hashContent(sourceBuffer);
if (entry[0] === contentHash && (!shouldWrite || entry[2] === 'clean')) {
return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' };
if (entry[0] === null) {
if (hasDottedBasename(file.path)) {
return { status: 'unsupported' };
}
} else {
sourceBuffer = readFileSync(file.path);
contentHash = hashContent(sourceBuffer);
if (entry[0] === contentHash) {
return { status: 'unsupported' };
}
}
} else {
sourceBuffer = readFileSync(file.path);
contentHash = hashContent(sourceBuffer);
if (entry[0] === contentHash && (!shouldWrite || entry[2] === 'clean')) {
return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' };
}
}
}

Expand All @@ -60,7 +71,13 @@ const formatFile = async ({
return cache
? {
status: 'unsupported',
cacheEntry: [null, cache.optionsHash, 'unsupported'],
cacheEntry: [
hasDottedBasename(file.path)
? null
: (contentHash ?? hashContent(sourceBuffer ?? readFileSync(file.path))),
cache.optionsHash,
'unsupported',
],
}
: { status: 'unsupported' };
}
Expand Down
5 changes: 4 additions & 1 deletion packages/rstack/tests/fmt/cacheStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const namespace = 'test-namespace';
const firstEntry = ['content-a', 'options-a', 'clean'] as const;
const secondEntry = ['content-b', 'options-b', 'dirty'] as const;
const unsupportedEntry = [null, 'options-c', 'unsupported'] as const;
const hashedUnsupportedEntry = ['content-c', 'options-c', 'unsupported'] as const;

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

store.set('src/a.ts', firstEntry);
store.set('src/unknown.fixture', unsupportedEntry);
store.set('script', hashedUnsupportedEntry);
expect(await store.save()).toBe(true);
expect(await store.save()).toBe(false);

const loaded = await loadFmtCacheStore(cachePath, namespace);
expect(loaded.get('src/a.ts')).toEqual(firstEntry);
expect(loaded.get('src/unknown.fixture')).toEqual(unsupportedEntry);
expect(loaded.get('script')).toEqual(hashedUnsupportedEntry);
});
});

Expand Down Expand Up @@ -74,7 +77,7 @@ test('discards invalid data and entries from another namespace', async () => {
JSON.stringify({
version: fmtCacheVersion,
namespace,
files: { 'src/a.ts': ['content', 'options', 'unsupported'] },
files: { 'src/a.ts': [42, 'options', 'unsupported'] },
}),
JSON.stringify({
version: fmtCacheVersion,
Expand Down
34 changes: 34 additions & 0 deletions packages/rstack/tests/fmt/runnerCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,40 @@ test('caches unsupported parser results until final options change', async () =>
});
});

test('invalidates cached unsupported parser results when content changes without an extension', async () => {
await withTempProject(async (rootPath) => {
const filePath = writeProjectFile(rootPath, 'script', 'plain text\n');
const cache = createCache(rootPath);
const file = createRequest(filePath, {});

const first = await run([file], 'check', cache);
expect(first).toEqual({
exitCode: 2,
files: [],
processedFileCount: 0,
});
expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('script')).toEqual([
sha256(readFileSync(filePath)),
createOptionsHasher()(file.options),
'unsupported',
]);

await expect(run([file], 'check', cache)).resolves.toEqual(first);

writeFileSync(filePath, '#!/usr/bin/env node\nconst value=1');
await expect(run([file], 'check', cache)).resolves.toMatchObject({
exitCode: 1,
files: [{ path: filePath, status: 'different' }],
processedFileCount: 1,
});
expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('script')).toEqual([
sha256(readFileSync(filePath)),
createOptionsHasher()(file.options),
'dirty',
]);
});
});

test('caches only plugins with stable fingerprints', async () => {
await withTempProject(async (rootPath) => {
const filePath = writeProjectFile(rootPath, 'data.fixture', '{"value":true}');
Expand Down
25 changes: 25 additions & 0 deletions packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,28 @@ test('does not start the worker pool when every parser result is cached as unsup
expect(mocks.createFmtWorkerPoolCalls).toEqual([]);
});
});

test('starts the worker pool for a path-only unsupported entry without an extension', async () => {
await withTempProject(async (rootPath) => {
const filePath = writeProjectFile(rootPath, 'script', 'plain text');
const cachePath = path.join(rootPath, 'cache', 'fmt-v1.json');
const file: FmtFileRequest = { path: filePath, options: {} };
const optionsHash = createOptionsHasher()(file.options);
if (optionsHash === undefined) {
throw new Error('Expected cacheable formatter options.');
}

const store = await loadFmtCacheStore(cachePath, cacheNamespace);
store.set('script', [null, optionsHash, 'unsupported']);
await expect(store.save()).resolves.toBe(true);

await expect(
runFmtFiles({
files: [file],
mode: 'check',
cache: { filePath: cachePath, rootPath },
}),
).rejects.toThrow('worker startup failed');
expect(mocks.createFmtWorkerPoolCalls).toEqual([[1, undefined]]);
});
});
26 changes: 26 additions & 0 deletions packages/rstack/tests/fmt/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ test('returns cached states before resolving the parser', async () => {
await withTempProject(async (rootPath) => {
const source = 'const value=1';
const filePath = writeProjectFile(rootPath, 'example.ts', source);
const noExtensionPath = writeProjectFile(rootPath, 'script', source);
const missingPath = path.join(rootPath, 'missing.unknown');
const contentHash = sha256(source);
const optionsHash = 'options';
Expand All @@ -56,6 +57,8 @@ test('returns cached states before resolving the parser', async () => {
[[contentHash, optionsHash, 'clean'], filePath, false, 'unchanged'],
[[contentHash, optionsHash, 'dirty'], filePath, false, 'changed'],
[[contentHash, optionsHash, 'clean'], filePath, true, 'unchanged'],
[[contentHash, optionsHash, 'unsupported'], noExtensionPath, false, 'unsupported'],
[[contentHash, optionsHash, 'unsupported'], noExtensionPath, true, 'unsupported'],
[[null, optionsHash, 'unsupported'], missingPath, false, 'unsupported'],
[[null, optionsHash, 'unsupported'], missingPath, true, 'unsupported'],
] as const) {
Expand All @@ -78,6 +81,29 @@ test('returns cached states before resolving the parser', async () => {
});
});

test('does not trust path-only unsupported entries for files without extensions', async () => {
await withTempProject(async (rootPath) => {
const filePath = writeProjectFile(rootPath, 'script', '#!/usr/bin/env node\nconst value=1');

await expect(
formatFile({
file: {
path: filePath,
options: {},
},
shouldWrite: false,
cache: {
entry: [null, 'options', 'unsupported'],
optionsHash: 'options',
},
}),
).resolves.toEqual({
status: 'changed',
cacheEntry: [sha256(readFileSync(filePath)), 'options', 'dirty'],
});
});
});

test('resolves parser support before reading on a cache miss', async () => {
await withTempProject(async (rootPath) => {
await expect(
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. 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.
`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.

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

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

Expand Down