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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
10 changes: 10 additions & 0 deletions packages/rstack/src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -143,6 +144,15 @@ export async function setupCommands(): Promise<void> {
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;
Expand Down
85 changes: 82 additions & 3 deletions packages/rstack/src/fmt/cli.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<void> => {
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 };
226 changes: 226 additions & 0 deletions packages/rstack/tests/cli/fmt/index.test.ts
Original file line number Diff line number Diff line change
@@ -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('');
});