tailwind-ctp-intellisense/packages/vscode-tailwindcss/src/extension.ts

790 lines
25 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* --------------------------------------------------------------------------------------------
* 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,
languages as Languages,
ExtensionContext,
TextDocument,
OutputChannel,
WorkspaceFolder,
Uri,
commands,
SymbolInformation,
Position,
Range,
TextEditorDecorationType,
RelativePattern,
ConfigurationScope,
WorkspaceConfiguration,
CompletionItem,
CompletionItemKind,
CompletionList,
ProviderResult,
SnippetString,
TextEdit,
Selection,
} from 'vscode'
import {
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind,
State as LanguageClientState,
RevealOutputChannelOn,
Disposable,
} from 'vscode-languageclient/node'
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 minimatch from 'minimatch'
import { CONFIG_GLOB, CSS_GLOB } from 'tailwindcss-language-server/src/lib/constants'
import braces from 'braces'
import normalizePath from 'normalize-path'
const colorNames = Object.keys(namedColors)
const CLIENT_ID = 'tailwindcss-intellisense'
const CLIENT_NAME = 'Tailwind CSS IntelliSense'
let clients: Map<string, LanguageClient> = new Map()
let languages: Map<string, string[]> = new Map()
let searchedFolders: Set<string> = new Set()
function getUserLanguages(folder?: WorkspaceFolder): Record<string, string> {
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'))
.filter(([, value]) => value === true)
.map(([key]) => key)
.filter(Boolean)
}
function getExcludePatterns(scope: ConfigurationScope): string[] {
return [
...getGlobalExcludePatterns(scope),
...(<string[]>Workspace.getConfiguration('tailwindCSS', scope).get('files.exclude')).filter(
Boolean
),
]
}
function isExcluded(file: string, folder: WorkspaceFolder): boolean {
let exclude = getExcludePatterns(folder)
for (let pattern of exclude) {
if (minimatch(file, path.join(folder.uri.fsPath, pattern))) {
return true
}
}
return false
}
function mergeExcludes(settings: WorkspaceConfiguration, scope: ConfigurationScope): any {
return {
...settings,
files: {
...settings.files,
exclude: getExcludePatterns(scope),
},
}
}
async function fileContainsAtConfig(uri: Uri) {
let contents = (await Workspace.fs.readFile(uri)).toString()
return /@config\s*['"]/.test(contents)
}
function selectionsAreEqual(
aSelections: readonly Selection[],
bSelections: readonly Selection[]
): boolean {
if (aSelections.length !== bSelections.length) {
return false
}
for (let i = 0; i < aSelections.length; i++) {
if (!aSelections[i].isEqual(bSelections[i])) {
return false
}
}
return true
}
async function getActiveTextEditorProject(): Promise<{ version: string } | null> {
if (clients.size === 0) {
return null
}
let editor = Window.activeTextEditor
if (!editor) {
return null
}
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
}
try {
let project = await client.sendRequest<{ version: string } | null>('@/tailwindCSS/getProject', {
uri: uri.toString(),
})
return project
} catch {
return null
}
}
async function activeTextEditorSupportsClassSorting(): Promise<boolean> {
let project = await getActiveTextEditorProject()
if (!project) {
return false
}
return semver.gte(project.version, '3.0.0')
}
async function updateActiveTextEditorContext(): Promise<void> {
commands.executeCommand(
'setContext',
'tailwindCSS.activeTextEditorSupportsClassSorting',
await activeTextEditorSupportsClassSorting()
)
}
function resetActiveTextEditorContext(): void {
commands.executeCommand('setContext', 'tailwindCSS.activeTextEditorSupportsClassSorting', false)
}
export async function activate(context: ExtensionContext) {
let module = context.asAbsolutePath(path.join('dist', 'server.js'))
let prod = path.join('dist', 'tailwindServer.js')
try {
await Workspace.fs.stat(Uri.joinPath(context.extensionUri, prod))
module = context.asAbsolutePath(prod)
} catch (_) {}
let outputChannel: OutputChannel
context.subscriptions.push(
commands.registerCommand('tailwindCSS.showOutput', () => {
if (outputChannel) {
outputChannel.show()
}
})
)
async function sortSelection(): Promise<void> {
let { document, selections } = Window.activeTextEditor
if (selections.length === 0) {
return
}
let initialSelections = selections
let folder = Workspace.getWorkspaceFolder(document.uri)
if (clients.size === 0 || !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 result = await client.sendRequest<{ error: string } | { classLists: string[] }>(
'@/tailwindCSS/sortSelection',
{
uri: document.uri.toString(),
classLists: selections.map((selection) => document.getText(selection)),
}
)
if (
Window.activeTextEditor.document !== document ||
!selectionsAreEqual(initialSelections, Window.activeTextEditor.selections)
) {
return
}
if ('error' in result) {
throw Error(
{
'no-project': `No active Tailwind project found for file ${document.uri.fsPath}`,
}[result.error] ?? 'An unknown error occurred.'
)
}
let sortedClassLists = result.classLists
Window.activeTextEditor.edit((builder) => {
for (let i = 0; i < selections.length; i++) {
builder.replace(selections[i], sortedClassLists[i])
}
})
}
context.subscriptions.push(
commands.registerCommand('tailwindCSS.sortSelection', async () => {
try {
await sortSelection()
} catch (error) {
Window.showWarningMessage(`Couldnt sort Tailwind classes: ${error.message}`)
}
})
)
context.subscriptions.push(
Window.onDidChangeActiveTextEditor(async () => {
await updateActiveTextEditorContext()
})
)
let configWatcher = Workspace.createFileSystemWatcher(`**/${CONFIG_GLOB}`, false, true, true)
configWatcher.onDidCreate((uri) => {
let folder = Workspace.getWorkspaceFolder(uri)
if (!folder || isExcluded(uri.fsPath, folder)) {
return
}
bootWorkspaceClient(folder)
})
context.subscriptions.push(configWatcher)
let cssWatcher = Workspace.createFileSystemWatcher(`**/${CSS_GLOB}`, false, false, true)
async function bootClientIfCssFileContainsAtConfig(uri: Uri) {
let folder = Workspace.getWorkspaceFolder(uri)
if (!folder || isExcluded(uri.fsPath, folder)) {
return
}
if (await fileContainsAtConfig(uri)) {
bootWorkspaceClient(folder)
}
}
cssWatcher.onDidCreate(bootClientIfCssFileContainsAtConfig)
cssWatcher.onDidChange(bootClientIfCssFileContainsAtConfig)
context.subscriptions.push(cssWatcher)
// 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) => {
let toReboot = new Set<WorkspaceFolder>()
Workspace.textDocuments.forEach((document) => {
let folder = Workspace.getWorkspaceFolder(document.uri)
if (!folder) return
if (event.affectsConfiguration('tailwindCSS.experimental.configFile', folder)) {
toReboot.add(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)
}
})
for (let folder of toReboot) {
clients.get(folder.uri.toString())?.stop()
clients.delete(folder.uri.toString())
bootClientForFolderIfNeeded(folder)
}
})
)
let cssServerBooted = false
async function bootCssServer() {
if (cssServerBooted) return
cssServerBooted = 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 client = new LanguageClient(
'tailwindcss-intellisense-css',
'Tailwind CSS',
{
run: {
module,
transport: TransportKind.ipc,
},
debug: {
module,
transport: TransportKind.ipc,
options: {
execArgv: ['--nolazy', '--inspect=6051'],
},
},
},
{
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 = <T>(obj: ProviderResult<T>): obj is Thenable<T> =>
obj && (<any>obj)['then']
const r = next(document, position, context, token)
if (isThenable<CompletionItem[] | CompletionList | null | undefined>(r)) {
return r.then(updateProposals)
}
return updateProposals(r)
},
},
}
)
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
},
})
}
}
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([...defaultLanguages, ...Object.keys(getUserLanguages(folder))])
)
}
if (!outputChannel) {
outputChannel = Window.createOutputChannel(CLIENT_NAME)
context.subscriptions.push(outputChannel)
commands.executeCommand('setContext', 'tailwindCSS.hasOutputChannel', true)
}
let configuration = {
editor: Workspace.getConfiguration('editor', folder),
tailwindCSS: mergeExcludes(Workspace.getConfiguration('tailwindCSS', folder), folder),
}
let inspectPort = configuration.tailwindCSS.get('inspectPort')
let serverOptions: ServerOptions = {
run: {
module,
transport: TransportKind.ipc,
options: { execArgv: inspectPort === null ? [] : [`--inspect=${inspectPort}`] },
},
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: normalizePath(`${folder.uri.fsPath.replace(/[\[\]\{\}]/g, '?')}/**/*`),
})),
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 result = await next(item, token)
let selections = Window.activeTextEditor.selections
if (
result['data'] === 'variant' &&
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 =
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
}
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 = {
uri: Uri.parse(scopeUri),
languageId: doc.languageId,
}
}
}
let settings = Workspace.getConfiguration(section, scope)
if (section === 'tailwindCSS') {
return mergeExcludes(settings, scope)
}
return settings
})
},
},
},
initializationOptions: {
userLanguages: getUserLanguages(folder),
workspaceFile:
Workspace.workspaceFile?.scheme === 'file' ? Workspace.workspaceFile.fsPath : undefined,
},
synchronize: {
configurationSection: ['files', 'editor', 'tailwindCSS'],
},
}
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/clearColors', () => clearColors())
client.onNotification('@/tailwindCSS/projectInitialized', async () => {
await updateActiveTextEditorContext()
})
client.onNotification('@/tailwindCSS/projectReset', async () => {
await updateActiveTextEditorContext()
})
client.onNotification('@/tailwindCSS/projectsDestroyed', () => {
resetActiveTextEditorContext()
})
client.onRequest('@/tailwindCSS/getDocumentSymbols', async ({ uri }) => {
return commands.executeCommand<SymbolInformation[]>(
'vscode.executeDocumentSymbolProvider',
Uri.parse(uri)
)
})
client.onDidChangeState(({ newState }) => {
if (newState === LanguageClientState.Stopped) {
clearColors()
}
})
client.start()
clients.set(folder.uri.toString(), client)
}
async function bootClientForFolderIfNeeded(folder: WorkspaceFolder): Promise<void> {
let settings = Workspace.getConfiguration('tailwindCSS', folder)
if (settings.get('experimental.configFile') !== null) {
bootWorkspaceClient(folder)
return
}
let exclude = `{${getExcludePatterns(folder)
.flatMap((pattern) => braces.expand(pattern))
.join(',')
.replace(/{/g, '%7B')
.replace(/}/g, '%7D')}}`
let [configFile] = await Workspace.findFiles(
new RelativePattern(folder, `**/${CONFIG_GLOB}`),
exclude,
1
)
if (configFile) {
bootWorkspaceClient(folder)
return
}
let cssFiles = await Workspace.findFiles(new RelativePattern(folder, `**/${CSS_GLOB}`), exclude)
for (let cssFile of cssFiles) {
if (await fileContainsAtConfig(cssFile)) {
bootWorkspaceClient(folder)
return
}
}
}
async function didOpenTextDocument(document: TextDocument): Promise<void> {
if (document.languageId === 'tailwindcss') {
bootCssServer()
}
// 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 (searchedFolders.has(folder.uri.toString())) {
return
}
searchedFolders.add(folder.uri.toString())
await bootClientForFolderIfNeeded(folder)
}
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()
}
}
})
)
}
export function deactivate(): Thenable<void> {
let promises: Thenable<void>[] = []
for (let client of clients.values()) {
promises.push(client.stop())
}
return Promise.all(promises).then(() => undefined)
}