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
21 changes: 21 additions & 0 deletions packages/rstack/src/fmt/cli.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
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';
import { runFmtFiles } from './runner.ts';
import type { FmtMode, FmtRunResult, ResolvedFmtConfig } from './types.ts';

interface ParsedFmtCLIArgs {
cache: boolean;
mode: FmtMode;
patterns: string[];
ignorePaths: string[];
Expand All @@ -34,6 +38,7 @@ ${color.cyan('Options')}:
-l, --list-different Print paths of unformatted files
--ignore-path <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 <count> Number of parallel workers
Expand Down Expand Up @@ -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' },
Expand All @@ -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;
Expand All @@ -103,6 +110,7 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
}

return {
cache,
mode,
patterns: positionals,
ignorePaths,
Expand Down Expand Up @@ -238,6 +246,7 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
// exit code identifies "rs fmt refused to run".
try {
const {
cache,
help,
ignorePaths,
ignoreUnknown,
Expand Down Expand Up @@ -287,6 +296,17 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
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...');
}
Expand All @@ -295,6 +315,7 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
files,
mode,
maxWorkers,
cache: cacheContext,
});

if (result.processedFileCount === 0) {
Expand Down
87 changes: 83 additions & 4 deletions packages/rstack/tests/cli/fmt/index.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/rstack/tests/fmt/__snapshots__/cli.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Options:
-l, --list-different Print paths of unformatted files
--ignore-path <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 <count> Number of parallel workers
Expand Down
18 changes: 11 additions & 7 deletions packages/rstack/tests/fmt/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ test.each([

test('uses write mode by default', () => {
expect(parseFmtCLIArgs([])).toEqual({
cache: true,
mode: 'write',
patterns: [],
ignorePaths: [],
Expand All @@ -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: [],
Expand All @@ -51,6 +53,7 @@ test.each([

test('configures parallel worker count', () => {
expect(parseFmtCLIArgs(['--parallel-workers', '3'])).toEqual({
cache: true,
mode: 'write',
patterns: [],
ignorePaths: [],
Expand All @@ -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: [],
Expand All @@ -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: [],
Expand Down Expand Up @@ -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: [],
Expand All @@ -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: [],
Expand Down Expand Up @@ -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();
},
);
12 changes: 12 additions & 0 deletions website/docs/en/guide/cli/fmt.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions website/docs/en/guide/formatting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions website/docs/zh/guide/cli/fmt.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 没有匹配任何文件(包括所有匹配文件均被忽略的情况),则不输出诊断信息并成功退出:
Expand Down
15 changes: 15 additions & 0 deletions website/docs/zh/guide/formatting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)。

Expand Down Expand Up @@ -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 配置文件所在的目录解析。
Expand Down