diff --git a/README.md b/README.md index c793fdb..7071992 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ All features support multi-root workspace project. - Support `.qmllint.ini` configuration file - Code completion (requires PySide6 >= 6.4) - Preview QML file in a separate window (requires PySide6) +- Format QML file (requires PySide6 >= 6.5.2) ### Qt UI Files diff --git a/package.json b/package.json index 05da9ff..39e5ad2 100644 --- a/package.json +++ b/package.json @@ -143,11 +143,6 @@ "command": "qtForPython.compileTranslations", "title": "Compile Qt Translation File (lrelease)", "category": "Qt for Python" - }, - { - "command": "qtForPython.formatQml", - "title": "Format QML File (qmlformat)", - "category": "Qt for Python" } ], "menus": { @@ -191,11 +186,6 @@ "command": "qtForPython.compileTranslations", "when": "resourceExtname == .ts && resourceLangId == xml", "group": "qtForPython" - }, - { - "command": "qtForPython.formatQml", - "when": "resourceLangId == qml", - "group": "qtForPython" } ], "explorer/context": [ @@ -238,11 +228,6 @@ "command": "qtForPython.compileTranslations", "when": "resourceExtname == .ts && resourceLangId == xml", "group": "qtForPython" - }, - { - "command": "qtForPython.formatQml", - "when": "resourceLangId == qml", - "group": "qtForPython" } ], "editor/title": [ @@ -285,11 +270,6 @@ "command": "qtForPython.compileTranslations", "when": "resourceExtname == .ts && resourceLangId == xml", "group": "qtForPython" - }, - { - "command": "qtForPython.formatQml", - "when": "resourceLangId == qml", - "group": "qtForPython" } ], "editor/context": [ @@ -332,11 +312,6 @@ "command": "qtForPython.compileTranslations", "when": "resourceExtname == .ts && resourceLangId == xml", "group": "qtForPython" - }, - { - "command": "qtForPython.formatQml", - "when": "resourceLangId == qml", - "group": "qtForPython" } ] }, @@ -512,9 +487,7 @@ "items": { "type": "string" }, - "default": [ - "--inplace" - ], + "default": [], "markdownDescription": "The options passed to `qmlformat` executable for QML formatting. See [here](https://github.com/seanwu1105/vscode-qt-for-python#predefined-variables) for a detailed list of predefined variables.", "scope": "resource" } diff --git a/src/extension.ts b/src/extension.ts index 9f75f8f..c6606e8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,6 +3,7 @@ import { catchError, of } from 'rxjs' import type { ExtensionContext, OutputChannel } from 'vscode' import { window } from 'vscode' import { registerCommands$ } from './commands' +import { registerQmlFormatter$ } from './qmlformat/format-qml' import { registerQmlLanguageServer$ } from './qmlls/client' import { registerQssColorProvider } from './qss/color-provider' import { registerRccLiveExecution$ } from './rcc/rcc-live-execution' @@ -27,6 +28,7 @@ export async function activate({ registerUicLiveExecution$({ extensionUri }), registerRccLiveExecution$({ extensionUri }), registerQmlLanguageServer$({ extensionUri, outputChannel }), + registerQmlFormatter$({ extensionUri }), ] const observer: Partial> = { diff --git a/src/qmlformat/format-qml.ts b/src/qmlformat/format-qml.ts new file mode 100644 index 0000000..138e9c6 --- /dev/null +++ b/src/qmlformat/format-qml.ts @@ -0,0 +1,65 @@ +import { ReplaySubject, firstValueFrom, using } from 'rxjs' +import { Range, TextEdit, languages } from 'vscode' +import type { URI } from 'vscode-uri' +import type { ExecError, StdErrError } from '../run' +import { run } from '../run' +import { getToolCommand$ } from '../tool-utils' +import type { SuccessResult } from '../types' +import { type ErrorResult } from '../types' + +export function registerQmlFormatter$({ + extensionUri, +}: { + readonly extensionUri: URI +}) { + const formatResult$ = new ReplaySubject< + SuccessResult | ErrorResult<'NotFound'> | ExecError | StdErrError + >(1) + + return using( + () => { + const disposable = languages.registerDocumentFormattingEditProvider( + 'qml', + { + async provideDocumentFormattingEdits(document) { + const getToolCommandResult = await firstValueFrom( + getToolCommand$({ + tool: 'qmlformat', + extensionUri, + resource: document.uri, + }), + ) + if (getToolCommandResult.kind !== 'Success') { + formatResult$.next(getToolCommandResult) + return [] + } + + const { command, options } = getToolCommandResult.value + const runResult = await run({ + command: [...command, ...options, document.uri.fsPath], + }) + if (runResult.kind !== 'Success') { + formatResult$.next(runResult) + return [] + } + + const formatted = runResult.value.stdout + const fullRange = document.validateRange( + new Range( + document.lineAt(0).range.start, + document.lineAt(document.lineCount - 1).range.end, + ), + ) + formatResult$.next({ + kind: 'Success', + value: `Formatted ${document.uri.fsPath}`, + }) + return [TextEdit.replace(fullRange, formatted)] + }, + }, + ) + return { unsubscribe: () => disposable.dispose() } + }, + () => formatResult$.asObservable(), + ) +} diff --git a/src/test/suite/qmlformat/format-qml.test.ts b/src/test/suite/qmlformat/format-qml.test.ts new file mode 100644 index 0000000..6ba842d --- /dev/null +++ b/src/test/suite/qmlformat/format-qml.test.ts @@ -0,0 +1,52 @@ +import * as assert from 'node:assert' +import * as path from 'node:path' +import type { TextDocument, TextEdit } from 'vscode' +import { WorkspaceEdit, commands, window, workspace } from 'vscode' +import { URI } from 'vscode-uri' +import { + E2E_TIMEOUT, + TEST_ASSETS_PATH, + setupE2EEnvironment, +} from '../test-utils' + +suite('format-qml/e2e', () => { + suiteSetup(async function () { + this.timeout(E2E_TIMEOUT) + await setupE2EEnvironment() + }) + + suite('when a qml file is open', () => { + const sampleFilenameNoExt = 'unformatted' + let document: TextDocument + + setup(async function () { + this.timeout(E2E_TIMEOUT) + + document = await workspace.openTextDocument( + URI.file( + path.resolve(TEST_ASSETS_PATH, 'qml', `${sampleFilenameNoExt}.qml`), + ), + ) + await window.showTextDocument(document) + }) + + teardown(async function () { + this.timeout(E2E_TIMEOUT) + await commands.executeCommand('workbench.action.closeActiveEditor') + }) + + test('should be able to run formatQml command', async () => { + const originalContent = document.getText() + const edits: TextEdit[] = await commands.executeCommand( + 'vscode.executeFormatDocumentProvider', + document.uri, + ) + + const workspaceEdit = new WorkspaceEdit() + workspaceEdit.set(document.uri, edits) + await workspace.applyEdit(workspaceEdit) + + return assert.notDeepStrictEqual(originalContent, document.getText()) + }).timeout(E2E_TIMEOUT) + }) +}).timeout(E2E_TIMEOUT) diff --git a/src/test/suite/test-utils.ts b/src/test/suite/test-utils.ts index 0117450..f7d1a2e 100644 --- a/src/test/suite/test-utils.ts +++ b/src/test/suite/test-utils.ts @@ -2,7 +2,7 @@ import * as assert from 'node:assert' import * as path from 'node:path' import { extensions, workspace } from 'vscode' import { URI } from 'vscode-uri' -import { notNil } from '../../utils' +import { isNil, notNil } from '../../utils' // eslint-disable-next-line @typescript-eslint/no-var-requires const { name, publisher } = require('../../../package.json') @@ -55,16 +55,20 @@ export async function waitFor( } const start = Date.now() + let error: unknown | undefined while (Date.now() - start < (options?.timeout ?? defaultOptions.timeout)) { try { return await callback() } catch (e) { + error = e await sleep(options?.interval ?? defaultOptions.interval) } } - throw new Error( - `Timeout during waitFor: ${options?.timeout ?? defaultOptions.timeout}ms`, - ) + if (isNil(error)) + throw new Error( + `Timeout during waitFor: ${options?.timeout ?? defaultOptions.timeout}ms`, + ) + throw error } export async function forceDeleteFile(filename: string) {