diff --git a/docs/errors/OXDT0007.md b/docs/errors/OXDT0007.md new file mode 100644 index 000000000..638422ce1 --- /dev/null +++ b/docs/errors/OXDT0007.md @@ -0,0 +1,16 @@ +--- +outline: deep +--- +# OXDT0007: Failed to Run Oxfmt + +## Message +> Failed to run Oxfmt: `{reason}` + +## Cause +Oxfmt could not be started for the current workspace. + +## Fix +Install Oxfmt, check its configuration, and run format again. + +## Source +- [`packages/oxc/src/node/rpc/functions/oxfmt-run.ts`](https://github.com/vitejs/devtools/blob/main/packages/oxc/src/node/rpc/functions/oxfmt-run.ts) — reports execution failures. diff --git a/docs/errors/OXDT0008.md b/docs/errors/OXDT0008.md new file mode 100644 index 000000000..a4d3974bd --- /dev/null +++ b/docs/errors/OXDT0008.md @@ -0,0 +1,19 @@ +--- +outline: deep +--- +# OXDT0008: Failed to Delete Format Result + +## Message +> Failed to delete format result "`{resultId}`": `{reason}` + +## Cause +The result ID is not numeric, the result no longer exists, or the project directory does not allow deletion. + +## Example +Deleting a format result that another process has already removed. + +## Fix +Refresh the format result list, use a numeric ID from the list, and ensure the project directory is writable. + +## Source +- [`packages/oxc/src/node/rpc/functions/oxfmt-delete-result.ts`](https://github.com/vitejs/devtools/blob/main/packages/oxc/src/node/rpc/functions/oxfmt-delete-result.ts) — Validates the result ID and deletes its log directory. diff --git a/docs/errors/index.md b/docs/errors/index.md index 8674fb36f..d1d6a4fce 100644 --- a/docs/errors/index.md +++ b/docs/errors/index.md @@ -81,3 +81,5 @@ Emitted by `@vitejs/devtools-oxc`. | [OXDT0004](./OXDT0004) | error | Oxlint Config Inspection Failed | | [OXDT0005](./OXDT0005) | error | Oxlint Setup Failed | | [OXDT0006](./OXDT0006) | error | Oxfmt Setup Failed | +| [OXDT0007](./OXDT0007) | error | Failed to Run Oxfmt | +| [OXDT0008](./OXDT0008) | error | Failed to Delete Format Result | diff --git a/packages/oxc/src/app/components/RunOxfmtDialog.vue b/packages/oxc/src/app/components/RunOxfmtDialog.vue new file mode 100644 index 000000000..e02ba4018 --- /dev/null +++ b/packages/oxc/src/app/components/RunOxfmtDialog.vue @@ -0,0 +1,129 @@ + + + diff --git a/packages/oxc/src/app/pages/index.vue b/packages/oxc/src/app/pages/index.vue index 03cc0a027..5b2d8f105 100644 --- a/packages/oxc/src/app/pages/index.vue +++ b/packages/oxc/src/app/pages/index.vue @@ -53,6 +53,16 @@ const tools = computed(() => { }, ] const oxfmtViews: ToolView[] = [ + ...(overview.value.oxfmt.installed + ? [ + { + title: 'Format Inspector', + description: 'Run and inspect formatting', + icon: 'i-ph-magnifying-glass-duotone', + to: '/oxfmt/format', + }, + ] + : []), { title: 'Documents', description: 'Guides and references', diff --git a/packages/oxc/src/app/pages/oxfmt.vue b/packages/oxc/src/app/pages/oxfmt.vue index 982408ce4..ab81a80c4 100644 --- a/packages/oxc/src/app/pages/oxfmt.vue +++ b/packages/oxc/src/app/pages/oxfmt.vue @@ -1,6 +1,15 @@ + + diff --git a/packages/oxc/src/node/__tests__/oxfmt-delete-result.test.ts b/packages/oxc/src/node/__tests__/oxfmt-delete-result.test.ts new file mode 100644 index 000000000..33bc5481f --- /dev/null +++ b/packages/oxc/src/node/__tests__/oxfmt-delete-result.test.ts @@ -0,0 +1,40 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { oxfmtDeleteResult } from '../rpc/functions/oxfmt-delete-result' + +it('deletes only the selected log and rejects invalid or missing IDs', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'oxfmt-delete-')) + try { + for (const [id, mode] of [ + ['1', 'check'], + ['2', 'write'], + ]) { + const dir = join(cwd, '.devtools-oxc', 'fmt', id) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'log.json'), JSON.stringify({ mode })) + } + const source = join(cwd, 'index.ts') + await writeFile(source, 'export const value = 1\n') + const { handler } = oxfmtDeleteResult.setup!({ cwd } as any) + for (const resultId of ['../..', '', '/tmp', '1/../../..']) { + await expect(handler({ resultId })).rejects.toMatchObject({ code: 'OXDT0008' }) + } + await handler({ resultId: '2' }) + await expect(readFile(join(cwd, '.devtools-oxc/fmt/2/log.json'))).rejects.toMatchObject({ + code: 'ENOENT', + }) + await expect(readFile(join(cwd, '.devtools-oxc/fmt/1/log.json'), 'utf8')).resolves.toContain( + 'check', + ) + await expect(readFile(source, 'utf8')).resolves.toBe('export const value = 1\n') + await expect(handler({ resultId: '2' })).rejects.toMatchObject({ code: 'OXDT0008' }) + await handler({ resultId: '1' }) + await expect(readFile(join(cwd, '.devtools-oxc/fmt/1/log.json'))).rejects.toMatchObject({ + code: 'ENOENT', + }) + } finally { + await rm(cwd, { recursive: true, force: true }) + } +}) diff --git a/packages/oxc/src/node/__tests__/oxfmt-run.test.ts b/packages/oxc/src/node/__tests__/oxfmt-run.test.ts new file mode 100644 index 000000000..7c498c0e2 --- /dev/null +++ b/packages/oxc/src/node/__tests__/oxfmt-run.test.ts @@ -0,0 +1,136 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + getOxfmtFormatCommand, + getOxfmtRunError, + listOxfmtFormatResults, + parseOxfmtFormatOutput, + saveOxfmtFormatResult, +} from '../rpc/functions/oxfmt-run' + +const fixtures: string[] = [] + +async function createFixture() { + const cwd = await mkdtemp(join(tmpdir(), 'oxfmt-run-')) + fixtures.push(cwd) + return cwd +} + +afterEach(async () => { + await Promise.all(fixtures.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +describe('getOxfmtFormatCommand', () => { + it('uses Vite+ when available and Oxfmt otherwise', () => { + expect(getOxfmtFormatCommand(false, true)).toEqual({ + command: 'vp', + args: ['fmt', '--check'], + }) + expect(getOxfmtFormatCommand(true, false)).toEqual({ + command: 'oxfmt', + args: ['--write'], + }) + }) + + it('returns Oxfmt diagnostics without whitespace', () => { + expect(getOxfmtRunError(' Invalid config.\n')).toBe('Invalid config.') + expect(getOxfmtRunError(' \n')).toBeUndefined() + }) + + it('persists each parsed check under its timestamp directory', async () => { + const cwd = await createFixture() + await writeFile(join(cwd, '.gitignore'), '') + const log = parseOxfmtFormatOutput( + 'Checking formatting...\n\nAll matched files use the correct format.\nFinished in 113ms on 11 files using 8 threads.', + )! + + await saveOxfmtFormatResult(cwd, log) + + await expect(listOxfmtFormatResults(cwd)).resolves.toMatchObject([ + { mode: 'check', status: 'clean', summary: { durationMs: 113 } }, + ]) + }) +}) + +describe('parseOxfmtFormatOutput', () => { + it('persists write results and uses the exit code for their status', async () => { + const cwd = await createFixture() + await writeFile(join(cwd, '.gitignore'), '') + const stdout = 'src/main.ts (0.5ms)\nFinished in 2ms on 1 files using 8 threads.' + const log = parseOxfmtFormatOutput(stdout, 'write', 0) + expect(log).toMatchObject({ + mode: 'write', + status: 'clean', + files: [{ path: 'src/main.ts', durationMs: 0.5 }], + }) + expect(parseOxfmtFormatOutput(stdout, 'write', 1).status).toBe('error') + await saveOxfmtFormatResult(cwd, log) + await expect(listOxfmtFormatResults(cwd)).resolves.toMatchObject([log]) + }) + + it('treats legacy logs as checks and sorts their files by duration', async () => { + const cwd = await createFixture() + const dir = join(cwd, '.devtools-oxc', 'fmt', '1') + await mkdir(dir, { recursive: true }) + const { mode: _mode, ...legacy } = parseOxfmtFormatOutput('') + await writeFile( + join(dir, 'log.json'), + JSON.stringify({ + timestamp: 1, + ...legacy, + files: [ + { path: 'eslint.config.js', durationMs: 1 }, + { path: 'index.html', durationMs: 115 }, + { path: 'vite.config.js', durationMs: 2 }, + ], + }), + ) + await expect(listOxfmtFormatResults(cwd)).resolves.toMatchObject([ + { + mode: 'check', + files: [ + { path: 'index.html', durationMs: 115 }, + { path: 'vite.config.js', durationMs: 2 }, + { path: 'eslint.config.js', durationMs: 1 }, + ], + }, + ]) + }) + + it('parses files and summary when formatting is needed', () => { + expect( + parseOxfmtFormatOutput(`Checking formatting... +index.html (103ms) +src/main.js (200ms) +Format issues found in above 2 files. Run without \`--check\` to fix. +Finished in 113ms on 11 files using 8 threads.`), + ).toEqual({ + mode: 'check', + status: 'issues', + files: [ + { path: 'src/main.js', durationMs: 200 }, + { path: 'index.html', durationMs: 103 }, + ], + summary: { durationMs: 113, fileCount: 11, threadCount: 8 }, + stdout: `Checking formatting... +index.html (103ms) +src/main.js (200ms) +Format issues found in above 2 files. Run without \`--check\` to fix. +Finished in 113ms on 11 files using 8 threads.`, + }) + }) + + it('records failed checks even when Oxfmt does not print a summary', () => { + expect( + parseOxfmtFormatOutput( + 'Checking formatting...\n\nAll matched files use the correct format.\nFinished in 113ms on 11 files using 8 threads.', + ), + ).toMatchObject({ status: 'clean', files: [] }) + expect(parseOxfmtFormatOutput('Oxfmt failed to load config.')).toMatchObject({ + status: 'error', + summary: { durationMs: 0, fileCount: 0, threadCount: 0 }, + }) + }) +}) diff --git a/packages/oxc/src/node/diagnostics.ts b/packages/oxc/src/node/diagnostics.ts index daba01e2f..32d98478f 100644 --- a/packages/oxc/src/node/diagnostics.ts +++ b/packages/oxc/src/node/diagnostics.ts @@ -5,6 +5,11 @@ export const diagnostics = /* #__PURE__ */ defineDiagnostics({ docsBase: 'https://devtools.vite.dev/errors', reporters: [/* #__PURE__ */ createConsoleReporter()], codes: { + OXDT0008: { + why: (p: { resultId: string; reason: string }) => + `Failed to delete format result "${p.resultId}": ${p.reason}`, + fix: 'Use a numeric ID from the format result list and check that the result exists and the project directory is writable.', + }, OXDT0001: { why: (p: { reason: string }) => `Failed to create a lint result: ${p.reason}`, fix: 'Check that oxlint is installed, its configuration is valid, and the project directory is writable.', @@ -31,5 +36,9 @@ export const diagnostics = /* #__PURE__ */ defineDiagnostics({ why: (p: { reason: string }) => `Failed to set up Oxfmt: ${p.reason}`, fix: 'Check the project package manager and configuration, then try again.', }, + OXDT0007: { + why: (p: { reason: string }) => `Failed to run Oxfmt: ${p.reason}`, + fix: 'Check that Oxfmt is installed and its configuration is valid, then try again.', + }, }, }) diff --git a/packages/oxc/src/node/rpc/functions/oxfmt-delete-result.ts b/packages/oxc/src/node/rpc/functions/oxfmt-delete-result.ts new file mode 100644 index 000000000..fe2a3ebfd --- /dev/null +++ b/packages/oxc/src/node/rpc/functions/oxfmt-delete-result.ts @@ -0,0 +1,27 @@ +import { rm } from 'node:fs/promises' +import { Diagnostic } from 'nostics' +import { resolve } from 'pathe' +import { diagnostics } from '../../diagnostics' +import { defineOxcRpc } from '../_define' + +export const oxfmtDeleteResult = defineOxcRpc({ + name: 'devtools-oxc:delete-format-result', + type: 'action', + setup: context => ({ + handler: async ({ resultId }: { resultId: string }) => { + try { + if (typeof resultId !== 'string' || !/^\d+$/.test(resultId)) { + throw diagnostics.OXDT0008({ resultId, reason: 'Invalid format result ID.' }) + } + await rm(resolve(context.cwd, '.devtools-oxc', 'fmt', resultId), { recursive: true }) + } catch (error) { + if (error instanceof Diagnostic) throw error + throw diagnostics.OXDT0008({ + resultId, + reason: error instanceof Error ? error.message : String(error), + cause: error, + }) + } + }, + }), +}) diff --git a/packages/oxc/src/node/rpc/functions/oxfmt-list-results.ts b/packages/oxc/src/node/rpc/functions/oxfmt-list-results.ts new file mode 100644 index 000000000..5efdb7b72 --- /dev/null +++ b/packages/oxc/src/node/rpc/functions/oxfmt-list-results.ts @@ -0,0 +1,11 @@ +import { defineOxcRpc } from '../_define' +import { listOxfmtFormatResults } from './oxfmt-run' + +export const oxfmtListResults = defineOxcRpc({ + name: 'devtools-oxc:list-format-results', + type: 'query', + jsonSerializable: true, + setup: context => ({ + handler: () => listOxfmtFormatResults(context.cwd), + }), +}) diff --git a/packages/oxc/src/node/rpc/functions/oxfmt-run.ts b/packages/oxc/src/node/rpc/functions/oxfmt-run.ts new file mode 100644 index 000000000..c79dcabee --- /dev/null +++ b/packages/oxc/src/node/rpc/functions/oxfmt-run.ts @@ -0,0 +1,146 @@ +import { existsSync } from 'node:fs' +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import { Diagnostic } from 'nostics' +import { resolve } from 'pathe' +import { x } from 'tinyexec' +import { diagnostics } from '../../diagnostics' +import { isVitePlusInstalled } from '../../utils/vite-plus' +import { ensureOxcGitignored } from '../../utils/oxlint' +import { defineOxcRpc } from '../_define' +import { isGitDirty } from './oxfmt-setup' + +type OxfmtCommand = { command: string; args: string[] } + +export type OxfmtFormatLog = { + mode: 'check' | 'write' + status: 'clean' | 'issues' | 'error' + files: { path: string; durationMs: number }[] + summary: { durationMs: number; fileCount: number; threadCount: number } + stdout: string +} + +export type OxfmtFormatResult = OxfmtFormatLog & { timestamp: number } + +export async function saveOxfmtFormatResult(root: string, log: OxfmtFormatLog) { + const timestamp = Date.now() + const dir = resolve(root, '.devtools-oxc', 'fmt', String(timestamp)) + await ensureOxcGitignored(root) + await mkdir(dir, { recursive: true }) + await writeFile(resolve(dir, 'log.json'), JSON.stringify({ timestamp, ...log }, null, 2), 'utf-8') +} + +export async function listOxfmtFormatResults(root: string): Promise { + const dir = resolve(root, '.devtools-oxc', 'fmt') + if (!existsSync(dir)) return [] + + const results = await Promise.all( + (await readdir(dir, { withFileTypes: true })) + .filter(entry => entry.isDirectory() && /^\d+$/.test(entry.name)) + .sort((a, b) => Number(b.name) - Number(a.name)) + .map(async entry => { + try { + const result = JSON.parse( + await readFile(resolve(dir, entry.name, 'log.json'), 'utf-8'), + ) as OxfmtFormatResult + result.mode ??= 'check' + result.files.sort((a, b) => b.durationMs - a.durationMs) + return result + } catch { + return null + } + }), + ) + return results.filter(result => result !== null) +} + +export function parseOxfmtFormatOutput( + stdout: string, + mode: OxfmtFormatLog['mode'] = 'check', + exitCode = 0, +): OxfmtFormatLog { + const status = + mode === 'write' + ? exitCode === 0 + ? 'clean' + : 'error' + : stdout.includes('All matched files use the correct format.') + ? 'clean' + : /Format issues found in above \d+ files\./.test(stdout) + ? 'issues' + : 'error' + const summary = stdout.match(/Finished in (\d+(?:\.\d+)?)ms on (\d+) files using (\d+) threads\./) + + return { + mode, + status, + files: [...stdout.matchAll(/^(.+) \((\d+(?:\.\d+)?)ms\)$/gm)] + .map(([, path, durationMs]) => ({ path: path!, durationMs: Number(durationMs) })) + .sort((a, b) => b.durationMs - a.durationMs), + summary: { + durationMs: Number(summary?.[1] ?? 0), + fileCount: Number(summary?.[2] ?? 0), + threadCount: Number(summary?.[3] ?? 0), + }, + stdout, + } +} + +export function getOxfmtFormatCommand(write: boolean, vitePlus: boolean): OxfmtCommand { + const option = write ? '--write' : '--check' + if (vitePlus) return { command: 'vp', args: ['fmt', option] } + return { command: 'oxfmt', args: [option] } +} + +export function getOxfmtRunError(stderr: string) { + return stderr.trim() || undefined +} + +async function getPreview(root: string, write: boolean) { + const command = getOxfmtFormatCommand(write, isVitePlusInstalled(root)) + return { + command: [command.command, ...command.args].join(' '), + gitDirty: write && (await isGitDirty(root)), + } +} + +export const oxfmtFormatPreview = defineOxcRpc({ + name: 'devtools-oxc:oxfmt-format-preview', + type: 'query', + jsonSerializable: true, + setup: context => ({ + handler: ({ write }: { write: boolean }) => getPreview(context.cwd, write), + }), +}) + +export const oxfmtRun = defineOxcRpc({ + name: 'devtools-oxc:run-format', + type: 'action', + jsonSerializable: true, + setup: context => ({ + handler: async ({ write }: { write: boolean }) => { + try { + const command = getOxfmtFormatCommand(write, isVitePlusInstalled(context.cwd)) + const result = await x(command.command, command.args, { + nodeOptions: { cwd: context.cwd, env: { FORCE_COLOR: '0', NO_COLOR: '1' } }, + }) + const reason = getOxfmtRunError(result.stderr) + if (reason) { + throw diagnostics.OXDT0007({ + reason, + }) + } + await saveOxfmtFormatResult( + context.cwd, + parseOxfmtFormatOutput(result.stdout, write ? 'write' : 'check', result.exitCode), + ) + return { exitCode: result.exitCode } + } catch (error) { + if (error instanceof Diagnostic) throw error + throw diagnostics.OXDT0007({ + reason: error instanceof Error ? error.message : String(error), + cause: error, + }) + } + }, + }), +}) diff --git a/packages/oxc/src/node/rpc/functions/oxfmt-setup.ts b/packages/oxc/src/node/rpc/functions/oxfmt-setup.ts index 3dc1ba386..2553bce0c 100644 --- a/packages/oxc/src/node/rpc/functions/oxfmt-setup.ts +++ b/packages/oxc/src/node/rpc/functions/oxfmt-setup.ts @@ -26,7 +26,7 @@ async function getSetupCommands(root: string, migrate: boolean): Promise { +export async function isGitDirty(root: string): Promise { try { const result = await x('git', ['-C', root, 'status', '--porcelain'], { nodeOptions: { cwd: root }, diff --git a/packages/oxc/src/node/rpc/index.ts b/packages/oxc/src/node/rpc/index.ts index 73f67aff7..ec779e01f 100644 --- a/packages/oxc/src/node/rpc/index.ts +++ b/packages/oxc/src/node/rpc/index.ts @@ -16,6 +16,9 @@ import { oxlintWaitForSetup, } from './functions/oxlint-setup' import { oxfmtSetup, oxfmtSetupPreview } from './functions/oxfmt-setup' +import { oxfmtFormatPreview, oxfmtRun } from './functions/oxfmt-run' +import { oxfmtListResults } from './functions/oxfmt-list-results' +import { oxfmtDeleteResult } from './functions/oxfmt-delete-result' export const rpcFunctions = [ oxlintRun, @@ -33,6 +36,10 @@ export const rpcFunctions = [ oxlintWaitForSetup, oxfmtSetup, oxfmtSetupPreview, + oxfmtFormatPreview, + oxfmtRun, + oxfmtListResults, + oxfmtDeleteResult, openInEditor, ] as const