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') +} 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..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' @@ -51,7 +53,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 +86,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) @@ -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 @@ -1734,3 +1745,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-server/src/tw.ts b/packages/tailwindcss-language-server/src/tw.ts index 07d3956a9..64eceb806 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' @@ -735,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) }), ) @@ -847,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) { @@ -870,6 +883,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 +953,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 +1107,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 +1185,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/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.js b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.ts similarity index 81% rename from packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js rename to packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.ts index 4ddee0f2c..d6a60a2bc 100644 --- a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js +++ b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.ts @@ -3,6 +3,11 @@ import { expect, test } from 'vitest' import { withFixture } from '../common' import { css, defineTest, json } from '../../src/testing' import { createClient } from '../utils/client' +import { + DocumentDiagnosticReport, + PublishDiagnosticsNotification, + PublishDiagnosticsParams, +} from 'vscode-languageserver' withFixture('basic', (c) => { function testFixture(fixture) { @@ -11,14 +16,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 +49,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 +64,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 +164,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 +265,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)) @@ -506,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', + }, + }, + ], + }, + ]) + }, +}) 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 { 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'