Skip to content

Commit 30e8fbd

Browse files
authored
feat(fmt): cache check and list results (#225)
1 parent 279b050 commit 30e8fbd

6 files changed

Lines changed: 354 additions & 40 deletions

File tree

packages/rstack/src/fmt/runner.ts

Lines changed: 79 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
import { cacheNamespace, createCacheKeyResolver, createOptionsHasher } from './cacheIdentity.ts';
2+
import { loadFmtCacheStore } from './cacheStore.ts';
3+
import type { FmtCacheEntry, FmtCacheStore } from './cacheStore.ts';
14
import type {
5+
FmtFileCache,
26
FmtExitCode,
37
FmtFileRequest,
48
FmtFileResult,
@@ -11,6 +15,18 @@ import type { FmtWorkerPool } from './workerPool.ts';
1115
type FormatFile = FmtWorkerPool['formatFile'];
1216
type FmtFileOutcome = FmtFileResult | 'unchanged' | 'unsupported';
1317

18+
interface FmtFileRun {
19+
outcome: FmtFileOutcome;
20+
key?: string;
21+
entry?: FmtCacheEntry;
22+
}
23+
24+
interface RunCache {
25+
store: FmtCacheStore;
26+
resolveKey: ReturnType<typeof createCacheKeyResolver>;
27+
hashOptions: ReturnType<typeof createOptionsHasher>;
28+
}
29+
1430
interface FmtWorkerPoolResult {
1531
files: FmtFileResult[];
1632
processedFileCount: number;
@@ -32,22 +48,47 @@ const runFmtFile = async (
3248
file: FmtFileRequest,
3349
shouldWrite: boolean,
3450
formatFile: FormatFile,
35-
): Promise<FmtFileOutcome> => {
36-
try {
37-
const result = await formatFile(file, shouldWrite);
38-
if (result === 'unchanged' || result === 'unsupported') {
39-
return result;
51+
cache?: RunCache,
52+
): Promise<FmtFileRun> => {
53+
let key: string | undefined;
54+
let fileCache: FmtFileCache | undefined;
55+
56+
if (cache) {
57+
key = cache.resolveKey(file.path);
58+
if (key !== undefined) {
59+
const optionsHash = cache.hashOptions(file.options);
60+
if (optionsHash === undefined) {
61+
key = undefined;
62+
} else {
63+
fileCache = {
64+
entry: cache.store.get(key),
65+
optionsHash,
66+
};
67+
}
4068
}
69+
}
4170

42-
return {
43-
path: file.path,
44-
status: shouldWrite ? 'written' : 'different',
45-
};
71+
try {
72+
const result = await formatFile(file, shouldWrite, fileCache);
73+
const outcome: FmtFileOutcome =
74+
result.status === 'changed'
75+
? {
76+
path: file.path,
77+
status: shouldWrite ? 'written' : 'different',
78+
}
79+
: result.status;
80+
81+
if (key !== undefined && result.cacheEntry) {
82+
return { outcome, key, entry: result.cacheEntry };
83+
}
84+
return { outcome };
4685
} catch (error) {
4786
return {
48-
path: file.path,
49-
status: 'error',
50-
error,
87+
outcome: {
88+
path: file.path,
89+
status: 'error',
90+
error,
91+
},
5192
};
5293
}
5394
};
@@ -57,7 +98,8 @@ const runPriorityFmtFiles = async (
5798
files: FmtFileRequest[],
5899
shouldWrite: boolean,
59100
formatFile: FormatFile,
60-
): Promise<FmtFileOutcome[]> => {
101+
cache?: RunCache,
102+
): Promise<FmtFileRun[]> => {
61103
const priority: number[] = [];
62104
const rest: number[] = [];
63105

@@ -67,9 +109,9 @@ const runPriorityFmtFiles = async (
67109

68110
const order = priority.concat(rest);
69111
const outcomes = await Promise.all(
70-
order.map((index) => runFmtFile(files[index], shouldWrite, formatFile)),
112+
order.map((index) => runFmtFile(files[index], shouldWrite, formatFile, cache)),
71113
);
72-
const results = new Array<FmtFileOutcome>(files.length);
114+
const results = new Array<FmtFileRun>(files.length);
73115
for (let index = 0; index < order.length; index++) {
74116
results[order[index]] = outcomes[index];
75117
}
@@ -81,28 +123,32 @@ const runFmtFilesInWorkerPool = async (
81123
files: FmtFileRequest[],
82124
shouldWrite: boolean,
83125
maxWorkers?: number,
126+
cache?: RunCache,
84127
): Promise<FmtWorkerPoolResult> => {
85128
const { createFmtWorkerPool } = await import('./workerPool.ts');
86129
const workerPool = await createFmtWorkerPool(files.length, maxWorkers);
87130

88131
try {
89132
const results =
90133
workerPool.workerCount >= minPriorityWorkers
91-
? await runPriorityFmtFiles(files, shouldWrite, workerPool.formatFile)
134+
? await runPriorityFmtFiles(files, shouldWrite, workerPool.formatFile, cache)
92135
: await Promise.all(
93-
files.map((file) => runFmtFile(file, shouldWrite, workerPool.formatFile)),
136+
files.map((file) => runFmtFile(file, shouldWrite, workerPool.formatFile, cache)),
94137
);
95138
const processedFiles: FmtFileResult[] = [];
96139
let processedFileCount = 0;
97140

98-
for (const result of results) {
99-
if (result === 'unsupported') {
141+
for (const { outcome, key, entry } of results) {
142+
if (key !== undefined && entry) {
143+
cache?.store.set(key, entry);
144+
}
145+
if (outcome === 'unsupported') {
100146
continue;
101147
}
102148

103149
processedFileCount++;
104-
if (result !== 'unchanged') {
105-
processedFiles.push(result);
150+
if (outcome !== 'unchanged') {
151+
processedFiles.push(outcome);
106152
}
107153
}
108154

@@ -133,12 +179,23 @@ const runFmtFiles = async ({
133179
files,
134180
mode,
135181
maxWorkers,
182+
cache,
136183
}: RunFmtFilesOptions): Promise<FmtRunResult> => {
137184
const shouldWrite = mode === 'write';
185+
let runCache: RunCache | undefined;
186+
if (files.length > 0 && cache && !shouldWrite) {
187+
runCache = {
188+
store: await loadFmtCacheStore(cache.filePath, cacheNamespace),
189+
resolveKey: createCacheKeyResolver(cache.rootPath),
190+
hashOptions: createOptionsHasher(),
191+
};
192+
}
193+
138194
const result =
139195
files.length === 0
140196
? { files: [], processedFileCount: 0 }
141-
: await runFmtFilesInWorkerPool(files, shouldWrite, maxWorkers);
197+
: await runFmtFilesInWorkerPool(files, shouldWrite, maxWorkers, runCache);
198+
await runCache?.store.save().catch(() => false);
142199

143200
return {
144201
...result,

packages/rstack/src/fmt/types.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Config as PrettierConfig, Options as PrettierOptions } from 'prettier';
2+
import type { FmtCacheEntry } from './cacheStore.ts';
23

34
/** Plugin objects cannot cross worker boundaries and are not planned for support. */
45
type FmtPluginSpecifier = string | URL;
@@ -71,6 +72,23 @@ interface FmtFileRequest {
7172
options: ResolvedFmtOptions;
7273
}
7374

75+
interface FmtCacheContext {
76+
/** Persistent cache file to load and update. */
77+
filePath: string;
78+
/** Root used to create portable per-file cache keys. */
79+
rootPath: string;
80+
}
81+
82+
interface FmtFileCache {
83+
entry: FmtCacheEntry | undefined;
84+
optionsHash: string;
85+
}
86+
87+
interface FmtWorkerResult {
88+
status: 'changed' | 'unchanged' | 'unsupported';
89+
cacheEntry?: FmtCacheEntry;
90+
}
91+
7492
type FmtMode = 'write' | 'check' | 'list-different';
7593
type FmtExitCode = 0 | 1 | 2;
7694

@@ -81,6 +99,8 @@ interface RunFmtFilesOptions {
8199
mode: FmtMode;
82100
/** Maximum number of formatting workers. */
83101
maxWorkers?: number;
102+
/** Internal persistent cache context. Currently used only by check and list modes. */
103+
cache?: FmtCacheContext;
84104
}
85105

86106
interface SuccessfulFmtFileResult {
@@ -106,14 +126,17 @@ interface FmtRunResult {
106126

107127
export type {
108128
DiscoverFmtFilesOptions,
129+
FmtCacheContext,
109130
FmtConfig,
110131
FmtConfigDefinition,
111132
FmtExitCode,
112133
FmtFileResult,
113134
FmtFileRequest,
135+
FmtFileCache,
114136
FmtMode,
115137
FmtPluginSpecifier,
116138
FmtRunResult,
139+
FmtWorkerResult,
117140
ResolvedFmtConfig,
118141
ResolvedFmtOptions,
119142
RunFmtFilesOptions,

packages/rstack/src/fmt/worker.ts

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,64 @@
11
// Derived from @prettier/cli, see THIRD_PARTY_NOTICES.md
22

3+
import { createHash } from 'node:crypto';
34
import { readFileSync, writeFileSync } from 'node:fs';
4-
import { formatFmtSource } from './format.ts';
5-
import type { FmtFileRequest } from './types.ts';
6-
7-
type FormatFileResult = 'changed' | 'unchanged' | 'unsupported';
5+
import type { FmtCacheEntry } from './cacheStore.ts';
6+
import type { FmtFileCache, FmtFileRequest, FmtWorkerResult } from './types.ts';
87

98
interface FormatFileTask {
109
file: FmtFileRequest;
1110
shouldWrite: boolean;
11+
cache?: FmtFileCache;
1212
}
1313

14+
const hashContent = (content: Uint8Array): string =>
15+
createHash('sha256').update(content).digest('hex');
16+
1417
/**
1518
* Use synchronous direct I/O inside the dedicated worker to avoid libuv
1619
* scheduling overhead. This prioritizes throughput over crash-safe replacement.
1720
*/
18-
const formatFile = async ({ file, shouldWrite }: FormatFileTask): Promise<FormatFileResult> => {
19-
const result = await formatFmtSource(file, () => readFileSync(file.path, 'utf8'));
21+
const formatFile = async ({
22+
file,
23+
shouldWrite,
24+
cache,
25+
}: FormatFileTask): Promise<FmtWorkerResult> => {
26+
let source: string | undefined;
27+
let contentHash: string | undefined;
28+
29+
if (cache && !shouldWrite) {
30+
const content = readFileSync(file.path);
31+
contentHash = hashContent(content);
32+
source = content.toString('utf8');
33+
34+
const { entry, optionsHash } = cache;
35+
if (entry?.[0] === contentHash && entry[1] === optionsHash) {
36+
return { status: entry[2] === 'clean' ? 'unchanged' : 'changed' };
37+
}
38+
}
39+
40+
const { formatFmtSource } = await import('./format.ts');
41+
const result = await formatFmtSource(file, () => (source ??= readFileSync(file.path, 'utf8')));
2042
if (result.status === 'unsupported') {
21-
return 'unsupported';
43+
return { status: 'unsupported' };
2244
}
2345

24-
const { source, formatted } = result;
25-
if (source === formatted) {
26-
return 'unchanged';
46+
const unchanged = result.source === result.formatted;
47+
48+
if (!unchanged && shouldWrite) {
49+
writeFileSync(file.path, result.formatted, 'utf8');
2750
}
2851

29-
if (shouldWrite) {
30-
writeFileSync(file.path, formatted, 'utf8');
52+
const status = unchanged ? 'unchanged' : 'changed';
53+
if (!cache || contentHash === undefined) {
54+
return { status };
3155
}
3256

33-
return 'changed';
57+
const cacheEntry: FmtCacheEntry = [contentHash, cache.optionsHash, unchanged ? 'clean' : 'dirty'];
58+
return { status, cacheEntry };
3459
};
3560

36-
/** Confirms that the worker module and its runtime dependencies are ready. */
61+
/** Confirms that the worker module is ready. Formatter dependencies load only on a cache miss. */
3762
const initializeFmtWorker = (): true => true;
3863

3964
export { formatFile, initializeFmtWorker };

packages/rstack/src/fmt/workerPool.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { availableParallelism } from 'node:os';
44
import Tinypool from 'tinypool';
5-
import type { FmtFileRequest } from './types.ts';
5+
import type { FmtFileCache, FmtFileRequest } from './types.ts';
66

77
type FmtWorkerMethods = typeof import('./worker.ts');
88

@@ -11,6 +11,7 @@ interface FmtWorkerPool {
1111
formatFile: (
1212
file: FmtFileRequest,
1313
shouldWrite: boolean,
14+
cache?: FmtFileCache,
1415
) => ReturnType<FmtWorkerMethods['formatFile']>;
1516
terminate: () => Promise<void>;
1617
}
@@ -57,7 +58,8 @@ const createFmtWorkerPool = async (
5758

5859
return {
5960
workerCount,
60-
formatFile: (file, shouldWrite) => pool.run({ file, shouldWrite }, { name: 'formatFile' }),
61+
formatFile: (file, shouldWrite, cache) =>
62+
pool.run({ file, shouldWrite, cache }, { name: 'formatFile' }),
6163
terminate: () => pool.destroy(),
6264
};
6365
};

0 commit comments

Comments
 (0)