diff --git a/.changeset/oxfmtrc-prettier-format-config.md b/.changeset/oxfmtrc-prettier-format-config.md new file mode 100644 index 00000000..90601398 --- /dev/null +++ b/.changeset/oxfmtrc-prettier-format-config.md @@ -0,0 +1,5 @@ +--- +"@arkts/language-server": patch +--- + +feat: load `.oxfmtrc.json` / `.prettierrc.json` when formatting ArkTS files diff --git a/packages/language-server/src/index.ts b/packages/language-server/src/index.ts index 499ce67a..a7b77aea 100644 --- a/packages/language-server/src/index.ts +++ b/packages/language-server/src/index.ts @@ -23,6 +23,7 @@ import { ProjectDetectorManagerService } from './classes/project-manager' import { patchResolver } from './patches/patch-resolver' import { patchSemantic } from './patches/patch-semantic' import { resolveDiagnosticMessages } from './utils/diagnostic-messages-resolver' +import { formatEtsDocument } from './utils/formatter-config' const ets = Object.assign({}, ETS) patchResolver(ets) @@ -30,7 +31,7 @@ patchResolver(ets) const connection = createConnection() const server = createServer(connection) -connection.onRequest('ets/formatDocument', async e => format(e.textDocument.uri, e.textDocument.text)) +connection.onRequest('ets/formatDocument', async e => formatEtsDocument(e.textDocument, format)) connection.onInitialize(async (params) => { const diagnosticMessages = await resolveDiagnosticMessages(params, logger, fileUri) diff --git a/packages/language-server/src/utils/formatter-config.ts b/packages/language-server/src/utils/formatter-config.ts new file mode 100644 index 00000000..6d769b7c --- /dev/null +++ b/packages/language-server/src/utils/formatter-config.ts @@ -0,0 +1,234 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { Uri } from '@vstils/core' + +/** Prettier / oxfmt option keys that oxk's `format(..., options)` accepts. */ +export const FORMATTER_OPTION_KEYS = [ + 'printWidth', + 'tabWidth', + 'useTabs', + 'semi', + 'singleQuote', + 'jsxSingleQuote', + 'quoteProps', + 'trailingComma', + 'bracketSpacing', + 'bracketSameLine', + 'arrowParens', + 'endOfLine', + 'singleAttributePerLine', + 'objectWrap', + 'embeddedLanguageFormatting', + 'insertFinalNewline', +] as const + +export type FormatterOptionKey = typeof FORMATTER_OPTION_KEYS[number] +export type FormatterOptions = Partial> + +/** Nearest-directory lookup order. oxfmt files win over Prettier files. */ +export const FORMATTER_CONFIG_FILENAMES = [ + '.oxfmtrc.json', + '.oxfmtrc.jsonc', + '.prettierrc.json', + '.prettierrc.jsonc', + '.prettierrc', +] as const + +export interface FormatterConfigFs { + isFile(filePath: string): Promise + readFile(filePath: string): Promise +} + +export interface ResolvedFormatterConfig { + path: string + options?: FormatterOptions +} + +const defaultFs: FormatterConfigFs = { + async isFile(filePath) { + try { + const stat = await fs.stat(filePath) + return stat.isFile() + } + catch { + return false + } + }, + readFile(filePath) { + return fs.readFile(filePath, 'utf8') + }, +} + +function isUriWithScheme(value: string): boolean { + return /^[a-z][a-z\d+.-]*:\/\//i.test(value) || value.startsWith('file:') || value.startsWith('untitled:') +} + +/** + * Convert a document URI or filesystem path to a local path used for config walk. + * Non-file schemes (untitled, etc.) return `undefined` so formatting still runs with defaults. + */ +export function toDocumentFsPath(documentUri: string): string | undefined { + if (!documentUri) return undefined + if (!isUriWithScheme(documentUri)) return documentUri + try { + const uri = Uri.parse(documentUri) + if (uri.scheme !== 'file') return undefined + return uri.fsPath + } + catch { + return undefined + } +} + +/** Strip `//` and `/* *\/` comments while preserving string contents. */ +export function stripJsonComments(text: string): string { + let result = '' + let index = 0 + let inString: '"' | '\'' | '`' | null = null + let escaped = false + + while (index < text.length) { + const char = text[index] + const next = text[index + 1] + + if (inString) { + result += char + if (escaped) escaped = false + else if (char === '\\') escaped = true + else if (char === inString) inString = null + index++ + continue + } + + if (char === '"' || char === '\'' || char === '`') { + inString = char + result += char + index++ + continue + } + + if (char === '/' && next === '/') { + index += 2 + while (index < text.length && text[index] !== '\n') index++ + continue + } + + if (char === '/' && next === '*') { + index += 2 + while (index < text.length && !(text[index] === '*' && text[index + 1] === '/')) index++ + index = Math.min(index + 2, text.length) + result += ' ' + continue + } + + result += char + index++ + } + + return result +} + +function stripTrailingCommas(text: string): string { + return text.replace(/,(\s*[}\]])/g, '$1') +} + +export function parseJsonc(text: string): unknown { + const stripped = stripJsonComments(text) + try { + return JSON.parse(stripped) + } + catch { + return JSON.parse(stripTrailingCommas(stripped)) + } +} + +export function pickFormatterOptions(raw: unknown): FormatterOptions | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined + const record = raw as Record + const options: FormatterOptions = {} + for (const key of FORMATTER_OPTION_KEYS) { + if (record[key] !== undefined) options[key] = record[key] + } + return Object.keys(options).length > 0 ? options : undefined +} + +function parseConfigFile(fileName: string, text: string): FormatterOptions | undefined | false { + try { + if (fileName === 'package.json') { + const pkg = parseJsonc(text) as { prettier?: unknown } | undefined + if (!pkg || typeof pkg !== 'object') return false + if (pkg.prettier === undefined) return false + if (!pkg.prettier || typeof pkg.prettier !== 'object' || Array.isArray(pkg.prettier)) return false + return pickFormatterOptions(pkg.prettier) + } + return pickFormatterOptions(parseJsonc(text)) + } + catch { + return false + } +} + +export async function resolveFormatterConfig( + documentPath: string, + fileSystem: FormatterConfigFs = defaultFs, +): Promise { + if (!documentPath) return undefined + + let directory = path.dirname(path.resolve(documentPath)) + const seen = new Set() + + while (!seen.has(directory)) { + seen.add(directory) + + for (const fileName of FORMATTER_CONFIG_FILENAMES) { + const configPath = path.join(directory, fileName) + if (!await fileSystem.isFile(configPath)) continue + try { + const parsed = parseConfigFile(fileName, await fileSystem.readFile(configPath)) + if (parsed === false) continue + return { path: configPath, options: parsed || undefined } + } + catch { + continue + } + } + + const packageJsonPath = path.join(directory, 'package.json') + if (await fileSystem.isFile(packageJsonPath)) { + try { + const parsed = parseConfigFile('package.json', await fileSystem.readFile(packageJsonPath)) + if (parsed !== false) return { path: packageJsonPath, options: parsed || undefined } + } + catch { + // keep walking when package.json is unreadable or has no prettier field + } + } + + const parent = path.dirname(directory) + if (parent === directory) break + directory = parent + } + + return undefined +} + +export interface FormatDocumentInput { + uri: string + text: string +} + +export interface FormatDocumentResult { + code: string + errors: string[] +} + +export async function formatEtsDocument( + textDocument: FormatDocumentInput, + formatImpl: (filename: string, sourceText: string, options?: FormatterOptions) => Promise | FormatDocumentResult, + fileSystem: FormatterConfigFs = defaultFs, +): Promise { + const fsPath = toDocumentFsPath(textDocument.uri) + const filename = fsPath ?? textDocument.uri + const config = fsPath ? await resolveFormatterConfig(fsPath, fileSystem) : undefined + return formatImpl(filename, textDocument.text, config?.options) +} diff --git a/packages/language-server/test/formatter-config.test.ts b/packages/language-server/test/formatter-config.test.ts new file mode 100644 index 00000000..a72f0b35 --- /dev/null +++ b/packages/language-server/test/formatter-config.test.ts @@ -0,0 +1,249 @@ +import type { FormatterConfigFs } from '../src/utils/formatter-config' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vite-plus/test' +import { + formatEtsDocument, + parseJsonc, + pickFormatterOptions, + resolveFormatterConfig, + stripJsonComments, + toDocumentFsPath, +} from '../src/utils/formatter-config' + +function createMemoryFs(files: Record): FormatterConfigFs { + const normalized = new Map( + Object.entries(files).map(([filePath, content]) => [path.normalize(filePath), content]), + ) + return { + async isFile(filePath) { + return normalized.has(path.normalize(filePath)) + }, + async readFile(filePath) { + const content = normalized.get(path.normalize(filePath)) + if (content === undefined) throw new Error(`ENOENT: ${filePath}`) + return content + }, + } +} + +describe('toDocumentFsPath', () => { + it('returns filesystem paths unchanged', () => { + expect(toDocumentFsPath('/workspace/entry.ets')).toBe('/workspace/entry.ets') + }) + + it('converts file URIs to filesystem paths', () => { + expect(toDocumentFsPath('file:///workspace/entry.ets')).toBe('/workspace/entry.ets') + }) + + it('ignores untitled documents', () => { + expect(toDocumentFsPath('untitled:Untitled-1')).toBeUndefined() + }) +}) + +describe('stripJsonComments / parseJsonc', () => { + it('keeps comment-like text inside strings', () => { + expect(stripJsonComments('{"url": "https://example.com//path"}')).toBe('{"url": "https://example.com//path"}') + }) + + it('parses jsonc with comments and trailing commas', () => { + expect(parseJsonc(`{ + // line comment + "singleQuote": true, /* block */ + "printWidth": 80, + }`)).toEqual({ + singleQuote: true, + printWidth: 80, + }) + }) +}) + +describe('pickFormatterOptions', () => { + it('keeps prettier/oxfmt keys and drops schema and plugins', () => { + expect(pickFormatterOptions({ + $schema: './node_modules/oxfmt/configuration_schema.json', + printWidth: 80, + singleQuote: true, + plugins: ['prettier-plugin-foo'], + ignorePatterns: ['dist/**'], + })).toEqual({ + printWidth: 80, + singleQuote: true, + }) + }) + + it('returns undefined when no known format keys exist', () => { + expect(pickFormatterOptions({ $schema: 'x' })).toBeUndefined() + }) +}) + +describe('resolveFormatterConfig', () => { + it('prefers .oxfmtrc.json over .prettierrc.json in the same directory', async () => { + const root = path.join('/tmp', 'fmt-prefer') + const documentPath = path.join(root, 'src', 'entry.ets') + const resolved = await resolveFormatterConfig(documentPath, createMemoryFs({ + [path.join(root, 'src', '.prettierrc.json')]: '{"printWidth": 120}', + [path.join(root, 'src', '.oxfmtrc.json')]: '{"printWidth": 80, "singleQuote": true}', + })) + + expect(resolved).toEqual({ + path: path.join(root, 'src', '.oxfmtrc.json'), + options: { printWidth: 80, singleQuote: true }, + }) + }) + + it('walks up to a parent .prettierrc.json', async () => { + const root = path.join('/tmp', 'fmt-walk') + const documentPath = path.join(root, 'entry', 'src', 'pages', 'Index.ets') + const resolved = await resolveFormatterConfig(documentPath, createMemoryFs({ + [path.join(root, 'entry', '.prettierrc.json')]: '{"tabWidth": 4, "semi": false}', + })) + + expect(resolved).toEqual({ + path: path.join(root, 'entry', '.prettierrc.json'), + options: { tabWidth: 4, semi: false }, + }) + }) + + it('reads prettier options from package.json', async () => { + const root = path.join('/tmp', 'fmt-pkg') + const documentPath = path.join(root, 'Index.ets') + const resolved = await resolveFormatterConfig(documentPath, createMemoryFs({ + [path.join(root, 'package.json')]: '{"name":"demo","prettier":{"useTabs":true,"printWidth":90}}', + })) + + expect(resolved).toEqual({ + path: path.join(root, 'package.json'), + options: { useTabs: true, printWidth: 90 }, + }) + }) + + it('skips package.json without a prettier object and keeps walking', async () => { + const root = path.join('/tmp', 'fmt-pkg-skip') + const documentPath = path.join(root, 'module', 'Index.ets') + const resolved = await resolveFormatterConfig(documentPath, createMemoryFs({ + [path.join(root, 'module', 'package.json')]: '{"name":"module"}', + [path.join(root, '.oxfmtrc.jsonc')]: `{ + // project default + "singleQuote": true, + }`, + })) + + expect(resolved).toEqual({ + path: path.join(root, '.oxfmtrc.jsonc'), + options: { singleQuote: true }, + }) + }) + + it('skips an invalid nearest config and uses the next valid file', async () => { + const root = path.join('/tmp', 'fmt-invalid') + const documentPath = path.join(root, 'Index.ets') + const resolved = await resolveFormatterConfig(documentPath, createMemoryFs({ + [path.join(root, '.oxfmtrc.json')]: '{ not json', + [path.join(root, '.prettierrc.json')]: '{"trailingComma":"es5"}', + })) + + expect(resolved).toEqual({ + path: path.join(root, '.prettierrc.json'), + options: { trailingComma: 'es5' }, + }) + }) + + it('returns a config path with empty options when the file only has $schema', async () => { + const root = path.join('/tmp', 'fmt-schema') + const documentPath = path.join(root, 'Index.ets') + const resolved = await resolveFormatterConfig(documentPath, createMemoryFs({ + [path.join(root, '.oxfmtrc.json')]: '{"$schema":"./schema.json"}', + [path.join(root, '.prettierrc.json')]: '{"printWidth": 120}', + })) + + expect(resolved).toEqual({ + path: path.join(root, '.oxfmtrc.json'), + options: undefined, + }) + }) + + it('loads a real .oxfmtrc.json from disk', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'arkts-fmt-config-')) + try { + const nested = path.join(root, 'entry', 'src') + await fs.mkdir(nested, { recursive: true }) + await fs.writeFile(path.join(root, '.oxfmtrc.json'), '{\n "printWidth": 80,\n "singleQuote": true\n}\n', 'utf8') + const documentPath = path.join(nested, 'Index.ets') + await fs.writeFile(documentPath, 'const name = "ark"\n', 'utf8') + + const resolved = await resolveFormatterConfig(documentPath) + expect(resolved).toEqual({ + path: path.join(root, '.oxfmtrc.json'), + options: { printWidth: 80, singleQuote: true }, + }) + } + finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) +}) + +describe('oxk format options', () => { + it('applies prettier-style options from a resolved config file', async () => { + const { format } = await import('@ohos-rs/oxk') + const result = await formatEtsDocument( + { + uri: 'file:///tmp/fmt-oxk/src/entry.ets', + text: 'const name = "arkts"; const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];', + }, + format, + createMemoryFs({ + '/tmp/fmt-oxk/.prettierrc.json': '{"singleQuote":true,"printWidth":40}', + }), + ) + + expect(result.errors).toEqual([]) + expect(result.code).toContain('const name = \'arkts\'') + expect(result.code).toContain('\n') + }) +}) + +describe('formatEtsDocument', () => { + it('passes resolved options and a filesystem path to oxk format', async () => { + const calls: unknown[] = [] + const result = await formatEtsDocument( + { + uri: 'file:///tmp/fmt-format/src/entry.ets', + text: `const name = "ark"`, + }, + async (filename, sourceText, options) => { + calls.push({ filename, sourceText, options }) + return { code: sourceText, errors: [] } + }, + createMemoryFs({ + '/tmp/fmt-format/.prettierrc.json': '{"singleQuote":true,"printWidth":80}', + }), + ) + + expect(result).toEqual({ code: `const name = "ark"`, errors: [] }) + expect(calls).toEqual([{ + filename: '/tmp/fmt-format/src/entry.ets', + sourceText: `const name = "ark"`, + options: { singleQuote: true, printWidth: 80 }, + }]) + }) + + it('formats untitled documents without looking up a config file', async () => { + const calls: unknown[] = [] + await formatEtsDocument( + { uri: 'untitled:Untitled-1', text: 'const x = 1' }, + async (filename, sourceText, options) => { + calls.push({ filename, sourceText, options }) + return { code: sourceText, errors: [] } + }, + ) + + expect(calls).toEqual([{ + filename: 'untitled:Untitled-1', + sourceText: 'const x = 1', + options: undefined, + }]) + }) +})