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'