diff --git a/index.js b/index.js index 835b8be2..100752f8 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,13 @@ 'use strict'; -const { LanguageClient, SettingMonitor, ExecuteCommandRequest } = require('vscode-languageclient'); -const { workspace, commands: Commands, window: Window } = require('vscode'); +const { + LanguageClient, + SettingMonitor, + ExecuteCommandRequest, + DocumentFormattingRequest, + TextDocumentIdentifier, +} = require('vscode-languageclient'); +const { workspace, commands: Commands, window: Window, languages: Languages } = require('vscode'); /** * @typedef {import('vscode').ExtensionContext} ExtensionContext @@ -37,6 +43,69 @@ exports.activate = ({ subscriptions }) => { }, ); + client.onReady().then(() => { + /** + * Map of registered formatters by language ID. + * @type {Map} + */ + const registeredFormatters = new Map(); + + client.onNotification('stylelint/languageIdsAdded', (/** @type {string[]} */ langIds) => { + for (const langId of langIds) { + // Avoid registering another formatter if we already registered one for the same language ID. + if (registeredFormatters.has(langId)) { + return; + } + + const formatter = Languages.registerDocumentFormattingEditProvider(langId, { + provideDocumentFormattingEdits(textDocument, options) { + const params = { + textDocument: TextDocumentIdentifier.create(textDocument.uri.toString()), + options, // Editor formatting options, overriden by stylelint config. + }; + + // Request that the language server formats the document. + return client + .sendRequest(DocumentFormattingRequest.type, params) + .then(undefined, () => { + Window.showErrorMessage( + 'Failed to format the document using stylelint. Please consider opening an issue with steps to reproduce.', + ); + + return null; + }); + }, + }); + + // Keep track of the new formatter. + registeredFormatters.set(langId, formatter); + } + }); + + client.onNotification('stylelint/languageIdsRemoved', (/** @type {string[]} */ langIds) => { + for (const langId of langIds) { + const formatter = registeredFormatters.get(langId); + + if (!formatter) { + return; + } + + // Unregisters formatter. + formatter.dispose(); + registeredFormatters.delete(langId); + } + }); + + // Make sure that formatters are disposed when extension is unloaded. + subscriptions.push({ + dispose() { + for (const formatter of registeredFormatters.values()) { + formatter.dispose(); + } + }, + }); + }); + subscriptions.push( Commands.registerCommand('stylelint.executeAutofix', async () => { const textEditor = Window.activeTextEditor; diff --git a/server.js b/server.js index a9057fc6..8ae19fec 100644 --- a/server.js +++ b/server.js @@ -245,10 +245,36 @@ async function validate(document) { /** * @param {TextDocument} document + * @param {import('vscode-languageserver').FormattingOptions?} formattingOptions Formatting options to use. + * Overriden by stylelint configuration. * @returns {Promise} */ -async function getFixes(document) { - const options = await buildStylelintOptions(document, { fix: true }); +async function getFixes(document, formattingOptions = null) { + /** @type {Partial} */ + const baseOptions = { fix: true }; + + // If formatting options were provided, translate them to their corresponding rules. + // NOTE: There is no equivalent rule for trimFinalNewlines, so it is not respected. + if (formattingOptions) { + const { insertSpaces, tabSize, insertFinalNewline, trimTrailingWhitespace } = formattingOptions; + + /** @type {Record} */ + const rules = { + indentation: [insertSpaces ? tabSize : 'tab'], + }; + + if (insertFinalNewline !== undefined) { + rules['no-missing-end-of-source-newline'] = insertFinalNewline; + } + + if (trimTrailingWhitespace !== undefined) { + rules['no-eol-whitespace'] = trimTrailingWhitespace; + } + + baseOptions.config = { rules }; + } + + const options = await buildStylelintOptions(document, baseOptions); try { const result = await stylelintVSCode( @@ -319,6 +345,7 @@ connection.onInitialize(() => { executeCommandProvider: { commands: [CommandIds.applyAutoFix], }, + documentFormattingProvider: true, codeActionProvider: { codeActionKinds: [CodeActionKind.QuickFix, StylelintSourceFixAll] }, completionProvider: {}, }, @@ -350,6 +377,16 @@ connection.onDidChangeConfiguration(({ settings }) => { clearDiagnostics(document); } + if (removeLanguages.length > 0) { + connection.sendNotification('stylelint/languageIdsRemoved', removeLanguages); + } + + const addLanguages = validateLanguages.filter((lang) => !oldValidateLanguages.includes(lang)); + + if (addLanguages.length > 0) { + connection.sendNotification('stylelint/languageIdsAdded', addLanguages); + } + validateAll(); }); connection.onDidChangeWatchedFiles(validateAll); @@ -400,6 +437,22 @@ connection.onExecuteCommand(async (params) => { return {}; }); +connection.onDocumentFormatting((params) => { + if (!params.textDocument) { + return null; + } + + /** @type { { uri: string } } */ + const identifier = params.textDocument; + const uri = identifier.uri; + const document = documents.get(uri); + + if (!document || !isValidateOn(document)) { + return null; + } + + return getFixes(document, params.options); +}); connection.onCodeAction(async (params) => { const only = params.context.only !== undefined ? params.context.only[0] : undefined; const isSource = only === CodeActionKind.Source; diff --git a/test/ws-formatter-test/.vscode/settings.json b/test/ws-formatter-test/.vscode/settings.json new file mode 100644 index 00000000..6d166663 --- /dev/null +++ b/test/ws-formatter-test/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "[css]": { + "editor.defaultFormatter": "stylelint.vscode-stylelint" + } +} diff --git a/test/ws-formatter-test/index.js b/test/ws-formatter-test/index.js new file mode 100644 index 00000000..820e84e7 --- /dev/null +++ b/test/ws-formatter-test/index.js @@ -0,0 +1,41 @@ +'use strict'; + +const fs = require('fs/promises'); +const path = require('path'); + +const pWaitFor = require('p-wait-for'); +const test = require('tape'); +const { extensions, workspace, Uri, commands, window } = require('vscode'); + +const run = () => + test('vscode-stylelint', async (t) => { + const expectedCss = await fs.readFile(path.resolve(__dirname, 'test.expected.css'), 'utf8'); + + await commands.executeCommand('vscode.openFolder', Uri.file(__dirname)); + + const vscodeStylelint = extensions.getExtension('stylelint.vscode-stylelint'); + + // Open the './test.input.css' file. + const cssDocument = await workspace.openTextDocument(path.resolve(__dirname, 'test.input.css')); + + await window.showTextDocument(cssDocument); + + await commands.executeCommand('editor.action.indentUsingTabs', { + tabSize: 4, + indentSize: 4, + insertSpaces: false, + }); + + await pWaitFor(() => vscodeStylelint.isActive, { timeout: 2000 }); + + await commands.executeCommand('editor.action.formatDocument'); + + t.equal(cssDocument.getText(), expectedCss, 'should format document using formatting options.'); + + t.end(); + }); + +exports.run = (root, done) => { + test.onFinish(done); + run(); +}; diff --git a/test/ws-formatter-test/test.expected.css b/test/ws-formatter-test/test.expected.css new file mode 100644 index 00000000..195b6bcf --- /dev/null +++ b/test/ws-formatter-test/test.expected.css @@ -0,0 +1,3 @@ +a { + color: red; +} diff --git a/test/ws-formatter-test/test.input.css b/test/ws-formatter-test/test.input.css new file mode 100644 index 00000000..fdece7b4 --- /dev/null +++ b/test/ws-formatter-test/test.input.css @@ -0,0 +1,3 @@ +a { + color: red; +}