Skip to content

Commit 06d4704

Browse files
authored
feat(fmt): add single-file formatting core (#115)
1 parent 6c563ef commit 06d4704

3 files changed

Lines changed: 164 additions & 1 deletion

File tree

packages/rstack/src/fmt/format.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { format, formatWithCursor, getFileInfo } from 'prettier';
2+
import { resolveFmtOptions } from './config.ts';
3+
import type { FormatTextOptions, FormatTextResult } from './types.ts';
4+
5+
/** Formats source text without reading formatter config or ignore files. */
6+
const formatText = async (
7+
source: string,
8+
{ filePath, cursorOffset, config }: FormatTextOptions,
9+
): Promise<FormatTextResult> => {
10+
const options = resolveFmtOptions(filePath, config);
11+
12+
if (options.plugins?.length) {
13+
throw new Error('Prettier plugins are not supported yet.');
14+
}
15+
16+
const parser =
17+
options.parser ??
18+
(
19+
await getFileInfo(filePath, {
20+
ignorePath: [],
21+
resolveConfig: false,
22+
withNodeModules: true,
23+
})
24+
).inferredParser;
25+
26+
if (!parser) {
27+
return {
28+
status: 'skipped',
29+
reason: 'unsupported',
30+
};
31+
}
32+
33+
const formatOptions = {
34+
...options,
35+
filepath: filePath,
36+
parser,
37+
};
38+
39+
if (cursorOffset === undefined) {
40+
return {
41+
status: 'formatted',
42+
formatted: await format(source, formatOptions),
43+
};
44+
}
45+
46+
const result = await formatWithCursor(source, {
47+
...formatOptions,
48+
cursorOffset,
49+
});
50+
51+
return {
52+
status: 'formatted',
53+
formatted: result.formatted,
54+
cursorOffset: result.cursorOffset,
55+
};
56+
};
57+
58+
export { formatText };

packages/rstack/src/fmt/types.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,32 @@ interface ResolvedFmtConfig {
1919
ignorePatterns: string[];
2020
}
2121

22-
export type { FmtConfig, FmtConfigDefinition, ResolvedFmtConfig };
22+
interface FormatTextOptions {
23+
/** File path used to resolve per-file options and infer the parser. */
24+
filePath: string;
25+
/** Cursor offset in the source to preserve across formatting. */
26+
cursorOffset?: number;
27+
/** Resolved project config used to derive per-file options. */
28+
config: ResolvedFmtConfig;
29+
}
30+
31+
interface FormattedTextResult {
32+
status: 'formatted';
33+
formatted: string;
34+
cursorOffset?: number;
35+
}
36+
37+
interface SkippedTextResult {
38+
status: 'skipped';
39+
reason: 'unsupported';
40+
}
41+
42+
type FormatTextResult = FormattedTextResult | SkippedTextResult;
43+
44+
export type {
45+
FmtConfig,
46+
FmtConfigDefinition,
47+
FormatTextOptions,
48+
FormatTextResult,
49+
ResolvedFmtConfig,
50+
};
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import path from 'node:path';
2+
import { expect, test } from 'rstack/test';
3+
import { normalizeFmtConfig } from '../../src/fmt/config.ts';
4+
import { formatText } from '../../src/fmt/format.ts';
5+
6+
const rootPath = import.meta.dirname;
7+
8+
test('applies per-file overrides and maps the cursor', async () => {
9+
const source = 'const value={message:"hello"}';
10+
const config = normalizeFmtConfig(
11+
{
12+
overrides: [
13+
{
14+
files: '*.ts',
15+
options: {
16+
singleQuote: true,
17+
},
18+
},
19+
],
20+
},
21+
rootPath,
22+
);
23+
24+
const result = await formatText(source, {
25+
config,
26+
cursorOffset: source.indexOf('message'),
27+
filePath: path.join(rootPath, 'example.ts'),
28+
});
29+
30+
expect(result).toEqual({
31+
status: 'formatted',
32+
formatted: "const value = { message: 'hello' };\n",
33+
cursorOffset: 16,
34+
});
35+
});
36+
37+
test('returns unsupported when no parser can be inferred', async () => {
38+
const config = normalizeFmtConfig(undefined, rootPath);
39+
40+
await expect(
41+
formatText('plain text', {
42+
config,
43+
filePath: path.join(rootPath, 'unknown.extension'),
44+
}),
45+
).resolves.toEqual({
46+
status: 'skipped',
47+
reason: 'unsupported',
48+
});
49+
});
50+
51+
test('uses an explicit parser for unknown file extensions', async () => {
52+
const config = normalizeFmtConfig({ parser: 'babel' }, rootPath);
53+
54+
const result = await formatText('const value={nested:true}', {
55+
config,
56+
filePath: path.join(rootPath, 'unknown.extension'),
57+
});
58+
59+
expect(result).toEqual({
60+
status: 'formatted',
61+
formatted: 'const value = { nested: true };\n',
62+
});
63+
});
64+
65+
test('formats an explicitly provided node_modules file', async () => {
66+
const config = normalizeFmtConfig(undefined, rootPath);
67+
68+
const result = await formatText('const value={nested:true}', {
69+
config,
70+
filePath: path.join(rootPath, 'node_modules', 'example', 'index.js'),
71+
});
72+
73+
expect(result).toEqual({
74+
status: 'formatted',
75+
formatted: 'const value = { nested: true };\n',
76+
});
77+
});

0 commit comments

Comments
 (0)