diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cbdd726..c68115a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] +### Changed + +- The extension no longer blocks VS Code's startup. Thanks to [@robole](https://github.com/robole) for the idea. ([#257](https://github.com/stylelint/vscode-stylelint/pull/257)) + ## [1.0.2](https://github.com/stylelint/vscode-stylelint/compare/v1.0.1...v1.0.2) (2021-10-26) ### Fixed diff --git a/package-lock.json b/package-lock.json index 8a1b16f8..aad4b94f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "stylelint": "^14.0.0", "stylelint-processor-glamorous": "^0.3.0", "stylelint-processor-styled-components": "^1.10.0", + "typed-emitter": "^1.4.0", "typescript": "^4.4.3", "zen-observable": "^0.8.15" }, @@ -8881,6 +8882,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-emitter": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.4.0.tgz", + "integrity": "sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==", + "dev": true + }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -16060,6 +16067,12 @@ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true }, + "typed-emitter": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.4.0.tgz", + "integrity": "sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==", + "dev": true + }, "typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", diff --git a/package.json b/package.json index 68c2af41..d37cad11 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "check" ], "activationEvents": [ - "*" + "onStartupFinished" ], "contributes": { "configuration": { @@ -200,6 +200,7 @@ "stylelint": "^14.0.0", "stylelint-processor-glamorous": "^0.3.0", "stylelint-processor-styled-components": "^1.10.0", + "typed-emitter": "^1.4.0", "typescript": "^4.4.3", "zen-observable": "^0.8.15" }, diff --git a/src/index.js b/src/index.js index fae76959..6622771e 100644 --- a/src/index.js +++ b/src/index.js @@ -1,19 +1,24 @@ 'use strict'; +const events = require('events'); const { LanguageClient, SettingMonitor, ExecuteCommandRequest, } = require('vscode-languageclient/node'); const { workspace, commands: Commands, window: Window } = require('vscode'); -const { CommandId } = require('./utils/types'); +const { CommandId, Notification, ApiEvent } = require('./utils/types'); /** * @param {vscode.ExtensionContext} context + * @returns {ExtensionPublicApi} */ -exports.activate = ({ subscriptions }) => { +function activate({ subscriptions }) { const serverPath = require.resolve('./start-server.js'); + /** @type {ExtensionPublicApi} */ + const api = new events.EventEmitter(); + const client = new LanguageClient( 'Stylelint', { @@ -39,6 +44,12 @@ exports.activate = ({ subscriptions }) => { }, ); + client.onReady().then(() => { + client.onNotification(Notification.DidRegisterDocumentFormattingEditProvider, () => { + api.emit(ApiEvent.DidRegisterDocumentFormattingEditProvider); + }); + }); + subscriptions.push( Commands.registerCommand('stylelint.executeAutofix', async () => { const textEditor = Window.activeTextEditor; @@ -66,5 +77,12 @@ exports.activate = ({ subscriptions }) => { }); }), ); + subscriptions.push(new SettingMonitor(client, 'stylelint.enable').start()); + + return api; +} + +module.exports = { + activate, }; diff --git a/src/server/modules/__tests__/formatter.js b/src/server/modules/__tests__/formatter.js index 1341f70c..9b799d82 100644 --- a/src/server/modules/__tests__/formatter.js +++ b/src/server/modules/__tests__/formatter.js @@ -3,11 +3,13 @@ const { DocumentFormattingRequest } = require('vscode-languageserver-protocol'); const { Position, TextEdit } = require('vscode-languageserver-types'); +const { Notification } = require('../../../utils/types'); const { FormatterModule } = require('../formatter'); const mockContext = { connection: { onDocumentFormatting: jest.fn(), + sendNotification: jest.fn(), client: { register: jest.fn() }, }, documents: { get: jest.fn() }, @@ -215,7 +217,7 @@ describe('FormatterModule', () => { ); }); - test("with no debug log level, onDidChangeValidateLanguages shouldn't log languages", () => { + test("with no debug log level, onDidChangeValidateLanguages shouldn't log languages", async () => { mockLogger.isDebugEnabled.mockReturnValue(false); const module = new FormatterModule(getParams(true)); @@ -229,7 +231,7 @@ describe('FormatterModule', () => { }), ); - module.onDidChangeValidateLanguages({ + await module.onDidChangeValidateLanguages({ languages: new Set(['foo']), removedLanguages: new Set(), }); @@ -238,7 +240,7 @@ describe('FormatterModule', () => { expect(mockLogger.debug).not.toHaveBeenCalledWith('Registering formatter for languages'); }); - test("without client dynamic registration support, onDidChangeValidateLanguages shouldn't register a formatter", () => { + test("without client dynamic registration support, onDidChangeValidateLanguages shouldn't register a formatter", async () => { const module = new FormatterModule(getParams(true)); module.onInitialize( @@ -251,7 +253,7 @@ describe('FormatterModule', () => { }), ); - module.onDidChangeValidateLanguages({ + await module.onDidChangeValidateLanguages({ languages: new Set(['foo']), removedLanguages: new Set(), }); @@ -259,7 +261,7 @@ describe('FormatterModule', () => { expect(mockContext.connection.client.register).not.toHaveBeenCalled(); }); - test('with client dynamic registration support, onDidChangeValidateLanguages should register a formatter', () => { + test('with client dynamic registration support, onDidChangeValidateLanguages should register a formatter', async () => { const module = new FormatterModule(getParams(true)); module.onInitialize( @@ -272,7 +274,7 @@ describe('FormatterModule', () => { }), ); - module.onDidChangeValidateLanguages({ + await module.onDidChangeValidateLanguages({ languages: new Set(['foo']), removedLanguages: new Set(), }); @@ -283,7 +285,7 @@ describe('FormatterModule', () => { ); }); - test('without languages to validate, onDidChangeValidateLanguages should register a formatter', () => { + test('when a formatter is registered, a notification should be sent', async () => { const module = new FormatterModule(getParams(true)); module.onInitialize( @@ -296,7 +298,31 @@ describe('FormatterModule', () => { }), ); - module.onDidChangeValidateLanguages({ + await module.onDidChangeValidateLanguages({ + languages: new Set(['foo']), + removedLanguages: new Set(), + }); + + expect(mockContext.connection.sendNotification).toHaveBeenCalledWith( + Notification.DidRegisterDocumentFormattingEditProvider, + {}, + ); + }); + + test('without languages to validate, onDidChangeValidateLanguages should register a formatter', async () => { + const module = new FormatterModule(getParams(true)); + + module.onInitialize( + /** @type {any} */ ({ + capabilities: { + textDocument: { + formatting: { dynamicRegistration: true }, + }, + }, + }), + ); + + await module.onDidChangeValidateLanguages({ languages: new Set(), removedLanguages: new Set(), }); @@ -304,15 +330,11 @@ describe('FormatterModule', () => { expect(mockContext.connection.client.register).not.toHaveBeenCalled(); }); - test('when a formatter was already registered, onDidChangeValidateLanguages should dispose the old registration', () => { + test('when a formatter was already registered, onDidChangeValidateLanguages should dispose the old registration', async () => { mockLogger.isDebugEnabled.mockReturnValue(true); - const fakePromise = (/** @type {any} */ resolutionValue) => ({ - then: (/** @type {Function} */ resolve) => resolve(resolutionValue), - }); - - const mockRegistration = { dispose: jest.fn(() => fakePromise()) }; + const mockRegistration = { dispose: jest.fn() }; - mockContext.connection.client.register.mockReturnValueOnce(fakePromise(mockRegistration)); + mockContext.connection.client.register.mockResolvedValueOnce(mockRegistration); const module = new FormatterModule(getParams(true)); @@ -326,12 +348,12 @@ describe('FormatterModule', () => { }), ); - module.onDidChangeValidateLanguages({ + await module.onDidChangeValidateLanguages({ languages: new Set(['foo']), removedLanguages: new Set(), }); - module.onDidChangeValidateLanguages({ + await module.onDidChangeValidateLanguages({ languages: new Set(['bar']), removedLanguages: new Set(['foo']), }); diff --git a/src/server/modules/formatter.js b/src/server/modules/formatter.js index 4395885e..ead1a495 100644 --- a/src/server/modules/formatter.js +++ b/src/server/modules/formatter.js @@ -2,6 +2,7 @@ const { DocumentFormattingRequest } = require('vscode-languageserver-protocol'); const { formattingOptionsToRules } = require('../../utils/stylelint'); +const { Notification } = require('../../utils/types'); /** * @implements {LanguageServerModule} @@ -29,9 +30,8 @@ class FormatterModule { #registerDynamically = false; /** - * A promise that resolves to the disposable for the dynamically registered - * document formatter. - * @type {Promise | undefined} + * The disposable for the dynamically registered document formatter. + * @type {lsp.Disposable | undefined} */ #registration = undefined; @@ -120,9 +120,9 @@ class FormatterModule { /** * @param {DidChangeValidateLanguagesParams} params - * @returns {void} + * @returns {Promise} */ - onDidChangeValidateLanguages({ languages }) { + async onDidChangeValidateLanguages({ languages }) { if (this.#logger?.isDebugEnabled()) { this.#logger?.debug('Received onDidChangeValidateLanguages', { languages: [...languages] }); } @@ -134,11 +134,9 @@ class FormatterModule { if (this.#registration) { this.#logger?.debug('Disposing old formatter registration'); - void this.#registration - .then((disposable) => disposable.dispose()) - .then(() => { - this.#logger?.debug('Old formatter registration disposed'); - }); + this.#registration.dispose(); + + this.#logger?.debug('Old formatter registration disposed'); } // If there are languages that should be validated, register a formatter for those @@ -154,11 +152,16 @@ class FormatterModule { this.#logger?.debug('Registering formatter for languages', { languages: [...languages] }); } - this.#registration = this.#context.connection.client.register( + this.#registration = await this.#context.connection.client.register( DocumentFormattingRequest.type, { documentSelector }, ); + this.#context.connection.sendNotification( + Notification.DidRegisterDocumentFormattingEditProvider, + {}, + ); + this.#logger?.debug('Formatter registered'); } } diff --git a/src/utils/types.js b/src/utils/types.js index 098d9250..66dea3ae 100644 --- a/src/utils/types.js +++ b/src/utils/types.js @@ -29,6 +29,25 @@ const DisableReportRuleNames = { Illegal: 'reportDisables', }; +/** + * Language server notification names. + * @enum {string} + */ +const Notification = { + DidRegisterDocumentFormattingEditProvider: + 'textDocument/didRegisterDocumentFormattingEditProvider', +}; + +/** + * Extension API event names. + * @enum {keyof ExtensionEvents} + */ +const ApiEvent = { + DidRegisterDocumentFormattingEditProvider: /** @type {keyof ExtensionEvents} */ ( + 'DidRegisterDocumentFormattingEditProvider' + ), +}; + /** * Error thrown when a rule's option is invalid. */ @@ -46,5 +65,7 @@ module.exports = { CommandId, CodeActionKind, DisableReportRuleNames, + Notification, + ApiEvent, InvalidOptionError, }; diff --git a/test/e2e/formatter/index.js b/test/e2e/formatter/index.js index f610516d..b2ac218d 100644 --- a/test/e2e/formatter/index.js +++ b/test/e2e/formatter/index.js @@ -2,9 +2,35 @@ const path = require('path'); -const { workspace, commands, window } = require('vscode'); +const { workspace, commands, window, extensions } = require('vscode'); +const { ApiEvent } = require('../../../src/utils/types'); describe('vscode-stylelint', () => { + beforeAll(async () => { + const extension = extensions.getExtension('stylelint.vscode-stylelint'); + + if (!extension) { + throw new Error('Unable to find Stylelint extension'); + } + + const api = /** @type {ExtensionPublicApi} */ (extension.exports); + + await /** @type {Promise} */ ( + new Promise((resolve, reject) => { + const timeout = setTimeout( + () => + reject(new Error('Did not receive DidRegisterDocumentFormattingEditProvider event')), + 2000, + ); + + api.on(ApiEvent.DidRegisterDocumentFormattingEditProvider, () => { + clearTimeout(timeout); + resolve(); + }); + }) + ); + }); + it('should format document using formatting options', async () => { // Open the './test.css' file. const cssDocument = await workspace.openTextDocument(path.resolve(__dirname, 'test.css')); diff --git a/types/index.d.ts b/types/index.d.ts index 43d4c638..5d384e4c 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -392,3 +392,15 @@ type LanguageServerFormatterOptions = { connection: lsp.Connection; preferredKeyOrder?: string[]; }; + +/** + * VS Code extension event names. + */ +interface ExtensionEvents { + DidRegisterDocumentFormattingEditProvider: () => void; +} + +/** + * VS Code extension public API. + */ +type ExtensionPublicApi = import('typed-emitter').default;