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
24 changes: 14 additions & 10 deletions packages/rstack/src/fmt/cacheStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import path from 'node:path';
const fmtCacheFileName = 'v1.json';
const fmtCacheVersion = 1;

type FmtCacheState = 'clean' | 'dirty';
type FmtCacheEntry = readonly [contentHash: string, optionsHash: string, state: FmtCacheState];
type FmtCacheState = 'clean' | 'dirty' | 'unsupported';
type FmtCacheEntry =
| readonly [contentHash: string, optionsHash: string, state: 'clean' | 'dirty']
| readonly [contentHash: null, optionsHash: string, state: 'unsupported'];

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

const parseCacheEntry = (value: unknown): FmtCacheEntry | undefined => {
if (
!Array.isArray(value) ||
value.length !== 3 ||
typeof value[0] !== 'string' ||
typeof value[1] !== 'string' ||
(value[2] !== 'clean' && value[2] !== 'dirty')
) {
if (!Array.isArray(value) || value.length !== 3 || typeof value[1] !== 'string') {
return;
}

if (value[2] === 'unsupported') {
return value[0] === null ? [null, value[1], value[2]] : undefined;
}
if (typeof value[0] !== 'string' || (value[2] !== 'clean' && value[2] !== 'dirty')) {
return;
}

Expand Down Expand Up @@ -120,7 +123,8 @@ class FmtCacheStoreImpl implements FmtCacheStore {
return;
}

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

Expand Down
64 changes: 47 additions & 17 deletions packages/rstack/src/fmt/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ interface FmtFileRun {
entry?: FmtCacheEntry;
}

interface FmtFileRunTask {
file: FmtFileRequest;
key?: string;
cache?: FmtFileCache;
}

interface RunCache {
store: FmtCacheStore;
resolveKey: ReturnType<typeof createCacheKeyResolver>;
Expand Down Expand Up @@ -81,13 +87,8 @@ const loadPluginFingerprints = async (
return fingerprints;
};

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

Expand All @@ -106,8 +107,29 @@ const runFmtFile = async (
}
}

return { file, key, cache: fileCache };
};

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

/** Converts a formatter outcome into the shared per-file result. */
const runFmtFile = async (
task: FmtFileRunTask,
shouldWrite: boolean,
formatFile: FormatFile,
): Promise<FmtFileRun> => {
if (isCachedUnsupported(task)) {
return { outcome: 'unsupported' };
}

const { file, key, cache } = task;
try {
const result = await formatFile(file, shouldWrite, fileCache);
const result = await formatFile(file, shouldWrite, cache);
const outcome: FmtFileOutcome =
result.status === 'changed'
? {
Expand All @@ -133,23 +155,22 @@ const runFmtFile = async (

/** Starts slower Markdown parsers first while preserving order within both priority groups. */
const runPriorityFmtFiles = async (
files: FmtFileRequest[],
tasks: FmtFileRunTask[],
shouldWrite: boolean,
formatFile: FormatFile,
cache?: RunCache,
): Promise<FmtFileRun[]> => {
const priority: number[] = [];
const rest: number[] = [];

for (let index = 0; index < files.length; index++) {
(isMarkdown(files[index]) ? priority : rest).push(index);
for (let index = 0; index < tasks.length; index++) {
(isMarkdown(tasks[index].file) ? priority : rest).push(index);
}

const order = priority.concat(rest);
const outcomes = await Promise.all(
order.map((index) => runFmtFile(files[index], shouldWrite, formatFile, cache)),
order.map((index) => runFmtFile(tasks[index], shouldWrite, formatFile)),
);
const results = new Array<FmtFileRun>(files.length);
const results = new Array<FmtFileRun>(tasks.length);
for (let index = 0; index < order.length; index++) {
results[order[index]] = outcomes[index];
}
Expand All @@ -163,15 +184,24 @@ const runFmtFilesInWorkerPool = async (
maxWorkers?: number,
cache?: RunCache,
): Promise<FmtWorkerPoolResult> => {
const tasks = files.map((file) => createFmtFileRunTask(file, cache));
const pendingFileCount = tasks.reduce(
(count, task) => count + (isCachedUnsupported(task) ? 0 : 1),
0,
);
if (pendingFileCount === 0) {
return { files: [], processedFileCount: 0 };
}

const { createFmtWorkerPool } = await import('./workerPool.ts');
const workerPool = await createFmtWorkerPool(files.length, maxWorkers);
const workerPool = await createFmtWorkerPool(pendingFileCount, maxWorkers);

try {
const results =
workerPool.workerCount >= minPriorityWorkers
? await runPriorityFmtFiles(files, shouldWrite, workerPool.formatFile, cache)
? await runPriorityFmtFiles(tasks, shouldWrite, workerPool.formatFile)
: await Promise.all(
files.map((file) => runFmtFile(file, shouldWrite, workerPool.formatFile, cache)),
tasks.map((task) => runFmtFile(task, shouldWrite, workerPool.formatFile)),
);
const processedFiles: FmtFileResult[] = [];
let processedFileCount = 0;
Expand Down
13 changes: 11 additions & 2 deletions packages/rstack/src/fmt/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,13 @@ 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);
const { entry } = cache;
if (entry[0] === contentHash && (!shouldWrite || entry[2] === 'clean')) {
return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' };
}
Expand All @@ -53,7 +57,12 @@ const formatFile = async ({
const { formatFmtSource } = await import('./format.ts');
const result = await formatFmtSource(file, () => (source ??= readSource()));
if (result.status === 'unsupported') {
return { status: 'unsupported' };
return cache
? {
status: 'unsupported',
cacheEntry: [null, cache.optionsHash, 'unsupported'],
Comment thread
chenjiahan marked this conversation as resolved.
}
: { status: 'unsupported' };
}

const unchanged = result.source === result.formatted;
Expand Down
13 changes: 13 additions & 0 deletions packages/rstack/tests/fmt/cacheStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { withTempProject } from './helpers.ts';
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 readCache = (filePath: string): FmtCacheFile =>
JSON.parse(readFileSync(filePath, 'utf8')) as FmtCacheFile;
Expand All @@ -20,11 +21,13 @@ test('writes entries that can be loaded by another store', async () => {
expect(existsSync(cachePath)).toBe(false);

store.set('src/a.ts', firstEntry);
store.set('src/unknown.fixture', unsupportedEntry);
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);
});
});

Expand Down Expand Up @@ -68,6 +71,16 @@ test('discards invalid data and entries from another namespace', async () => {
namespace,
files: { 'src/a.ts': ['content', 'options', 'unknown'] },
}),
JSON.stringify({
version: fmtCacheVersion,
namespace,
files: { 'src/a.ts': ['content', 'options', 'unsupported'] },
}),
JSON.stringify({
version: fmtCacheVersion,
namespace,
files: { 'src/a.ts': [null, 'options', 'clean'] },
}),
];

for (const content of invalidContents) {
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 @@ -121,6 +121,40 @@ test('invalidates entries when final options change', async () => {
});
});

test('caches unsupported parser results until final options change', async () => {
await withTempProject(async (rootPath) => {
const filePath = writeProjectFile(rootPath, 'data.unknown', '{"value":true}');
const cache = createCache(rootPath);
const unsupported = createRequest(filePath, {});

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

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

const supported = createRequest(filePath, { parser: 'json' });
await expect(run([supported], 'check', cache)).resolves.toMatchObject({
exitCode: 1,
files: [{ path: filePath, status: 'different' }],
processedFileCount: 1,
});
expect((await loadFmtCacheStore(cache.filePath, cacheNamespace)).get('data.unknown')).toEqual([
sha256(readFileSync(filePath)),
createOptionsHasher()(supported.options),
'dirty',
]);
});
});

test('caches only plugins with stable fingerprints', async () => {
await withTempProject(async (rootPath) => {
const filePath = writeProjectFile(rootPath, 'data.fixture', '{"value":true}');
Expand Down
32 changes: 32 additions & 0 deletions packages/rstack/tests/fmt/runnerWorkerPreflight.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { beforeEach, expect, rs, test } from 'rstack/test';
import { cacheNamespace, createOptionsHasher } from '../../src/fmt/cacheIdentity.ts';
import { loadFmtCacheStore } from '../../src/fmt/cacheStore.ts';
import { runFmtFiles } from '../../src/fmt/runner.ts';
import type { FmtFileRequest } from '../../src/fmt/types.ts';
import { withTempProject, writeProjectFile } from './helpers.ts';
Expand Down Expand Up @@ -51,3 +54,32 @@ test('does not start the worker pool when there are no files', async () => {
});
expect(mocks.createFmtWorkerPoolCalls).toEqual([]);
});

test('does not start the worker pool when every parser result is cached as unsupported', async () => {
await withTempProject(async (rootPath) => {
const filePath = writeProjectFile(rootPath, 'example.unknown', '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('example.unknown', [null, optionsHash, 'unsupported']);
await expect(store.save()).resolves.toBe(true);

await expect(
runFmtFiles({
files: [file],
mode: 'check',
cache: { filePath: cachePath, rootPath },
}),
).resolves.toEqual({
exitCode: 2,
files: [],
processedFileCount: 0,
});
expect(mocks.createFmtWorkerPoolCalls).toEqual([]);
});
});
20 changes: 13 additions & 7 deletions packages/rstack/tests/fmt/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,25 +48,28 @@ 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 missingPath = path.join(rootPath, 'missing.unknown');
const contentHash = sha256(source);
const optionsHash = 'options';

for (const [state, shouldWrite, status] of [
['clean', false, 'unchanged'],
['dirty', false, 'changed'],
['clean', true, 'unchanged'],
for (const [entry, targetPath, shouldWrite, status] of [
[[contentHash, optionsHash, 'clean'], filePath, false, 'unchanged'],
[[contentHash, optionsHash, 'dirty'], filePath, false, 'changed'],
[[contentHash, optionsHash, 'clean'], filePath, true, 'unchanged'],
[[null, optionsHash, 'unsupported'], missingPath, false, 'unsupported'],
[[null, optionsHash, 'unsupported'], missingPath, true, 'unsupported'],
] as const) {
await expect(
formatFile({
file: {
path: filePath,
path: targetPath,
options: {
parser: 'unknown-parser',
},
},
shouldWrite,
cache: {
entry: [contentHash, optionsHash, state],
entry,
optionsHash,
},
}),
Expand All @@ -89,6 +92,9 @@ test('resolves parser support before reading on a cache miss', async () => {
optionsHash: 'options',
},
}),
).resolves.toEqual({ status: 'unsupported' });
).resolves.toEqual({
status: 'unsupported',
cacheEntry: [null, 'options', 'unsupported'],
});
});
});
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. 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 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.

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

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

Expand Down