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
97 changes: 82 additions & 15 deletions packages/rstack/src/fmt/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import { parseArgs } from 'node:util';
import { color, logger } from 'rslog';
import { loadRstackConfig } from '../config.ts';
Expand Down Expand Up @@ -83,35 +84,92 @@ const getDisplayPath = (cwd: string, filePath: string): string => {
return path.sep === '\\' ? relativePath.replaceAll('\\', '/') : relativePath;
};

const logFmtResult = (result: FmtRunResult, mode: FmtMode, cwd: string): void => {
const prettyTime = (seconds: number): string => {
const format = (time: string, unit: 'm' | 's') => color.bold(`${time}${unit}`);

if (seconds < 10) {
const digits = seconds >= 0.01 ? 2 : 3;
return format(seconds.toFixed(digits), 's');
}

if (seconds < 60) {
return format(seconds.toFixed(1), 's');
}

const minutes = Math.floor(seconds / 60);
const minutesLabel = format(minutes.toFixed(0), 'm');
const remainingSeconds = seconds % 60;

if (remainingSeconds === 0) {
return minutesLabel;
}

const secondsLabel = format(remainingSeconds.toFixed(remainingSeconds % 1 === 0 ? 0 : 1), 's');

return `${minutesLabel} ${secondsLabel}`;
};

const formatCount = (count: number): string => color.bold(count);
const formatFileCount = (count: number, isError = false): string => {
const formattedCount = formatCount(count);
return `${isError ? color.red(formattedCount) : formattedCount} ${count === 1 ? 'file' : 'files'}`;
};

const logFmtResult = (
result: FmtRunResult,
mode: FmtMode,
cwd: string,
matchedFileCount: number,
durationSeconds: number,
): void => {
let differentCount = 0;
let errorCount = 0;

for (const file of result.files) {
const displayPath = getDisplayPath(cwd, file.path);

if (file.status === 'written') {
logger.success(displayPath);
} else if (file.status === 'different') {
continue;
}

const displayPath = getDisplayPath(cwd, file.path);
if (file.status === 'different') {
differentCount++;
logger[mode === 'check' ? 'error' : 'log'](displayPath);
} else if (file.status === 'error') {
errorCount++;
logger.error(`${displayPath}: ${String(file.error)}`);
}
}

if (mode === 'write') {
if (result.exitCode !== 0) {
return;
}

const writtenCount = result.files.length;
const matchedFiles = formatFileCount(matchedFileCount);
const time = prettyTime(durationSeconds);
if (writtenCount > 0) {
logger.success(`Formatted ${formatCount(writtenCount)} of ${matchedFiles} in ${time}.`);
} else {
logger.success(`Checked ${matchedFiles} in ${time}. No changes needed.`);
}
return;
}

if (mode !== 'check') {
return;
}

if (differentCount > 0) {
const files = differentCount === 1 ? 'file' : 'files';
const count = color.bold(color.red(differentCount));
const writeCommand = color.cyan('rs fmt --write');
logger.error(`Code style issues found in ${count} ${files}. Run ${writeCommand} to fix.`);
} else if (errorCount === 0) {
logger.success('All matched files are correctly formatted.');
const differentFiles = formatFileCount(differentCount, true);
const matchedFiles = formatFileCount(matchedFileCount);
const checkOption = color.cyan('--check');
logger.error(
`Formatting issues found in ${differentFiles}. Run without ${checkOption} to fix.`,
);
logger.info(`Checked ${matchedFiles} in ${prettyTime(durationSeconds)}.`);
} else if (result.exitCode === 0) {
logger.success(
`Checked ${formatFileCount(matchedFileCount)} in ${prettyTime(durationSeconds)}. No issues found.`,
);
}
};

Expand All @@ -123,6 +181,7 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
}

const cwd = process.cwd();
const startTime = performance.now();

try {
const { configs, filePath } = await loadRstackConfig();
Expand All @@ -133,6 +192,13 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
});
const files = await discoverFmtFiles({ cwd, patterns, config });

if (files.length === 0) {
if (mode !== 'list-different') {
logger.info('No files matched.');
}
return;
}

if (mode === 'check') {
logger.start('Checking formatting...');
}
Expand All @@ -143,13 +209,14 @@ const runFmtCLI = async (args: string[]): Promise<void> => {
maxWorkers,
});

logFmtResult(result, mode, cwd);
const durationSeconds = (performance.now() - startTime) / 1000;
logFmtResult(result, mode, cwd, files.length, durationSeconds);
process.exitCode = result.exitCode;
} catch (error) {
logger.error(error);
process.exitCode = 2;
}
};

export { fmtHelpMessage, parseFmtCLIArgs, runFmtCLI };
export { fmtHelpMessage, parseFmtCLIArgs, prettyTime, runFmtCLI };
export type { ParsedFmtCLIArgs };
69 changes: 51 additions & 18 deletions packages/rstack/tests/cli/fmt/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ const runCLI = (args: string[]) => {

const runFmt = (args: string[] = []) => runCLI(['fmt', ...args]);

const normalizeDuration = (output: string): string =>
output.replace(/\d+m(?: \d+(?:\.\d+)?s)?|\d+(?:\.\d+)?s/g, '<duration>');

const expectWriteSummary = (
output: string,
matchedFileCount: number,
writtenCount: number,
): void => {
const files = matchedFileCount === 1 ? 'file' : 'files';
const message = writtenCount
? `Formatted ${writtenCount} of ${matchedFileCount} ${files} in <duration>.`
: `Checked ${matchedFileCount} ${files} in <duration>. No changes needed.`;
expect(normalizeDuration(output)).toBe(`success ${message}\n`);
};

beforeEach(() => {
projectPath = mkdtempSync(path.join(import.meta.dirname, 'test-temp-fmt-'));
// Prevent repository-level ignore rules from affecting the fixture.
Expand Down Expand Up @@ -76,7 +91,7 @@ test('supports format as an alias for fmt', () => {
const result = runCLI(['format', 'index.ts']);

expect(result.status).toBe(0);
expect(result.stdout).toBe('success index.ts\n');
expectWriteSummary(result.stdout, 1, 1);
expect(result.stderr).toBe('');
expect(readProjectFile('index.ts')).toBe('const message = "hello";\n');
});
Expand All @@ -97,11 +112,21 @@ test('formats the current directory with Prettier defaults', () => {
const result = runFmt();

expect(result.status).toBe(0);
expect(result.stdout).toBe('success index.ts\n');
expectWriteSummary(result.stdout, 2, 1);
expect(result.stderr).toBe('');
expect(readProjectFile('index.ts')).toBe('const message = "hello";\n');
});

test('summarizes write mode when no files change', () => {
writeProjectFile('index.ts', 'const message = "hello";\n');

const result = runFmt(['index.ts']);

expect(result.status).toBe(0);
expectWriteSummary(result.stdout, 1, 0);
expect(result.stderr).toBe('');
});

test('does not sort package.json by default', () => {
writeProjectFile('package.json', packageJsonSource);

Expand Down Expand Up @@ -139,7 +164,7 @@ test('supports configuring the worker count', () => {
const result = runFmt(['--parallel-workers', '1', 'first.ts', 'second.ts']);

expect(result.status).toBe(0);
expect(result.stdout).toBe('success first.ts\nsuccess second.ts\n');
expectWriteSummary(result.stdout, 2, 2);
expect(result.stderr).toBe('');
expect(readProjectFile('first.ts')).toBe('const first = "first";\n');
expect(readProjectFile('second.ts')).toBe('const second = "second";\n');
Expand All @@ -154,7 +179,7 @@ test('does not load Prettier config or ignore files', () => {
const result = runFmt(['index.ts']);

expect(result.status).toBe(0);
expect(result.stdout).toBe('success index.ts\n');
expectWriteSummary(result.stdout, 1, 1);
expect(result.stderr).toBe('');
expect(readProjectFile('index.ts')).toBe('function getMessage() {\n return "hello";\n}\n');
});
Expand Down Expand Up @@ -186,7 +211,7 @@ define.fmt({
const result = runFmt(['--write', 'src/**/*.ts']);

expect(result.status).toBe(0);
expect(result.stdout).toBe('success src/index.test.ts\nsuccess src/index.ts\n');
expectWriteSummary(result.stdout, 2, 2);
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");
Expand All @@ -209,7 +234,7 @@ define.fmt({
const result = runFmt(['index.ts', '--config', 'custom.config.ts']);

expect(result.status).toBe(0);
expect(result.stdout).toBe('success index.ts\n');
expectWriteSummary(result.stdout, 1, 1);
expect(result.stderr).toBe('');
expect(readProjectFile('index.ts')).toBe("const message = 'hello';\n");
});
Expand All @@ -221,19 +246,21 @@ test('checks formatting without writing files', () => {
const result = runFmt(['--check', 'index.ts']);

expect(result.status).toBe(1);
expect(result.stdout).toBe('start Checking formatting...\n');
expect(normalizeDuration(result.stdout)).toBe(
'start Checking formatting...\ninfo Checked 1 file in <duration>.\n',
);
expect(result.stderr).toContain('error index.ts');
expect(result.stderr).toContain(
'error Code style issues found in 1 file. Run rs fmt --write to fix.',
expect(normalizeDuration(result.stderr)).toContain(
'error Formatting issues found in 1 file. Run without --check 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(
'start Checking formatting...\nsuccess All matched files are correctly formatted.\n',
expect(normalizeDuration(formattedResult.stdout)).toBe(
'start Checking formatting...\nsuccess Checked 1 file in <duration>. No issues found.\n',
);
expect(formattedResult.stderr).toBe('');
});
Expand Down Expand Up @@ -278,7 +305,7 @@ define.fmt({
const result = runFmt(['*.fixture']);

expect(result.status).toBe(0);
expect(result.stdout).toBe('success first.fixture\nsuccess second.fixture\n');
expectWriteSummary(result.stdout, 2, 2);
expect(result.stderr).toBe('');
expect(readProjectFile('first.fixture')).toBe('{ "first": true }\n');
expect(readProjectFile('second.fixture')).toBe('{ "second": true }\n');
Expand Down Expand Up @@ -306,7 +333,7 @@ define.fmt({
const result = runFmt(['data.fixture', 'index.ts']);

expect(result.status).toBe(0);
expect(result.stdout).toBe('success data.fixture\nsuccess index.ts\n');
expectWriteSummary(result.stdout, 2, 2);
expect(result.stderr).toBe('');
expect(readProjectFile('data.fixture')).toBe('{ "value": true }\n');
expect(readProjectFile('index.ts')).toBe('const value = true;\n');
Expand Down Expand Up @@ -343,10 +370,16 @@ test('returns exit code 2 for formatting errors', () => {
expect(result.stderr).toContain('error index.ts: SyntaxError:');
});

test('succeeds when no files can be formatted', () => {
const result = runFmt(['missing/**/*.ts']);
test('reports when no files match', () => {
const writeResult = runFmt(['missing/**/*.ts']);

expect(result.status).toBe(0);
expect(result.stdout).toBe('');
expect(result.stderr).toBe('');
expect(writeResult.status).toBe(0);
expect(writeResult.stdout).toBe('info No files matched.\n');
expect(writeResult.stderr).toBe('');

const checkResult = runFmt(['--check', 'missing/**/*.ts']);

expect(checkResult.status).toBe(0);
expect(checkResult.stdout).toBe('info No files matched.\n');
expect(checkResult.stderr).toBe('');
});
18 changes: 17 additions & 1 deletion packages/rstack/tests/fmt/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
import { stripVTControlCharacters } from 'node:util';
import { expect, test } from 'rstack/test';
import { fmtHelpMessage, parseFmtCLIArgs } from '../../src/fmt/cli.ts';
import { fmtHelpMessage, parseFmtCLIArgs, prettyTime } from '../../src/fmt/cli.ts';

test.each([
[0, '0.000s'],
[0.009, '0.009s'],
[0.01, '0.01s'],
[9.876, '9.88s'],
[10, '10.0s'],
[59.9, '59.9s'],
[60, '1m'],
[61, '1m 1s'],
[61.25, '1m 1.3s'],
[125.25, '2m 5.3s'],
] as const)('formats %s seconds as %s', (seconds, expected) => {
expect(stripVTControlCharacters(prettyTime(seconds))).toBe(expected);
});

test('uses write mode by default', () => {
expect(parseFmtCLIArgs([])).toEqual({
Expand Down
Loading