Skip to content

Commit cad4ab3

Browse files
committed
feat(fmt): enable cache by default
1 parent fabe1b1 commit cad4ab3

8 files changed

Lines changed: 170 additions & 11 deletions

File tree

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1+
import path from 'node:path';
12
import { performance } from 'node:perf_hooks';
23
import { color, logger } from 'rslog';
34
import { parseArgs } from '../cli/args.ts';
45
import { loadRstackConfig } from '../config.ts';
6+
import { ensureProjectCacheDir } from '../projectCache.ts';
7+
import { fmtCacheFileName } from './cacheStore.ts';
58
import { resolveFmtConfig } from './config.ts';
69
import { discoverFmtFiles } from './discovery.ts';
710
import { createRelativePathResolver, toPosixPath } from './pathHelpers.ts';
811
import { runFmtFiles } from './runner.ts';
912
import type { FmtMode, FmtRunResult, ResolvedFmtConfig } from './types.ts';
1013

1114
interface ParsedFmtCLIArgs {
15+
cache: boolean;
1216
mode: FmtMode;
1317
patterns: string[];
1418
ignorePaths: string[];
@@ -34,6 +38,7 @@ ${color.cyan('Options')}:
3438
-l, --list-different Print paths of unformatted files
3539
--ignore-path <path> Path to an additional ignore file (repeatable)
3640
-u, --ignore-unknown Ignore unknown files
41+
--no-cache Disable the formatting cache
3742
--no-error-on-unmatched-pattern Do not error when no files match
3843
--with-node-modules Process files inside node_modules
3944
--parallel-workers <count> Number of parallel workers
@@ -62,6 +67,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
6267
'list-different': { type: 'boolean', short: 'l' },
6368
'ignore-path': { type: 'string', multiple: true },
6469
'ignore-unknown': { type: 'boolean', short: 'u' },
70+
'no-cache': { type: 'boolean' },
6571
'no-error-on-unmatched-pattern': { type: 'boolean' },
6672
'with-node-modules': { type: 'boolean' },
6773
'parallel-workers': { type: 'string' },
@@ -81,6 +87,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
8187
}
8288

8389
const mode = check ? 'check' : listDifferent ? 'list-different' : 'write';
90+
const cache = !(values.noCache ?? false);
8491
const ignorePaths = values.ignorePath ?? [];
8592
const ignoreUnknown = values.ignoreUnknown ?? false;
8693
const noErrorOnUnmatchedPattern = values.noErrorOnUnmatchedPattern ?? false;
@@ -103,6 +110,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
103110
}
104111

105112
return {
113+
cache,
106114
mode,
107115
patterns: positionals,
108116
ignorePaths,
@@ -238,6 +246,7 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
238246
// exit code identifies "rs fmt refused to run".
239247
try {
240248
const {
249+
cache,
241250
help,
242251
ignorePaths,
243252
ignoreUnknown,
@@ -287,6 +296,17 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
287296
return;
288297
}
289298

299+
let cacheContext;
300+
if (cache) {
301+
const cacheDir = await ensureProjectCacheDir(config.rootPath);
302+
if (cacheDir.status === 'available') {
303+
cacheContext = {
304+
filePath: path.join(cacheDir.path, fmtCacheFileName),
305+
rootPath: config.rootPath,
306+
};
307+
}
308+
}
309+
290310
if (mode === 'check') {
291311
logger.start('Checking formatting...');
292312
}
@@ -295,6 +315,7 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
295315
files,
296316
mode,
297317
maxWorkers,
318+
cache: cacheContext,
298319
});
299320

300321
if (result.processedFileCount === 0) {

‎packages/rstack/tests/cli/fmt/index.test.ts‎

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { spawnSync } from 'node:child_process';
2-
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
33
import path from 'node:path';
44
import { afterEach, beforeEach, expect, test } from 'rstack/test';
55
import { RSTACK_BIN_PATH } from '#test-helpers';
@@ -33,19 +33,19 @@ const writeFixturePlugin = (): void => {
3333
);
3434
};
3535

36-
const runCLI = (args: string[], input?: string) => {
36+
const runCLI = (args: string[], input?: string, cwd = projectPath) => {
3737
const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: '1' };
3838
delete env.FORCE_COLOR;
3939

4040
return spawnSync(process.execPath, [RSTACK_BIN_PATH, ...args], {
41-
cwd: projectPath,
41+
cwd,
4242
encoding: 'utf8',
4343
env,
4444
input,
4545
});
4646
};
4747

48-
const runFmt = (args: string[] = []) => runCLI(['fmt', ...args]);
48+
const runFmt = (args: string[] = [], cwd = projectPath) => runCLI(['fmt', ...args], undefined, cwd);
4949

5050
const runFmtStdin = (args: string[], input: string) => runCLI(['fmt', ...args], input);
5151

@@ -164,6 +164,83 @@ test('summarizes write mode when no files change', () => {
164164
expect(result.stderr).toBe('');
165165
});
166166

167+
test.each([
168+
['write', []],
169+
['check', ['--check']],
170+
['list-different', ['--list-different']],
171+
] as const)('uses the default cache in %s mode', (_, args) => {
172+
writeProjectFile('index.ts', 'const value = 1;\n');
173+
174+
const result = runFmt([...args, 'index.ts']);
175+
176+
expect(result.status).toBe(0);
177+
expect(readProjectFile('.rstack/cache/.gitignore')).toBe('*\n');
178+
expect(JSON.parse(readProjectFile('.rstack/cache/fmt-v1.json'))).toMatchObject({
179+
version: 1,
180+
files: {
181+
'index.ts': [expect.any(String), expect.any(String), 'clean'],
182+
},
183+
});
184+
});
185+
186+
test('--no-cache bypasses cache reads and writes', () => {
187+
writeProjectFile('index.ts', 'const value=1');
188+
189+
const first = runFmt(['--no-cache', 'index.ts']);
190+
191+
expect(first.status).toBe(0);
192+
expect(existsSync(path.join(projectPath, '.rstack'))).toBe(false);
193+
194+
writeProjectFile('.rstack/cache/fmt-v1.json', 'stale');
195+
writeProjectFile('index.ts', 'const value=2');
196+
const second = runFmt(['--no-cache', 'index.ts']);
197+
198+
expect(second.status).toBe(0);
199+
expect(readProjectFile('index.ts')).toBe('const value = 2;\n');
200+
expect(readProjectFile('.rstack/cache/fmt-v1.json')).toBe('stale');
201+
expect(existsSync(path.join(projectPath, '.rstack/cache/.gitignore'))).toBe(false);
202+
});
203+
204+
test('uses an explicit config root cache from a subdirectory', () => {
205+
const appPath = path.join(projectPath, 'packages/app');
206+
writeProjectFile('packages/app/index.ts', 'const value=1');
207+
208+
const result = runFmt(['index.ts', '--config', '../../rstack.config.ts'], appPath);
209+
210+
expect(result.status).toBe(0);
211+
expect(readProjectFile('packages/app/index.ts')).toBe('const value = 1;\n');
212+
expect(existsSync(path.join(projectPath, '.rstack/cache/fmt-v1.json'))).toBe(true);
213+
expect(existsSync(path.join(appPath, '.rstack'))).toBe(false);
214+
expect(JSON.parse(readProjectFile('.rstack/cache/fmt-v1.json'))).toMatchObject({
215+
files: {
216+
'packages/app/index.ts': [expect.any(String), expect.any(String), 'clean'],
217+
},
218+
});
219+
});
220+
221+
test('recovers from a corrupted cache', () => {
222+
writeProjectFile('index.ts', 'const value = 1;\n');
223+
const first = runFmt(['--check', 'index.ts']);
224+
writeProjectFile('.rstack/cache/fmt-v1.json', '{');
225+
226+
const second = runFmt(['--check', 'index.ts']);
227+
228+
expect(second.status).toBe(0);
229+
expect(normalizeDuration(second.stdout)).toBe(normalizeDuration(first.stdout));
230+
expect(second.stderr).toBe(first.stderr);
231+
expect(JSON.parse(readProjectFile('.rstack/cache/fmt-v1.json'))).toMatchObject({ version: 1 });
232+
});
233+
234+
test('formats without a writable cache directory', () => {
235+
writeProjectFile('.rstack', 'not a directory');
236+
writeProjectFile('index.ts', 'const value=1');
237+
238+
const result = runFmt(['index.ts']);
239+
240+
expect(result.status).toBe(0);
241+
expect(readProjectFile('index.ts')).toBe('const value = 1;\n');
242+
});
243+
167244
test('does not sort package.json by default', () => {
168245
writeProjectFile('package.json', packageJsonSource);
169246

@@ -461,6 +538,7 @@ test('formats stdin for the given filepath', () => {
461538
expect(result.status).toBe(0);
462539
expect(result.stdout).toBe('const message = "hello";\n');
463540
expect(result.stderr).toBe('');
541+
expect(existsSync(path.join(projectPath, '.rstack'))).toBe(false);
464542
});
465543

466544
test('applies define.fmt options and overrides to stdin', () => {
@@ -622,6 +700,7 @@ test('returns exit code 2 when no files match', () => {
622700
);
623701
expect(result.stderr).not.toContain('\n at ');
624702
}
703+
expect(existsSync(path.join(projectPath, '.rstack'))).toBe(false);
625704
});
626705

627706
test('allows no files to match with --no-error-on-unmatched-pattern', () => {

‎packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Options:
1212
-l, --list-different Print paths of unformatted files
1313
--ignore-path <path> Path to an additional ignore file (repeatable)
1414
-u, --ignore-unknown Ignore unknown files
15+
--no-cache Disable the formatting cache
1516
--no-error-on-unmatched-pattern Do not error when no files match
1617
--with-node-modules Process files inside node_modules
1718
--parallel-workers <count> Number of parallel workers

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ test.each([
1919

2020
test('uses write mode by default', () => {
2121
expect(parseFmtCLIArgs([])).toEqual({
22+
cache: true,
2223
mode: 'write',
2324
patterns: [],
2425
ignorePaths: [],
@@ -38,6 +39,7 @@ test.each([
3839
['--list-different', 'list-different'],
3940
] as const)('parses %s mode', (option, mode) => {
4041
expect(parseFmtCLIArgs([option])).toEqual({
42+
cache: true,
4143
mode,
4244
patterns: [],
4345
ignorePaths: [],
@@ -51,6 +53,7 @@ test.each([
5153

5254
test('configures parallel worker count', () => {
5355
expect(parseFmtCLIArgs(['--parallel-workers', '3'])).toEqual({
56+
cache: true,
5457
mode: 'write',
5558
patterns: [],
5659
ignorePaths: [],
@@ -75,6 +78,7 @@ test('preserves file paths and globs', () => {
7578
const patterns = ['src/file with spaces.ts', 'src/**/*.{js,ts}', '!src/generated/**'];
7679

7780
expect(parseFmtCLIArgs([patterns[0], '--check', ...patterns.slice(1)])).toEqual({
81+
cache: true,
7882
mode: 'check',
7983
patterns,
8084
ignorePaths: [],
@@ -88,6 +92,7 @@ test('preserves file paths and globs', () => {
8892

8993
test('treats arguments after the terminator as paths', () => {
9094
expect(parseFmtCLIArgs(['--check', '--', '--write', '--help'])).toEqual({
95+
cache: true,
9196
mode: 'check',
9297
patterns: ['--write', '--help'],
9398
ignorePaths: [],
@@ -118,12 +123,17 @@ test.each(['-u', '--ignore-unknown', '--ignoreUnknown'])('parses %s', (option) =
118123
expect(parseFmtCLIArgs([option]).ignoreUnknown).toBe(true);
119124
});
120125

126+
test('parses --no-cache', () => {
127+
expect(parseFmtCLIArgs(['--no-cache']).cache).toBe(false);
128+
});
129+
121130
test('parses --with-node-modules', () => {
122131
expect(parseFmtCLIArgs(['--with-node-modules']).withNodeModules).toBe(true);
123132
});
124133

125134
test('parses --stdin-filepath', () => {
126135
expect(parseFmtCLIArgs(['--stdin-filepath', 'src/index.ts'])).toEqual({
136+
cache: true,
127137
mode: 'write',
128138
patterns: [],
129139
ignorePaths: [],
@@ -138,6 +148,7 @@ test('parses --stdin-filepath', () => {
138148

139149
test('accepts a worker count with --stdin-filepath', () => {
140150
expect(parseFmtCLIArgs(['--stdin-filepath', 'index.ts', '--parallel-workers', '2'])).toEqual({
151+
cache: true,
141152
mode: 'write',
142153
patterns: [],
143154
ignorePaths: [],
@@ -182,10 +193,3 @@ test.each([
182193
'The --write, --check, and --list-different options cannot be used together.',
183194
);
184195
});
185-
186-
test.each(['--unknown', '--no-cache', '--no-parallel'])(
187-
'rejects unsupported option %s',
188-
(option) => {
189-
expect(() => parseFmtCLIArgs([option])).toThrow();
190-
},
191-
);

‎website/docs/en/guide/cli/fmt.mdx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,18 @@ rs fmt -l
121121

122122
The option uses the same exit codes as `--check` and cannot be combined with `--write` or `--check`.
123123

124+
### `--no-cache`
125+
126+
Disable the persistent formatting cache for the current invocation:
127+
128+
```bash
129+
rs fmt --no-cache
130+
```
131+
132+
Without this option, `rs fmt` stores cache data in `.rstack/cache` under the Rstack configuration root. `--no-cache` prevents the command from reading, creating, or updating that cache. Stdin formatting never uses the persistent cache.
133+
134+
See [Cache](../formatting#cache) for cache behavior and cleanup guidance.
135+
124136
### `--no-error-on-unmatched-pattern`
125137

126138
Exit successfully without diagnostics when no files match the provided paths or globs, including when all matching files are ignored:

‎website/docs/en/guide/formatting.mdx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Rstack CLI includes a formatter built on [Prettier](https://prettier.io/). Compa
66

77
- **Parallel formatting**: Files are formatted concurrently in a worker pool.
88
- **Yuku parser**: The high-performance [Yuku](https://yuku.fyi/) parser is used by default for JavaScript, JSX, and TypeScript files.
9+
- **Persistent cache**: Content-based results let later runs skip formatting unchanged files.
910

1011
`rs fmt` supports Prettier options and plugins and adds built-in capabilities such as [sorting package.json fields](#sort-package-json).
1112

@@ -173,6 +174,20 @@ define.fmt({
173174
});
174175
```
175176

177+
## Cache
178+
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. Files that use custom Prettier plugins currently bypass the cache.
180+
181+
The default cache file is `.rstack/cache/fmt-v1.json` 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.
182+
183+
Use [`--no-cache`](./cli/fmt#--no-cache) to run without reading, creating, or updating the cache:
184+
185+
```bash
186+
rs fmt --no-cache
187+
```
188+
189+
You can safely delete `.rstack/cache` to clear cached results. Do not treat the entire `.rstack` directory as disposable because it may also contain user-maintained Git hook scripts.
190+
176191
## Prettier plugins
177192

178193
To add formatting capabilities that are not built into Rstack, install the corresponding [Prettier plugin](https://prettier.io/docs/plugins) and add it to `plugins`. Plugins can be referenced by package name, file path, or URL. Package names and relative paths are resolved from the directory containing the Rstack configuration file.

‎website/docs/zh/guide/cli/fmt.mdx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,18 @@ rs fmt -l
121121

122122
此选项与 `--check` 使用相同的退出状态码,且不能与 `--write` 或 `--check` 同时使用。
123123

124+
### `--no-cache`
125+
126+
在当前调用中关闭持久化格式化缓存:
127+
128+
```bash
129+
rs fmt --no-cache
130+
```
131+
132+
默认情况下,`rs fmt` 会将缓存数据保存在 Rstack 配置根目录下的 `.rstack/cache` 中。`--no-cache` 会阻止命令读取、创建或更新该缓存。stdin 格式化始终不会使用持久化缓存。
133+
134+
缓存行为和清理方式请参考[缓存](../formatting#cache)。
135+
124136
### `--no-error-on-unmatched-pattern`
125137

126138
如果传入的路径或 glob 没有匹配任何文件(包括所有匹配文件均被忽略的情况),则不输出诊断信息并成功退出:

‎website/docs/zh/guide/formatting.mdx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Rstack CLI 提供了基于 [Prettier](https://prettier.io/) 的格式化工具
66

77
- **并行格式化**:通过 worker 池并行格式化文件。
88
- **Yuku 解析器**:默认使用高性能的 [Yuku](https://yuku.fyi/) 解析器处理 JavaScript、JSX 和 TypeScript 文件。
9+
- **持久化缓存**:基于文件内容缓存结果,后续运行可以跳过未变化文件的格式化。
910

1011
`rs fmt` 兼容 Prettier 的选项和插件,并提供更多内置能力,例如支持[排序 package.json 字段](#sort-package-json)。
1112

@@ -173,6 +174,20 @@ define.fmt({
173174
});
174175
```
175176

177+
## 缓存 \{#cache}
178+
179+
`rs fmt` 默认会在基于文件的 `--write`、`--check` 和 `--list-different` 调用中使用持久化缓存。缓存条目基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。使用自定义 Prettier 插件的文件目前会绕过缓存。
180+
181+
默认缓存文件位于 Rstack 配置根目录下的 `.rstack/cache/fmt-v1.json`。从子目录运行命令时,仍会使用解析到的 `rstack.config.*` 文件旁的缓存。stdin 格式化不会使用该缓存。
182+
183+
使用 [`--no-cache`](./cli/fmt#--no-cache) 可以在运行时跳过缓存读取、创建和更新:
184+
185+
```bash
186+
rs fmt --no-cache
187+
```
188+
189+
可以安全删除 `.rstack/cache` 来清理缓存结果。不要将整个 `.rstack` 目录视为可随意删除的内容,因为其中还可能包含用户维护的 Git hook 脚本。
190+
176191
## Prettier 插件 \{#prettier-plugins}
177192

178193
如果需要使用 Rstack 未内置的格式化能力,可以安装相应的 [Prettier 插件](https://prettier.io/docs/plugins),并添加到 `plugins` 中。插件支持通过包名、文件路径或 URL 引用,其中包名和相对路径基于 Rstack 配置文件所在的目录解析。

0 commit comments

Comments
 (0)