diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index a01416d..f1ad9bb 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -1,7 +1,10 @@ +import path from 'node:path'; import { performance } from 'node:perf_hooks'; import { color, logger } from 'rslog'; import { parseArgs } from '../cli/args.ts'; import { loadRstackConfig } from '../config.ts'; +import { ensureProjectCacheDir } from '../projectCache.ts'; +import { fmtCacheFileName } from './cacheStore.ts'; import { resolveFmtConfig } from './config.ts'; import { discoverFmtFiles } from './discovery.ts'; import { createRelativePathResolver, toPosixPath } from './pathHelpers.ts'; @@ -9,6 +12,7 @@ import { runFmtFiles } from './runner.ts'; import type { FmtMode, FmtRunResult, ResolvedFmtConfig } from './types.ts'; interface ParsedFmtCLIArgs { + cache: boolean; mode: FmtMode; patterns: string[]; ignorePaths: string[]; @@ -34,6 +38,7 @@ ${color.cyan('Options')}: -l, --list-different Print paths of unformatted files --ignore-path Path to an additional ignore file (repeatable) -u, --ignore-unknown Ignore unknown files + --no-cache Disable the formatting cache --no-error-on-unmatched-pattern Do not error when no files match --with-node-modules Process files inside node_modules --parallel-workers Number of parallel workers @@ -62,6 +67,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { 'list-different': { type: 'boolean', short: 'l' }, 'ignore-path': { type: 'string', multiple: true }, 'ignore-unknown': { type: 'boolean', short: 'u' }, + 'no-cache': { type: 'boolean' }, 'no-error-on-unmatched-pattern': { type: 'boolean' }, 'with-node-modules': { type: 'boolean' }, 'parallel-workers': { type: 'string' }, @@ -81,6 +87,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { } const mode = check ? 'check' : listDifferent ? 'list-different' : 'write'; + const cache = !(values.noCache ?? false); const ignorePaths = values.ignorePath ?? []; const ignoreUnknown = values.ignoreUnknown ?? false; const noErrorOnUnmatchedPattern = values.noErrorOnUnmatchedPattern ?? false; @@ -103,6 +110,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { } return { + cache, mode, patterns: positionals, ignorePaths, @@ -238,6 +246,7 @@ const runFmtCLI = async (args: string[]): Promise => { // exit code identifies "rs fmt refused to run". try { const { + cache, help, ignorePaths, ignoreUnknown, @@ -287,6 +296,17 @@ const runFmtCLI = async (args: string[]): Promise => { return; } + let cacheContext; + if (cache) { + const cacheDir = await ensureProjectCacheDir(config.rootPath); + if (cacheDir.status === 'available') { + cacheContext = { + filePath: path.join(cacheDir.path, fmtCacheFileName), + rootPath: config.rootPath, + }; + } + } + if (mode === 'check') { logger.start('Checking formatting...'); } @@ -295,6 +315,7 @@ const runFmtCLI = async (args: string[]): Promise => { files, mode, maxWorkers, + cache: cacheContext, }); if (result.processedFileCount === 0) { diff --git a/packages/rstack/tests/cli/fmt/index.test.ts b/packages/rstack/tests/cli/fmt/index.test.ts index 166cc6b..d5ab131 100644 --- a/packages/rstack/tests/cli/fmt/index.test.ts +++ b/packages/rstack/tests/cli/fmt/index.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { afterEach, beforeEach, expect, test } from 'rstack/test'; import { RSTACK_BIN_PATH } from '#test-helpers'; @@ -33,19 +33,19 @@ const writeFixturePlugin = (): void => { ); }; -const runCLI = (args: string[], input?: string) => { +const runCLI = (args: string[], input?: string, cwd = projectPath) => { const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: '1' }; delete env.FORCE_COLOR; return spawnSync(process.execPath, [RSTACK_BIN_PATH, ...args], { - cwd: projectPath, + cwd, encoding: 'utf8', env, input, }); }; -const runFmt = (args: string[] = []) => runCLI(['fmt', ...args]); +const runFmt = (args: string[] = [], cwd = projectPath) => runCLI(['fmt', ...args], undefined, cwd); const runFmtStdin = (args: string[], input: string) => runCLI(['fmt', ...args], input); @@ -164,6 +164,83 @@ test('summarizes write mode when no files change', () => { expect(result.stderr).toBe(''); }); +test.each([ + ['write', []], + ['check', ['--check']], + ['list-different', ['--list-different']], +] as const)('uses the default cache in %s mode', (_, args) => { + writeProjectFile('index.ts', 'const value = 1;\n'); + + const result = runFmt([...args, 'index.ts']); + + expect(result.status).toBe(0); + expect(readProjectFile('.rstack/cache/.gitignore')).toBe('*\n'); + expect(JSON.parse(readProjectFile('.rstack/cache/fmt-v1.json'))).toMatchObject({ + version: 1, + files: { + 'index.ts': [expect.any(String), expect.any(String), 'clean'], + }, + }); +}); + +test('--no-cache bypasses cache reads and writes', () => { + writeProjectFile('index.ts', 'const value=1'); + + const first = runFmt(['--no-cache', 'index.ts']); + + expect(first.status).toBe(0); + expect(existsSync(path.join(projectPath, '.rstack'))).toBe(false); + + writeProjectFile('.rstack/cache/fmt-v1.json', 'stale'); + writeProjectFile('index.ts', 'const value=2'); + const second = runFmt(['--no-cache', 'index.ts']); + + expect(second.status).toBe(0); + expect(readProjectFile('index.ts')).toBe('const value = 2;\n'); + expect(readProjectFile('.rstack/cache/fmt-v1.json')).toBe('stale'); + expect(existsSync(path.join(projectPath, '.rstack/cache/.gitignore'))).toBe(false); +}); + +test('uses an explicit config root cache from a subdirectory', () => { + const appPath = path.join(projectPath, 'packages/app'); + writeProjectFile('packages/app/index.ts', 'const value=1'); + + const result = runFmt(['index.ts', '--config', '../../rstack.config.ts'], appPath); + + expect(result.status).toBe(0); + expect(readProjectFile('packages/app/index.ts')).toBe('const value = 1;\n'); + expect(existsSync(path.join(projectPath, '.rstack/cache/fmt-v1.json'))).toBe(true); + expect(existsSync(path.join(appPath, '.rstack'))).toBe(false); + expect(JSON.parse(readProjectFile('.rstack/cache/fmt-v1.json'))).toMatchObject({ + files: { + 'packages/app/index.ts': [expect.any(String), expect.any(String), 'clean'], + }, + }); +}); + +test('recovers from a corrupted cache', () => { + writeProjectFile('index.ts', 'const value = 1;\n'); + const first = runFmt(['--check', 'index.ts']); + writeProjectFile('.rstack/cache/fmt-v1.json', '{'); + + const second = runFmt(['--check', 'index.ts']); + + expect(second.status).toBe(0); + expect(normalizeDuration(second.stdout)).toBe(normalizeDuration(first.stdout)); + expect(second.stderr).toBe(first.stderr); + expect(JSON.parse(readProjectFile('.rstack/cache/fmt-v1.json'))).toMatchObject({ version: 1 }); +}); + +test('formats without a writable cache directory', () => { + writeProjectFile('.rstack', 'not a directory'); + writeProjectFile('index.ts', 'const value=1'); + + const result = runFmt(['index.ts']); + + expect(result.status).toBe(0); + expect(readProjectFile('index.ts')).toBe('const value = 1;\n'); +}); + test('does not sort package.json by default', () => { writeProjectFile('package.json', packageJsonSource); @@ -461,6 +538,7 @@ test('formats stdin for the given filepath', () => { expect(result.status).toBe(0); expect(result.stdout).toBe('const message = "hello";\n'); expect(result.stderr).toBe(''); + expect(existsSync(path.join(projectPath, '.rstack'))).toBe(false); }); test('applies define.fmt options and overrides to stdin', () => { @@ -622,6 +700,7 @@ test('returns exit code 2 when no files match', () => { ); expect(result.stderr).not.toContain('\n at '); } + expect(existsSync(path.join(projectPath, '.rstack'))).toBe(false); }); test('allows no files to match with --no-error-on-unmatched-pattern', () => { diff --git a/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap b/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap index d0d098a..3dda6d6 100644 --- a/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap +++ b/packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap @@ -12,6 +12,7 @@ Options: -l, --list-different Print paths of unformatted files --ignore-path Path to an additional ignore file (repeatable) -u, --ignore-unknown Ignore unknown files + --no-cache Disable the formatting cache --no-error-on-unmatched-pattern Do not error when no files match --with-node-modules Process files inside node_modules --parallel-workers Number of parallel workers diff --git a/packages/rstack/tests/fmt/cli.test.ts b/packages/rstack/tests/fmt/cli.test.ts index 4512e43..dd8cf66 100644 --- a/packages/rstack/tests/fmt/cli.test.ts +++ b/packages/rstack/tests/fmt/cli.test.ts @@ -19,6 +19,7 @@ test.each([ test('uses write mode by default', () => { expect(parseFmtCLIArgs([])).toEqual({ + cache: true, mode: 'write', patterns: [], ignorePaths: [], @@ -38,6 +39,7 @@ test.each([ ['--list-different', 'list-different'], ] as const)('parses %s mode', (option, mode) => { expect(parseFmtCLIArgs([option])).toEqual({ + cache: true, mode, patterns: [], ignorePaths: [], @@ -51,6 +53,7 @@ test.each([ test('configures parallel worker count', () => { expect(parseFmtCLIArgs(['--parallel-workers', '3'])).toEqual({ + cache: true, mode: 'write', patterns: [], ignorePaths: [], @@ -75,6 +78,7 @@ test('preserves file paths and globs', () => { const patterns = ['src/file with spaces.ts', 'src/**/*.{js,ts}', '!src/generated/**']; expect(parseFmtCLIArgs([patterns[0], '--check', ...patterns.slice(1)])).toEqual({ + cache: true, mode: 'check', patterns, ignorePaths: [], @@ -88,6 +92,7 @@ test('preserves file paths and globs', () => { test('treats arguments after the terminator as paths', () => { expect(parseFmtCLIArgs(['--check', '--', '--write', '--help'])).toEqual({ + cache: true, mode: 'check', patterns: ['--write', '--help'], ignorePaths: [], @@ -118,12 +123,17 @@ test.each(['-u', '--ignore-unknown', '--ignoreUnknown'])('parses %s', (option) = expect(parseFmtCLIArgs([option]).ignoreUnknown).toBe(true); }); +test('parses --no-cache', () => { + expect(parseFmtCLIArgs(['--no-cache']).cache).toBe(false); +}); + test('parses --with-node-modules', () => { expect(parseFmtCLIArgs(['--with-node-modules']).withNodeModules).toBe(true); }); test('parses --stdin-filepath', () => { expect(parseFmtCLIArgs(['--stdin-filepath', 'src/index.ts'])).toEqual({ + cache: true, mode: 'write', patterns: [], ignorePaths: [], @@ -138,6 +148,7 @@ test('parses --stdin-filepath', () => { test('accepts a worker count with --stdin-filepath', () => { expect(parseFmtCLIArgs(['--stdin-filepath', 'index.ts', '--parallel-workers', '2'])).toEqual({ + cache: true, mode: 'write', patterns: [], ignorePaths: [], @@ -182,10 +193,3 @@ test.each([ 'The --write, --check, and --list-different options cannot be used together.', ); }); - -test.each(['--unknown', '--no-cache', '--no-parallel'])( - 'rejects unsupported option %s', - (option) => { - expect(() => parseFmtCLIArgs([option])).toThrow(); - }, -); diff --git a/website/docs/en/guide/cli/fmt.mdx b/website/docs/en/guide/cli/fmt.mdx index 0bb5076..783b9b7 100644 --- a/website/docs/en/guide/cli/fmt.mdx +++ b/website/docs/en/guide/cli/fmt.mdx @@ -121,6 +121,18 @@ rs fmt -l The option uses the same exit codes as `--check` and cannot be combined with `--write` or `--check`. +### `--no-cache` + +Disable the persistent formatting cache for the current invocation: + +```bash +rs fmt --no-cache +``` + +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. + +See [Cache](../formatting#cache) for cache behavior and cleanup guidance. + ### `--no-error-on-unmatched-pattern` Exit successfully without diagnostics when no files match the provided paths or globs, including when all matching files are ignored: diff --git a/website/docs/en/guide/formatting.mdx b/website/docs/en/guide/formatting.mdx index 27c1ce6..e0f008b 100644 --- a/website/docs/en/guide/formatting.mdx +++ b/website/docs/en/guide/formatting.mdx @@ -6,6 +6,7 @@ Rstack CLI includes a formatter built on [Prettier](https://prettier.io/). Compa - **Parallel formatting**: Files are formatted concurrently in a worker pool. - **Yuku parser**: The high-performance [Yuku](https://yuku.fyi/) parser is used by default for JavaScript, JSX, and TypeScript files. +- **Persistent cache**: Content-based results let later runs skip formatting unchanged files. `rs fmt` supports Prettier options and plugins and adds built-in capabilities such as [sorting package.json fields](#sort-package-json). @@ -173,6 +174,20 @@ 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. Files that use custom Prettier plugins currently bypass the cache. + +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. + +Use [`--no-cache`](./cli/fmt#--no-cache) to run without reading, creating, or updating the cache: + +```bash +rs fmt --no-cache +``` + +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. + ## Prettier plugins 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. diff --git a/website/docs/zh/guide/cli/fmt.mdx b/website/docs/zh/guide/cli/fmt.mdx index 290e615..bae7213 100644 --- a/website/docs/zh/guide/cli/fmt.mdx +++ b/website/docs/zh/guide/cli/fmt.mdx @@ -121,6 +121,18 @@ rs fmt -l 此选项与 `--check` 使用相同的退出状态码,且不能与 `--write` 或 `--check` 同时使用。 +### `--no-cache` + +在当前调用中关闭持久化格式化缓存: + +```bash +rs fmt --no-cache +``` + +默认情况下,`rs fmt` 会将缓存数据保存在 Rstack 配置根目录下的 `.rstack/cache` 中。`--no-cache` 会阻止命令读取、创建或更新该缓存。stdin 格式化始终不会使用持久化缓存。 + +缓存行为和清理方式请参考[缓存](../formatting#cache)。 + ### `--no-error-on-unmatched-pattern` 如果传入的路径或 glob 没有匹配任何文件(包括所有匹配文件均被忽略的情况),则不输出诊断信息并成功退出: diff --git a/website/docs/zh/guide/formatting.mdx b/website/docs/zh/guide/formatting.mdx index 3b39705..7a6a09e 100644 --- a/website/docs/zh/guide/formatting.mdx +++ b/website/docs/zh/guide/formatting.mdx @@ -6,6 +6,7 @@ Rstack CLI 提供了基于 [Prettier](https://prettier.io/) 的格式化工具 - **并行格式化**:通过 worker 池并行格式化文件。 - **Yuku 解析器**:默认使用高性能的 [Yuku](https://yuku.fyi/) 解析器处理 JavaScript、JSX 和 TypeScript 文件。 +- **持久化缓存**:基于文件内容缓存结果,后续运行可以跳过未变化文件的格式化。 `rs fmt` 兼容 Prettier 的选项和插件,并提供更多内置能力,例如支持[排序 package.json 字段](#sort-package-json)。 @@ -173,6 +174,20 @@ define.fmt({ }); ``` +## 缓存 \{#cache} + +`rs fmt` 默认会在基于文件的 `--write`、`--check` 和 `--list-different` 调用中使用持久化缓存。缓存条目基于文件内容和最终格式化选项;任意一项发生变化时,文件都会重新格式化。使用自定义 Prettier 插件的文件目前会绕过缓存。 + +默认缓存文件位于 Rstack 配置根目录下的 `.rstack/cache/fmt-v1.json`。从子目录运行命令时,仍会使用解析到的 `rstack.config.*` 文件旁的缓存。stdin 格式化不会使用该缓存。 + +使用 [`--no-cache`](./cli/fmt#--no-cache) 可以在运行时跳过缓存读取、创建和更新: + +```bash +rs fmt --no-cache +``` + +可以安全删除 `.rstack/cache` 来清理缓存结果。不要将整个 `.rstack` 目录视为可随意删除的内容,因为其中还可能包含用户维护的 Git hook 脚本。 + ## Prettier 插件 \{#prettier-plugins} 如果需要使用 Rstack 未内置的格式化能力,可以安装相应的 [Prettier 插件](https://prettier.io/docs/plugins),并添加到 `plugins` 中。插件支持通过包名、文件路径或 URL 引用,其中包名和相对路径基于 Rstack 配置文件所在的目录解析。