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[]