Skip to content

Commit 62fffd1

Browse files
committed
feat(fmt): add rs fmt command
1 parent b9ffdec commit 62fffd1

4 files changed

Lines changed: 319 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ It also covers local development needs outside Rstack's scope, with Prettier for
2121
| `rs lint` | Lint code | [Rslint](https://github.com/web-infra-dev/rslint) |
2222
| `rs lib` | Build library | [Rslib](https://github.com/web-infra-dev/rslib) |
2323
| `rs doc` | Serve or build docs | [Rspress](https://github.com/web-infra-dev/rspress) |
24-
| `rs fmt` | Format code (TODO) | [Prettier](https://github.com/prettier/prettier) |
24+
| `rs fmt` | Format code | [Prettier](https://github.com/prettier/prettier) |
2525
| `rs setup` | Install Git hooks | - |
2626
| `rs staged` | Run tasks on staged Git files | [lint-staged](https://github.com/lint-staged/lint-staged) |
2727

packages/rstack/src/cli/commands.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ ${color.cyan('Commands')}:
2020
preview Preview the app production build
2121
lib Build library
2222
doc Serve or build docs
23+
fmt Format code
2324
lint Lint code
2425
test Run tests
2526
staged Run tasks on staged Git files
@@ -143,6 +144,15 @@ export async function setupCommands(): Promise<void> {
143144
return;
144145
}
145146

147+
if (command === 'fmt') {
148+
const { runFmtCLI } = await import(
149+
/* rspackChunkName: 'fmt' */
150+
'../fmt/cli.ts'
151+
);
152+
await runFmtCLI(args.slice(1));
153+
return;
154+
}
155+
146156
if (command === 'staged') {
147157
await runStagedCLI(args.slice(1));
148158
return;

packages/rstack/src/fmt/cli.ts

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import path from 'node:path';
12
import { parseArgs } from 'node:util';
2-
import { color } from 'rslog';
3-
import type { FmtMode } from './types.ts';
3+
import { color, logger } from 'rslog';
4+
import { loadRstackConfig } from '../config.ts';
5+
import { resolveFmtConfig } from './config.ts';
6+
import { discoverFmtFiles } from './discovery.ts';
7+
import { runFmtFiles } from './runner.ts';
8+
import type { FmtMode, FmtRunResult } from './types.ts';
49

510
interface ParsedFmtCLIArgs {
611
mode: FmtMode;
@@ -50,5 +55,79 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => {
5055
};
5156
};
5257

53-
export { fmtHelpMessage, parseFmtCLIArgs };
58+
const getDisplayPath = (cwd: string, filePath: string): string => {
59+
const relativePath = path.relative(cwd, filePath);
60+
return path.sep === '\\' ? relativePath.replaceAll('\\', '/') : relativePath;
61+
};
62+
63+
const logFmtResult = (result: FmtRunResult, mode: FmtMode, cwd: string): void => {
64+
let differentCount = 0;
65+
let errorCount = 0;
66+
67+
for (const file of result.files) {
68+
const displayPath = getDisplayPath(cwd, file.path);
69+
70+
if (file.status === 'written') {
71+
logger.log(displayPath);
72+
} else if (file.status === 'different') {
73+
differentCount++;
74+
logger[mode === 'check' ? 'warn' : 'log'](displayPath);
75+
} else if (file.status === 'error') {
76+
errorCount++;
77+
logger.error(`${displayPath}: ${String(file.error)}`);
78+
}
79+
}
80+
81+
if (mode !== 'check') {
82+
return;
83+
}
84+
85+
if (differentCount > 0) {
86+
const files = differentCount === 1 ? 'file' : 'files';
87+
logger.warn(
88+
`Code style issues found in ${differentCount} ${files}. Run rs fmt --write to fix.`,
89+
);
90+
} else if (errorCount === 0) {
91+
logger.log('All matched files use Prettier code style!');
92+
}
93+
};
94+
95+
const runFmtCLI = async (args: string[]): Promise<void> => {
96+
const { help, mode, patterns } = parseFmtCLIArgs(args);
97+
if (help) {
98+
console.log(fmtHelpMessage);
99+
return;
100+
}
101+
102+
const cwd = process.cwd();
103+
104+
try {
105+
const { configs, filePath } = await loadRstackConfig();
106+
const config = await resolveFmtConfig({
107+
definition: configs.fmt,
108+
configFilePath: filePath,
109+
cwd,
110+
});
111+
const files = await discoverFmtFiles({ cwd, patterns, config });
112+
113+
if (mode === 'check') {
114+
logger.log('Checking formatting...');
115+
}
116+
117+
const result = await runFmtFiles({
118+
files,
119+
mode,
120+
cache: false,
121+
parallel: false,
122+
});
123+
124+
logFmtResult(result, mode, cwd);
125+
process.exitCode = result.exitCode;
126+
} catch (error) {
127+
logger.error(error);
128+
process.exitCode = 2;
129+
}
130+
};
131+
132+
export { fmtHelpMessage, parseFmtCLIArgs, runFmtCLI };
54133
export type { ParsedFmtCLIArgs };
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { spawnSync } from 'node:child_process';
2+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3+
import path from 'node:path';
4+
import { afterEach, beforeEach, expect, test } from 'rstack/test';
5+
import { RSTACK_BIN_PATH } from '#test-helpers';
6+
7+
let projectPath: string;
8+
9+
const writeProjectFile = (filePath: string, content: string): string => {
10+
const absolutePath = path.join(projectPath, filePath);
11+
mkdirSync(path.dirname(absolutePath), { recursive: true });
12+
writeFileSync(absolutePath, content);
13+
return absolutePath;
14+
};
15+
16+
const readProjectFile = (filePath: string): string =>
17+
readFileSync(path.join(projectPath, filePath), 'utf8');
18+
19+
const runCLI = (args: string[]) => {
20+
const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: '1' };
21+
delete env.FORCE_COLOR;
22+
23+
return spawnSync(process.execPath, [RSTACK_BIN_PATH, ...args], {
24+
cwd: projectPath,
25+
encoding: 'utf8',
26+
env,
27+
});
28+
};
29+
30+
const runFmt = (args: string[] = []) => runCLI(['fmt', ...args]);
31+
32+
beforeEach(() => {
33+
projectPath = mkdtempSync(path.join(import.meta.dirname, 'fmt-project-'));
34+
writeProjectFile('rstack.config.ts', 'export {};\n');
35+
});
36+
37+
afterEach(() => {
38+
rmSync(projectPath, { force: true, recursive: true });
39+
});
40+
41+
test('displays fmt help without loading config', () => {
42+
writeProjectFile('rstack.config.ts', 'throw new Error("must not load");\n');
43+
44+
const topLevel = runCLI(['--help']);
45+
const fmt = runFmt(['--help']);
46+
47+
expect(topLevel.status).toBe(0);
48+
expect(topLevel.stdout).toContain('fmt Format code');
49+
expect(fmt.status).toBe(0);
50+
expect(fmt.stdout).toContain('Usage:\n $ rs fmt [options] [files/globs...]');
51+
expect(fmt.stderr).toBe('');
52+
});
53+
54+
test('returns exit code 1 for invalid arguments', () => {
55+
const result = runFmt(['--write', '--check']);
56+
57+
expect(result.status).toBe(1);
58+
expect(result.stdout).toBe('');
59+
expect(result.stderr).toContain(
60+
'The --write, --check, and --list-different options cannot be used together.',
61+
);
62+
});
63+
64+
test('formats the current directory with Prettier defaults', () => {
65+
writeProjectFile('index.ts', 'const message="hello"');
66+
67+
const result = runFmt();
68+
69+
expect(result.status).toBe(0);
70+
expect(result.stdout).toBe('index.ts\n');
71+
expect(result.stderr).toBe('');
72+
expect(readProjectFile('index.ts')).toBe('const message = "hello";\n');
73+
});
74+
75+
test('does not load Prettier config or ignore files', () => {
76+
writeProjectFile('.prettierrc.json', '{ "singleQuote": true, "semi": false }\n');
77+
writeProjectFile('.prettierignore', 'index.ts\n');
78+
writeProjectFile('.editorconfig', 'root = true\n\n[*]\nindent_style = space\nindent_size = 8\n');
79+
writeProjectFile('index.ts', "function getMessage(){\n return 'hello'\n}");
80+
81+
const result = runFmt(['index.ts']);
82+
83+
expect(result.status).toBe(0);
84+
expect(result.stdout).toBe('index.ts\n');
85+
expect(result.stderr).toBe('');
86+
expect(readProjectFile('index.ts')).toBe('function getMessage() {\n return "hello";\n}\n');
87+
});
88+
89+
test('applies define.fmt options, overrides, ignore patterns, and globs', () => {
90+
writeProjectFile(
91+
'rstack.config.ts',
92+
`import { define } from 'rstack';
93+
94+
define.fmt({
95+
singleQuote: true,
96+
ignorePatterns: ['src/ignored.ts'],
97+
overrides: [
98+
{
99+
files: '*.test.ts',
100+
options: {
101+
semi: false,
102+
},
103+
},
104+
],
105+
});
106+
`,
107+
);
108+
writeProjectFile('src/index.ts', 'const message="hello"');
109+
writeProjectFile('src/index.test.ts', 'const test="test"');
110+
writeProjectFile('src/ignored.ts', 'const ignored="ignored"');
111+
writeProjectFile('src/index.js', 'const javascript="untouched"');
112+
113+
const result = runFmt(['--write', 'src/**/*.ts']);
114+
115+
expect(result.status).toBe(0);
116+
expect(result.stdout).toBe('src/index.test.ts\nsrc/index.ts\n');
117+
expect(result.stderr).toBe('');
118+
expect(readProjectFile('src/index.ts')).toBe("const message = 'hello';\n");
119+
expect(readProjectFile('src/index.test.ts')).toBe("const test = 'test'\n");
120+
expect(readProjectFile('src/ignored.ts')).toBe('const ignored="ignored"');
121+
expect(readProjectFile('src/index.js')).toBe('const javascript="untouched"');
122+
});
123+
124+
test('uses an explicit Rstack config', () => {
125+
writeProjectFile(
126+
'custom.config.ts',
127+
`import { define } from 'rstack';
128+
129+
define.fmt({
130+
singleQuote: true,
131+
});
132+
`,
133+
);
134+
writeProjectFile('index.ts', 'const message="hello"');
135+
136+
const result = runFmt(['index.ts', '--config', 'custom.config.ts']);
137+
138+
expect(result.status).toBe(0);
139+
expect(result.stdout).toBe('index.ts\n');
140+
expect(result.stderr).toBe('');
141+
expect(readProjectFile('index.ts')).toBe("const message = 'hello';\n");
142+
});
143+
144+
test('checks formatting without writing files', () => {
145+
const source = 'const message="hello"';
146+
writeProjectFile('index.ts', source);
147+
148+
const result = runFmt(['--check', 'index.ts']);
149+
150+
expect(result.status).toBe(1);
151+
expect(result.stdout).toBe('Checking formatting...\n');
152+
expect(result.stderr).toContain('warn index.ts');
153+
expect(result.stderr).toContain(
154+
'warn Code style issues found in 1 file. Run rs fmt --write to fix.',
155+
);
156+
expect(readProjectFile('index.ts')).toBe(source);
157+
158+
writeProjectFile('index.ts', 'const message = "hello";\n');
159+
const formattedResult = runFmt(['--check', 'index.ts']);
160+
161+
expect(formattedResult.status).toBe(0);
162+
expect(formattedResult.stdout).toBe(
163+
'Checking formatting...\nAll matched files use Prettier code style!\n',
164+
);
165+
expect(formattedResult.stderr).toBe('');
166+
});
167+
168+
test('lists only paths that differ', () => {
169+
const source = 'const message="hello"';
170+
writeProjectFile('src/index.ts', source);
171+
writeProjectFile('src/formatted.ts', 'const formatted = true;\n');
172+
173+
const result = runFmt(['--list-different', 'src/*.ts']);
174+
175+
expect(result.status).toBe(1);
176+
expect(result.stdout).toBe('src/index.ts\n');
177+
expect(result.stderr).toBe('');
178+
expect(readProjectFile('src/index.ts')).toBe(source);
179+
});
180+
181+
test('returns exit code 2 for config errors', () => {
182+
writeProjectFile('rstack.config.ts', 'throw new Error("invalid fmt config");\n');
183+
184+
const result = runFmt(['index.ts']);
185+
186+
expect(result.status).toBe(2);
187+
expect(result.stdout).toBe('');
188+
expect(result.stderr).toContain('invalid fmt config');
189+
});
190+
191+
test('returns exit code 2 for unsupported plugins', () => {
192+
writeProjectFile(
193+
'rstack.config.ts',
194+
`import { define } from 'rstack';
195+
196+
define.fmt({
197+
plugins: ['prettier-plugin-example'],
198+
});
199+
`,
200+
);
201+
writeProjectFile('index.ts', 'const message="hello"');
202+
203+
const result = runFmt(['index.ts']);
204+
205+
expect(result.status).toBe(2);
206+
expect(result.stdout).toBe('');
207+
expect(result.stderr).toContain('Prettier plugins are not supported yet.');
208+
});
209+
210+
test('returns exit code 2 for formatting errors', () => {
211+
writeProjectFile('index.ts', 'const value = ;');
212+
213+
const result = runFmt(['index.ts']);
214+
215+
expect(result.status).toBe(2);
216+
expect(result.stdout).toBe('');
217+
expect(result.stderr).toContain('error index.ts: SyntaxError: Expression expected.');
218+
});
219+
220+
test('succeeds when no files can be formatted', () => {
221+
const result = runFmt(['missing/**/*.ts']);
222+
223+
expect(result.status).toBe(0);
224+
expect(result.stdout).toBe('');
225+
expect(result.stderr).toBe('');
226+
});

0 commit comments

Comments
 (0)