diff --git a/packages/tailwindcss-language-server/src/projects.ts b/packages/tailwindcss-language-server/src/projects.ts index 04160569..21e7bb3b 100644 --- a/packages/tailwindcss-language-server/src/projects.ts +++ b/packages/tailwindcss-language-server/src/projects.ts @@ -1086,6 +1086,11 @@ export async function createProjectService( refreshDiagnostics() updateCapabilities() + + let isTestMode = params.initializationOptions?.testMode ?? false + if (!isTestMode) return + + connection.sendNotification('@/tailwindCSS/projectReloaded') } for (let entry of projectConfig.config.entries) { diff --git a/packages/tailwindcss-language-server/src/tw.ts b/packages/tailwindcss-language-server/src/tw.ts index 20ac0158..792c887d 100644 --- a/packages/tailwindcss-language-server/src/tw.ts +++ b/packages/tailwindcss-language-server/src/tw.ts @@ -21,6 +21,8 @@ import type { WorkspaceFolder, CodeLensParams, CodeLens, + ServerCapabilities, + ClientCapabilities, } from 'vscode-languageserver/node' import { CompletionRequest, @@ -624,6 +626,8 @@ export class TW { console.log(`[Global] Initializing projects...`) + await this.updateCommonCapabilities() + // init projects for documents that are _already_ open let readyDocuments: string[] = [] let enabledProjectCount = 0 @@ -640,8 +644,6 @@ export class TW { console.log(`[Global] Initialized ${enabledProjectCount} projects`) - this.setupLSPHandlers() - this.disposables.push( this.connection.onDidChangeConfiguration(async ({ settings }) => { let previousExclude = globalSettings.tailwindCSS.files.exclude @@ -763,7 +765,7 @@ export class TW { this.connection, params, this.documentService, - () => this.updateCapabilities(), + () => this.updateProjectCapabilities(), () => { for (let document of this.documentService.getAllDocuments()) { let project = this.getProject(document) @@ -810,9 +812,7 @@ export class TW { } setupLSPHandlers() { - if (this.lspHandlersAdded) { - return - } + if (this.lspHandlersAdded) return this.lspHandlersAdded = true this.connection.onHover(this.onHover.bind(this)) @@ -858,43 +858,84 @@ export class TW { } } - private updateCapabilities() { - if (!supportsDynamicRegistration(this.initializeParams)) { - this.connection.client.register(DidChangeConfigurationNotification.type, undefined) - return + // Common capabilities are always supported by the language server and do not + // require any project-specific information to know how to configure them. + // + // These capabilities will stay valid until/unless the server has to restart + // in which case they'll be unregistered and then re-registered once project + // discovery has completed + private commonRegistrations: BulkUnregistration | undefined + private async updateCommonCapabilities() { + let capabilities = BulkRegistration.create() + + let client = this.initializeParams.capabilities + + if (client.textDocument?.hover?.dynamicRegistration) { + capabilities.add(HoverRequest.type, { documentSelector: null }) } - if (this.registrations) { - this.registrations.then((r) => r.dispose()) + if (client.textDocument?.colorProvider?.dynamicRegistration) { + capabilities.add(DocumentColorRequest.type, { documentSelector: null }) } - let projects = Array.from(this.projects.values()) + if (client.textDocument?.codeAction?.dynamicRegistration) { + capabilities.add(CodeActionRequest.type, { documentSelector: null }) + } - let capabilities = BulkRegistration.create() + if (client.textDocument?.codeLens?.dynamicRegistration) { + capabilities.add(CodeLensRequest.type, { documentSelector: null }) + } + + if (client.textDocument?.documentLink?.dynamicRegistration) { + capabilities.add(DocumentLinkRequest.type, { documentSelector: null }) + } + + if (client.workspace?.didChangeConfiguration?.dynamicRegistration) { + capabilities.add(DidChangeConfigurationNotification.type, undefined) + } - // TODO: We should *not* be re-registering these capabilities - // IDEA: These should probably be static registrations up front - capabilities.add(HoverRequest.type, { documentSelector: null }) - capabilities.add(DocumentColorRequest.type, { documentSelector: null }) - capabilities.add(CodeActionRequest.type, { documentSelector: null }) - capabilities.add(CodeLensRequest.type, { documentSelector: null }) - capabilities.add(DocumentLinkRequest.type, { documentSelector: null }) - capabilities.add(DidChangeConfigurationNotification.type, undefined) - - // TODO: Only re-register this if trigger characters change - capabilities.add(CompletionRequest.type, { + this.commonRegistrations = await this.connection.client.register(capabilities) + } + + // These capabilities depend on the projects we've found to appropriately + // configure them. This may mean collecting information from all discovered + // projects to determine what we can do and how + private updateProjectCapabilities() { + this.updateTriggerCharacters() + } + + private lastTriggerCharacters: Set | undefined + private completionRegistration: Disposable | undefined + private async updateTriggerCharacters() { + // If the client does not suppory dynamic registration of completions then + // we cannot update the set of trigger characters + let client = this.initializeParams.capabilities + if (!client.textDocument?.completion?.dynamicRegistration) return + + // The new set of trigger characters is all the static ones plus + // any characters from any separator in v3 config + let chars = new Set(TRIGGER_CHARACTERS) + + for (let project of this.projects.values()) { + let sep = project.state.separator + if (typeof sep !== 'string') continue + + sep = sep.slice(-1) + if (!sep) continue + + chars.add(sep) + } + + // If the trigger characters haven't changed then we don't need to do anything + if (equal(Array.from(chars), Array.from(this.lastTriggerCharacters ?? []))) return + this.lastTriggerCharacters = chars + + this.completionRegistration?.dispose() + this.completionRegistration = await this.connection.client.register(CompletionRequest.type, { documentSelector: null, resolveProvider: true, - triggerCharacters: [ - ...TRIGGER_CHARACTERS, - ...projects - .map((project) => project.state.separator) - .filter((sep) => typeof sep === 'string') - .map((sep) => sep.slice(-1)), - ].filter(Boolean), + triggerCharacters: Array.from(chars), }) - - this.registrations = this.connection.client.register(capabilities) } private getProject(document: TextDocumentIdentifier): ProjectService { @@ -1016,47 +1057,58 @@ export class TW { this.connection.onInitialize(async (params: InitializeParams): Promise => { this.initializeParams = params - if (supportsDynamicRegistration(params)) { - return { - capabilities: { - textDocumentSync: TextDocumentSyncKind.Full, - workspace: { - workspaceFolders: { - changeNotifications: true, - }, - }, - }, - } - } - this.setupLSPHandlers() return { - capabilities: { - textDocumentSync: TextDocumentSyncKind.Full, - hoverProvider: true, - colorProvider: true, - codeActionProvider: true, - codeLensProvider: { - resolveProvider: false, - }, - documentLinkProvider: {}, - completionProvider: { - resolveProvider: true, - triggerCharacters: [...TRIGGER_CHARACTERS, ':'], - }, - workspace: { - workspaceFolders: { - changeNotifications: true, - }, - }, - }, + capabilities: this.computeServerCapabilities(params.capabilities), } }) this.connection.onInitialized(() => this.init()) } + computeServerCapabilities(client: ClientCapabilities) { + let capabilities: ServerCapabilities = { + textDocumentSync: TextDocumentSyncKind.Full, + workspace: { + workspaceFolders: { + changeNotifications: true, + }, + }, + } + + if (!client.textDocument?.hover?.dynamicRegistration) { + capabilities.hoverProvider = true + } + + if (!client.textDocument?.colorProvider?.dynamicRegistration) { + capabilities.colorProvider = true + } + + if (!client.textDocument?.codeAction?.dynamicRegistration) { + capabilities.codeActionProvider = true + } + + if (!client.textDocument?.codeLens?.dynamicRegistration) { + capabilities.codeLensProvider = { + resolveProvider: false, + } + } + + if (!client.textDocument?.completion?.dynamicRegistration) { + capabilities.completionProvider = { + resolveProvider: true, + triggerCharacters: [...TRIGGER_CHARACTERS, ':'], + } + } + + if (!client.textDocument?.documentLink?.dynamicRegistration) { + capabilities.documentLinkProvider = {} + } + + return capabilities + } + listen() { this.connection.listen() } @@ -1070,10 +1122,11 @@ export class TW { this.refreshDiagnostics() - if (this.registrations) { - this.registrations.then((r) => r.dispose()) - this.registrations = undefined - } + this.commonRegistrations?.dispose() + this.commonRegistrations = undefined + + this.completionRegistration?.dispose() + this.completionRegistration = undefined this.disposables.forEach((d) => d.dispose()) this.disposables.length = 0 @@ -1106,13 +1159,3 @@ export class TW { } } } - -function supportsDynamicRegistration(params: InitializeParams): boolean { - return ( - params.capabilities.textDocument?.hover?.dynamicRegistration && - params.capabilities.textDocument?.colorProvider?.dynamicRegistration && - params.capabilities.textDocument?.codeAction?.dynamicRegistration && - params.capabilities.textDocument?.completion?.dynamicRegistration && - params.capabilities.textDocument?.documentLink?.dynamicRegistration - ) -} diff --git a/packages/tailwindcss-language-server/tests/env/capabilities.test.ts b/packages/tailwindcss-language-server/tests/env/capabilities.test.ts new file mode 100644 index 00000000..22eac608 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/env/capabilities.test.ts @@ -0,0 +1,184 @@ +import { expect } from 'vitest' +import { defineTest, js } from '../../src/testing' +import { createClient } from '../utils/client' +import * as fs from 'node:fs/promises' + +defineTest({ + name: 'Changing the separator registers new trigger characters', + fs: { + 'tailwind.config.js': js` + module.exports = { + separator: ':', + } + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ root, client }) => { + // Initially don't have any registered capabilities because dynamic + // registration is delayed until after project initialization + expect(client.serverCapabilities).toEqual([]) + + // We open a document so a project gets initialized + await client.open({ + lang: 'html', + text: '
', + }) + + // And now capabilities are registered + expect(client.serverCapabilities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: 'textDocument/hover', + }), + + expect.objectContaining({ + method: 'textDocument/completion', + registerOptions: { + documentSelector: null, + resolveProvider: true, + triggerCharacters: ['"', "'", '`', ' ', '.', '(', '[', ']', '!', '/', '-', ':'], + }, + }), + ]), + ) + + let countBeforeChange = client.serverCapabilities.length + let capabilitiesDidChange = Promise.race([ + new Promise((_, reject) => { + setTimeout(() => reject('capabilities did not change within 5s'), 5_000) + }), + + new Promise((resolve) => { + client.onServerCapabilitiesChanged(() => { + if (client.serverCapabilities.length !== countBeforeChange) return + resolve() + }) + }), + ]) + + await fs.writeFile( + `${root}/tailwind.config.js`, + js` + module.exports = { + separator: '_', + } + `, + ) + + // After changing the config + client.notifyChangedFiles({ + changed: [`${root}/tailwind.config.js`], + }) + + // We should see that the capabilities have changed + await capabilitiesDidChange + + // Capabilities are now registered + expect(client.serverCapabilities).toContainEqual( + expect.objectContaining({ + method: 'textDocument/hover', + }), + ) + + expect(client.serverCapabilities).toContainEqual( + expect.objectContaining({ + method: 'textDocument/completion', + registerOptions: { + documentSelector: null, + resolveProvider: true, + triggerCharacters: ['"', "'", '`', ' ', '.', '(', '[', ']', '!', '/', '-', '_'], + }, + }), + ) + + expect(client.serverCapabilities).not.toContainEqual( + expect.objectContaining({ + method: 'textDocument/completion', + registerOptions: { + documentSelector: null, + resolveProvider: true, + triggerCharacters: ['"', "'", '`', ' ', '.', '(', '[', ']', '!', '/', '-', ':'], + }, + }), + ) + }, +}) + +defineTest({ + name: 'Config updates do not register new trigger characters if the separator has not changed', + fs: { + 'tailwind.config.js': js` + module.exports = { + separator: ':', + theme: { + colors: { + primary: '#f00', + } + } + } + `, + }, + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ root, client }) => { + // Initially don't have any registered capabilities because dynamic + // registration is delayed until after project initialization + expect(client.serverCapabilities).toEqual([]) + + // We open a document so a project gets initialized + await client.open({ + lang: 'html', + text: '
', + }) + + // And now capabilities are registered + expect(client.serverCapabilities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + method: 'textDocument/hover', + }), + + expect.objectContaining({ + method: 'textDocument/completion', + registerOptions: { + documentSelector: null, + resolveProvider: true, + triggerCharacters: ['"', "'", '`', ' ', '.', '(', '[', ']', '!', '/', '-', ':'], + }, + }), + ]), + ) + + let idsBefore = client.serverCapabilities.map((cap) => cap.id) + + await fs.writeFile( + `${root}/tailwind.config.js`, + js` + module.exports = { + separator: ':', + theme: { + colors: { + primary: '#0f0', + } + } + } + `, + ) + + let didReload = new Promise((resolve) => { + client.conn.onNotification('@/tailwindCSS/projectReloaded', resolve) + }) + + // After changing the config + client.notifyChangedFiles({ + changed: [`${root}/tailwind.config.js`], + }) + + // Wait for the project to finish building + await didReload + + // No capabilities should have changed + let idsAfter = client.serverCapabilities.map((cap) => cap.id) + + expect(idsBefore).toEqual(idsAfter) + }, +}) diff --git a/packages/tailwindcss-language-server/tests/env/restart.test.ts b/packages/tailwindcss-language-server/tests/env/restart.test.ts index 49b632c3..35b595f8 100644 --- a/packages/tailwindcss-language-server/tests/env/restart.test.ts +++ b/packages/tailwindcss-language-server/tests/env/restart.test.ts @@ -140,6 +140,9 @@ defineTest({ }, }) + expect(client.serverCapabilities).not.toEqual([]) + let ids1 = client.serverCapabilities.map((cap) => cap.id) + // Remove the CSS file let didRestart = new Promise((resolve) => { client.conn.onNotification('@/tailwindCSS/serverRestarted', resolve) @@ -147,6 +150,9 @@ defineTest({ await fs.unlink(path.resolve(root, 'app.css')) await didRestart + expect(client.serverCapabilities).not.toEqual([]) + let ids2 = client.serverCapabilities.map((cap) => cap.id) + //
// ^ let hover2 = await doc.hover({ line: 0, character: 13 }) @@ -164,11 +170,23 @@ defineTest({ ) await didRestartAgain + expect(client.serverCapabilities).not.toEqual([]) + let ids3 = client.serverCapabilities.map((cap) => cap.id) + await new Promise((resolve) => setTimeout(resolve, 500)) //
// ^ let hover3 = await doc.hover({ line: 0, character: 13 }) expect(hover3).toEqual(null) + + expect(ids1).not.toContainEqual(expect.toBeOneOf(ids2)) + expect(ids1).not.toContainEqual(expect.toBeOneOf(ids3)) + + expect(ids2).not.toContainEqual(expect.toBeOneOf(ids1)) + expect(ids2).not.toContainEqual(expect.toBeOneOf(ids3)) + + expect(ids3).not.toContainEqual(expect.toBeOneOf(ids1)) + expect(ids3).not.toContainEqual(expect.toBeOneOf(ids2)) }, }) diff --git a/packages/tailwindcss-language-server/tests/utils/client.ts b/packages/tailwindcss-language-server/tests/utils/client.ts index 760b805b..681843a8 100644 --- a/packages/tailwindcss-language-server/tests/utils/client.ts +++ b/packages/tailwindcss-language-server/tests/utils/client.ts @@ -6,16 +6,21 @@ import { CompletionList, CompletionParams, Diagnostic, + DidChangeWatchedFilesNotification, Disposable, DocumentLink, DocumentLinkRequest, DocumentSymbol, DocumentSymbolRequest, + FileChangeType, + FileEvent, Hover, NotificationHandler, ProtocolConnection, PublishDiagnosticsParams, + Registration, SymbolInformation, + UnregistrationRequest, WorkspaceFolder, } from 'vscode-languageserver' import type { Position } from 'vscode-languageserver-textdocument' @@ -83,6 +88,12 @@ export interface DocumentDescriptor { settings?: Settings } +export interface ChangedFiles { + created?: string[] + changed?: string[] + deleted?: string[] +} + export interface ClientDocument { /** * The URI to the document @@ -191,6 +202,21 @@ export interface Client extends ClientWorkspace { */ readonly conn: ProtocolConnection + /** + * Get the currently registered server capabilities + */ + serverCapabilities: Registration[] + + /** + * Get the currently registered server capabilities + */ + onServerCapabilitiesChanged(cb: () => void): void + + /** + * Tell the server that files on disk have changed + */ + notifyChangedFiles(changes: ChangedFiles): Promise + /** * Get a workspace by name */ @@ -428,12 +454,40 @@ export async function createClient(opts: ClientOptions): Promise { }) } + let serverCapabilityChangeCallbacks: (() => void)[] = [] + + function onServerCapabilitiesChanged(cb: () => void) { + serverCapabilityChangeCallbacks.push(cb) + } + + let registeredCapabilities: Registration[] = [] + conn.onRequest(RegistrationRequest.type, ({ registrations }) => { trace('Registering capabilities') for (let registration of registrations) { + registeredCapabilities.push(registration) + trace('-', registration.method) + } + + for (let cb of serverCapabilityChangeCallbacks) cb() + }) + + conn.onRequest(UnregistrationRequest.type, ({ unregisterations }) => { + trace('Unregistering capabilities') + + let idsToRemove = new Set() + + for (let registration of unregisterations) { + idsToRemove.add(registration.id) trace('-', registration.method) } + + registeredCapabilities = registeredCapabilities.filter( + (capability) => !idsToRemove.has(capability.id), + ) + + for (let cb of serverCapabilityChangeCallbacks) cb() }) // TODO: Remove this its a hack @@ -493,8 +547,33 @@ export async function createClient(opts: ClientOptions): Promise { await initPromise } + function notifyChangedFiles(changes: ChangedFiles) { + let events: FileEvent[] = [] + + for (const path of changes?.created ?? []) { + events.push({ uri: URI.file(path).toString(), type: FileChangeType.Created }) + } + + for (const path of changes?.changed ?? []) { + events.push({ uri: URI.file(path).toString(), type: FileChangeType.Changed }) + } + + for (const path of changes?.deleted ?? []) { + events.push({ uri: URI.file(path).toString(), type: FileChangeType.Deleted }) + } + + return conn.sendNotification(DidChangeWatchedFilesNotification.type, { + changes: events, + }) + } + return { ...clientWorkspaces[0], + get serverCapabilities() { + return registeredCapabilities + }, + onServerCapabilitiesChanged, + notifyChangedFiles, workspace, updateSettings, } diff --git a/packages/vscode-tailwindcss/CHANGELOG.md b/packages/vscode-tailwindcss/CHANGELOG.md index 122181c8..53c9592a 100644 --- a/packages/vscode-tailwindcss/CHANGELOG.md +++ b/packages/vscode-tailwindcss/CHANGELOG.md @@ -2,7 +2,7 @@ ## Prerelease -- Nothing yet! +- Improve dynamic capability registration in the language server ([#1327](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1327)) # 0.14.16