From 62fffd1e5e7870563f4321e22d5b60bbfdca7ce9 Mon Sep 17 00:00:00 2001 From: neverland Date: Fri, 31 Jul 2026 15:25:40 +0800 Subject: [PATCH] feat(fmt): add rs fmt command --- README.md | 2 +- packages/rstack/src/cli/commands.ts | 10 + packages/rstack/src/fmt/cli.ts | 85 +++++++- packages/rstack/tests/cli/fmt/index.test.ts | 226 ++++++++++++++++++++ 4 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 packages/rstack/tests/cli/fmt/index.test.ts diff --git a/README.md b/README.md index 95bada7..18ea8ce 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ It also covers local development needs outside Rstack's scope, with Prettier for | `rs lint` | Lint code | [Rslint](https://github.com/web-infra-dev/rslint) | | `rs lib` | Build library | [Rslib](https://github.com/web-infra-dev/rslib) | | `rs doc` | Serve or build docs | [Rspress](https://github.com/web-infra-dev/rspress) | -| `rs fmt` | Format code (TODO) | [Prettier](https://github.com/prettier/prettier) | +| `rs fmt` | Format code | [Prettier](https://github.com/prettier/prettier) | | `rs setup` | Install Git hooks | - | | `rs staged` | Run tasks on staged Git files | [lint-staged](https://github.com/lint-staged/lint-staged) | diff --git a/packages/rstack/src/cli/commands.ts b/packages/rstack/src/cli/commands.ts index 1af9c95..6cdea3c 100644 --- a/packages/rstack/src/cli/commands.ts +++ b/packages/rstack/src/cli/commands.ts @@ -20,6 +20,7 @@ ${color.cyan('Commands')}: preview Preview the app production build lib Build library doc Serve or build docs + fmt Format code lint Lint code test Run tests staged Run tasks on staged Git files @@ -143,6 +144,15 @@ export async function setupCommands(): Promise { return; } + if (command === 'fmt') { + const { runFmtCLI } = await import( + /* rspackChunkName: 'fmt' */ + '../fmt/cli.ts' + ); + await runFmtCLI(args.slice(1)); + return; + } + if (command === 'staged') { await runStagedCLI(args.slice(1)); return; diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index 3738feb..a0ab669 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -1,6 +1,11 @@ +import path from 'node:path'; import { parseArgs } from 'node:util'; -import { color } from 'rslog'; -import type { FmtMode } from './types.ts'; +import { color, logger } from 'rslog'; +import { loadRstackConfig } from '../config.ts'; +import { resolveFmtConfig } from './config.ts'; +import { discoverFmtFiles } from './discovery.ts'; +import { runFmtFiles } from './runner.ts'; +import type { FmtMode, FmtRunResult } from './types.ts'; interface ParsedFmtCLIArgs { mode: FmtMode; @@ -50,5 +55,79 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { }; }; -export { fmtHelpMessage, parseFmtCLIArgs }; +const getDisplayPath = (cwd: string, filePath: string): string => { + const relativePath = path.relative(cwd, filePath); + return path.sep === '\\' ? relativePath.replaceAll('\\', '/') : relativePath; +}; + +const logFmtResult = (result: FmtRunResult, mode: FmtMode, cwd: string): void => { + let differentCount = 0; + let errorCount = 0; + + for (const file of result.files) { + const displayPath = getDisplayPath(cwd, file.path); + + if (file.status === 'written') { + logger.log(displayPath); + } else if (file.status === 'different') { + differentCount++; + logger[mode === 'check' ? 'warn' : 'log'](displayPath); + } else if (file.status === 'error') { + errorCount++; + logger.error(`${displayPath}: ${String(file.error)}`); + } + } + + if (mode !== 'check') { + return; + } + + if (differentCount > 0) { + const files = differentCount === 1 ? 'file' : 'files'; + logger.warn( + `Code style issues found in ${differentCount} ${files}. Run rs fmt --write to fix.`, + ); + } else if (errorCount === 0) { + logger.log('All matched files use Prettier code style!'); + } +}; + +const runFmtCLI = async (args: string[]): Promise => { + const { help, mode, patterns } = parseFmtCLIArgs(args); + if (help) { + console.log(fmtHelpMessage); + return; + } + + const cwd = process.cwd(); + + try { + const { configs, filePath } = await loadRstackConfig(); + const config = await resolveFmtConfig({ + definition: configs.fmt, + configFilePath: filePath, + cwd, + }); + const files = await discoverFmtFiles({ cwd, patterns, config }); + + if (mode === 'check') { + logger.log('Checking formatting...'); + } + + const result = await runFmtFiles({ + files, + mode, + cache: false, + parallel: false, + }); + + logFmtResult(result, mode, cwd); + process.exitCode = result.exitCode; + } catch (error) { + logger.error(error); + process.exitCode = 2; + } +}; + +export { fmtHelpMessage, parseFmtCLIArgs, runFmtCLI }; export type { ParsedFmtCLIArgs }; diff --git a/packages/rstack/tests/cli/fmt/index.test.ts b/packages/rstack/tests/cli/fmt/index.test.ts new file mode 100644 index 0000000..6f30566 --- /dev/null +++ b/packages/rstack/tests/cli/fmt/index.test.ts @@ -0,0 +1,226 @@ +import { spawnSync } from 'node:child_process'; +import { 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'; + +let projectPath: string; + +const writeProjectFile = (filePath: string, content: string): string => { + const absolutePath = path.join(projectPath, filePath); + mkdirSync(path.dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, content); + return absolutePath; +}; + +const readProjectFile = (filePath: string): string => + readFileSync(path.join(projectPath, filePath), 'utf8'); + +const runCLI = (args: string[]) => { + const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: '1' }; + delete env.FORCE_COLOR; + + return spawnSync(process.execPath, [RSTACK_BIN_PATH, ...args], { + cwd: projectPath, + encoding: 'utf8', + env, + }); +}; + +const runFmt = (args: string[] = []) => runCLI(['fmt', ...args]); + +beforeEach(() => { + projectPath = mkdtempSync(path.join(import.meta.dirname, 'fmt-project-')); + writeProjectFile('rstack.config.ts', 'export {};\n'); +}); + +afterEach(() => { + rmSync(projectPath, { force: true, recursive: true }); +}); + +test('displays fmt help without loading config', () => { + writeProjectFile('rstack.config.ts', 'throw new Error("must not load");\n'); + + const topLevel = runCLI(['--help']); + const fmt = runFmt(['--help']); + + expect(topLevel.status).toBe(0); + expect(topLevel.stdout).toContain('fmt Format code'); + expect(fmt.status).toBe(0); + expect(fmt.stdout).toContain('Usage:\n $ rs fmt [options] [files/globs...]'); + expect(fmt.stderr).toBe(''); +}); + +test('returns exit code 1 for invalid arguments', () => { + const result = runFmt(['--write', '--check']); + + expect(result.status).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain( + 'The --write, --check, and --list-different options cannot be used together.', + ); +}); + +test('formats the current directory with Prettier defaults', () => { + writeProjectFile('index.ts', 'const message="hello"'); + + const result = runFmt(); + + expect(result.status).toBe(0); + expect(result.stdout).toBe('index.ts\n'); + expect(result.stderr).toBe(''); + expect(readProjectFile('index.ts')).toBe('const message = "hello";\n'); +}); + +test('does not load Prettier config or ignore files', () => { + writeProjectFile('.prettierrc.json', '{ "singleQuote": true, "semi": false }\n'); + writeProjectFile('.prettierignore', 'index.ts\n'); + writeProjectFile('.editorconfig', 'root = true\n\n[*]\nindent_style = space\nindent_size = 8\n'); + writeProjectFile('index.ts', "function getMessage(){\n return 'hello'\n}"); + + const result = runFmt(['index.ts']); + + expect(result.status).toBe(0); + expect(result.stdout).toBe('index.ts\n'); + expect(result.stderr).toBe(''); + expect(readProjectFile('index.ts')).toBe('function getMessage() {\n return "hello";\n}\n'); +}); + +test('applies define.fmt options, overrides, ignore patterns, and globs', () => { + writeProjectFile( + 'rstack.config.ts', + `import { define } from 'rstack'; + +define.fmt({ + singleQuote: true, + ignorePatterns: ['src/ignored.ts'], + overrides: [ + { + files: '*.test.ts', + options: { + semi: false, + }, + }, + ], +}); +`, + ); + writeProjectFile('src/index.ts', 'const message="hello"'); + writeProjectFile('src/index.test.ts', 'const test="test"'); + writeProjectFile('src/ignored.ts', 'const ignored="ignored"'); + writeProjectFile('src/index.js', 'const javascript="untouched"'); + + const result = runFmt(['--write', 'src/**/*.ts']); + + expect(result.status).toBe(0); + expect(result.stdout).toBe('src/index.test.ts\nsrc/index.ts\n'); + expect(result.stderr).toBe(''); + expect(readProjectFile('src/index.ts')).toBe("const message = 'hello';\n"); + expect(readProjectFile('src/index.test.ts')).toBe("const test = 'test'\n"); + expect(readProjectFile('src/ignored.ts')).toBe('const ignored="ignored"'); + expect(readProjectFile('src/index.js')).toBe('const javascript="untouched"'); +}); + +test('uses an explicit Rstack config', () => { + writeProjectFile( + 'custom.config.ts', + `import { define } from 'rstack'; + +define.fmt({ + singleQuote: true, +}); +`, + ); + writeProjectFile('index.ts', 'const message="hello"'); + + const result = runFmt(['index.ts', '--config', 'custom.config.ts']); + + expect(result.status).toBe(0); + expect(result.stdout).toBe('index.ts\n'); + expect(result.stderr).toBe(''); + expect(readProjectFile('index.ts')).toBe("const message = 'hello';\n"); +}); + +test('checks formatting without writing files', () => { + const source = 'const message="hello"'; + writeProjectFile('index.ts', source); + + const result = runFmt(['--check', 'index.ts']); + + expect(result.status).toBe(1); + expect(result.stdout).toBe('Checking formatting...\n'); + expect(result.stderr).toContain('warn index.ts'); + expect(result.stderr).toContain( + 'warn Code style issues found in 1 file. Run rs fmt --write to fix.', + ); + expect(readProjectFile('index.ts')).toBe(source); + + writeProjectFile('index.ts', 'const message = "hello";\n'); + const formattedResult = runFmt(['--check', 'index.ts']); + + expect(formattedResult.status).toBe(0); + expect(formattedResult.stdout).toBe( + 'Checking formatting...\nAll matched files use Prettier code style!\n', + ); + expect(formattedResult.stderr).toBe(''); +}); + +test('lists only paths that differ', () => { + const source = 'const message="hello"'; + writeProjectFile('src/index.ts', source); + writeProjectFile('src/formatted.ts', 'const formatted = true;\n'); + + const result = runFmt(['--list-different', 'src/*.ts']); + + expect(result.status).toBe(1); + expect(result.stdout).toBe('src/index.ts\n'); + expect(result.stderr).toBe(''); + expect(readProjectFile('src/index.ts')).toBe(source); +}); + +test('returns exit code 2 for config errors', () => { + writeProjectFile('rstack.config.ts', 'throw new Error("invalid fmt config");\n'); + + const result = runFmt(['index.ts']); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('invalid fmt config'); +}); + +test('returns exit code 2 for unsupported plugins', () => { + writeProjectFile( + 'rstack.config.ts', + `import { define } from 'rstack'; + +define.fmt({ + plugins: ['prettier-plugin-example'], +}); +`, + ); + writeProjectFile('index.ts', 'const message="hello"'); + + const result = runFmt(['index.ts']); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('Prettier plugins are not supported yet.'); +}); + +test('returns exit code 2 for formatting errors', () => { + writeProjectFile('index.ts', 'const value = ;'); + + const result = runFmt(['index.ts']); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('error index.ts: SyntaxError: Expression expected.'); +}); + +test('succeeds when no files can be formatted', () => { + const result = runFmt(['missing/**/*.ts']); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); +});