From 9d7006dd9f6ae09b7df1246f25c6e5f2027c1b1d Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 24 Apr 2024 13:13:51 -0400 Subject: [PATCH] Use one server to handle all folders in a workspace (#957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix strict mode typescript errors * Refactor * Load user languages when booting It makes more sense for our server to ask for them like we do all other config options rather than expecting them to be passed in during initialization * Only start one server for all workspace folders * Check all workspace folders when booting the server This is a bit of a behavioral change but if _any_ folder in your current workspace might need the language server when we’ll boot it. We do still wait for an open document currently. * Remove note * Remove unused expect error directive It’ll need to come back once I upgrade TypeScript * Fix folder count check * Run prettier --- .../src/language/cssServer.ts | 2 +- .../src/projects.ts | 5 +- .../tailwindcss-language-server/src/tw.ts | 14 +- .../tests/fixtures/v4/workspaces/package.json | 4 +- packages/vscode-tailwindcss/src/extension.ts | 623 +++++++----------- .../vscode-tailwindcss/src/servers/css.ts | 151 +++++ .../vscode-tailwindcss/src/servers/index.ts | 1 + 7 files changed, 407 insertions(+), 393 deletions(-) create mode 100644 packages/vscode-tailwindcss/src/servers/css.ts create mode 100644 packages/vscode-tailwindcss/src/servers/index.ts diff --git a/packages/tailwindcss-language-server/src/language/cssServer.ts b/packages/tailwindcss-language-server/src/language/cssServer.ts index 0622f056..a9b85498 100644 --- a/packages/tailwindcss-language-server/src/language/cssServer.ts +++ b/packages/tailwindcss-language-server/src/language/cssServer.ts @@ -26,7 +26,7 @@ let connection = createConnection(ProposedFeatures.all) interceptLogs(console, connection) process.on('unhandledRejection', (e: any) => { - console.error("Unhandled exception", e) + console.error('Unhandled exception', e) }) let documents: TextDocuments = new TextDocuments(TextDocument) diff --git a/packages/tailwindcss-language-server/src/projects.ts b/packages/tailwindcss-language-server/src/projects.ts index f5d18d87..6119aed0 100644 --- a/packages/tailwindcss-language-server/src/projects.ts +++ b/packages/tailwindcss-language-server/src/projects.ts @@ -182,6 +182,7 @@ export async function createProjectService( watchPatterns: (patterns: string[]) => void, initialTailwindVersion: string, getConfiguration: (uri?: string) => Promise, + userLanguages: Record, ): Promise { let enabled = false const folder = projectConfig.folder @@ -206,9 +207,7 @@ export async function createProjectService( editor: { connection, folder, - userLanguages: params.initializationOptions?.userLanguages - ? params.initializationOptions.userLanguages - : {}, + userLanguages, // TODO capabilities: { configuration: true, diff --git a/packages/tailwindcss-language-server/src/tw.ts b/packages/tailwindcss-language-server/src/tw.ts index bd25668a..cf1f397e 100644 --- a/packages/tailwindcss-language-server/src/tw.ts +++ b/packages/tailwindcss-language-server/src/tw.ts @@ -42,7 +42,7 @@ import { equal } from '@tailwindcss/language-service/src/util/array' import { CONFIG_GLOB, CSS_GLOB, PACKAGE_LOCK_GLOB } from './lib/constants' import { clearRequireCache, isObject, changeAffectsFile } from './utils' import { DocumentService } from './documents' -import { createProjectService, type ProjectService, DocumentSelectorPriority } from './projects' +import { createProjectService, type ProjectService } from './projects' import { type SettingsCache, createSettingsCache } from './config' import { readCssFile } from './util/css' import { ProjectLocator, type ProjectConfig } from './project-locator' @@ -163,6 +163,15 @@ export class TW { let globalSettings = await this.settingsCache.get() let ignore = globalSettings.tailwindCSS.files.exclude + // Get user languages for the given workspace folder + let folderSettings = await this.settingsCache.get(base) + let userLanguages = folderSettings.tailwindCSS.includeLanguages + + // Fall back to settings defined in `initializationOptions` if invalid + if (!isObject(userLanguages)) { + userLanguages = this.initializeParams.initializationOptions?.userLanguages ?? {} + } + let cssFileConfigMap: Map = new Map() let configTailwindVersionMap: Map = new Map() @@ -489,6 +498,7 @@ export class TW { this.initializeParams, this.watchPatterns, configTailwindVersionMap.get(projectConfig.configPath), + userLanguages, ), ), ) @@ -604,6 +614,7 @@ export class TW { params: InitializeParams, watchPatterns: (patterns: string[]) => void, tailwindVersion: string, + userLanguages: Record, ): Promise { let key = String(this.projectCounter++) const project = await createProjectService( @@ -627,6 +638,7 @@ export class TW { (patterns: string[]) => watchPatterns(patterns), tailwindVersion, this.settingsCache.get, + userLanguages, ) this.projects.set(key, project) diff --git a/packages/tailwindcss-language-server/tests/fixtures/v4/workspaces/package.json b/packages/tailwindcss-language-server/tests/fixtures/v4/workspaces/package.json index b481791d..fb3a2653 100644 --- a/packages/tailwindcss-language-server/tests/fixtures/v4/workspaces/package.json +++ b/packages/tailwindcss-language-server/tests/fixtures/v4/workspaces/package.json @@ -1,5 +1,7 @@ { - "workspaces": ["packages/*"], + "workspaces": [ + "packages/*" + ], "dependencies": { "tailwindcss": "^4.0.0-alpha.12" } diff --git a/packages/vscode-tailwindcss/src/extension.ts b/packages/vscode-tailwindcss/src/extension.ts index bf00387a..13b0fd9e 100755 --- a/packages/vscode-tailwindcss/src/extension.ts +++ b/packages/vscode-tailwindcss/src/extension.ts @@ -1,35 +1,27 @@ -/* -------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - * ------------------------------------------------------------------------------------------ */ import * as path from 'path' import type { ExtensionContext, TextDocument, WorkspaceFolder, - TextEditorDecorationType, ConfigurationScope, WorkspaceConfiguration, - CompletionList, - ProviderResult, Selection, } from 'vscode' import { workspace as Workspace, window as Window, - languages as Languages, Uri, commands, SymbolInformation, Position, Range, RelativePattern, - CompletionItem, - CompletionItemKind, - SnippetString, - TextEdit, } from 'vscode' -import type { LanguageClientOptions, ServerOptions, Disposable } from 'vscode-languageclient/node' +import type { + DocumentFilter, + LanguageClientOptions, + ServerOptions, +} from 'vscode-languageclient/node' import { LanguageClient, TransportKind, @@ -39,35 +31,33 @@ import { import { languages as defaultLanguages } from '@tailwindcss/language-service/src/util/languages' import * as semver from '@tailwindcss/language-service/src/util/semver' import isObject from '@tailwindcss/language-service/src/util/isObject' -import { dedupe, equal } from '@tailwindcss/language-service/src/util/array' import namedColors from 'color-name' import picomatch from 'picomatch' import { CONFIG_GLOB, CSS_GLOB } from '@tailwindcss/language-server/src/lib/constants' import braces from 'braces' import normalizePath from 'normalize-path' +import * as servers from './servers/index' const colorNames = Object.keys(namedColors) const CLIENT_ID = 'tailwindcss-intellisense' const CLIENT_NAME = 'Tailwind CSS IntelliSense' -let clients: Map = new Map() -let languages: Map = new Map() -let searchedFolders: Set = new Set() +let currentClient: Promise | null = null function getUserLanguages(folder?: WorkspaceFolder): Record { const langs = Workspace.getConfiguration('tailwindCSS', folder).includeLanguages return isObject(langs) ? langs : {} } -function getGlobalExcludePatterns(scope: ConfigurationScope): string[] { - return Object.entries(Workspace.getConfiguration('files', scope).get('exclude')) +function getGlobalExcludePatterns(scope: ConfigurationScope | null): string[] { + return Object.entries(Workspace.getConfiguration('files', scope)?.get('exclude') ?? []) .filter(([, value]) => value === true) .map(([key]) => key) .filter(Boolean) } -function getExcludePatterns(scope: ConfigurationScope): string[] { +function getExcludePatterns(scope: ConfigurationScope | null): string[] { return [ ...getGlobalExcludePatterns(scope), ...(Workspace.getConfiguration('tailwindCSS', scope).get('files.exclude')).filter( @@ -88,7 +78,7 @@ function isExcluded(file: string, folder: WorkspaceFolder): boolean { return false } -function mergeExcludes(settings: WorkspaceConfiguration, scope: ConfigurationScope): any { +function mergeExcludes(settings: WorkspaceConfiguration, scope: ConfigurationScope | null): any { return { ...settings, files: { @@ -130,29 +120,32 @@ function selectionsAreEqual( } async function getActiveTextEditorProject(): Promise<{ version: string } | null> { - if (clients.size === 0) { - return null - } + // No editor, no project let editor = Window.activeTextEditor - if (!editor) { - return null - } + if (!editor) return null + + // No server yet, no project + if (!currentClient) return null + + // No workspace folder, no project let uri = editor.document.uri let folder = Workspace.getWorkspaceFolder(uri) - if (!folder) { - return null - } - let client = clients.get(folder.uri.toString()) - if (!client) { - return null - } - if (isExcluded(uri.fsPath, folder)) { - return null + if (!folder) return null + + // Excluded file, no project + if (isExcluded(uri.fsPath, folder)) return null + + interface ProjectData { + version: string } + + // Ask the server for the project try { - let project = await client.sendRequest<{ version: string } | null>('@/tailwindCSS/getProject', { + let client = await currentClient + let project = await client.sendRequest('@/tailwindCSS/getProject', { uri: uri.toString(), }) + return project } catch { return null @@ -203,6 +196,8 @@ export async function activate(context: ExtensionContext) { } catch (_) {} async function sortSelection(): Promise { + if (!Window.activeTextEditor) return + let { document, selections } = Window.activeTextEditor if (selections.length === 0) { @@ -212,14 +207,11 @@ export async function activate(context: ExtensionContext) { let initialSelections = selections let folder = Workspace.getWorkspaceFolder(document.uri) - if (clients.size === 0 || !folder || isExcluded(document.uri.fsPath, folder)) { + if (!currentClient || !folder || isExcluded(document.uri.fsPath, folder)) { throw Error(`No active Tailwind project found for file ${document.uri.fsPath}`) } - let client = clients.get(folder.uri.toString()) - if (!client) { - throw Error(`No active Tailwind project found for file ${document.uri.fsPath}`) - } + let client = await currentClient let result = await client.sendRequest<{ error: string } | { classLists: string[] }>( '@/tailwindCSS/sortSelection', @@ -257,7 +249,7 @@ export async function activate(context: ExtensionContext) { try { await sortSelection() } catch (error) { - Window.showWarningMessage(`Couldn’t sort Tailwind classes: ${error.message}`) + Window.showWarningMessage(`Couldn’t sort Tailwind classes: ${(error as any)?.message}`) } }), ) @@ -270,12 +262,12 @@ export async function activate(context: ExtensionContext) { let configWatcher = Workspace.createFileSystemWatcher(`**/${CONFIG_GLOB}`, false, true, true) - configWatcher.onDidCreate((uri) => { + configWatcher.onDidCreate(async (uri) => { let folder = Workspace.getWorkspaceFolder(uri) if (!folder || isExcluded(uri.fsPath, folder)) { return } - bootWorkspaceClient(folder) + await bootWorkspaceClient() }) context.subscriptions.push(configWatcher) @@ -288,7 +280,7 @@ export async function activate(context: ExtensionContext) { return } if (await fileMayBeTailwindRelated(uri)) { - bootWorkspaceClient(folder) + await bootWorkspaceClient() } } @@ -301,297 +293,172 @@ export async function activate(context: ExtensionContext) { // not just the language IDs // e.g. "plaintext" already exists but you change it from "html" to "css" context.subscriptions.push( - Workspace.onDidChangeConfiguration((event) => { - let toReboot = new Set() - - Workspace.textDocuments.forEach((document) => { - let folder = Workspace.getWorkspaceFolder(document.uri) - if (!folder) return - if (event.affectsConfiguration('tailwindCSS.experimental.configFile', folder)) { - toReboot.add(folder) - } + Workspace.onDidChangeConfiguration(async (event) => { + let folders = Workspace.workspaceFolders ?? [] + + let needsReboot = folders.some((folder) => { + return ( + event.affectsConfiguration('tailwindCSS.experimental.configFile', folder) || + // TODO: Only reboot if the MAPPING changed instead of just the languages + // e.g. "plaintext" already exists but you change it from "html" to "css" + // TODO: This should not cause a reboot of the server but should instead + // have the server update its internal state + event.affectsConfiguration('tailwindCSS.includeLanguages', folder) + ) }) - ;[...clients].forEach(([key, client]) => { - const folder = Workspace.getWorkspaceFolder(Uri.parse(key)) - let reboot = false - - if (event.affectsConfiguration('tailwindCSS.includeLanguages', folder)) { - const userLanguages = getUserLanguages(folder) - if (userLanguages) { - const userLanguageIds = Object.keys(userLanguages) - const newLanguages = dedupe([...defaultLanguages, ...userLanguageIds]) - if (!equal(newLanguages, languages.get(folder.uri.toString()))) { - languages.set(folder.uri.toString(), newLanguages) - reboot = true - } - } - } - - if (event.affectsConfiguration('tailwindCSS.experimental.configFile', folder)) { - reboot = true - } - if (reboot && client) { - toReboot.add(folder) - } - }) + if (!needsReboot) { + return + } - for (let folder of toReboot) { - clients.get(folder.uri.toString())?.stop() - clients.delete(folder.uri.toString()) - bootClientForFolderIfNeeded(folder) + // Stop the current server (if any) + if (currentClient) { + let client = await currentClient + await client.stop() } + + currentClient = null + + // Start the server again with the new configuration + await bootWorkspaceClient() }), ) - let cssServerBooted = false - async function bootCssServer() { - if (cssServerBooted) return - cssServerBooted = true + function bootWorkspaceClient() { + currentClient ??= bootIfNeeded() - let module = context.asAbsolutePath(path.join('dist', 'cssServer.js')) - let prod = path.join('dist', 'tailwindModeServer.js') + return currentClient + } - try { - await Workspace.fs.stat(Uri.joinPath(context.extensionUri, prod)) - module = context.asAbsolutePath(prod) - } catch (_) {} + async function bootIfNeeded() { + outputChannel.appendLine(`Booting server...`) - let client = new LanguageClient( - 'tailwindcss-intellisense-css', - 'Tailwind CSS', - { - run: { - module, - transport: TransportKind.ipc, - }, - debug: { - module, - transport: TransportKind.ipc, - options: { - execArgv: ['--nolazy', '--inspect=6051'], - }, - }, + let colorDecorationType = Window.createTextEditorDecorationType({ + before: { + width: '0.8em', + height: '0.8em', + contentText: ' ', + border: '0.1em solid', + margin: '0.1em 0.2em 0', }, - { - documentSelector: [{ language: 'tailwindcss' }], - outputChannelName: 'Tailwind CSS Language Mode', - synchronize: { configurationSection: ['css'] }, - middleware: { - provideCompletionItem(document, position, context, token, next) { - function updateRanges(item: CompletionItem) { - const range = item.range - if ( - range instanceof Range && - range.end.isAfter(position) && - range.start.isBeforeOrEqual(position) - ) { - item.range = { inserting: new Range(range.start, position), replacing: range } - } - } - function updateLabel(item: CompletionItem) { - if (item.kind === CompletionItemKind.Color) { - item.label = { - label: item.label as string, - description: item.documentation as string, - } - } - } - function updateProposals( - r: CompletionItem[] | CompletionList | null | undefined, - ): CompletionItem[] | CompletionList | null | undefined { - if (r) { - ;(Array.isArray(r) ? r : r.items).forEach(updateRanges) - ;(Array.isArray(r) ? r : r.items).forEach(updateLabel) - } - return r - } - const isThenable = (obj: ProviderResult): obj is Thenable => - obj && (obj)['then'] - - const r = next(document, position, context, token) - if (isThenable(r)) { - return r.then(updateProposals) - } - return updateProposals(r) - }, + dark: { + before: { + borderColor: '#eeeeee', }, }, - ) - - await client.start() - context.subscriptions.push(initCompletionProvider()) - - function initCompletionProvider(): Disposable { - const regionCompletionRegExpr = /^(\s*)(\/(\*\s*(#\w*)?)?)?$/ - - return Languages.registerCompletionItemProvider(['tailwindcss'], { - provideCompletionItems(doc: TextDocument, pos: Position) { - let lineUntilPos = doc.getText(new Range(new Position(pos.line, 0), pos)) - let match = lineUntilPos.match(regionCompletionRegExpr) - if (match) { - let range = new Range(new Position(pos.line, match[1].length), pos) - let beginProposal = new CompletionItem('#region', CompletionItemKind.Snippet) - beginProposal.range = range - TextEdit.replace(range, '/* #region */') - beginProposal.insertText = new SnippetString('/* #region $1*/') - beginProposal.documentation = 'Folding Region Start' - beginProposal.filterText = match[2] - beginProposal.sortText = 'za' - let endProposal = new CompletionItem('#endregion', CompletionItemKind.Snippet) - endProposal.range = range - endProposal.insertText = '/* #endregion */' - endProposal.documentation = 'Folding Region End' - endProposal.sortText = 'zb' - endProposal.filterText = match[2] - return [beginProposal, endProposal] - } - return null + light: { + before: { + borderColor: '#000000', }, - }) - } - } + }, + }) - function bootWorkspaceClient(folder: WorkspaceFolder) { - if (clients.has(folder.uri.toString())) { - return - } + context.subscriptions.push(colorDecorationType) - let colorDecorationType: TextEditorDecorationType + /** + * Clear all decorated colors from all visible text editors + */ function clearColors(): void { - if (colorDecorationType) { - colorDecorationType.dispose() - colorDecorationType = undefined + for (let editor of Window.visibleTextEditors) { + editor.setDecorations(colorDecorationType!, []) } } - context.subscriptions.push({ - dispose() { - if (colorDecorationType) { - colorDecorationType.dispose() - } - }, - }) - outputChannel.appendLine(`Booting server for ${folder.uri.toString()}...`) + let documentFilters: DocumentFilter[] = [] - // placeholder so we don't boot another server before this one is ready - clients.set(folder.uri.toString(), null) + for (let folder of Workspace.workspaceFolders ?? []) { + let langs = new Set([...defaultLanguages, ...Object.keys(getUserLanguages(folder))]) - if (!languages.has(folder.uri.toString())) { - languages.set( - folder.uri.toString(), - dedupe([...defaultLanguages, ...Object.keys(getUserLanguages(folder))]), - ) + for (let language of langs) { + documentFilters.push({ + scheme: 'file', + language, + pattern: normalizePath(`${folder.uri.fsPath.replace(/[\[\]\{\}]/g, '?')}/**/*`), + }) + } } - let configuration = { - editor: Workspace.getConfiguration('editor', folder), - tailwindCSS: mergeExcludes(Workspace.getConfiguration('tailwindCSS', folder), folder), - } + let module = context.asAbsolutePath(path.join('dist', 'server.js')) + let prod = path.join('dist', 'tailwindServer.js') - let inspectPort = configuration.tailwindCSS.get('inspectPort') + try { + await Workspace.fs.stat(Uri.joinPath(context.extensionUri, prod)) + module = context.asAbsolutePath(prod) + } catch (_) {} + + let workspaceFile = + Workspace.workspaceFile?.scheme === 'file' ? Workspace.workspaceFile : undefined + let inspectPort = + Workspace.getConfiguration('tailwindCSS', workspaceFile).get('inspectPort') ?? + null let serverOptions: ServerOptions = { run: { module, transport: TransportKind.ipc, - options: { execArgv: inspectPort === null ? [] : [`--inspect=${inspectPort}`] }, + options: { + execArgv: inspectPort === null ? [] : [`--inspect=${inspectPort}`], + }, }, debug: { module, transport: TransportKind.ipc, options: { - execArgv: ['--nolazy', `--inspect=${6011 + clients.size}`], + execArgv: ['--nolazy', `--inspect=6011`], }, }, } let clientOptions: LanguageClientOptions = { - documentSelector: languages.get(folder.uri.toString()).map((language) => ({ - scheme: 'file', - language, - pattern: normalizePath(`${folder.uri.fsPath.replace(/[\[\]\{\}]/g, '?')}/**/*`), - })), + documentSelector: documentFilters, diagnosticCollectionName: CLIENT_ID, - workspaceFolder: folder, outputChannel: outputChannel, revealOutputChannelOn: RevealOutputChannelOn.Never, middleware: { - provideCompletionItem(document, position, context, token, next) { - let workspaceFolder = Workspace.getWorkspaceFolder(document.uri) - if (workspaceFolder !== folder) { - return null - } - return next(document, position, context, token) - }, - provideHover(document, position, token, next) { - let workspaceFolder = Workspace.getWorkspaceFolder(document.uri) - if (workspaceFolder !== folder) { - return null - } - return next(document, position, token) - }, - handleDiagnostics(uri, diagnostics, next) { - let workspaceFolder = Workspace.getWorkspaceFolder(uri) - if (workspaceFolder !== folder) { - return - } - next(uri, diagnostics) - }, - provideCodeActions(document, range, context, token, next) { - let workspaceFolder = Workspace.getWorkspaceFolder(document.uri) - if (workspaceFolder !== folder) { - return null - } - return next(document, range, context, token) - }, async resolveCompletionItem(item, token, next) { + let editor = Window.activeTextEditor + if (!editor) return null + let result = await next(item, token) - let selections = Window.activeTextEditor.selections + if (!result) return result + + let selections = editor.selections + let edits = result.additionalTextEdits || [] + + if (selections.length <= 1 || edits.length === 0 || result['data'] !== 'variant') { + return result + } + + let length = selections[0].start.character - edits[0].range.start.character + let prefixLength = edits[0].range.end.character - edits[0].range.start.character + + let ranges = selections.map((selection) => { + return new Range( + new Position(selection.start.line, selection.start.character - length), + new Position(selection.start.line, selection.start.character - length + prefixLength), + ) + }) if ( - result['data'] === 'variant' && - selections.length > 1 && - result.additionalTextEdits?.length > 0 + ranges + .map((range) => editor!.document.getText(range)) + .every((text, _index, arr) => arr.indexOf(text) === 0) ) { - let length = - selections[0].start.character - result.additionalTextEdits[0].range.start.character - let prefixLength = - result.additionalTextEdits[0].range.end.character - - result.additionalTextEdits[0].range.start.character - - let ranges = selections.map((selection) => { - return new Range( - new Position(selection.start.line, selection.start.character - length), - new Position( - selection.start.line, - selection.start.character - length + prefixLength, - ), - ) + // all the same + result.additionalTextEdits = ranges.map((range) => { + return { range, newText: edits[0].newText } }) - if ( - ranges - .map((range) => Window.activeTextEditor.document.getText(range)) - .every((text, _index, arr) => arr.indexOf(text) === 0) - ) { - // all the same - result.additionalTextEdits = ranges.map((range) => { - return { range, newText: result.additionalTextEdits[0].newText } - }) - } else { - result.insertText = - typeof result.label === 'string' ? result.label : result.label.label - result.additionalTextEdits = [] - } + } else { + result.insertText = typeof result.label === 'string' ? result.label : result.label.label + result.additionalTextEdits = [] } + return result }, - async provideDocumentColors(document, token, next) { - let workspaceFolder = Workspace.getWorkspaceFolder(document.uri) - if (workspaceFolder !== folder) { - return null - } + async provideDocumentColors(document, token, next) { let colors = await next(document, token) + if (!colors) return colors + let editableColors = colors.filter((color) => { let text = Workspace.textDocuments.find((doc) => doc === document)?.getText(color.range) ?? '' @@ -599,65 +466,35 @@ export async function activate(context: ExtensionContext) { `-\\[(${colorNames.join('|')}|((?:#|rgba?\\(|hsla?\\())[^\\]]+)\\]$`, ).test(text) }) - let nonEditableColors = colors.filter((color) => !editableColors.includes(color)) - if (!colorDecorationType) { - colorDecorationType = Window.createTextEditorDecorationType({ - before: { - width: '0.8em', - height: '0.8em', - contentText: ' ', - border: '0.1em solid', - margin: '0.1em 0.2em 0', - }, - dark: { - before: { - borderColor: '#eeeeee', - }, - }, - light: { - before: { - borderColor: '#000000', - }, - }, - }) - } + let nonEditableColors = colors.filter((color) => !editableColors.includes(color)) let editors = Window.visibleTextEditors.filter((editor) => editor.document === document) // Make sure we show document colors for all visible editors // Not just the first one for a given document - editors.forEach((editor) => { + for (let editor of editors) { editor.setDecorations( colorDecorationType, nonEditableColors.map(({ range, color }) => ({ range, renderOptions: { before: { - backgroundColor: `rgba(${color.red * 255}, ${color.green * 255}, ${ - color.blue * 255 - }, ${color.alpha})`, + backgroundColor: `rgba(${color.red * 255}, ${color.green * 255}, ${color.blue * 255}, ${color.alpha})`, }, }, })), ) - }) + } return editableColors }, + workspace: { configuration: (params) => { return params.items.map(({ section, scopeUri }) => { - let scope: ConfigurationScope = folder - if (scopeUri) { - let doc = Workspace.textDocuments.find((doc) => doc.uri.toString() === scopeUri) - if (doc) { - scope = { - uri: Uri.parse(scopeUri), - languageId: doc.languageId, - } - } - } + let scope: ConfigurationScope | null = scopeUri ? Uri.parse(scopeUri) : null + let settings = Workspace.getConfiguration(section, scope) if (section === 'tailwindCSS') { @@ -669,59 +506,80 @@ export async function activate(context: ExtensionContext) { }, }, }, + initializationOptions: { - userLanguages: getUserLanguages(folder), - workspaceFile: - Workspace.workspaceFile?.scheme === 'file' ? Workspace.workspaceFile.fsPath : undefined, - }, - synchronize: { - configurationSection: ['files', 'editor', 'tailwindCSS'], + workspaceFile: workspaceFile?.fsPath ?? undefined, }, } let client = new LanguageClient(CLIENT_ID, CLIENT_NAME, serverOptions, clientOptions) - client.onNotification('@/tailwindCSS/error', async ({ message }) => { - let action = await Window.showErrorMessage(message, 'Go to output') - if (action === 'Go to output') { - commands.executeCommand('tailwindCSS.showOutput') - } - }) + client.onNotification('@/tailwindCSS/error', showError) + client.onNotification('@/tailwindCSS/clearColors', clearColors) + client.onNotification('@/tailwindCSS/projectInitialized', updateActiveTextEditorContext) + client.onNotification('@/tailwindCSS/projectReset', updateActiveTextEditorContext) + client.onNotification('@/tailwindCSS/projectsDestroyed', resetActiveTextEditorContext) + client.onRequest('@/tailwindCSS/getDocumentSymbols', showSymbols) + + interface ErrorNotification { + message: string + } - client.onNotification('@/tailwindCSS/clearColors', () => clearColors()) + async function showError({ message }: ErrorNotification) { + let action = await Window.showErrorMessage(message, 'Go to output') + if (action !== 'Go to output') return + commands.executeCommand('tailwindCSS.showOutput') + } - client.onNotification('@/tailwindCSS/projectInitialized', async () => { - await updateActiveTextEditorContext() - }) - client.onNotification('@/tailwindCSS/projectReset', async () => { - await updateActiveTextEditorContext() - }) - client.onNotification('@/tailwindCSS/projectsDestroyed', () => { - resetActiveTextEditorContext() - }) + interface DocumentSymbolsRequest { + uri: string + } - client.onRequest('@/tailwindCSS/getDocumentSymbols', async ({ uri }) => { + function showSymbols({ uri }: DocumentSymbolsRequest) { return commands.executeCommand( 'vscode.executeDocumentSymbolProvider', Uri.parse(uri), ) - }) + } client.onDidChangeState(({ newState }) => { - if (newState === LanguageClientState.Stopped) { - clearColors() - } + if (newState !== LanguageClientState.Stopped) return + clearColors() }) - client.start() - clients.set(folder.uri.toString(), client) + await client.start() + + return client + } + + async function bootClientIfNeeded(): Promise { + if (currentClient) { + return + } + + if (!anyFolderNeedsLanguageServer(Workspace.workspaceFolders ?? [])) { + return + } + + await bootWorkspaceClient() + } + + async function anyFolderNeedsLanguageServer( + folders: readonly WorkspaceFolder[], + ): Promise { + for (let folder of folders) { + if (await folderNeedsLanguageServer(folder)) { + return true + } + } + + return false } - async function bootClientForFolderIfNeeded(folder: WorkspaceFolder): Promise { + async function folderNeedsLanguageServer(folder: WorkspaceFolder): Promise { let settings = Workspace.getConfiguration('tailwindCSS', folder) if (settings.get('experimental.configFile') !== null) { - bootWorkspaceClient(folder) - return + return true } let exclude = `{${getExcludePatterns(folder) @@ -730,32 +588,32 @@ export async function activate(context: ExtensionContext) { .replace(/{/g, '%7B') .replace(/}/g, '%7D')}}` - let [configFile] = await Workspace.findFiles( + let configFiles = await Workspace.findFiles( new RelativePattern(folder, `**/${CONFIG_GLOB}`), exclude, 1, ) - if (configFile) { - bootWorkspaceClient(folder) - return + for (let file of configFiles) { + return true } let cssFiles = await Workspace.findFiles(new RelativePattern(folder, `**/${CSS_GLOB}`), exclude) - for (let cssFile of cssFiles) { - outputChannel.appendLine(`Checking if ${cssFile.fsPath} may be Tailwind-related…`) + for (let file of cssFiles) { + outputChannel.appendLine(`Checking if ${file.fsPath} may be Tailwind-related…`) - if (await fileMayBeTailwindRelated(cssFile)) { - bootWorkspaceClient(folder) - return + if (await fileMayBeTailwindRelated(file)) { + return true } } + + return false } async function didOpenTextDocument(document: TextDocument): Promise { if (document.languageId === 'tailwindcss') { - bootCssServer() + servers.css.boot(context, outputChannel) } // We are only interested in language mode text @@ -765,41 +623,32 @@ export async function activate(context: ExtensionContext) { let uri = document.uri let folder = Workspace.getWorkspaceFolder(uri) + // Files outside a folder can't be handled. This might depend on the language. // Single file languages like JSON might handle files outside the workspace folders. - if (!folder) { - return - } - - if (searchedFolders.has(folder.uri.toString())) { - return - } + if (!folder) return - searchedFolders.add(folder.uri.toString()) - - await bootClientForFolderIfNeeded(folder) + await bootClientIfNeeded() } context.subscriptions.push(Workspace.onDidOpenTextDocument(didOpenTextDocument)) Workspace.textDocuments.forEach(didOpenTextDocument) context.subscriptions.push( - Workspace.onDidChangeWorkspaceFolders((event) => { - for (let folder of event.removed) { - let client = clients.get(folder.uri.toString()) - if (client) { - searchedFolders.delete(folder.uri.toString()) - clients.delete(folder.uri.toString()) - client.stop() - } - } + Workspace.onDidChangeWorkspaceFolders(async () => { + let folderCount = Workspace.workspaceFolders?.length ?? 0 + if (folderCount > 0) return + if (!currentClient) return + + let client = await currentClient + client.stop() + currentClient = null }), ) } -export function deactivate(): Thenable { - let promises: Thenable[] = [] - for (let client of clients.values()) { - promises.push(client.stop()) - } - return Promise.all(promises).then(() => undefined) +export async function deactivate(): Promise { + if (!currentClient) return + + let client = await currentClient + await client.stop() } diff --git a/packages/vscode-tailwindcss/src/servers/css.ts b/packages/vscode-tailwindcss/src/servers/css.ts new file mode 100644 index 00000000..7d9b8591 --- /dev/null +++ b/packages/vscode-tailwindcss/src/servers/css.ts @@ -0,0 +1,151 @@ +import * as path from 'path' +import type { + ExtensionContext, + TextDocument, + CompletionList, + ProviderResult, + OutputChannel, +} from 'vscode' +import { + workspace as Workspace, + languages as Languages, + Uri, + Position, + Range, + CompletionItem, + CompletionItemKind, + SnippetString, + TextEdit, +} from 'vscode' +import type { Disposable, LanguageClientOptions, ServerOptions } from 'vscode-languageclient/node' +import { LanguageClient, TransportKind } from 'vscode-languageclient/node' + +let booted = false + +/** + * Start the CSS language server + * We have a customized version of the CSS language server that supports Tailwind CSS completions + * It operates in the Tailwind CSS language mode only + */ +export async function boot(context: ExtensionContext, outputChannel: OutputChannel) { + if (booted) return + booted = true + + let module = context.asAbsolutePath(path.join('dist', 'cssServer.js')) + let prod = path.join('dist', 'tailwindModeServer.js') + + try { + await Workspace.fs.stat(Uri.joinPath(context.extensionUri, prod)) + module = context.asAbsolutePath(prod) + } catch (_) {} + + let serverOptions: ServerOptions = { + run: { + module, + transport: TransportKind.ipc, + }, + debug: { + module, + transport: TransportKind.ipc, + options: { + execArgv: ['--nolazy', '--inspect=6051'], + }, + }, + } + + let clientOptions: LanguageClientOptions = { + documentSelector: [{ language: 'tailwindcss' }], + outputChannelName: 'Tailwind CSS Language Mode', + synchronize: { configurationSection: ['css'] }, + middleware: { + provideCompletionItem(document, position, context, token, next) { + function updateRanges(item: CompletionItem) { + const range = item.range + if ( + range instanceof Range && + range.end.isAfter(position) && + range.start.isBeforeOrEqual(position) + ) { + item.range = { inserting: new Range(range.start, position), replacing: range } + } + } + + function updateLabel(item: CompletionItem) { + if (item.kind === CompletionItemKind.Color) { + item.label = { + label: item.label as string, + description: item.documentation as string, + } + } + } + + function updateProposals( + r: CompletionItem[] | CompletionList | null | undefined, + ): CompletionItem[] | CompletionList | null | undefined { + if (r) { + ;(Array.isArray(r) ? r : r.items).forEach(updateRanges) + ;(Array.isArray(r) ? r : r.items).forEach(updateLabel) + } + return r + } + + const isThenable = (obj: ProviderResult): obj is Thenable => + obj && (obj)['then'] + + const r = next(document, position, context, token) + + if (isThenable(r)) { + return r.then(updateProposals) + } + + return updateProposals(r) + }, + }, + } + + outputChannel.appendLine(`Booting CSS server for Tailwind CSS language mode`) + + let client = new LanguageClient( + 'tailwindcss-intellisense-css', + 'Tailwind CSS', + serverOptions, + clientOptions, + ) + + await client.start() + + context.subscriptions.push(initCompletionProvider()) + + function initCompletionProvider(): Disposable { + const regionCompletionRegExpr = /^(\s*)(\/(\*\s*(#\w*)?)?)?$/ + + return Languages.registerCompletionItemProvider(['tailwindcss'], { + provideCompletionItems(doc: TextDocument, pos: Position) { + let lineUntilPos = doc.getText(new Range(new Position(pos.line, 0), pos)) + let match = lineUntilPos.match(regionCompletionRegExpr) + if (!match) { + return null + } + + let range = new Range(new Position(pos.line, match[1].length), pos) + + let beginProposal = new CompletionItem('#region', CompletionItemKind.Snippet) + beginProposal.range = range + TextEdit.replace(range, '/* #region */') + beginProposal.insertText = new SnippetString('/* #region $1*/') + beginProposal.documentation = 'Folding Region Start' + beginProposal.filterText = match[2] + beginProposal.sortText = 'za' + + let endProposal = new CompletionItem('#endregion', CompletionItemKind.Snippet) + endProposal.range = range + endProposal.insertText = '/* #endregion */' + endProposal.documentation = 'Folding Region End' + endProposal.sortText = 'zb' + endProposal.filterText = match[2] + + return [beginProposal, endProposal] + }, + }) + } +} diff --git a/packages/vscode-tailwindcss/src/servers/index.ts b/packages/vscode-tailwindcss/src/servers/index.ts new file mode 100644 index 00000000..2b027a71 --- /dev/null +++ b/packages/vscode-tailwindcss/src/servers/index.ts @@ -0,0 +1 @@ +export * as css from './css'