diff --git a/packages/tailwindcss-language-server/src/config.ts b/packages/tailwindcss-language-server/src/config.ts index 90fb3207..d8364d06 100644 --- a/packages/tailwindcss-language-server/src/config.ts +++ b/packages/tailwindcss-language-server/src/config.ts @@ -12,6 +12,7 @@ function getDefaultSettings(): Settings { return { editor: { tabSize: 2 }, tailwindCSS: { + inspectPort: null, emmetCompletions: false, classAttributes: ['class', 'className', 'ngClass', 'class:list'], codeActions: true, diff --git a/packages/tailwindcss-language-server/src/testing/index.ts b/packages/tailwindcss-language-server/src/testing/index.ts index 92976755..2435ca0f 100644 --- a/packages/tailwindcss-language-server/src/testing/index.ts +++ b/packages/tailwindcss-language-server/src/testing/index.ts @@ -1,4 +1,4 @@ -import { afterAll, onTestFinished, test, TestOptions } from 'vitest' +import { onTestFinished, test, TestOptions } from 'vitest' import * as fs from 'node:fs/promises' import * as path from 'node:path' import * as proc from 'node:child_process' @@ -16,7 +16,7 @@ export interface Storage { export interface TestConfig { name: string - fs: Storage + fs?: Storage prepare?(utils: TestUtils): Promise handle(utils: TestUtils & Extras): void | Promise @@ -43,8 +43,10 @@ async function setup(config: TestConfig): Promise { await fs.mkdir(baseDir, { recursive: true }) - await prepareFileSystem(baseDir, config.fs) - await installDependencies(baseDir, config.fs) + if (config.fs) { + await prepareFileSystem(baseDir, config.fs) + await installDependencies(baseDir, config.fs) + } onTestFinished(async (result) => { // Once done, move all the files to a new location diff --git a/packages/tailwindcss-language-server/src/tw.ts b/packages/tailwindcss-language-server/src/tw.ts index b795be5c..efb12a34 100644 --- a/packages/tailwindcss-language-server/src/tw.ts +++ b/packages/tailwindcss-language-server/src/tw.ts @@ -553,6 +553,7 @@ export class TW { configTailwindVersionMap.get(projectConfig.configPath), userLanguages, resolver, + baseUri, ), ), ) @@ -663,6 +664,11 @@ export class TW { }), ) } + + // TODO: This is a hack and shouldn't be necessary + if (isTestMode) { + await this.connection.sendNotification('@/tailwindCSS/serverReady') + } } private filterNewWatchPatterns(patterns: string[]) { @@ -684,6 +690,7 @@ export class TW { tailwindVersion: string, userLanguages: Record, resolver: Resolver, + baseUri: URI, ): Promise { let key = String(this.projectCounter++) const project = await createProjectService( @@ -717,6 +724,7 @@ export class TW { } this.connection.sendNotification('@/tailwindCSS/projectDetails', { + uri: baseUri.toString(), config: projectConfig.configPath, tailwind: projectConfig.tailwind, }) diff --git a/packages/tailwindcss-language-server/tests/common.ts b/packages/tailwindcss-language-server/tests/common.ts index 37339159..9b40b3d3 100644 --- a/packages/tailwindcss-language-server/tests/common.ts +++ b/packages/tailwindcss-language-server/tests/common.ts @@ -1,33 +1,21 @@ import * as path from 'node:path' import { beforeAll, describe } from 'vitest' -import { connect, launch } from './connection' -import { - CompletionRequest, - ConfigurationRequest, - DidChangeConfigurationNotification, - DidChangeTextDocumentNotification, - DidOpenTextDocumentNotification, - InitializeRequest, - InitializedNotification, - RegistrationRequest, - InitializeParams, - DidOpenTextDocumentParams, - MessageType, -} from 'vscode-languageserver-protocol' -import type { ClientCapabilities, ProtocolConnection } from 'vscode-languageclient' +import { DidChangeTextDocumentNotification } from 'vscode-languageserver' +import type { ProtocolConnection } from 'vscode-languageclient' import type { Feature } from '@tailwindcss/language-service/src/features' -import { clearLanguageBoundariesCache } from '@tailwindcss/language-service/src/util/getLanguageBoundaries' -import { CacheMap } from '../src/cache-map' +import { URI } from 'vscode-uri' +import { Client, createClient } from './utils/client' type Settings = any interface FixtureContext extends Pick { - client: ProtocolConnection + client: Client openDocument: (params: { text: string lang?: string dir?: string + name?: string | null settings?: Settings }) => Promise<{ uri: string; updateSettings: (settings: Settings) => Promise }> updateSettings: (settings: Settings) => Promise @@ -57,117 +45,22 @@ export interface InitOptions { * Extra initialization options to pass to the LSP */ options?: Record + + /** + * Settings to provide the server immediately when it starts + */ + settings?: Settings } export async function init( fixture: string | string[], opts: InitOptions = {}, ): Promise { - let settings = {} - let docSettings = new Map() - - const { client } = opts?.mode === 'spawn' ? await launch() : await connect() - - if (opts?.mode === 'spawn') { - client.onNotification('window/logMessage', ({ message, type }) => { - if (type === MessageType.Error) { - console.error(message) - } else if (type === MessageType.Warning) { - console.warn(message) - } else if (type === MessageType.Info) { - console.info(message) - } else if (type === MessageType.Log) { - console.log(message) - } else if (type === MessageType.Debug) { - console.debug(message) - } - }) - } - - const capabilities: ClientCapabilities = { - textDocument: { - codeAction: { dynamicRegistration: true }, - codeLens: { dynamicRegistration: true }, - colorProvider: { dynamicRegistration: true }, - completion: { - completionItem: { - commitCharactersSupport: true, - documentationFormat: ['markdown', 'plaintext'], - snippetSupport: true, - }, - completionItemKind: { - valueSet: [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, - 25, - ], - }, - contextSupport: true, - dynamicRegistration: true, - }, - definition: { dynamicRegistration: true }, - documentHighlight: { dynamicRegistration: true }, - documentLink: { dynamicRegistration: true }, - documentSymbol: { - dynamicRegistration: true, - symbolKind: { - valueSet: [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, - 25, 26, - ], - }, - }, - formatting: { dynamicRegistration: true }, - hover: { - contentFormat: ['markdown', 'plaintext'], - dynamicRegistration: true, - }, - implementation: { dynamicRegistration: true }, - onTypeFormatting: { dynamicRegistration: true }, - publishDiagnostics: { relatedInformation: true }, - rangeFormatting: { dynamicRegistration: true }, - references: { dynamicRegistration: true }, - rename: { dynamicRegistration: true }, - signatureHelp: { - dynamicRegistration: true, - signatureInformation: { documentationFormat: ['markdown', 'plaintext'] }, - }, - synchronization: { - didSave: true, - dynamicRegistration: true, - willSave: true, - willSaveWaitUntil: true, - }, - typeDefinition: { dynamicRegistration: true }, - }, - workspace: { - applyEdit: true, - configuration: true, - didChangeConfiguration: { dynamicRegistration: true }, - didChangeWatchedFiles: { dynamicRegistration: true }, - executeCommand: { dynamicRegistration: true }, - symbol: { - dynamicRegistration: true, - symbolKind: { - valueSet: [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, - 25, 26, - ], - }, - }, - workspaceEdit: { documentChanges: true }, - workspaceFolders: true, - }, - experimental: { - tailwind: { - projectDetails: true, - }, - }, - } - - const fixtures = Array.isArray(fixture) ? fixture : [fixture] + let workspaces: Record = {} + let fixtures = Array.isArray(fixture) ? fixture : [fixture] - function fixtureUri(fixture: string) { - return `file://${path.resolve('./tests/fixtures', fixture)}` + function fixturePath(fixture: string) { + return path.resolve('./tests/fixtures', fixture) } function resolveUri(...parts: string[]) { @@ -176,86 +69,44 @@ export async function init( ? path.resolve('./tests/fixtures', ...parts) : path.resolve('./tests/fixtures', fixtures[0], ...parts) - return `file://${filepath}` + return URI.file(filepath).toString() } - const workspaceFolders = fixtures.map((fixture) => ({ - name: `Fixture ${fixture}`, - uri: fixtureUri(fixture), - })) - - const rootUri = fixtures.length > 1 ? null : workspaceFolders[0].uri - - await client.sendRequest(InitializeRequest.type, { - processId: -1, - rootUri, - capabilities, - trace: 'off', - workspaceFolders, - initializationOptions: { - testMode: true, - ...(opts.options ?? {}), - }, - } as InitializeParams) - - await client.sendNotification(InitializedNotification.type) - - client.onRequest(ConfigurationRequest.type, (params) => { - return params.items.map((item) => { - if (docSettings.has(item.scopeUri!)) { - return docSettings.get(item.scopeUri!)[item.section!] ?? {} - } - return settings[item.section!] ?? {} - }) - }) - - let initPromise = new Promise((resolve) => { - client.onRequest(RegistrationRequest.type, ({ registrations }) => { - if (registrations.some((r) => r.method === CompletionRequest.method)) { - resolve() - } + for (let [idx, fixture] of fixtures.entries()) { + workspaces[`Fixture ${idx}`] = fixturePath(fixture) + } - return null - }) + let client = await createClient({ + server: 'tailwindcss', + mode: opts.mode, + options: opts.options, + root: workspaces, + settings: opts.settings, }) - interface PromiseWithResolvers extends Promise { - resolve: (value?: T | PromiseLike) => void - reject: (reason?: any) => void - } - - let openingDocuments = new CacheMap>() + let counter = 0 let projectDetails: any = null - client.onNotification('@/tailwindCSS/projectDetails', (params) => { - console.log('[TEST] Project detailed changed') - projectDetails = params + client.project().then((project) => { + projectDetails = project }) - client.onNotification('@/tailwindCSS/documentReady', (params) => { - console.log('[TEST] Document ready', params.uri) - openingDocuments.get(params.uri)?.resolve() - }) - - // This is a global cache that must be reset between tests for accurate results - clearLanguageBoundariesCache() - - let counter = 0 - return { client, - fixtureUri, + fixtureUri(fixture: string) { + return URI.file(fixturePath(fixture)).toString() + }, get project() { return projectDetails }, sendRequest(type: any, params: any) { - return client.sendRequest(type, params) + return client.conn.sendRequest(type, params) }, sendNotification(type: any, params?: any) { - return client.sendNotification(type, params) + return client.conn.sendNotification(type, params) }, onNotification(type: any, callback: any) { - return client.onNotification(type, callback) + return client.conn.onNotification(type, callback) }, async openDocument({ text, @@ -267,59 +118,35 @@ export async function init( text: string lang?: string dir?: string - name?: string + name?: string | null settings?: Settings }) { let uri = resolveUri(dir, name ?? `file-${counter++}`) - docSettings.set(uri, settings) - let openPromise = openingDocuments.remember(uri, () => { - let resolve = () => {} - let reject = () => {} - - let p = new Promise((_resolve, _reject) => { - resolve = _resolve - reject = _reject - }) - - return Object.assign(p, { - resolve, - reject, - }) + let doc = await client.open({ + lang, + text, + uri, + settings, }) - await client.sendNotification(DidOpenTextDocumentNotification.type, { - textDocument: { - uri, - languageId: lang, - version: 1, - text, - }, - } as DidOpenTextDocumentParams) - - // If opening a document stalls then it's probably because this promise is not being resolved - // This can happen if a document is not covered by one of the selectors because of it's URI - await initPromise - await openPromise - return { - uri, + get uri() { + return doc.uri.toString() + }, async updateSettings(settings: Settings) { - docSettings.set(uri, settings) - await client.sendNotification(DidChangeConfigurationNotification.type) + await doc.update({ settings }) }, } }, async updateSettings(newSettings: Settings) { - settings = newSettings - await client.sendNotification(DidChangeConfigurationNotification.type) + await client.updateSettings(newSettings) }, async updateFile(file: string, text: string) { let uri = resolveUri(file) - - await client.sendNotification(DidChangeTextDocumentNotification.type, { + await client.conn.sendNotification(DidChangeTextDocumentNotification.type, { textDocument: { uri, version: counter++ }, contentChanges: [{ text }], }) @@ -337,7 +164,7 @@ export function withFixture(fixture: string, callback: (c: FixtureContext) => vo // to the connection object without having to resort to using a Proxy Object.setPrototypeOf(c, await init(fixture)) - return () => c.client.dispose() + return () => c.client.conn.dispose() }) callback(c) @@ -360,7 +187,7 @@ export function withWorkspace({ // to the connection object without having to resort to using a Proxy Object.setPrototypeOf(c, await init(fixtures)) - return () => c.client.dispose() + return () => c.client.conn.dispose() }) run(c) diff --git a/packages/tailwindcss-language-server/tests/connection.ts b/packages/tailwindcss-language-server/tests/connection.ts deleted file mode 100644 index 21e56766..00000000 --- a/packages/tailwindcss-language-server/tests/connection.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { fork } from 'node:child_process' -import { createConnection } from 'vscode-languageserver/node' -import type { ProtocolConnection } from 'vscode-languageclient/node' - -import { Duplex } from 'node:stream' -import { TW } from '../src/tw' - -class TestStream extends Duplex { - _write(chunk: string, _encoding: string, done: () => void) { - this.emit('data', chunk) - done() - } - - _read(_size: number) {} -} - -export async function connect() { - let input = new TestStream() - let output = new TestStream() - - let server = createConnection(input, output) - let tw = new TW(server) - tw.setup() - tw.listen() - - let client = createConnection(output, input) as unknown as ProtocolConnection - client.listen() - - return { - client, - } -} - -export async function launch() { - let child = fork('./bin/tailwindcss-language-server', { silent: true }) - - let client = createConnection(child.stdout!, child.stdin!) as unknown as ProtocolConnection - - client.listen() - - return { - client, - } -} diff --git a/packages/tailwindcss-language-server/tests/env/custom-languages.test.js b/packages/tailwindcss-language-server/tests/env/custom-languages.test.js index 75663051..b5e60a59 100644 --- a/packages/tailwindcss-language-server/tests/env/custom-languages.test.js +++ b/packages/tailwindcss-language-server/tests/env/custom-languages.test.js @@ -1,24 +1,20 @@ +// @ts-check import { test } from 'vitest' import { init } from '../common' -import { CompletionRequest, HoverRequest } from 'vscode-languageserver' test('Unknown languages do not provide completions', async ({ expect }) => { - let c = await init('basic') + let { client } = await init('basic') - let textDocument = await c.openDocument({ + let doc = await client.open({ lang: 'some-lang', text: '
', }) - let hover = await c.sendRequest(HoverRequest.type, { - textDocument, - position: { line: 0, character: 13 }, - }) + let hover = await doc.hover({ line: 0, character: 13 }) expect(hover).toEqual(null) - let completion = await c.sendRequest(CompletionRequest.type, { - textDocument, + let completion = await doc.completions({ position: { line: 0, character: 13 }, context: { triggerKind: 1 }, }) @@ -27,7 +23,7 @@ test('Unknown languages do not provide completions', async ({ expect }) => { }) test('Custom languages may be specified via init options (deprecated)', async ({ expect }) => { - let c = await init('basic', { + let { client } = await init('basic', { options: { userLanguages: { 'some-lang': 'html', @@ -35,15 +31,12 @@ test('Custom languages may be specified via init options (deprecated)', async ({ }, }) - let textDocument = await c.openDocument({ + let doc = await client.open({ lang: 'some-lang', text: '
', }) - let hover = await c.sendRequest(HoverRequest.type, { - textDocument, - position: { line: 0, character: 13 }, - }) + let hover = await doc.hover({ line: 0, character: 13 }) expect(hover).toEqual({ contents: { @@ -54,35 +47,31 @@ test('Custom languages may be specified via init options (deprecated)', async ({ range: { start: { line: 0, character: 12 }, end: { line: 0, character: 21 } }, }) - let completion = await c.sendRequest(CompletionRequest.type, { - textDocument, + let completion = await doc.completions({ position: { line: 0, character: 13 }, context: { triggerKind: 1 }, }) - expect(completion.items.length).toBe(11509) + expect(completion?.items.length).toBe(11509) }) test('Custom languages may be specified via settings', async ({ expect }) => { - let c = await init('basic') - - await c.updateSettings({ - tailwindCSS: { - includeLanguages: { - 'some-lang': 'html', + let { client } = await init('basic', { + settings: { + tailwindCSS: { + includeLanguages: { + 'some-lang': 'html', + }, }, }, }) - let textDocument = await c.openDocument({ + let doc = await client.open({ lang: 'some-lang', text: '
', }) - let hover = await c.sendRequest(HoverRequest.type, { - textDocument, - position: { line: 0, character: 13 }, - }) + let hover = await doc.hover({ line: 0, character: 13 }) expect(hover).toEqual({ contents: { @@ -93,60 +82,51 @@ test('Custom languages may be specified via settings', async ({ expect }) => { range: { start: { line: 0, character: 12 }, end: { line: 0, character: 21 } }, }) - let completion = await c.sendRequest(CompletionRequest.type, { - textDocument, + let completion = await doc.completions({ position: { line: 0, character: 13 }, context: { triggerKind: 1 }, }) - expect(completion.items.length).toBe(11509) + expect(completion?.items.length).toBe(11509) }) test('Custom languages are merged from init options and settings', async ({ expect }) => { - let c = await init('basic', { + let { client } = await init('basic', { options: { userLanguages: { 'some-lang': 'html', }, }, - }) - await c.updateSettings({ - tailwindCSS: { - includeLanguages: { - 'other-lang': 'html', + settings: { + tailwindCSS: { + includeLanguages: { + 'other-lang': 'html', + }, }, }, }) - let textDocument = await c.openDocument({ + let doc = await client.open({ lang: 'some-lang', text: '
', }) - let hover = await c.sendRequest(HoverRequest.type, { - textDocument, - position: { line: 0, character: 13 }, - }) + let hover = await doc.hover({ line: 0, character: 13 }) - let completion = await c.sendRequest(CompletionRequest.type, { - textDocument, + let completion = await doc.completions({ position: { line: 0, character: 13 }, context: { triggerKind: 1 }, }) - textDocument = await c.openDocument({ + let doc2 = await client.open({ lang: 'other-lang', text: '
', }) - let hover2 = await c.sendRequest(HoverRequest.type, { - textDocument, - position: { line: 0, character: 13 }, - }) + let hover2 = await doc2.hover({ line: 0, character: 13 }) - let completion2 = await c.sendRequest(CompletionRequest.type, { - textDocument, + let completion2 = await doc2.completions({ position: { line: 0, character: 13 }, context: { triggerKind: 1 }, }) @@ -169,36 +149,33 @@ test('Custom languages are merged from init options and settings', async ({ expe range: { start: { line: 0, character: 12 }, end: { line: 0, character: 21 } }, }) - expect(completion.items.length).toBe(11509) - expect(completion2.items.length).toBe(11509) + expect(completion?.items.length).toBe(11509) + expect(completion2?.items.length).toBe(11509) }) test('Language mappings from settings take precedence', async ({ expect }) => { - let c = await init('basic', { + let { client } = await init('basic', { options: { userLanguages: { 'some-lang': 'css', }, }, - }) - await c.updateSettings({ - tailwindCSS: { - includeLanguages: { - 'some-lang': 'html', + settings: { + tailwindCSS: { + includeLanguages: { + 'some-lang': 'html', + }, }, }, }) - let textDocument = await c.openDocument({ + let doc = await client.open({ lang: 'some-lang', text: '
', }) - let hover = await c.sendRequest(HoverRequest.type, { - textDocument, - position: { line: 0, character: 13 }, - }) + let hover = await doc.hover({ line: 0, character: 13 }) expect(hover).toEqual({ contents: { @@ -209,11 +186,10 @@ test('Language mappings from settings take precedence', async ({ expect }) => { range: { start: { line: 0, character: 12 }, end: { line: 0, character: 21 } }, }) - let completion = await c.sendRequest(CompletionRequest.type, { - textDocument, + let completion = await doc.completions({ position: { line: 0, character: 13 }, context: { triggerKind: 1 }, }) - expect(completion.items.length).toBe(11509) + expect(completion?.items.length).toBe(11509) }) diff --git a/packages/tailwindcss-language-server/tests/env/v4.test.js b/packages/tailwindcss-language-server/tests/env/v4.test.js index 310c9a34..1ae5caf4 100644 --- a/packages/tailwindcss-language-server/tests/env/v4.test.js +++ b/packages/tailwindcss-language-server/tests/env/v4.test.js @@ -1,9 +1,9 @@ +// @ts-check + import { expect } from 'vitest' -import { init } from '../common' -import { HoverRequest } from 'vscode-languageserver' import { css, defineTest, html, js, json } from '../../src/testing' import dedent from 'dedent' -import { CompletionRequest } from 'vscode-languageserver-protocol' +import { createClient } from '../utils/client' defineTest({ name: 'v4, no npm, uses fallback', @@ -12,36 +12,27 @@ defineTest({ @import 'tailwindcss'; `, }, - prepare: async ({ root }) => ({ c: await init(root) }), - handle: async ({ c }) => { - let textDocument = await c.openDocument({ + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let doc = await client.open({ lang: 'html', text: '
', }) - expect(c.project).toMatchObject({ + expect(await client.project()).toMatchObject({ tailwind: { version: '4.0.6', isDefaultVersion: true, }, }) - let hover = await c.sendRequest(HoverRequest.type, { - textDocument, - - //
({ - c: await init(root, { mode: 'spawn' }), - }), + prepare: async ({ root }) => ({ client: await createClient({ root, mode: 'spawn' }) }), - handle: async ({ c }) => { - let textDocument = await c.openDocument({ + handle: async ({ client }) => { + let doc = await client.open({ lang: 'html', text: '
', }) - expect(c.project).toMatchObject({ + expect(await client.project()).toMatchObject({ tailwind: { version: '4.0.6', isDefaultVersion: true, }, }) - let hover = await c.sendRequest(HoverRequest.type, { - textDocument, - - //
- // ^ - position: { line: 0, character: 13 }, - }) + //
+ // ^ + let hover = await doc.hover({ line: 0, character: 13 }) expect(hover).toEqual({ contents: { @@ -137,13 +122,9 @@ defineTest({ }, }) - let hoverFromPlugin = await c.sendRequest(HoverRequest.type, { - textDocument, - - //
- // ^ - position: { line: 0, character: 23 }, - }) + //
+ // ^ + let hoverFromPlugin = await doc.hover({ line: 0, character: 23 }) expect(hoverFromPlugin).toEqual({ contents: { @@ -176,36 +157,27 @@ defineTest({ @import 'tailwindcss'; `, }, - prepare: async ({ root }) => ({ c: await init(root) }), - handle: async ({ c }) => { - let textDocument = await c.openDocument({ + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let doc = await client.open({ lang: 'html', text: '
', }) - expect(c.project).toMatchObject({ + expect(await client.project()).toMatchObject({ tailwind: { version: '4.0.1', isDefaultVersion: false, }, }) - let hover = await c.sendRequest(HoverRequest.type, { - textDocument, - - //
+ // ^ + let hover = await doc.hover({ line: 0, character: 13 }) - let completion = await c.sendRequest(CompletionRequest.type, { - textDocument, - context: { triggerKind: 1 }, - - //
+ // ^ + let completion = await doc.completions({ line: 0, character: 31 }) expect(hover).toEqual({ contents: { @@ -222,7 +194,7 @@ defineTest({ }, }) - expect(completion.items.length).toBe(12288) + expect(completion?.items.length).toBe(12288) }, }) @@ -250,27 +222,23 @@ defineTest({ } `, }, - prepare: async ({ root }) => ({ c: await init(root) }), - handle: async ({ c }) => { - let textDocument = await c.openDocument({ + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let doc = await client.open({ lang: 'html', text: '
', }) - expect(c.project).toMatchObject({ + expect(await client.project()).toMatchObject({ tailwind: { version: '4.0.1', isDefaultVersion: false, }, }) - let hover = await c.sendRequest(HoverRequest.type, { - textDocument, - - //
- // ^ - position: { line: 0, character: 13 }, - }) + //
+ // ^ + let hover = await doc.hover({ line: 0, character: 13 }) expect(hover).toEqual({ contents: { @@ -306,27 +274,23 @@ defineTest({ } `, }, - prepare: async ({ root }) => ({ c: await init(root) }), - handle: async ({ c }) => { - let textDocument = await c.openDocument({ + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let doc = await client.open({ lang: 'html', text: '
', }) - expect(c.project).toMatchObject({ + expect(await client.project()).toMatchObject({ tailwind: { version: '4.0.6', isDefaultVersion: true, }, }) - let hover = await c.sendRequest(HoverRequest.type, { - textDocument, - - //
- // ^ - position: { line: 0, character: 13 }, - }) + //
+ // ^ + let hover = await doc.hover({ line: 0, character: 13 }) expect(hover).toEqual({ contents: { @@ -368,46 +332,41 @@ defineTest({ } `, }, - prepare: async ({ root }) => ({ c: await init(root) }), - handle: async ({ c }) => { - await c.updateSettings({ - tailwindCSS: { - experimental: { - configFile: { - 'a/app.css': 'c/a/**', - 'b/app.css': 'c/b/**', + prepare: async ({ root }) => ({ + client: await createClient({ + root, + settings: { + tailwindCSS: { + experimental: { + configFile: { + 'a/app.css': 'c/a/**', + 'b/app.css': 'c/b/**', + }, }, }, }, - }) - - let documentA = await c.openDocument({ + }), + }), + handle: async ({ client }) => { + let documentA = await client.open({ lang: 'html', text: '
', name: 'c/a/index.html', }) - let documentB = await c.openDocument({ + let documentB = await client.open({ lang: 'html', text: '
', name: 'c/b/index.html', }) - let hoverA = await c.sendRequest(HoverRequest.type, { - textDocument: documentA, - - //
- // ^ - position: { line: 0, character: 13 }, - }) - - let hoverB = await c.sendRequest(HoverRequest.type, { - textDocument: documentB, + //
+ // ^ + let hoverA = await documentA.hover({ line: 0, character: 13 }) - //
- // ^ - position: { line: 0, character: 13 }, - }) + //
+ // ^ + let hoverB = await documentB.hover({ line: 0, character: 13 }) expect(hoverA).toEqual({ contents: { @@ -457,46 +416,41 @@ defineTest({ } `, }, - prepare: async ({ root }) => ({ c: await init(root) }), - handle: async ({ c }) => { - await c.updateSettings({ - tailwindCSS: { - experimental: { - configFile: { - 'a/app.css': 'c/a/**', - 'b/app.css': 'c/b/**', + prepare: async ({ root }) => ({ + client: await createClient({ + root, + settings: { + tailwindCSS: { + experimental: { + configFile: { + 'a/app.css': 'c/a/**', + 'b/app.css': 'c/b/**', + }, }, }, }, - }) - - let documentA = await c.openDocument({ + }), + }), + handle: async ({ client }) => { + let documentA = await client.open({ lang: 'html', text: '
', name: 'c/a/index.html', }) - let documentB = await c.openDocument({ + let documentB = await client.open({ lang: 'html', text: '
', name: 'c/b/index.html', }) - let hoverA = await c.sendRequest(HoverRequest.type, { - textDocument: documentA, - - //
- // ^ - position: { line: 0, character: 13 }, - }) - - let hoverB = await c.sendRequest(HoverRequest.type, { - textDocument: documentB, + //
+ // ^ + let hoverA = await documentA.hover({ line: 0, character: 13 }) - //
- // ^ - position: { line: 0, character: 13 }, - }) + //
+ // ^ + let hoverB = await documentB.hover({ line: 0, character: 13 }) expect(hoverA).toEqual({ contents: { @@ -537,9 +491,9 @@ defineTest({ @import 'tailwindcss'; `, }, - prepare: async ({ root }) => ({ c: await init(root) }), - handle: async ({ c }) => { - let document = await c.openDocument({ + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ lang: 'vue', text: html` @@ -550,13 +504,9 @@ defineTest({ `, }) - let hover = await c.sendRequest(HoverRequest.type, { - textDocument: document, - - // return
- // ^ - position: { line: 2, character: 24 }, - }) + // return
+ // ^ + let hover = await document.hover({ line: 2, character: 24 }) expect(hover).toEqual({ contents: { @@ -587,20 +537,16 @@ defineTest({ @import 'tailwindcss'; `, }, - prepare: async ({ root }) => ({ c: await init(root) }), - handle: async ({ c }) => { - let document = await c.openDocument({ + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ lang: 'html', text: '
', }) - let hover = await c.sendRequest(HoverRequest.type, { - textDocument: document, - - //
- // ^ - position: { line: 0, character: 13 }, - }) + //
+ // ^ + let hover = await document.hover({ line: 0, character: 13 }) expect(hover).toEqual({ contents: { @@ -636,19 +582,16 @@ defineTest({ } `, }, - prepare: async ({ root }) => ({ c: await init(root) }), - handle: async ({ c }) => { - let document = await c.openDocument({ + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ lang: 'html', text: '
', }) //
// ^ - let hover = await c.sendRequest(HoverRequest.type, { - textDocument: document, - position: { line: 0, character: 13 }, - }) + let hover = await document.hover({ line: 0, character: 13 }) expect(hover).toEqual({ contents: { @@ -696,19 +639,16 @@ defineTest({ } `, }, - prepare: async ({ root }) => ({ c: await init(root) }), - handle: async ({ c }) => { - let document = await c.openDocument({ + prepare: async ({ root }) => ({ client: await createClient({ root }) }), + handle: async ({ client }) => { + let document = await client.open({ lang: 'html', text: '
', }) //
// ^ - let hover = await c.sendRequest(HoverRequest.type, { - textDocument: document, - position: { line: 0, character: 13 }, - }) + let hover = await document.hover({ line: 0, character: 13 }) expect(hover).toEqual({ contents: { diff --git a/packages/tailwindcss-language-server/tests/env/workspace-folders.test.ts b/packages/tailwindcss-language-server/tests/env/workspace-folders.test.ts index c97899b5..e9b06d23 100644 --- a/packages/tailwindcss-language-server/tests/env/workspace-folders.test.ts +++ b/packages/tailwindcss-language-server/tests/env/workspace-folders.test.ts @@ -1,5 +1,4 @@ import { test } from 'vitest' -import * as path from 'node:path' import { withWorkspace } from '../common' import { DidChangeWorkspaceFoldersNotification, HoverRequest } from 'vscode-languageserver' diff --git a/packages/tailwindcss-language-server/tests/hover/hover.test.js b/packages/tailwindcss-language-server/tests/hover/hover.test.js index 84841680..63c2e32c 100644 --- a/packages/tailwindcss-language-server/tests/hover/hover.test.js +++ b/packages/tailwindcss-language-server/tests/hover/hover.test.js @@ -29,6 +29,7 @@ withFixture('basic', (c) => { testHover('disabled', { text: '
', + position: { line: 0, character: 13 }, settings: { tailwindCSS: { hovers: false }, }, @@ -202,6 +203,7 @@ withFixture('v4/basic', (c) => { testHover('disabled', { text: '
', + position: { line: 0, character: 13 }, settings: { tailwindCSS: { hovers: false }, }, diff --git a/packages/tailwindcss-language-server/tests/utils/client.ts b/packages/tailwindcss-language-server/tests/utils/client.ts new file mode 100644 index 00000000..e02a8393 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/utils/client.ts @@ -0,0 +1,787 @@ +import type { Settings } from '@tailwindcss/language-service/src/util/state' +import { + ClientCapabilities, + CompletionList, + CompletionParams, + Diagnostic, + DidChangeWorkspaceFoldersNotification, + Disposable, + DocumentLink, + DocumentLinkRequest, + DocumentSymbol, + DocumentSymbolRequest, + Hover, + NotificationHandler, + ProtocolConnection, + PublishDiagnosticsParams, + SymbolInformation, + WorkspaceFolder, +} from 'vscode-languageserver' +import type { Position } from 'vscode-languageserver-textdocument' +import { + ConfigurationRequest, + HoverRequest, + DidOpenTextDocumentNotification, + CompletionRequest, + DidChangeConfigurationNotification, + DidChangeTextDocumentNotification, + DidCloseTextDocumentNotification, + PublishDiagnosticsNotification, + InitializeRequest, + InitializedNotification, + RegistrationRequest, + MessageType, + LogMessageNotification, +} from 'vscode-languageserver' +import { URI, Utils as URIUtils } from 'vscode-uri' +import { + DocumentReady, + DocumentReadyNotification, + ProjectDetails, + ProjectDetailsNotification, +} from './messages' +import { createConfiguration, Configuration } from './configuration' +import { clearLanguageBoundariesCache } from '@tailwindcss/language-service/src/util/getLanguageBoundaries' +import { DefaultMap } from '../../src/util/default-map' +import { connect, ConnectOptions } from './connection' +import type { DeepPartial } from './types' +import { styleText } from 'node:util' + +export interface DocumentDescriptor { + /** + * The language the document is written in + */ + lang: string + + /** + * The content of the document + */ + text: string + + /** + * The name or file path to the document + * + * By default a unique path is generated at the root of the workspace + * + * Mutually exclusive with `uri`. If both are given `uri` takes precedence + */ + name?: string + + /** + * A full URI to the document + * + * Mutually exclusive with `name`. If both are given`uri` takes precedence + * + * @deprecated use `name` instead + */ + uri?: string + + /** + * Any document-specific language-server settings + */ + settings?: Settings +} + +export interface ClientDocument { + /** + * The URI to the document + */ + uri: URI + + /** + * Re-open the document after it has been closed + * + * You may not open a document that is already open + */ + reopen(): Promise + + /** + * The diagnostics for the current version of this document + */ + diagnostics(): Promise + + /** + * Links in the document + */ + links(): Promise + + /** + * The diagnostics for the current version of this document + */ + symbols(): Promise + + /** + * Update the document with new information + * + * Renaming a document is not allowed nor is changing its language + */ + update(desc: Partial): Promise + + /** + * Close the document + */ + close(): Promise + + /** + * Trigger a hover request at the given position + */ + hover(position: Position): Promise + + /** + * Trigger completions at the given position + */ + completions(position: Position): Promise + completions(params: Omit): Promise +} + +export interface ClientOptions extends ConnectOptions { + /** + * The path to the workspace root + * + * In the case of multiple workspaces this should be an object with names as + * keys and paths as values. These names can then be used in `workspace()` + * to open documents in a specific workspace + * + * The server is *NOT* run from any of these directories so no assumptions + * are made about where the server is running. + */ + root: string | Record + + /** + * Initialization options to pass to the LSP + */ + options?: Record + + /** + * Whether or not to log `window/logMessage` events + * + * If a server is running in-process this could be noisy as lots of logs + * would be duplicated + */ + log?: boolean + + /** + * Settings to provide the server immediately when it starts + */ + settings?: DeepPartial +} + +export interface Client extends ClientWorkspace { + /** + * The connection from the client to the server + */ + readonly conn: ProtocolConnection + + /** + * Get a workspace by name + */ + workspace(name: string): Promise + + /** + * Update the global settings for the server + */ + updateSettings(settings: DeepPartial): Promise +} + +export interface ClientWorkspaceOptions { + /** + * The connection from the client to the server + */ + conn: ProtocolConnection + + /** + * The folder this workspace is in + */ + folder: WorkspaceFolder + + /** + * The client settings cache + */ + configuration: Configuration + + /** + * A handler that can be used to request diagnostics for a document + */ + notifications: ClientNotifications +} + +/** + * Represents an open workspace + */ +export interface ClientWorkspace { + /** + * The connection from the client to the server + */ + conn: ProtocolConnection + + /** + * The name of this workspace + */ + name: string + + /** + * Open a document + */ + open(desc: DocumentDescriptor): Promise + + /** + * Update the settings for this workspace + */ + updateSettings(settings: Settings): Promise + + /** + * Get the details of the project + */ + project(): Promise +} + +function trace(msg: string, ...args: any[]) { + console.log( + `${styleText(['bold', 'blue', 'inverse'], ' TEST ')} ${styleText('dim', msg)}`, + ...args, + ) +} + +export async function createClient(opts: ClientOptions): Promise { + trace('Starting server') + + let conn = connect(opts) + + let initDone = () => {} + let initPromise = new Promise((resolve) => { + initDone = resolve + }) + + let workspaceFolders: WorkspaceFolder[] + + if (typeof opts.root === 'string') { + workspaceFolders = [ + { + name: 'default', + uri: URI.file(opts.root).toString(), + }, + ] + } else { + workspaceFolders = Object.entries(opts.root).map(([name, uri]) => ({ + name, + uri: URI.file(uri).toString(), + })) + } + + if (workspaceFolders.length === 0) throw new Error('No workspaces provided') + + trace('Workspace folders') + for (let folder of workspaceFolders) { + trace(`- ${folder.name}: ${folder.uri}`) + } + + function rewriteUri(url: string | URI | undefined) { + if (!url) return undefined + + let str = typeof url === 'string' ? url : url.toString() + + for (let folder of workspaceFolders) { + if (str.startsWith(`${folder.uri}/`)) { + return str.replace(`${folder.uri}/`, `{workspace:${folder.name}}/`) + } + } + + return str + } + + // This is a global cache that must be reset between tests for accurate results + clearLanguageBoundariesCache() + + let configuration = createConfiguration() + + if (opts.settings) { + configuration.set(null, opts.settings) + } + + let capabilities: ClientCapabilities = { + textDocument: { + codeAction: { dynamicRegistration: true }, + codeLens: { dynamicRegistration: true }, + colorProvider: { dynamicRegistration: true }, + completion: { + completionItem: { + commitCharactersSupport: true, + documentationFormat: ['markdown', 'plaintext'], + snippetSupport: true, + }, + completionItemKind: { + valueSet: [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 25, + ], + }, + contextSupport: true, + dynamicRegistration: true, + }, + definition: { dynamicRegistration: true }, + documentHighlight: { dynamicRegistration: true }, + documentLink: { dynamicRegistration: true }, + documentSymbol: { + dynamicRegistration: true, + symbolKind: { + valueSet: [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, + ], + }, + }, + formatting: { dynamicRegistration: true }, + hover: { + contentFormat: ['markdown', 'plaintext'], + dynamicRegistration: true, + }, + implementation: { dynamicRegistration: true }, + onTypeFormatting: { dynamicRegistration: true }, + publishDiagnostics: { relatedInformation: true }, + rangeFormatting: { dynamicRegistration: true }, + references: { dynamicRegistration: true }, + rename: { dynamicRegistration: true }, + signatureHelp: { + dynamicRegistration: true, + signatureInformation: { documentationFormat: ['markdown', 'plaintext'] }, + }, + synchronization: { + didSave: true, + dynamicRegistration: true, + willSave: true, + willSaveWaitUntil: true, + }, + typeDefinition: { dynamicRegistration: true }, + }, + workspace: { + applyEdit: true, + configuration: true, + didChangeConfiguration: { dynamicRegistration: true }, + didChangeWatchedFiles: { dynamicRegistration: true }, + executeCommand: { dynamicRegistration: true }, + symbol: { + dynamicRegistration: true, + symbolKind: { + valueSet: [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, + ], + }, + }, + workspaceEdit: { documentChanges: true }, + workspaceFolders: true, + }, + experimental: { + tailwind: { + projectDetails: true, + }, + }, + } + + trace('Client initializing') + await conn.sendRequest(InitializeRequest.type, { + processId: process.pid, + rootUri: workspaceFolders.length > 1 ? null : workspaceFolders[0].uri, + capabilities, + trace: 'off', + workspaceFolders, + initializationOptions: { + testMode: true, + ...opts.options, + }, + }) + + if (opts.log) { + conn.onNotification(LogMessageNotification.type, ({ message, type }) => { + if (type === MessageType.Error) { + console.error(message) + } else if (type === MessageType.Warning) { + console.warn(message) + } else if (type === MessageType.Info) { + console.info(message) + } else if (type === MessageType.Log) { + console.log(message) + } + }) + } + + conn.onRequest(RegistrationRequest.type, ({ registrations }) => { + trace('Registering capabilities') + + for (let registration of registrations) { + trace('-', registration.method) + } + }) + + // TODO: Remove this its a hack + conn.onNotification('@/tailwindCSS/serverReady', () => { + initDone() + }) + + // Handle requests for workspace configurations + conn.onRequest(ConfigurationRequest.type, ({ items }) => { + return items.map((item) => { + trace('Requesting configuration') + trace('- scope:', rewriteUri(item.scopeUri)) + trace('- section:', item.section) + + let sections = configuration.get(item.scopeUri ?? '/') + + if (item.section) { + return sections[item.section] ?? {} + } + + return sections + }) + }) + + let notifications = await createDocumentNotifications(conn) + + let clientWorkspaces: ClientWorkspace[] = [] + + for (const folder of workspaceFolders) { + clientWorkspaces.push( + await createClientWorkspace({ + conn, + folder, + configuration, + notifications, + }), + ) + } + + // Tell the server we're ready to receive requests and notifications + await conn.sendNotification(InitializedNotification.type) + trace('Client initializied') + + async function updateSettings(settings: Settings) { + configuration.set(null, settings) + await conn.sendNotification(DidChangeConfigurationNotification.type, { + settings, + }) + } + + async function workspace(name: string) { + return clientWorkspaces.find((w) => w.name === name) ?? null + } + + // TODO: Remove this, it's a bit of a hack + if (opts.server === 'tailwindcss') { + await initPromise + } + + return { + ...clientWorkspaces[0], + workspace, + updateSettings, + } +} + +export async function createClientWorkspace({ + conn, + folder, + configuration, + notifications, +}: ClientWorkspaceOptions): Promise { + function rewriteUri(url: string | URI) { + let str = typeof url === 'string' ? url : url.toString() + if (str.startsWith(`${folder.uri}/`)) { + return str.replace(`${folder.uri}/`, `{workspace:${folder.name}}/`) + } + } + + // TODO: Make this a request instead of a notification + let projectDetails = new Promise((resolve) => { + notifications.onProjectDetails(folder.uri, (params) => { + trace(`Project details changed:`) + trace(`- ${rewriteUri(params.config)}`) + trace(`- v${params.tailwind.version}`) + trace(`- ${params.tailwind.isDefaultVersion ? 'bundled' : 'local'}`) + trace(`- ${params.tailwind.features.join(', ')}`) + resolve(params) + }) + }) + + let index = 0 + async function createClientDocument(desc: DocumentDescriptor): Promise { + let state: 'closed' | 'opening' | 'opened' = 'closed' + + let uri = desc.uri + ? URI.parse(desc.uri) + : URIUtils.resolvePath( + URI.parse(folder.uri), + desc.name ? desc.name : `file-${++index}.${desc.lang}`, + ) + + 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') + if (state === 'opening') throw new Error('Document is currently opening') + + let start = process.hrtime.bigint() + + let wasOpened = new Promise((resolve) => { + notifications.onDocumentReady(uri.toString(), (params) => { + let elapsed = process.hrtime.bigint() - start + trace(`Document ready`) + trace(`- uri:`, rewriteUri(params.uri)) + trace(`- duration: %dms`, (Number(elapsed) / 1e6).toFixed(3)) + resolve() + }) + }) + + trace('Opening document') + trace(`- uri:`, rewriteUri(uri)) + + await requestDiagnostics(version) + + state = 'opening' + + try { + // Ask the server to open the document + await conn.sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { + uri: uri.toString(), + version: version++, + languageId: desc.lang, + text: desc.text, + }, + }) + + // Wait for it to respond that it has + await wasOpened + + // TODO: This works around a race condition where the document reports as ready + // but the capabilities and design system are not yet available + await new Promise((r) => setTimeout(r, 100)) + + state = 'opened' + } catch (e) { + state = 'closed' + throw e + } + } + + async function update(desc: DocumentDescriptor) { + if (desc.name) throw new Error('Cannot rename or move files') + if (desc.lang) throw new Error('Cannot change language') + + if (desc.settings) { + configuration.set(uri.toString(), desc.settings) + } + + if (desc.text) { + version += 1 + await requestDiagnostics(version) + await conn.sendNotification(DidChangeTextDocumentNotification.type, { + textDocument: { uri: uri.toString(), version }, + contentChanges: [{ text: desc.text }], + }) + } + } + + async function close() { + await conn.sendNotification(DidCloseTextDocumentNotification.type, { + textDocument: { uri: uri.toString() }, + }) + + state = 'closed' + } + + function hover(pos: Position) { + return conn.sendRequest(HoverRequest.type, { + position: pos, + textDocument: { + uri: uri.toString(), + }, + }) + } + + async function completions(pos: Position | Omit) { + let params = 'position' in pos ? pos : { position: pos } + + let list = await conn.sendRequest(CompletionRequest.type, { + ...params, + textDocument: { + uri: uri.toString(), + }, + }) + + if (Array.isArray(list)) { + return { + isIncomplete: false, + items: list, + } + } + + return list + } + + function diagnostics() { + return currentDiagnostics + } + + async function symbols() { + let results = await conn.sendRequest(DocumentSymbolRequest.type, { + textDocument: { + uri: uri.toString(), + }, + }) + + for (let result of results ?? []) { + if ('location' in result) { + result.location.uri = rewriteUri(result.location.uri)! + } + } + + return results + } + + async function links() { + let results = await conn.sendRequest(DocumentLinkRequest.type, { + textDocument: { + uri: uri.toString(), + }, + }) + + for (let result of results ?? []) { + if (result.target) result.target = rewriteUri(result.target) + } + + return results + } + + return { + uri, + reopen, + update, + close, + hover, + links, + symbols, + completions, + diagnostics, + } + } + + async function open(desc: DocumentDescriptor): Promise { + let doc = await createClientDocument(desc) + await doc.update({ settings: desc.settings }) + await doc.reopen() + return doc + } + + async function updateSettings(settings: Settings) { + configuration.set(folder.uri, settings) + } + + // TODO: This should not be a notification but instead a request + // We should "ask" for the project details instead of it giving them to us + async function project() { + return projectDetails + } + + return { + name: folder.name, + conn, + open, + updateSettings, + project, + } +} + +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 +} + +/** + * A tiny wrapper that lets us install multiple notification handlers for specific methods + * + * The implementation of vscode-jsonrpc only allows for one handler per method, but we want to + * install multiple handlers for the same method so we deal with that here + */ +async function createDocumentNotifications(conn: ProtocolConnection): Promise { + let readyHandlers = new DefaultMap | null)[]>(() => []) + conn.onNotification(DocumentReadyNotification.type, (params) => { + for (let handler of readyHandlers.get(params.uri)) { + if (!handler) continue + handler(params) + } + }) + + let diagnosticsHandlers = new DefaultMap | null)[]>(() => []) + conn.onNotification(PublishDiagnosticsNotification.type, (params) => { + for (let handler of diagnosticsHandlers.get(params.uri)) { + if (!handler) continue + handler(params) + } + }) + + let projectDetailsHandlers = new DefaultMap | null)[]>(() => []) + conn.onNotification(ProjectDetailsNotification.type, (params) => { + for (let handler of projectDetailsHandlers.get(params.uri)) { + if (!handler) continue + handler(params) + } + }) + + return { + onDocumentReady: (uri, handler) => { + let index = readyHandlers.get(uri).push(handler) - 1 + return { + dispose() { + readyHandlers.get(uri)[index] = null + }, + } + }, + + onPublishedDiagnostics: (uri, handler) => { + 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 { + dispose() { + projectDetailsHandlers.get(uri)[index] = null + }, + } + }, + } +} diff --git a/packages/tailwindcss-language-server/tests/utils/configuration.ts b/packages/tailwindcss-language-server/tests/utils/configuration.ts new file mode 100644 index 00000000..2b4d6fbc --- /dev/null +++ b/packages/tailwindcss-language-server/tests/utils/configuration.ts @@ -0,0 +1,90 @@ +import type { Settings } from '@tailwindcss/language-service/src/util/state' +import { URI } from 'vscode-uri' +import type { DeepPartial } from './types' +import { CacheMap } from '../../src/cache-map' +import deepmerge from 'deepmerge' + +export interface Configuration { + get(uri: string | null): Settings + set(uri: string | null, value: DeepPartial): void +} + +export function createConfiguration(): Configuration { + let defaults: Settings = { + editor: { + tabSize: 2, + }, + tailwindCSS: { + inspectPort: null, + emmetCompletions: false, + includeLanguages: {}, + classAttributes: ['class', 'className', 'ngClass', 'class:list'], + suggestions: true, + hovers: true, + codeActions: true, + validate: true, + showPixelEquivalents: true, + rootFontSize: 16, + colorDecorators: true, + lint: { + cssConflict: 'warning', + invalidApply: 'error', + invalidScreen: 'error', + invalidVariant: 'error', + invalidConfigPath: 'error', + invalidTailwindDirective: 'error', + invalidSourceDirective: 'error', + recommendedVariantOrder: 'warning', + }, + experimental: { + classRegex: [], + configFile: {}, + }, + files: { + exclude: ['**/.git/**', '**/node_modules/**', '**/.hg/**', '**/.svn/**'], + }, + }, + } + + /** + * Settings per file or directory URI + */ + let cache = new CacheMap() + + function compute(uri: URI | null) { + let groups: Partial[] = [ + // 1. Extension defaults + structuredClone(defaults), + + // 2. "Global" settings + cache.get(null) ?? {}, + ] + + // 3. Workspace and per-file settings + let components = uri ? uri.path.split('/') : [] + + for (let i = 0; i <= components.length; i++) { + let parts = components.slice(0, i) + if (parts.length === 0) continue + let path = parts.join('/') + let cached = cache.get(uri!.with({ path }).toString()) + if (!cached) continue + groups.push(cached) + } + + // Merge all the settings together + return deepmerge.all(groups, { + arrayMerge: (_target, source) => source, + }) + } + + function get(uri: string | null) { + return compute(uri ? URI.parse(uri) : null) + } + + function set(uri: string | null, value: Settings) { + cache.set(uri ? URI.parse(uri).toString() : null, value) + } + + return { get, set } +} diff --git a/packages/tailwindcss-language-server/tests/utils/connection.ts b/packages/tailwindcss-language-server/tests/utils/connection.ts new file mode 100644 index 00000000..f0eaa999 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/utils/connection.ts @@ -0,0 +1,69 @@ +import { fork } from 'node:child_process' +import { createConnection } from 'vscode-languageserver/node' +import type { ProtocolConnection } from 'vscode-languageclient/node' +import { Duplex, type Readable, type Writable } from 'node:stream' +import { TW } from '../../src/tw' + +class TestStream extends Duplex { + _write(chunk: string, _encoding: string, done: () => void) { + this.emit('data', chunk) + done() + } + + _read(_size: number) {} +} + +const SERVERS = { + tailwindcss: { + ServerClass: TW, + binaryPath: './bin/tailwindcss-language-server', + }, +} + +export interface ConnectOptions { + /** + * How to connect to the LSP: + * - `in-band` runs the server in the same process (default) + * - `spawn` launches the binary as a separate process, connects via stdio, + * and requires a rebuild of the server after making changes. + */ + mode?: 'in-band' | 'spawn' + + /** + * The server to connect to + */ + server?: keyof typeof SERVERS +} + +export function connect(opts: ConnectOptions) { + let server = opts.server ?? 'tailwindcss' + let mode = opts.mode ?? 'in-band' + + let details = SERVERS[server] + if (!details) { + throw new Error(`Unsupported connection: ${server} / ${mode}`) + } + + if (mode === 'in-band') { + let input = new TestStream() + let output = new TestStream() + + let server = new details.ServerClass(createConnection(input, output)) + server.setup() + server.listen() + + return connectStreams(output, input) + } else if (mode === 'spawn') { + let server = fork(details.binaryPath, { silent: true }) + + return connectStreams(server.stdout!, server.stdin!) + } + + throw new Error(`Unsupported connection: ${server} / ${mode}`) +} + +function connectStreams(input: Readable, output: Writable) { + let clientConn = createConnection(input, output) as unknown as ProtocolConnection + clientConn.listen() + return clientConn +} diff --git a/packages/tailwindcss-language-server/tests/utils/messages.ts b/packages/tailwindcss-language-server/tests/utils/messages.ts new file mode 100644 index 00000000..7061cb3d --- /dev/null +++ b/packages/tailwindcss-language-server/tests/utils/messages.ts @@ -0,0 +1,29 @@ +import { Feature } from '@tailwindcss/language-service/src/features' +import { MessageDirection, ProtocolNotificationType } from 'vscode-languageserver' +import type { DocumentUri } from 'vscode-languageserver-textdocument' + +export interface DocumentReady { + uri: DocumentUri +} + +export namespace DocumentReadyNotification { + export const method: '@/tailwindCSS/documentReady' = '@/tailwindCSS/documentReady' + export const messageDirection: MessageDirection = MessageDirection.clientToServer + export const type = new ProtocolNotificationType(method) +} + +export interface ProjectDetails { + uri: string + config: string + tailwind: { + version: string + features: Feature[] + isDefaultVersion: boolean + } +} + +export namespace ProjectDetailsNotification { + export const method: '@/tailwindCSS/projectDetails' = '@/tailwindCSS/projectDetails' + export const messageDirection: MessageDirection = MessageDirection.clientToServer + export const type = new ProtocolNotificationType(method) +} diff --git a/packages/tailwindcss-language-server/tests/utils/types.ts b/packages/tailwindcss-language-server/tests/utils/types.ts new file mode 100644 index 00000000..6be62396 --- /dev/null +++ b/packages/tailwindcss-language-server/tests/utils/types.ts @@ -0,0 +1,9 @@ +export type DeepPartial = { + [P in keyof T]?: T[P] extends (infer U)[] + ? U[] + : T[P] extends (...args: any) => any + ? T[P] | undefined + : T[P] extends object + ? DeepPartial + : T[P] +} diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index 621add49..95afe8ec 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -42,6 +42,7 @@ export type EditorSettings = { } export type TailwindCssSettings = { + inspectPort: number | null emmetCompletions: boolean includeLanguages: Record classAttributes: string[] @@ -64,7 +65,7 @@ export type TailwindCssSettings = { } experimental: { classRegex: string[] - configFile: string | Record + configFile: string | Record | null } files: { exclude: string[]