/* -------------------------------------------------------------------------------------------- * 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 { workspace as Workspace, window as Window, ExtensionContext, TextDocument, OutputChannel, WorkspaceFolder, Uri, commands, SymbolInformation, Position, Range, TextEditorDecorationType, RelativePattern, ConfigurationScope, } from 'vscode' import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind, State as LanguageClientState, RevealOutputChannelOn, } from 'vscode-languageclient/node' import { DEFAULT_LANGUAGES } from './lib/languages' import isObject from './util/isObject' import { dedupe, equal } from 'tailwindcss-language-service/src/util/array' import { names as namedColors } from '@ctrl/tinycolor' const colorNames = Object.keys(namedColors) const CLIENT_ID = 'tailwindcss-intellisense' const CLIENT_NAME = 'Tailwind CSS IntelliSense' const CONFIG_FILE_GLOB = 'tailwind.config.{js,cjs}' let clients: Map = new Map() let languages: Map = new Map() let searchedFolders: Set = new Set() let _sortedWorkspaceFolders: string[] | undefined function sortedWorkspaceFolders(): string[] { if (_sortedWorkspaceFolders === void 0) { _sortedWorkspaceFolders = Workspace.workspaceFolders ? Workspace.workspaceFolders .map((folder) => { let result = folder.uri.toString() if (result.charAt(result.length - 1) !== '/') { result = result + '/' } return result }) .sort((a, b) => { return a.length - b.length }) : [] } return _sortedWorkspaceFolders } function getOuterMostWorkspaceFolder(folder: WorkspaceFolder): WorkspaceFolder { let sorted = sortedWorkspaceFolders() for (let element of sorted) { let uri = folder.uri.toString() if (uri.charAt(uri.length - 1) !== '/') { uri = uri + '/' } if (uri.startsWith(element)) { return Workspace.getWorkspaceFolder(Uri.parse(element))! } } return folder } function getUserLanguages(folder?: WorkspaceFolder): Record { const langs = Workspace.getConfiguration('tailwindCSS', folder).includeLanguages return isObject(langs) ? langs : {} } export function activate(context: ExtensionContext) { let module = context.asAbsolutePath(path.join('dist', 'server', 'index.js')) let outputChannel: OutputChannel context.subscriptions.push( commands.registerCommand('tailwindCSS.showOutput', () => { if (outputChannel) { outputChannel.show() } }) ) let watcher = Workspace.createFileSystemWatcher(`**/${CONFIG_FILE_GLOB}`, false, true, true) watcher.onDidCreate((uri) => { let folder = Workspace.getWorkspaceFolder(uri) if (!folder) { return } folder = getOuterMostWorkspaceFolder(folder) bootWorkspaceClient(folder) }) context.subscriptions.push(watcher) // TODO: check if the actual language MAPPING changed // not just the language IDs // e.g. "plaintext" already exists but you change it from "html" to "css" context.subscriptions.push( Workspace.onDidChangeConfiguration((event) => { clients.forEach((client, key) => { const folder = Workspace.getWorkspaceFolder(Uri.parse(key)) if (event.affectsConfiguration('tailwindCSS', folder)) { const userLanguages = getUserLanguages(folder) if (userLanguages) { const userLanguageIds = Object.keys(userLanguages) const newLanguages = dedupe([...DEFAULT_LANGUAGES, ...userLanguageIds]) if (!equal(newLanguages, languages.get(folder.uri.toString()))) { languages.set(folder.uri.toString(), newLanguages) if (client) { clients.delete(folder.uri.toString()) client.stop() bootWorkspaceClient(folder) } } } } }) }) ) function bootWorkspaceClient(folder: WorkspaceFolder) { if (clients.has(folder.uri.toString())) { return } let colorDecorationType: TextEditorDecorationType function clearColors(): void { if (colorDecorationType) { colorDecorationType.dispose() colorDecorationType = undefined } } context.subscriptions.push({ dispose() { if (colorDecorationType) { colorDecorationType.dispose() } }, }) // placeholder so we don't boot another server before this one is ready clients.set(folder.uri.toString(), null) if (!languages.has(folder.uri.toString())) { languages.set( folder.uri.toString(), dedupe([...DEFAULT_LANGUAGES, ...Object.keys(getUserLanguages(folder))]) ) } if (!outputChannel) { outputChannel = Window.createOutputChannel(CLIENT_NAME) context.subscriptions.push(outputChannel) commands.executeCommand('setContext', 'tailwindCSS.hasOutputChannel', true) } let serverOptions: ServerOptions = { run: { module, transport: TransportKind.ipc }, debug: { module, transport: TransportKind.ipc, options: { execArgv: ['--nolazy', `--inspect=${6011 + clients.size}`], }, }, } let clientOptions: LanguageClientOptions = { documentSelector: languages.get(folder.uri.toString()).map((language) => ({ scheme: 'file', language, pattern: `${folder.uri.fsPath}/**/*`, })), diagnosticCollectionName: CLIENT_ID, workspaceFolder: folder, outputChannel: outputChannel, revealOutputChannelOn: RevealOutputChannelOn.Never, middleware: { async resolveCompletionItem(item, token, next) { let result = await next(item, token) let selections = Window.activeTextEditor.selections if (selections.length > 1 && result.additionalTextEdits?.length > 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 ) ) }) 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 = result.label result.additionalTextEdits = [] } } return result }, async provideDocumentColors(document, token, next) { let colors = await next(document, token) let editableColors = colors.filter((color) => { let text = Workspace.textDocuments.find((doc) => doc === document)?.getText(color.range) ?? '' return new RegExp( `-\\[(${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', }, }, }) } Window.visibleTextEditors .find((editor) => editor.document === document) ?.setDecorations( colorDecorationType, nonEditableColors.map(({ range, color }) => ({ range, renderOptions: { before: { 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 = { languageId: doc.languageId, } } } return Workspace.getConfiguration(section, scope) }) }, }, }, initializationOptions: { userLanguages: getUserLanguages(folder), configuration: { editor: Workspace.getConfiguration('editor', folder), tailwindCSS: Workspace.getConfiguration('tailwindCSS', folder), }, }, synchronize: { configurationSection: ['editor', 'tailwindCSS'], }, } let client = new LanguageClient(CLIENT_ID, CLIENT_NAME, serverOptions, clientOptions) client.onReady().then(() => { 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/clearColors', () => clearColors()) client.onRequest('@/tailwindCSS/getDocumentSymbols', async ({ uri }) => { return commands.executeCommand( 'vscode.executeDocumentSymbolProvider', Uri.parse(uri) ) }) }) client.onDidChangeState(({ newState }) => { if (newState === LanguageClientState.Stopped) { clearColors() } }) client.start() clients.set(folder.uri.toString(), client) } async function didOpenTextDocument(document: TextDocument): Promise { // We are only interested in language mode text if (document.uri.scheme !== 'file') { return } 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 we have nested workspace folders we only start a server on the outer most workspace folder. folder = getOuterMostWorkspaceFolder(folder) if (searchedFolders.has(folder.uri.toString())) return searchedFolders.add(folder.uri.toString()) let [configFile] = await Workspace.findFiles( new RelativePattern(folder, `**/${CONFIG_FILE_GLOB}`), '**/node_modules/**', 1 ) if (!configFile) { return } bootWorkspaceClient(folder) } context.subscriptions.push(Workspace.onDidOpenTextDocument(didOpenTextDocument)) Workspace.textDocuments.forEach(didOpenTextDocument) context.subscriptions.push( Workspace.onDidChangeWorkspaceFolders((event) => { _sortedWorkspaceFolders = undefined 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() } } }) ) } export function deactivate(): Thenable { let promises: Thenable[] = [] for (let client of clients.values()) { promises.push(client.stop()) } return Promise.all(promises).then(() => undefined) }