From dd39ca054917933f4003bfddde7fd8c23fcef8d2 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 22 Dec 2025 22:20:18 -0500 Subject: [PATCH 1/6] Rename test file --- .../diagnostics/{diagnostics.test.js => diagnostics.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/tailwindcss-language-server/tests/diagnostics/{diagnostics.test.js => diagnostics.test.ts} (100%) diff --git a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.ts similarity index 100% rename from packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js rename to packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.ts From 91993a0371552ec8b478259036404c9f966c7f4c Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 22 Dec 2025 22:38:24 -0500 Subject: [PATCH 2/6] Represent diagnostics using the pull model in the language service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LSP v3.17 added support for pull-model diagnostics which are modeled as a “report” that is either a full set of diagnostics for a document (and related documents) or “unchanged”. We can convert these to the push model by “pulling” the diagnostics like we were already doing before and inspecting the report before pushing diagnostics to the client. --- .../src/lsp/diagnosticsProvider.ts | 22 ------------ .../src/projects.ts | 26 +++++++++++++- .../src/codeActions/codeActionProvider.ts | 5 +-- .../src/diagnostics/diagnosticsProvider.ts | 36 ++++++++++++++++++- .../tailwindcss-language-service/src/index.ts | 2 +- 5 files changed, 64 insertions(+), 27 deletions(-) delete mode 100644 packages/tailwindcss-language-server/src/lsp/diagnosticsProvider.ts diff --git a/packages/tailwindcss-language-server/src/lsp/diagnosticsProvider.ts b/packages/tailwindcss-language-server/src/lsp/diagnosticsProvider.ts deleted file mode 100644 index 75fb87333..000000000 --- a/packages/tailwindcss-language-server/src/lsp/diagnosticsProvider.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { TextDocument } from 'vscode-languageserver-textdocument' -import type { State } from '@tailwindcss/language-service/src/util/state' -import { doValidate } from '@tailwindcss/language-service/src/diagnostics/diagnosticsProvider' -import isExcluded from '../util/isExcluded' - -export async function provideDiagnostics(state: State, document: TextDocument) { - if (await isExcluded(state, document)) { - clearDiagnostics(state, document) - } else { - state.editor?.connection.sendDiagnostics({ - uri: document.uri, - diagnostics: await doValidate(state, document), - }) - } -} - -export function clearDiagnostics(state: State, document: TextDocument): void { - state.editor?.connection.sendDiagnostics({ - uri: document.uri, - diagnostics: [], - }) -} diff --git a/packages/tailwindcss-language-server/src/projects.ts b/packages/tailwindcss-language-server/src/projects.ts index 3f741e32d..f81f4d7d2 100644 --- a/packages/tailwindcss-language-server/src/projects.ts +++ b/packages/tailwindcss-language-server/src/projects.ts @@ -51,7 +51,6 @@ import type { Variant, ClassEntry, } from '@tailwindcss/language-service/src/util/state' -import { provideDiagnostics } from './lsp/diagnosticsProvider' import { doCodeActions } from '@tailwindcss/language-service/src/codeActions/codeActionProvider' import { getDocumentColors } from '@tailwindcss/language-service/src/documentColorProvider' import { getDocumentLinks } from '@tailwindcss/language-service/src/documentLinksProvider' @@ -85,6 +84,7 @@ import { supportedFeatures } from '@tailwindcss/language-service/src/features' import { loadDesignSystem } from './util/v4' import { readCssFile } from './util/css' import type { DesignSystem } from '@tailwindcss/language-service/src/util/v4' +import { getDocumentDiagnostics } from '@tailwindcss/language-service/src/diagnostics/diagnosticsProvider' const colorNames = Object.keys(namedColors) @@ -1734,3 +1734,27 @@ function getContentDocumentSelectorFromConfigFile( priority: DocumentSelectorPriority.CONTENT_FILE, })) } + +async function provideDiagnostics(state: State, document: TextDocument) { + let connection = state.editor?.connection + if (!connection) return + + // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_publishDiagnostics + // + // > When a file changes it is the server’s responsibility to re-compute diagnostics and push them to the client. + // > If the computed set is empty it has to push the empty array to clear former diagnostics. + // + // Because a document can go from included -> excluded we must push + // diagnostics for excluded documents + if (await isExcluded(state, document)) { + connection.sendDiagnostics({ uri: document.uri, diagnostics: [] }) + return + } + + let report = await getDocumentDiagnostics(state, document) + + connection.sendDiagnostics({ + uri: document.uri, + diagnostics: report.items ?? [], + }) +} diff --git a/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts b/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts index b25e9f63e..8a99bdd5d 100644 --- a/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts +++ b/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts @@ -1,7 +1,7 @@ import type { CodeAction, CodeActionParams } from 'vscode-languageserver' import type { TextDocument } from 'vscode-languageserver-textdocument' import type { State } from '../util/state' -import { doValidate } from '../diagnostics/diagnosticsProvider' +import { getDocumentDiagnostics } from '../diagnostics/diagnosticsProvider' import { rangesEqual } from '../util/rangesEqual' import { type DiagnosticKind, @@ -27,7 +27,8 @@ async function getDiagnosticsFromCodeActionParams( only?: DiagnosticKind[], ): Promise { if (!document) return [] - let diagnostics = await doValidate(state, document, only) + let report = await getDocumentDiagnostics(state, document, only) + let diagnostics = report.items as AugmentedDiagnostic[] return params.context.diagnostics .map((diagnostic) => { diff --git a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts index 1244b181b..6c94231da 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts @@ -1,4 +1,5 @@ import type { TextDocument } from 'vscode-languageserver-textdocument' +import type { FullDocumentDiagnosticReport } from 'vscode-languageserver' import type { State } from '../util/state' import { DiagnosticKind, type AugmentedDiagnostic } from './types' import { getCssConflictDiagnostics } from './getCssConflictDiagnostics' @@ -12,6 +13,14 @@ import { getInvalidSourceDiagnostics } from './getInvalidSourceDiagnostics' import { getUsedBlocklistedClassDiagnostics } from './getUsedBlocklistedClassDiagnostics' import { getSuggestCanonicalClassesDiagnostics } from './canonical-classes' +/** + * This is exported because it was previously exported and may be in use by + * external, third-party clients. Do not use. + * + * TODO: Remove in v0.16.0 + * + * @deprecated Use `getDocumentDiagnostics` instead + */ export async function doValidate( state: State, document: TextDocument, @@ -28,9 +37,29 @@ export async function doValidate( DiagnosticKind.SuggestCanonicalClasses, ], ): Promise { + let report = await getDocumentDiagnostics(state, document, only) + return report.items as AugmentedDiagnostic[] +} + +export async function getDocumentDiagnostics( + state: State, + document: TextDocument, + only: DiagnosticKind[] = [ + DiagnosticKind.CssConflict, + DiagnosticKind.InvalidApply, + DiagnosticKind.InvalidScreen, + DiagnosticKind.InvalidVariant, + DiagnosticKind.InvalidConfigPath, + DiagnosticKind.InvalidTailwindDirective, + DiagnosticKind.InvalidSourceDirective, + DiagnosticKind.RecommendedVariantOrder, + DiagnosticKind.UsedBlocklistedClass, + DiagnosticKind.SuggestCanonicalClasses, + ], +): Promise { const settings = await state.editor.getConfiguration(document.uri) - return settings.tailwindCSS.validate + let items = settings.tailwindCSS.validate ? [ ...(only.includes(DiagnosticKind.CssConflict) ? await getCssConflictDiagnostics(state, document, settings) @@ -64,4 +93,9 @@ export async function doValidate( : []), ] : [] + + return { + kind: 'full', + items, + } } diff --git a/packages/tailwindcss-language-service/src/index.ts b/packages/tailwindcss-language-service/src/index.ts index b8ad02f69..a64e3d7c3 100644 --- a/packages/tailwindcss-language-service/src/index.ts +++ b/packages/tailwindcss-language-service/src/index.ts @@ -1,5 +1,5 @@ export { doComplete, resolveCompletionItem, completionsFromClassList } from './completionProvider' -export { doValidate } from './diagnostics/diagnosticsProvider' +export { doValidate, getDocumentDiagnostics } from './diagnostics/diagnosticsProvider' export { doHover } from './hoverProvider' export { doCodeActions } from './codeActions/codeActionProvider' export { getDocumentColors } from './documentColorProvider' From 318b34cf7d51a55c389ad2a2f9f5b8401f1e947a Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 22 Dec 2025 22:39:51 -0500 Subject: [PATCH 3/6] Add support for pull-model diagnostics to the server Now that the language service represents diagnostics using the pull model we can add explicit support for this to the server itself. --- .../src/projects.ts | 11 ++++ .../tailwindcss-language-server/src/tw.ts | 23 ++++++++ .../tests/code-actions/code-actions.test.js | 12 ++-- .../code-actions/code-actions.v2-jit.test.js | 12 ++-- .../code-actions/code-actions.v2.test.js | 11 ++-- .../code-actions/code-actions.v4.test.js | 12 ++-- .../tests/diagnostics/diagnostics.test.ts | 53 ++++++++--------- .../diagnostics/source-diagnostics.test.js | 10 ++-- .../tests/utils/client.ts | 57 +++++-------------- 9 files changed, 91 insertions(+), 110 deletions(-) diff --git a/packages/tailwindcss-language-server/src/projects.ts b/packages/tailwindcss-language-server/src/projects.ts index f81f4d7d2..f75fb66ea 100644 --- a/packages/tailwindcss-language-server/src/projects.ts +++ b/packages/tailwindcss-language-server/src/projects.ts @@ -17,6 +17,8 @@ import type { DocumentLink, CodeLensParams, CodeLens, + DocumentDiagnosticReport, + DocumentDiagnosticParams, } from 'vscode-languageserver/node' import { FileChangeType } from 'vscode-languageserver/node' import type { TextDocument } from 'vscode-languageserver-textdocument' @@ -107,6 +109,7 @@ export interface ProjectService { onHover(params: TextDocumentPositionParams): Promise onCompletion(params: CompletionParams): Promise onCompletionResolve(item: CompletionItem): Promise + onDiagnostic(params: DocumentDiagnosticParams): Promise provideDiagnostics(document: TextDocument): void provideDiagnosticsForce(document: TextDocument): void onDocumentColor(params: DocumentColorParams): Promise @@ -1232,6 +1235,14 @@ export async function createProjectService( return resolveCompletionItem(state, item) }, null) }, + async onDiagnostic(params: DocumentDiagnosticParams): Promise { + if (!state.enabled) return { kind: 'full', items: [] } + + let document = documentService.getDocument(params.textDocument.uri) + if (!document) return { kind: 'full', items: [] } + + return getDocumentDiagnostics(state, document) + }, async onCodeAction(params: CodeActionParams): Promise { return withFallback(async () => { if (!state.enabled) return null diff --git a/packages/tailwindcss-language-server/src/tw.ts b/packages/tailwindcss-language-server/src/tw.ts index 07d3956a9..e61d6b657 100644 --- a/packages/tailwindcss-language-server/src/tw.ts +++ b/packages/tailwindcss-language-server/src/tw.ts @@ -23,6 +23,8 @@ import type { CodeLens, ServerCapabilities, ClientCapabilities, + DocumentDiagnosticParams, + DocumentDiagnosticReport, } from 'vscode-languageserver/node' import { CompletionRequest, @@ -36,6 +38,7 @@ import { TextDocumentSyncKind, CodeLensRequest, DidChangeConfigurationNotification, + DocumentDiagnosticRequest, } from 'vscode-languageserver/node' import { URI } from 'vscode-uri' import normalizePath from 'normalize-path' @@ -870,6 +873,10 @@ export class TW { this.connection.onCodeLens(this.onCodeLens.bind(this)) this.connection.onDocumentLinks(this.onDocumentLinks.bind(this)) this.connection.onRequest(this.onRequest.bind(this)) + + if (this.initializeParams.capabilities.textDocument.diagnostic) { + this.connection.languages.diagnostics.on(this.onDiagnostic.bind(this)) + } } private onRequest( @@ -936,6 +943,10 @@ export class TW { capabilities.add(DocumentLinkRequest.type, { documentSelector: null }) } + if (client.textDocument?.diagnostic?.dynamicRegistration) { + capabilities.add(DocumentDiagnosticRequest.type, undefined) + } + if (client.workspace?.didChangeConfiguration?.dynamicRegistration) { capabilities.add(DidChangeConfigurationNotification.type, undefined) } @@ -1086,6 +1097,11 @@ export class TW { return this.getProject(params.textDocument)?.onCompletion(params) ?? null } + async onDiagnostic(params: DocumentDiagnosticParams): Promise { + await this.init() + return this.getProject(params.textDocument)?.onDiagnostic(params) ?? null + } + async onCompletionResolve(item: CompletionItem): Promise { await this.init() return this.projects.get(item.data?._projectKey)?.onCompletionResolve(item) ?? null @@ -1159,6 +1175,13 @@ export class TW { capabilities.documentLinkProvider = {} } + if (!client.textDocument?.diagnostic?.dynamicRegistration) { + capabilities.diagnosticProvider = { + interFileDependencies: false, + workspaceDiagnostics: false, + } + } + return capabilities } diff --git a/packages/tailwindcss-language-server/tests/code-actions/code-actions.test.js b/packages/tailwindcss-language-server/tests/code-actions/code-actions.test.js index 505e29605..1eb2565a1 100644 --- a/packages/tailwindcss-language-server/tests/code-actions/code-actions.test.js +++ b/packages/tailwindcss-language-server/tests/code-actions/code-actions.test.js @@ -9,14 +9,11 @@ withFixture('basic', (c) => { let { code, expected, language = 'html' } = JSON.parse(fixture) - let promise = new Promise((resolve) => { - c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => { - resolve(diagnostics) - }) - }) - let textDocument = await c.openDocument({ text: code, lang: language }) - let diagnostics = await promise + let report = await c.sendRequest('textDocument/diagnostic', { + textDocument: { uri: textDocument.uri }, + }) + let diagnostics = report.kind === 'unchanged' ? [] : report.items let res = await c.sendRequest('textDocument/codeAction', { textDocument, @@ -24,7 +21,6 @@ withFixture('basic', (c) => { diagnostics, }, }) - // console.log(JSON.stringify(res)) expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', textDocument.uri)) diff --git a/packages/tailwindcss-language-server/tests/code-actions/code-actions.v2-jit.test.js b/packages/tailwindcss-language-server/tests/code-actions/code-actions.v2-jit.test.js index 52941e20a..dfbdc892c 100644 --- a/packages/tailwindcss-language-server/tests/code-actions/code-actions.v2-jit.test.js +++ b/packages/tailwindcss-language-server/tests/code-actions/code-actions.v2-jit.test.js @@ -9,14 +9,11 @@ withFixture('v2-jit', (c) => { let { code, expected, language = 'html' } = JSON.parse(fixture) - let promise = new Promise((resolve) => { - c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => { - resolve(diagnostics) - }) - }) - let textDocument = await c.openDocument({ text: code, lang: language }) - let diagnostics = await promise + let report = await c.sendRequest('textDocument/diagnostic', { + textDocument: { uri: textDocument.uri }, + }) + let diagnostics = report.kind === 'unchanged' ? [] : report.items let res = await c.sendRequest('textDocument/codeAction', { textDocument, @@ -24,7 +21,6 @@ withFixture('v2-jit', (c) => { diagnostics, }, }) - // console.log(JSON.stringify(res)) expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', textDocument.uri)) diff --git a/packages/tailwindcss-language-server/tests/code-actions/code-actions.v2.test.js b/packages/tailwindcss-language-server/tests/code-actions/code-actions.v2.test.js index ade1b5f85..6d8e16f3f 100644 --- a/packages/tailwindcss-language-server/tests/code-actions/code-actions.v2.test.js +++ b/packages/tailwindcss-language-server/tests/code-actions/code-actions.v2.test.js @@ -9,14 +9,11 @@ withFixture('v2', (c) => { let { code, expected, language = 'html' } = JSON.parse(fixture) - let promise = new Promise((resolve) => { - c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => { - resolve(diagnostics) - }) - }) - let textDocument = await c.openDocument({ text: code, lang: language }) - let diagnostics = await promise + let report = await c.sendRequest('textDocument/diagnostic', { + textDocument: { uri: textDocument.uri }, + }) + let diagnostics = report.kind === 'unchanged' ? [] : report.items let res = await c.sendRequest('textDocument/codeAction', { textDocument, diff --git a/packages/tailwindcss-language-server/tests/code-actions/code-actions.v4.test.js b/packages/tailwindcss-language-server/tests/code-actions/code-actions.v4.test.js index 26ceedbe6..c1c72a12f 100644 --- a/packages/tailwindcss-language-server/tests/code-actions/code-actions.v4.test.js +++ b/packages/tailwindcss-language-server/tests/code-actions/code-actions.v4.test.js @@ -9,14 +9,11 @@ withFixture('v4/basic', (c) => { let { code, expected, language = 'html' } = JSON.parse(fixture) - let promise = new Promise((resolve) => { - c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => { - resolve(diagnostics) - }) - }) - let textDocument = await c.openDocument({ text: code, lang: language }) - let diagnostics = await promise + let report = await c.sendRequest('textDocument/diagnostic', { + textDocument: { uri: textDocument.uri }, + }) + let diagnostics = report.kind === 'unchanged' ? [] : report.items let res = await c.sendRequest('textDocument/codeAction', { textDocument, @@ -24,7 +21,6 @@ withFixture('v4/basic', (c) => { diagnostics, }, }) - // console.log(JSON.stringify(res)) expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', textDocument.uri)) diff --git a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.ts b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.ts index 4ddee0f2c..77fd90081 100644 --- a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.ts +++ b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.ts @@ -3,6 +3,9 @@ import { expect, test } from 'vitest' import { withFixture } from '../common' import { css, defineTest, json } from '../../src/testing' import { createClient } from '../utils/client' +import { + DocumentDiagnosticReport, +} from 'vscode-languageserver' withFixture('basic', (c) => { function testFixture(fixture) { @@ -11,14 +14,12 @@ withFixture('basic', (c) => { let { code, expected, language = 'html' } = JSON.parse(fixture) - let promise = new Promise((resolve) => { - c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => { - resolve(diagnostics) - }) + let doc = await c.openDocument({ text: code, lang: language }) + let report: DocumentDiagnosticReport = await c.sendRequest('textDocument/diagnostic', { + textDocument: { uri: doc.uri }, }) - let doc = await c.openDocument({ text: code, lang: language }) - let diagnostics = await promise + let diagnostics = report.kind === 'unchanged' ? [] : report.items expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', doc.uri)) @@ -46,14 +47,12 @@ withFixture('v4/basic', (c) => { let { code, expected, language = 'html' } = JSON.parse(fixture) - let promise = new Promise((resolve) => { - c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => { - resolve(diagnostics) - }) + let doc = await c.openDocument({ text: code, lang: language }) + let report: DocumentDiagnosticReport = await c.sendRequest('textDocument/diagnostic', { + textDocument: { uri: doc.uri }, }) - let doc = await c.openDocument({ text: code, lang: language }) - let diagnostics = await promise + let diagnostics = report.kind === 'unchanged' ? [] : report.items expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', doc.uri)) @@ -63,14 +62,12 @@ withFixture('v4/basic', (c) => { function testInline(fixture, { code, expected, language = 'html' }) { test(fixture, async () => { - let promise = new Promise((resolve) => { - c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => { - resolve(diagnostics) - }) + let doc = await c.openDocument({ text: code, lang: language }) + let report: DocumentDiagnosticReport = await c.sendRequest('textDocument/diagnostic', { + textDocument: { uri: doc.uri }, }) - let doc = await c.openDocument({ text: code, lang: language }) - let diagnostics = await promise + let diagnostics = report.kind === 'unchanged' ? [] : report.items expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', doc.uri)) @@ -165,14 +162,12 @@ withFixture('v4/basic', (c) => { withFixture('v4/with-prefix', (c) => { function testInline(fixture, { code, expected, language = 'html' }) { test(fixture, async () => { - let promise = new Promise((resolve) => { - c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => { - resolve(diagnostics) - }) + let doc = await c.openDocument({ text: code, lang: language }) + let report: DocumentDiagnosticReport = await c.sendRequest('textDocument/diagnostic', { + textDocument: { uri: doc.uri }, }) - let doc = await c.openDocument({ text: code, lang: language }) - let diagnostics = await promise + let diagnostics = report.kind === 'unchanged' ? [] : report.items expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', doc.uri)) @@ -268,14 +263,12 @@ withFixture('v4/with-prefix', (c) => { withFixture('v4/basic', (c) => { function testMatch(name, { code, expected, language = 'html' }) { test(name, async () => { - let promise = new Promise((resolve) => { - c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => { - resolve(diagnostics) - }) + let doc = await c.openDocument({ text: code, lang: language }) + let report: DocumentDiagnosticReport = await c.sendRequest('textDocument/diagnostic', { + textDocument: { uri: doc.uri }, }) - let doc = await c.openDocument({ text: code, lang: language }) - let diagnostics = await promise + let diagnostics = report.kind === 'unchanged' ? [] : report.items expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', doc.uri)) diff --git a/packages/tailwindcss-language-server/tests/diagnostics/source-diagnostics.test.js b/packages/tailwindcss-language-server/tests/diagnostics/source-diagnostics.test.js index 0024ed43e..dd5fca867 100644 --- a/packages/tailwindcss-language-server/tests/diagnostics/source-diagnostics.test.js +++ b/packages/tailwindcss-language-server/tests/diagnostics/source-diagnostics.test.js @@ -4,14 +4,12 @@ import { withFixture } from '../common' withFixture('v4/basic', (c) => { function runTest(name, { code, expected, language }) { test(name, async () => { - let promise = new Promise((resolve) => { - c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => { - resolve(diagnostics) - }) + let doc = await c.openDocument({ text: code, lang: language }) + let report = await c.sendRequest('textDocument/diagnostic', { + textDocument: { uri: doc.uri }, }) - let doc = await c.openDocument({ text: code, lang: language }) - let diagnostics = await promise + let diagnostics = report.kind === 'unchanged' ? [] : report.items expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', doc.uri)) diff --git a/packages/tailwindcss-language-server/tests/utils/client.ts b/packages/tailwindcss-language-server/tests/utils/client.ts index 17db7a1ed..407e61f58 100644 --- a/packages/tailwindcss-language-server/tests/utils/client.ts +++ b/packages/tailwindcss-language-server/tests/utils/client.ts @@ -8,6 +8,7 @@ import { Diagnostic, DidChangeWatchedFilesNotification, Disposable, + DocumentDiagnosticRequest, DocumentLink, DocumentLinkRequest, DocumentSymbol, @@ -372,6 +373,7 @@ export async function createClient(opts: ClientOptions): Promise { dynamicRegistration: true, }, definition: { dynamicRegistration: true }, + diagnostic: { dynamicRegistration: true }, documentHighlight: { dynamicRegistration: true }, documentLink: { dynamicRegistration: true }, documentSymbol: { @@ -631,31 +633,6 @@ export async function createClientWorkspace({ ) let version = 1 - let currentDiagnostics: Promise = Promise.resolve([]) - - async function requestDiagnostics(version: number) { - let start = process.hrtime.bigint() - - trace('Waiting for diagnostics') - trace('- uri:', rewriteUri(uri)) - - currentDiagnostics = new Promise((resolve) => { - notifications.onPublishedDiagnostics(uri.toString(), (params) => { - // We recieved diagnostics for different version of this document - if (params.version !== undefined) { - if (params.version !== version) return - } - - let elapsed = process.hrtime.bigint() - start - - trace('Loaded diagnostics') - trace(`- uri:`, rewriteUri(params.uri)) - trace(`- duration: %dms`, (Number(elapsed) / 1e6).toFixed(3)) - - resolve(params.diagnostics) - }) - }) - } async function reopen() { if (state === 'opened') throw new Error('Document is already open') @@ -676,8 +653,6 @@ export async function createClientWorkspace({ trace('Opening document') trace(`- uri:`, rewriteUri(uri)) - await requestDiagnostics(version) - state = 'opening' try { @@ -715,7 +690,6 @@ export async function createClientWorkspace({ if (desc.text) { version += 1 - await requestDiagnostics(version) await conn.sendNotification(DidChangeTextDocumentNotification.type, { textDocument: { uri: uri.toString(), version }, contentChanges: [{ text: desc.text }], @@ -760,8 +734,18 @@ export async function createClientWorkspace({ return list } - function diagnostics() { - return currentDiagnostics + async function diagnostics() { + let report = await conn.sendRequest(DocumentDiagnosticRequest.type, { + textDocument: { + uri: uri.toString(), + }, + }) + + if (report.kind === 'unchanged') { + return [] + } + + return report.items } async function symbols() { @@ -844,10 +828,6 @@ export async function createClientWorkspace({ interface ClientNotifications { onDocumentReady(uri: string, handler: (params: DocumentReady) => void): Disposable - onPublishedDiagnostics( - uri: string, - handler: (params: PublishDiagnosticsParams) => void, - ): Disposable onProjectDetails(uri: string, handler: (params: ProjectDetails) => void): Disposable } @@ -892,15 +872,6 @@ async function createDocumentNotifications(conn: ProtocolConnection): Promise { - let index = diagnosticsHandlers.get(uri).push(handler) - 1 - return { - dispose() { - diagnosticsHandlers.get(uri)[index] = null - }, - } - }, - onProjectDetails: (uri, handler) => { let index = projectDetailsHandlers.get(uri).push(handler) - 1 return { From 0b22b00581236abe6202189b26ab3b362b0c56c2 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 22 Dec 2025 23:35:51 -0500 Subject: [PATCH 4/6] Add support for pull-model diagnostics to the Tailwind CSS language mode --- .../src/language/css-server.ts | 263 +++++++++++++++--- 1 file changed, 224 insertions(+), 39 deletions(-) diff --git a/packages/tailwindcss-language-server/src/language/css-server.ts b/packages/tailwindcss-language-server/src/language/css-server.ts index 73e967fcc..821070e0e 100644 --- a/packages/tailwindcss-language-server/src/language/css-server.ts +++ b/packages/tailwindcss-language-server/src/language/css-server.ts @@ -12,6 +12,13 @@ import { ConfigurationRequest, CompletionItemKind, Connection, + DocumentDiagnosticReportKind, + DocumentDiagnosticParams, + CancellationToken, + Diagnostic, + DocumentDiagnosticReport, + ResponseError, + LSPErrorCodes, } from 'vscode-languageserver/node' import { Position, TextDocument } from 'vscode-languageserver-textdocument' import { Utils, URI } from 'vscode-uri' @@ -29,8 +36,21 @@ export class CssServer { setup() { let connection = this.connection let documents = this.documents + let runtime: RuntimeEnvironment = { + timer: { + setImmediate(callback: (...args: any[]) => void, ms: number, ...args: any[]): Disposable { + const handle = setImmediate(callback, ms, ...args) + return { dispose: () => clearImmediate(handle) } + }, + setTimeout(callback: (...args: any[]) => void, ms: number, ...args: any[]): Disposable { + const handle = setTimeout(callback, ms, ...args) + return { dispose: () => clearTimeout(handle) } + }, + }, + } let cssLanguageService = getCSSLanguageService() + let diagnosticsSupport: DiagnosticsSupport | undefined let workspaceFolders: WorkspaceFolder[] @@ -67,6 +87,23 @@ export class CssServer { Number.MAX_VALUE, ) + let supportsDiagnosticPull = dlv(params.capabilities, 'textDocument.diagnostic', undefined) + if (supportsDiagnosticPull === undefined) { + diagnosticsSupport = registerDiagnosticsPushSupport( + documents, + connection, + runtime, + validateTextDocument, + ) + } else { + diagnosticsSupport = registerDiagnosticsPullSupport( + documents, + connection, + runtime, + validateTextDocument, + ) + } + return { capabilities: { textDocumentSync: TextDocumentSyncKind.Full, @@ -82,6 +119,11 @@ export class CssServer { codeActionProvider: true, documentLinkProvider: { resolveProvider: false }, renameProvider: true, + diagnosticProvider: { + documentSelector: null, + interFileDependencies: false, + workspaceDiagnostics: false, + }, }, } }) @@ -352,42 +394,7 @@ export class CssServer { cssLanguageService.configure(settings) // reset all document settings documentSettings = {} - documents.all().forEach(triggerValidation) - } - - const pendingValidationRequests: { [uri: string]: Disposable } = {} - const validationDelayMs = 500 - - const timer = { - setTimeout(callback: (...args: any[]) => void, ms: number, ...args: any[]): Disposable { - const handle = setTimeout(callback, ms, ...args) - return { dispose: () => clearTimeout(handle) } - }, - } - - documents.onDidChangeContent((change) => { - triggerValidation(change.document) - }) - - documents.onDidClose((event) => { - cleanPendingValidation(event.document) - connection.sendDiagnostics({ uri: event.document.uri, diagnostics: [] }) - }) - - function cleanPendingValidation(textDocument: TextDocument): void { - const request = pendingValidationRequests[textDocument.uri] - if (request) { - request.dispose() - delete pendingValidationRequests[textDocument.uri] - } - } - - function triggerValidation(textDocument: TextDocument): void { - cleanPendingValidation(textDocument) - pendingValidationRequests[textDocument.uri] = timer.setTimeout(() => { - delete pendingValidationRequests[textDocument.uri] - validateTextDocument(textDocument) - }, validationDelayMs) + diagnosticsSupport?.requestRefresh() } function createVirtualCssDocument(textDocument: TextDocument): TextDocument { @@ -401,12 +408,12 @@ export class CssServer { ) } - async function validateTextDocument(textDocument: TextDocument): Promise { + async function validateTextDocument(textDocument: TextDocument): Promise { textDocument = createVirtualCssDocument(textDocument) let settings = await getDocumentSettings(textDocument) - let diagnostics = cssLanguageService + let items = cssLanguageService .doValidation(textDocument, cssLanguageService.parseStylesheet(textDocument), settings) .filter((diagnostic) => { if ( @@ -420,7 +427,7 @@ export class CssServer { return true }) - connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }) + return items } } @@ -429,3 +436,181 @@ export class CssServer { this.connection.listen() } } + +type Validator = (textDocument: TextDocument) => Promise +type DiagnosticsSupport = { + dispose(): void + requestRefresh(): void +} + +export interface RuntimeEnvironment { + readonly timer: { + setImmediate(callback: (...args: any[]) => void, ...args: any[]): Disposable + setTimeout(callback: (...args: any[]) => void, ms: number, ...args: any[]): Disposable + } +} + +function formatError(message: string, err: any): string { + if (err instanceof Error) { + const error = err + return `${message}: ${error.message}\n${error.stack}` + } else if (typeof err === 'string') { + return `${message}: ${err}` + } else if (err) { + return `${message}: ${err.toString()}` + } + return message +} + +function registerDiagnosticsPushSupport( + documents: TextDocuments, + connection: Connection, + runtime: RuntimeEnvironment, + validate: Validator, +): DiagnosticsSupport { + const pendingValidationRequests: { [uri: string]: Disposable } = {} + const validationDelayMs = 500 + + const disposables: Disposable[] = [] + + // The content of a text document has changed. This event is emitted + // when the text document first opened or when its content has changed. + documents.onDidChangeContent( + (change) => { + triggerValidation(change.document) + }, + undefined, + disposables, + ) + + // a document has closed: clear all diagnostics + documents.onDidClose( + (event) => { + cleanPendingValidation(event.document) + connection.sendDiagnostics({ uri: event.document.uri, diagnostics: [] }) + }, + undefined, + disposables, + ) + + function cleanPendingValidation(textDocument: TextDocument): void { + const request = pendingValidationRequests[textDocument.uri] + if (request) { + request.dispose() + delete pendingValidationRequests[textDocument.uri] + } + } + + function triggerValidation(textDocument: TextDocument): void { + cleanPendingValidation(textDocument) + const request = (pendingValidationRequests[textDocument.uri] = runtime.timer.setTimeout( + async () => { + if (request === pendingValidationRequests[textDocument.uri]) { + try { + const diagnostics = await validate(textDocument) + if (request === pendingValidationRequests[textDocument.uri]) { + connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }) + } + delete pendingValidationRequests[textDocument.uri] + } catch (e) { + connection.console.error(formatError(`Error while validating ${textDocument.uri}`, e)) + } + } + }, + validationDelayMs, + )) + } + + return { + requestRefresh: () => { + documents.all().forEach(triggerValidation) + }, + dispose: () => { + disposables.forEach((d) => d.dispose()) + disposables.length = 0 + const keys = Object.keys(pendingValidationRequests) + for (const key of keys) { + pendingValidationRequests[key].dispose() + delete pendingValidationRequests[key] + } + }, + } +} + +function registerDiagnosticsPullSupport( + documents: TextDocuments, + connection: Connection, + runtime: RuntimeEnvironment, + validate: Validator, +): DiagnosticsSupport { + function newDocumentDiagnosticReport(diagnostics: Diagnostic[]): DocumentDiagnosticReport { + return { + kind: DocumentDiagnosticReportKind.Full, + items: diagnostics, + } + } + + const registration = connection.languages.diagnostics.on( + async (params: DocumentDiagnosticParams, token: CancellationToken) => { + return runSafeAsync( + runtime, + async () => { + const document = documents.get(params.textDocument.uri) + if (document) { + return newDocumentDiagnosticReport(await validate(document)) + } + return newDocumentDiagnosticReport([]) + }, + newDocumentDiagnosticReport([]), + `Error while computing diagnostics for ${params.textDocument.uri}`, + token, + ) + }, + ) + + function requestRefresh(): void { + connection.languages.diagnostics.refresh() + } + + return { + requestRefresh, + dispose: () => { + registration.dispose() + }, + } +} + +export function runSafeAsync( + runtime: RuntimeEnvironment, + func: () => Thenable, + errorVal: T, + errorMessage: string, + token: CancellationToken, +): Thenable> { + return new Promise>((resolve) => { + runtime.timer.setImmediate(() => { + if (token.isCancellationRequested) { + resolve(cancelValue()) + return + } + return func().then( + (result) => { + if (token.isCancellationRequested) { + resolve(cancelValue()) + return + } else { + resolve(result) + } + }, + (e) => { + console.error(formatError(errorMessage, e)) + resolve(errorVal) + }, + ) + }) + }) +} + +function cancelValue() { + return new ResponseError(LSPErrorCodes.RequestCancelled, 'Request cancelled') +} From 170368f40ae6104db2d20ccb7d0b66478340c4ad Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 22 Dec 2025 22:44:20 -0500 Subject: [PATCH 5/6] Stop pushing diagnostics to clients that support the pull model --- packages/tailwindcss-language-server/src/tw.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/tailwindcss-language-server/src/tw.ts b/packages/tailwindcss-language-server/src/tw.ts index e61d6b657..64eceb806 100644 --- a/packages/tailwindcss-language-server/src/tw.ts +++ b/packages/tailwindcss-language-server/src/tw.ts @@ -738,6 +738,11 @@ export class TW { this.disposables.push( this.documentService.onDidChangeContent((change) => { + // Don't push diagnostics to clients supporting the pull model + if (this.initializeParams.capabilities.textDocument?.diagnostic) { + return + } + this.getProject(change.document)?.provideDiagnostics(change.document) }), ) @@ -850,6 +855,11 @@ export class TW { } private refreshDiagnostics() { + // Don't push diagnostics to clients supporting the pull model + if (this.initializeParams.capabilities.textDocument?.diagnostic) { + return + } + for (let doc of this.documentService.getAllDocuments()) { let project = this.getProject(doc) if (project) { From ef17e39c5741cd39e020860960afde1af0f82401 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 22 Dec 2025 22:41:02 -0500 Subject: [PATCH 6/6] Verify push model diagnostics still function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If we’re connected to an older client that doesn’t support the pull model we must push them to it. --- .../tests/css/css-server.test.ts | 49 +++++++++++- .../tests/diagnostics/diagnostics.test.ts | 74 +++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss-language-server/tests/css/css-server.test.ts b/packages/tailwindcss-language-server/tests/css/css-server.test.ts index 18ba64cf9..409103008 100644 --- a/packages/tailwindcss-language-server/tests/css/css-server.test.ts +++ b/packages/tailwindcss-language-server/tests/css/css-server.test.ts @@ -1,7 +1,11 @@ import { expect } from 'vitest' import { css, defineTest } from '../../src/testing' import { createClient } from '../utils/client' -import { SymbolKind } from 'vscode-languageserver' +import { + PublishDiagnosticsNotification, + PublishDiagnosticsParams, + SymbolKind, +} from 'vscode-languageserver' defineTest({ name: '@custom-variant', @@ -713,3 +717,46 @@ defineTest({ expect(completionsF).toEqual({ isIncomplete: false, items: [] }) }, }) + +defineTest({ + name: 'Clients not supporting pull-model diagnostics will have them pushed', + prepare: async ({ root }) => ({ + client: await createClient({ + server: 'css', + root, + capabilities(caps) { + // Disable pull-model diagnostics + delete caps.textDocument!.diagnostic + }, + }), + }), + handle: async ({ client }) => { + let didPublishDiagnostics = new Promise((resolve) => { + client.conn.onNotification(PublishDiagnosticsNotification.type, resolve) + }) + + // We open a document so a project gets initialized + // This will cause the server to push diagnostics to the client + let doc = await client.open({ + lang: 'tailwindcss', + text: css` + @idonotexist { + color: red; + } + `, + }) + + let result = await didPublishDiagnostics + + expect(result.uri).toEqual(doc.uri.toString()) + expect(result.diagnostics).toEqual([ + { + code: 'unknownAtRules', + source: 'tailwindcss', + message: 'Unknown at rule @idonotexist', + severity: 2, + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 12 } }, + }, + ]) + }, +}) diff --git a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.ts b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.ts index 77fd90081..d6a60a2bc 100644 --- a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.ts +++ b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.ts @@ -5,6 +5,8 @@ import { css, defineTest, json } from '../../src/testing' import { createClient } from '../utils/client' import { DocumentDiagnosticReport, + PublishDiagnosticsNotification, + PublishDiagnosticsParams, } from 'vscode-languageserver' withFixture('basic', (c) => { @@ -499,3 +501,75 @@ defineTest({ ]) }, }) + +defineTest({ + name: 'Clients not supporting pull-model diagnostics will have them pushed', + fs: { + 'app.css': '@import "tailwindcss"', + }, + prepare: async ({ root }) => ({ + client: await createClient({ + root, + capabilities(caps) { + // Disable pull-model diagnostics + delete caps.textDocument!.diagnostic + }, + }), + }), + handle: async ({ client }) => { + let didPublishDiagnostics = new Promise((resolve) => { + client.conn.onNotification(PublishDiagnosticsNotification.type, resolve) + }) + + // We open a document so a project gets initialized + // This will cause the server to push diagnostics to the client + let doc = await client.open({ + lang: 'html', + text: '
', + }) + + let result = await didPublishDiagnostics + + expect(result.uri).toEqual(doc.uri.toString()) + expect(result.diagnostics).toMatchObject([ + { + code: 'cssConflict', + source: 'tailwindcss', + message: "'underline' applies the same CSS properties as 'line-through'.", + className: { + className: 'underline', + classList: { + classList: 'underline line-through', + }, + }, + otherClassNames: [ + { + className: 'line-through', + classList: { + classList: 'underline line-through', + }, + }, + ], + }, + { + code: 'cssConflict', + source: 'tailwindcss', + message: "'line-through' applies the same CSS properties as 'underline'.", + className: { + className: 'line-through', + classList: { + classList: 'underline line-through', + }, + }, + otherClassNames: [ + { + className: 'underline', + classList: { + classList: 'underline line-through', + }, + }, + ], + }, + ]) + }, +})