From 8363eb7117d89d2e6b844aab033296e76b1bb139 Mon Sep 17 00:00:00 2001 From: neverland Date: Thu, 30 Jul 2026 18:50:41 +0800 Subject: [PATCH] feat(fmt): add single-file formatting core --- packages/rstack/src/fmt/format.ts | 58 ++++++++++++++++++ packages/rstack/src/fmt/types.ts | 30 ++++++++- packages/rstack/tests/fmt/format.test.ts | 77 ++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 packages/rstack/src/fmt/format.ts create mode 100644 packages/rstack/tests/fmt/format.test.ts diff --git a/packages/rstack/src/fmt/format.ts b/packages/rstack/src/fmt/format.ts new file mode 100644 index 0000000..dcdb44e --- /dev/null +++ b/packages/rstack/src/fmt/format.ts @@ -0,0 +1,58 @@ +import { format, formatWithCursor, getFileInfo } from 'prettier'; +import { resolveFmtOptions } from './config.ts'; +import type { FormatTextOptions, FormatTextResult } from './types.ts'; + +/** Formats source text without reading formatter config or ignore files. */ +const formatText = async ( + source: string, + { filePath, cursorOffset, config }: FormatTextOptions, +): Promise => { + const options = resolveFmtOptions(filePath, config); + + if (options.plugins?.length) { + throw new Error('Prettier plugins are not supported yet.'); + } + + const parser = + options.parser ?? + ( + await getFileInfo(filePath, { + ignorePath: [], + resolveConfig: false, + withNodeModules: true, + }) + ).inferredParser; + + if (!parser) { + return { + status: 'skipped', + reason: 'unsupported', + }; + } + + const formatOptions = { + ...options, + filepath: filePath, + parser, + }; + + if (cursorOffset === undefined) { + return { + status: 'formatted', + formatted: await format(source, formatOptions), + }; + } + + const result = await formatWithCursor(source, { + ...formatOptions, + cursorOffset, + }); + + return { + status: 'formatted', + formatted: result.formatted, + cursorOffset: result.cursorOffset, + }; +}; + +export { formatText }; diff --git a/packages/rstack/src/fmt/types.ts b/packages/rstack/src/fmt/types.ts index b1c89af..8944223 100644 --- a/packages/rstack/src/fmt/types.ts +++ b/packages/rstack/src/fmt/types.ts @@ -19,4 +19,32 @@ interface ResolvedFmtConfig { ignorePatterns: string[]; } -export type { FmtConfig, FmtConfigDefinition, ResolvedFmtConfig }; +interface FormatTextOptions { + /** File path used to resolve per-file options and infer the parser. */ + filePath: string; + /** Cursor offset in the source to preserve across formatting. */ + cursorOffset?: number; + /** Resolved project config used to derive per-file options. */ + config: ResolvedFmtConfig; +} + +interface FormattedTextResult { + status: 'formatted'; + formatted: string; + cursorOffset?: number; +} + +interface SkippedTextResult { + status: 'skipped'; + reason: 'unsupported'; +} + +type FormatTextResult = FormattedTextResult | SkippedTextResult; + +export type { + FmtConfig, + FmtConfigDefinition, + FormatTextOptions, + FormatTextResult, + ResolvedFmtConfig, +}; diff --git a/packages/rstack/tests/fmt/format.test.ts b/packages/rstack/tests/fmt/format.test.ts new file mode 100644 index 0000000..1f92325 --- /dev/null +++ b/packages/rstack/tests/fmt/format.test.ts @@ -0,0 +1,77 @@ +import path from 'node:path'; +import { expect, test } from 'rstack/test'; +import { normalizeFmtConfig } from '../../src/fmt/config.ts'; +import { formatText } from '../../src/fmt/format.ts'; + +const rootPath = import.meta.dirname; + +test('applies per-file overrides and maps the cursor', async () => { + const source = 'const value={message:"hello"}'; + const config = normalizeFmtConfig( + { + overrides: [ + { + files: '*.ts', + options: { + singleQuote: true, + }, + }, + ], + }, + rootPath, + ); + + const result = await formatText(source, { + config, + cursorOffset: source.indexOf('message'), + filePath: path.join(rootPath, 'example.ts'), + }); + + expect(result).toEqual({ + status: 'formatted', + formatted: "const value = { message: 'hello' };\n", + cursorOffset: 16, + }); +}); + +test('returns unsupported when no parser can be inferred', async () => { + const config = normalizeFmtConfig(undefined, rootPath); + + await expect( + formatText('plain text', { + config, + filePath: path.join(rootPath, 'unknown.extension'), + }), + ).resolves.toEqual({ + status: 'skipped', + reason: 'unsupported', + }); +}); + +test('uses an explicit parser for unknown file extensions', async () => { + const config = normalizeFmtConfig({ parser: 'babel' }, rootPath); + + const result = await formatText('const value={nested:true}', { + config, + filePath: path.join(rootPath, 'unknown.extension'), + }); + + expect(result).toEqual({ + status: 'formatted', + formatted: 'const value = { nested: true };\n', + }); +}); + +test('formats an explicitly provided node_modules file', async () => { + const config = normalizeFmtConfig(undefined, rootPath); + + const result = await formatText('const value={nested:true}', { + config, + filePath: path.join(rootPath, 'node_modules', 'example', 'index.js'), + }); + + expect(result).toEqual({ + status: 'formatted', + formatted: 'const value = { nested: true };\n', + }); +});