From 3d95fe5e3339728edf70f2b7a3564dab20145f4c Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 18 Feb 2025 17:03:01 -0500 Subject: [PATCH 1/6] Update types --- packages/tailwindcss-language-server/src/config.ts | 1 + packages/tailwindcss-language-service/src/util/state.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) 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-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[] From 9ba34621bf7524dda0bd97a8e30c67e116ca17a0 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 18 Feb 2025 17:03:37 -0500 Subject: [PATCH 2/6] Fix hover tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While this doesn’t fail these should have positions to be more correct --- packages/tailwindcss-language-server/tests/hover/hover.test.js | 2 ++ 1 file changed, 2 insertions(+) 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 }, }, From 20148809097a98a61b2a0eb000c97195f64b67d1 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 18 Feb 2025 17:03:43 -0500 Subject: [PATCH 3/6] Cleanup --- .../tests/env/workspace-folders.test.ts | 1 - 1 file changed, 1 deletion(-) 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' From 30e4d3766a1ef05149ad5b339464de206cb6a25a Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 18 Feb 2025 17:04:22 -0500 Subject: [PATCH 4/6] Send workspace URI in project details notifications --- packages/tailwindcss-language-server/src/tw.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/tailwindcss-language-server/src/tw.ts b/packages/tailwindcss-language-server/src/tw.ts index b795be5c..8bf3b92d 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, ), ), ) @@ -684,6 +685,7 @@ export class TW { tailwindVersion: string, userLanguages: Record, resolver: Resolver, + baseUri: URI, ): Promise { let key = String(this.projectCounter++) const project = await createProjectService( @@ -717,6 +719,7 @@ export class TW { } this.connection.sendNotification('@/tailwindCSS/projectDetails', { + uri: baseUri.toString(), config: projectConfig.configPath, tailwind: projectConfig.tailwind, }) From 44e97407b2dca9ee5d244aa86d0d086fccb3f500 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 18 Feb 2025 17:05:05 -0500 Subject: [PATCH 5/6] Refactor server testing infra This builds out completely new APIs for testing language servers as well as stubs itself into the existing infra so the existing tests can be migrated incrementally --- .../src/testing/index.ts | 10 +- .../tailwindcss-language-server/src/tw.ts | 5 + .../tests/common.ts | 269 ++---- .../tests/connection.ts | 44 - .../tests/utils/client.ts | 787 ++++++++++++++++++ .../tests/utils/configuration.ts | 90 ++ .../tests/utils/connection.ts | 69 ++ .../tests/utils/messages.ts | 29 + .../tests/utils/types.ts | 9 + 9 files changed, 1043 insertions(+), 269 deletions(-) delete mode 100644 packages/tailwindcss-language-server/tests/connection.ts create mode 100644 packages/tailwindcss-language-server/tests/utils/client.ts create mode 100644 packages/tailwindcss-language-server/tests/utils/configuration.ts create mode 100644 packages/tailwindcss-language-server/tests/utils/connection.ts create mode 100644 packages/tailwindcss-language-server/tests/utils/messages.ts create mode 100644 packages/tailwindcss-language-server/tests/utils/types.ts 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 8bf3b92d..efb12a34 100644 --- a/packages/tailwindcss-language-server/src/tw.ts +++ b/packages/tailwindcss-language-server/src/tw.ts @@ -664,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[]) { 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/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] +} From fceef0cddb024e90ae485243499d07ff167345c9 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 18 Feb 2025 17:05:55 -0500 Subject: [PATCH 6/6] Convert some tests to new API --- .../tests/env/custom-languages.test.js | 112 +++---- .../tests/env/v4.test.js | 282 +++++++----------- 2 files changed, 155 insertions(+), 239 deletions(-) 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`