diff --git a/packages/tailwindcss-language-server/package.json b/packages/tailwindcss-language-server/package.json index 3b3f2dd3..f8736d19 100644 --- a/packages/tailwindcss-language-server/package.json +++ b/packages/tailwindcss-language-server/package.json @@ -13,8 +13,9 @@ }, "homepage": "https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme", "scripts": { - "build": "pnpm run clean && pnpm run _esbuild && pnpm run hashbang", + "build": "pnpm run clean && pnpm run _esbuild && pnpm run _esbuild:css && pnpm run hashbang", "_esbuild": "node ../../esbuild.mjs src/server.ts --outfile=bin/tailwindcss-language-server --minify", + "_esbuild:css": "node ../../esbuild.mjs src/language/css.ts --outfile=bin/css-language-server --minify", "clean": "rimraf bin", "hashbang": "node scripts/hashbang.mjs", "create-notices-file": "node scripts/createNoticesFile.mjs", diff --git a/packages/tailwindcss-language-server/src/language/css-server.ts b/packages/tailwindcss-language-server/src/language/css-server.ts new file mode 100644 index 00000000..a146cea4 --- /dev/null +++ b/packages/tailwindcss-language-server/src/language/css-server.ts @@ -0,0 +1,379 @@ +import { + getCSSLanguageService, + LanguageSettings, + DocumentContext, +} from 'vscode-css-languageservice/lib/esm/cssLanguageService' +import { + InitializeParams, + TextDocuments, + TextDocumentSyncKind, + WorkspaceFolder, + Disposable, + ConfigurationRequest, + CompletionItemKind, + Connection, +} from 'vscode-languageserver/node' +import { TextDocument } from 'vscode-languageserver-textdocument' +import { Utils, URI } from 'vscode-uri' +import { getLanguageModelCache } from './languageModelCache' +import { Stylesheet } from 'vscode-css-languageservice' +import dlv from 'dlv' +import { rewriteCss } from './rewriting' + +export class CssServer { + private documents: TextDocuments + constructor(private connection: Connection) { + this.documents = new TextDocuments(TextDocument) + } + + setup() { + let connection = this.connection + let documents = this.documents + + let cssLanguageService = getCSSLanguageService() + + let workspaceFolders: WorkspaceFolder[] + + let foldingRangeLimit = Number.MAX_VALUE + const MEDIA_MARKER = '℘' + + const stylesheets = getLanguageModelCache(10, 60, (document) => + cssLanguageService.parseStylesheet(document), + ) + documents.onDidOpen(({ document }) => { + connection.sendNotification('@/tailwindCSS/documentReady', { + uri: document.uri, + }) + }) + documents.onDidClose(({ document }) => { + stylesheets.onDocumentRemoved(document) + }) + connection.onShutdown(() => { + stylesheets.dispose() + }) + + connection.onInitialize((params: InitializeParams) => { + workspaceFolders = (params).workspaceFolders + if (!Array.isArray(workspaceFolders)) { + workspaceFolders = [] + if (params.rootPath) { + workspaceFolders.push({ name: '', uri: URI.file(params.rootPath).toString() }) + } + } + + foldingRangeLimit = dlv( + params.capabilities, + 'textDocument.foldingRange.rangeLimit', + Number.MAX_VALUE, + ) + + return { + capabilities: { + textDocumentSync: TextDocumentSyncKind.Full, + completionProvider: { resolveProvider: false, triggerCharacters: ['/', '-', ':'] }, + hoverProvider: true, + foldingRangeProvider: true, + colorProvider: {}, + definitionProvider: true, + documentHighlightProvider: true, + documentSymbolProvider: true, + selectionRangeProvider: true, + referencesProvider: true, + codeActionProvider: true, + documentLinkProvider: { resolveProvider: false }, + renameProvider: true, + }, + } + }) + + function getDocumentContext( + documentUri: string, + workspaceFolders: WorkspaceFolder[], + ): DocumentContext { + function getRootFolder(): string | undefined { + for (let folder of workspaceFolders) { + let folderURI = folder.uri + if (!folderURI.endsWith('/')) { + folderURI = folderURI + '/' + } + if (documentUri.startsWith(folderURI)) { + return folderURI + } + } + return undefined + } + + return { + resolveReference: (ref: string, base = documentUri) => { + if (ref[0] === '/') { + // resolve absolute path against the current workspace folder + let folderUri = getRootFolder() + if (folderUri) { + return folderUri + ref.substr(1) + } + } + base = base.substr(0, base.lastIndexOf('/') + 1) + return Utils.resolvePath(URI.parse(base), ref).toString() + }, + } + } + + async function withDocumentAndSettings( + uri: string, + callback: (result: { + document: TextDocument + settings: LanguageSettings | undefined + }) => T | Promise, + ): Promise { + let document = documents.get(uri) + if (!document) { + return null + } + return await callback({ + document: createVirtualCssDocument(document), + settings: await getDocumentSettings(document), + }) + } + + connection.onCompletion(async ({ textDocument, position }, _token) => + withDocumentAndSettings(textDocument.uri, async ({ document, settings }) => { + let result = await cssLanguageService.doComplete2( + document, + position, + stylesheets.get(document), + getDocumentContext(document.uri, workspaceFolders), + settings?.completion, + ) + return { + isIncomplete: result.isIncomplete, + items: result.items.flatMap((item) => { + // Add the `theme()` function + if (item.kind === CompletionItemKind.Function && item.label === 'calc()') { + return [ + item, + { + ...item, + label: 'theme()', + filterText: 'theme', + documentation: { + kind: 'markdown', + value: + 'Use the `theme()` function to access your Tailwind config values using dot notation.', + }, + command: { + title: '', + command: 'editor.action.triggerSuggest', + }, + textEdit: { + ...item.textEdit, + newText: item.textEdit.newText.replace(/^calc\(/, 'theme('), + }, + }, + ] + } + return item + }), + } + }), + ) + + connection.onHover(({ textDocument, position }, _token) => + withDocumentAndSettings(textDocument.uri, ({ document, settings }) => + cssLanguageService.doHover(document, position, stylesheets.get(document), settings?.hover), + ), + ) + + connection.onFoldingRanges(({ textDocument }, _token) => + withDocumentAndSettings(textDocument.uri, ({ document }) => + cssLanguageService.getFoldingRanges(document, { rangeLimit: foldingRangeLimit }), + ), + ) + + connection.onDocumentColor(({ textDocument }) => + withDocumentAndSettings(textDocument.uri, ({ document }) => + cssLanguageService.findDocumentColors(document, stylesheets.get(document)), + ), + ) + + connection.onColorPresentation(({ textDocument, color, range }) => + withDocumentAndSettings(textDocument.uri, ({ document }) => + cssLanguageService.getColorPresentations(document, stylesheets.get(document), color, range), + ), + ) + + connection.onDefinition(({ textDocument, position }) => + withDocumentAndSettings(textDocument.uri, ({ document }) => + cssLanguageService.findDefinition(document, position, stylesheets.get(document)), + ), + ) + + connection.onDocumentHighlight(({ textDocument, position }) => + withDocumentAndSettings(textDocument.uri, ({ document }) => + cssLanguageService.findDocumentHighlights(document, position, stylesheets.get(document)), + ), + ) + + connection.onDocumentSymbol(({ textDocument }) => + withDocumentAndSettings(textDocument.uri, ({ document }) => + cssLanguageService + .findDocumentSymbols(document, stylesheets.get(document)) + .map((symbol) => { + if (symbol.name === `@media (${MEDIA_MARKER})`) { + let doc = documents.get(symbol.location.uri) + let text = doc.getText(symbol.location.range) + let match = text.trim().match(/^(@[^\s]+)(?:([^{]+)[{]|([^;{]+);)/) + if (match) { + symbol.name = `${match[1]} ${match[2]?.trim() ?? match[3]?.trim()}` + } + } else if (symbol.name === `.placeholder`) { + let doc = documents.get(symbol.location.uri) + let text = doc.getText(symbol.location.range) + let match = text.trim().match(/^(@[^\s]+)(?:([^{]+)[{]|([^;{]+);)/) + if (match) { + symbol.name = `${match[1]} ${match[2]?.trim() ?? match[3]?.trim()}` + } + } + return symbol + }), + ), + ) + + connection.onSelectionRanges(({ textDocument, positions }) => + withDocumentAndSettings(textDocument.uri, ({ document }) => + cssLanguageService.getSelectionRanges(document, positions, stylesheets.get(document)), + ), + ) + + connection.onReferences(({ textDocument, position }) => + withDocumentAndSettings(textDocument.uri, ({ document }) => + cssLanguageService.findReferences(document, position, stylesheets.get(document)), + ), + ) + + connection.onCodeAction(({ textDocument, range, context }) => + withDocumentAndSettings(textDocument.uri, ({ document }) => + cssLanguageService.doCodeActions2(document, range, context, stylesheets.get(document)), + ), + ) + + connection.onDocumentLinks(({ textDocument }) => + withDocumentAndSettings(textDocument.uri, ({ document }) => + cssLanguageService.findDocumentLinks2( + document, + stylesheets.get(document), + getDocumentContext(document.uri, workspaceFolders), + ), + ), + ) + + connection.onRenameRequest(({ textDocument, position, newName }) => + withDocumentAndSettings(textDocument.uri, ({ document }) => + cssLanguageService.doRename(document, position, newName, stylesheets.get(document)), + ), + ) + + let documentSettings: { [key: string]: Thenable } = {} + documents.onDidClose((e) => { + delete documentSettings[e.document.uri] + }) + function getDocumentSettings( + textDocument: TextDocument, + ): Thenable { + let promise = documentSettings[textDocument.uri] + if (!promise) { + const configRequestParam = { + items: [{ scopeUri: textDocument.uri, section: 'css' }], + } + promise = connection + .sendRequest(ConfigurationRequest.type, configRequestParam) + .then((s) => s[0]) + documentSettings[textDocument.uri] = promise + } + return promise + } + + connection.onDidChangeConfiguration((change) => { + updateConfiguration(change.settings.css) + }) + + function updateConfiguration(settings: LanguageSettings) { + cssLanguageService.configure(settings) + // reset all document settings + documentSettings = {} + documents.all().forEach(triggerValidation) + } + + const pendingValidationRequests: { [uri: string]: Disposable } = {} + const validationDelayMs = 500 + + const timer = { + setTimeout(callback: (...args: any[]) => void, ms: number, ...args: any[]): Disposable { + const handle = setTimeout(callback, ms, ...args) + return { dispose: () => clearTimeout(handle) } + }, + } + + documents.onDidChangeContent((change) => { + triggerValidation(change.document) + }) + + documents.onDidClose((event) => { + cleanPendingValidation(event.document) + connection.sendDiagnostics({ uri: event.document.uri, diagnostics: [] }) + }) + + function cleanPendingValidation(textDocument: TextDocument): void { + const request = pendingValidationRequests[textDocument.uri] + if (request) { + request.dispose() + delete pendingValidationRequests[textDocument.uri] + } + } + + function triggerValidation(textDocument: TextDocument): void { + cleanPendingValidation(textDocument) + pendingValidationRequests[textDocument.uri] = timer.setTimeout(() => { + delete pendingValidationRequests[textDocument.uri] + validateTextDocument(textDocument) + }, validationDelayMs) + } + + function createVirtualCssDocument(textDocument: TextDocument): TextDocument { + let content = rewriteCss(textDocument.getText()) + + return TextDocument.create( + textDocument.uri, + textDocument.languageId, + textDocument.version, + content, + ) + } + + async function validateTextDocument(textDocument: TextDocument): Promise { + textDocument = createVirtualCssDocument(textDocument) + + let settings = await getDocumentSettings(textDocument) + + let diagnostics = cssLanguageService + .doValidation(textDocument, cssLanguageService.parseStylesheet(textDocument), settings) + .filter((diagnostic) => { + if ( + diagnostic.code === 'unknownAtRules' && + /Unknown at rule @(tailwind|apply|config|theme|plugin|source|utility|variant|custom-variant|slot)/.test( + diagnostic.message, + ) + ) { + return false + } + return true + }) + + connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }) + } + } + + listen() { + this.documents.listen(this.connection) + this.connection.listen() + } +} diff --git a/packages/tailwindcss-language-server/src/language/css.ts b/packages/tailwindcss-language-server/src/language/css.ts new file mode 100644 index 00000000..48dea575 --- /dev/null +++ b/packages/tailwindcss-language-server/src/language/css.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env node +import { createConnection, ProposedFeatures } from 'vscode-languageserver/node' +import { interceptLogs } from '../util/logs' +import { CssServer } from './css-server' + +let connection = + process.argv.length <= 2 + ? createConnection(ProposedFeatures.all, process.stdin, process.stdout) + : createConnection(ProposedFeatures.all) + +interceptLogs(console, connection) + +process.on('unhandledRejection', (e: any) => { + console.error('Unhandled exception', e) +}) + +let server = new CssServer(connection) +server.setup() +server.listen() diff --git a/packages/tailwindcss-language-server/src/language/cssServer.ts b/packages/tailwindcss-language-server/src/language/cssServer.ts deleted file mode 100644 index ecf3e318..00000000 --- a/packages/tailwindcss-language-server/src/language/cssServer.ts +++ /dev/null @@ -1,437 +0,0 @@ -import { - getCSSLanguageService, - LanguageSettings, - DocumentContext, -} from 'vscode-css-languageservice/lib/esm/cssLanguageService' -import { - createConnection, - InitializeParams, - ProposedFeatures, - TextDocuments, - TextDocumentSyncKind, - WorkspaceFolder, - Disposable, - ConfigurationRequest, - CompletionItemKind, -} from 'vscode-languageserver/node' -import { TextDocument } from 'vscode-languageserver-textdocument' -import { Utils, URI } from 'vscode-uri' -import { getLanguageModelCache } from './languageModelCache' -import { Stylesheet } from 'vscode-css-languageservice' -import dlv from 'dlv' -import { interceptLogs } from '../util/logs' - -let connection = createConnection(ProposedFeatures.all) - -interceptLogs(console, connection) - -process.on('unhandledRejection', (e: any) => { - console.error('Unhandled exception', e) -}) - -let documents: TextDocuments = new TextDocuments(TextDocument) - -let cssLanguageService = getCSSLanguageService() - -let workspaceFolders: WorkspaceFolder[] - -let foldingRangeLimit = Number.MAX_VALUE -const MEDIA_MARKER = '℘' - -const stylesheets = getLanguageModelCache(10, 60, (document) => - cssLanguageService.parseStylesheet(document), -) -documents.onDidClose(({ document }) => { - stylesheets.onDocumentRemoved(document) -}) -connection.onShutdown(() => { - stylesheets.dispose() -}) - -connection.onInitialize((params: InitializeParams) => { - workspaceFolders = (params).workspaceFolders - if (!Array.isArray(workspaceFolders)) { - workspaceFolders = [] - if (params.rootPath) { - workspaceFolders.push({ name: '', uri: URI.file(params.rootPath).toString() }) - } - } - - foldingRangeLimit = dlv( - params.capabilities, - 'textDocument.foldingRange.rangeLimit', - Number.MAX_VALUE, - ) - - return { - capabilities: { - textDocumentSync: TextDocumentSyncKind.Full, - completionProvider: { resolveProvider: false, triggerCharacters: ['/', '-', ':'] }, - hoverProvider: true, - foldingRangeProvider: true, - colorProvider: {}, - definitionProvider: true, - documentHighlightProvider: true, - documentSymbolProvider: true, - selectionRangeProvider: true, - referencesProvider: true, - codeActionProvider: true, - documentLinkProvider: { resolveProvider: false }, - renameProvider: true, - }, - } -}) - -function getDocumentContext( - documentUri: string, - workspaceFolders: WorkspaceFolder[], -): DocumentContext { - function getRootFolder(): string | undefined { - for (let folder of workspaceFolders) { - let folderURI = folder.uri - if (!folderURI.endsWith('/')) { - folderURI = folderURI + '/' - } - if (documentUri.startsWith(folderURI)) { - return folderURI - } - } - return undefined - } - - return { - resolveReference: (ref: string, base = documentUri) => { - if (ref[0] === '/') { - // resolve absolute path against the current workspace folder - let folderUri = getRootFolder() - if (folderUri) { - return folderUri + ref.substr(1) - } - } - base = base.substr(0, base.lastIndexOf('/') + 1) - return Utils.resolvePath(URI.parse(base), ref).toString() - }, - } -} - -async function withDocumentAndSettings( - uri: string, - callback: (result: { - document: TextDocument - settings: LanguageSettings | undefined - }) => T | Promise, -): Promise { - let document = documents.get(uri) - if (!document) { - return null - } - return await callback({ - document: createVirtualCssDocument(document), - settings: await getDocumentSettings(document), - }) -} - -connection.onCompletion(async ({ textDocument, position }, _token) => - withDocumentAndSettings(textDocument.uri, async ({ document, settings }) => { - let result = await cssLanguageService.doComplete2( - document, - position, - stylesheets.get(document), - getDocumentContext(document.uri, workspaceFolders), - settings?.completion, - ) - return { - isIncomplete: result.isIncomplete, - items: result.items.flatMap((item) => { - // Add the `theme()` function - if (item.kind === CompletionItemKind.Function && item.label === 'calc()') { - return [ - item, - { - ...item, - label: 'theme()', - filterText: 'theme', - documentation: { - kind: 'markdown', - value: - 'Use the `theme()` function to access your Tailwind config values using dot notation.', - }, - command: { - title: '', - command: 'editor.action.triggerSuggest', - }, - textEdit: { - ...item.textEdit, - newText: item.textEdit.newText.replace(/^calc\(/, 'theme('), - }, - }, - ] - } - return item - }), - } - }), -) - -connection.onHover(({ textDocument, position }, _token) => - withDocumentAndSettings(textDocument.uri, ({ document, settings }) => - cssLanguageService.doHover(document, position, stylesheets.get(document), settings?.hover), - ), -) - -connection.onFoldingRanges(({ textDocument }, _token) => - withDocumentAndSettings(textDocument.uri, ({ document }) => - cssLanguageService.getFoldingRanges(document, { rangeLimit: foldingRangeLimit }), - ), -) - -connection.onDocumentColor(({ textDocument }) => - withDocumentAndSettings(textDocument.uri, ({ document }) => - cssLanguageService.findDocumentColors(document, stylesheets.get(document)), - ), -) - -connection.onColorPresentation(({ textDocument, color, range }) => - withDocumentAndSettings(textDocument.uri, ({ document }) => - cssLanguageService.getColorPresentations(document, stylesheets.get(document), color, range), - ), -) - -connection.onDefinition(({ textDocument, position }) => - withDocumentAndSettings(textDocument.uri, ({ document }) => - cssLanguageService.findDefinition(document, position, stylesheets.get(document)), - ), -) - -connection.onDocumentHighlight(({ textDocument, position }) => - withDocumentAndSettings(textDocument.uri, ({ document }) => - cssLanguageService.findDocumentHighlights(document, position, stylesheets.get(document)), - ), -) - -connection.onDocumentSymbol(({ textDocument }) => - withDocumentAndSettings(textDocument.uri, ({ document }) => - cssLanguageService.findDocumentSymbols(document, stylesheets.get(document)).map((symbol) => { - if (symbol.name === `@media (${MEDIA_MARKER})`) { - let doc = documents.get(symbol.location.uri) - let text = doc.getText(symbol.location.range) - let match = text.trim().match(/^(@[^\s]+)(?:([^{]+)[{]|([^;{]+);)/) - if (match) { - symbol.name = `${match[1]} ${match[2]?.trim() ?? match[3]?.trim()}` - } - } else if (symbol.name === `.placeholder`) { - let doc = documents.get(symbol.location.uri) - let text = doc.getText(symbol.location.range) - let match = text.trim().match(/^(@[^\s]+)(?:([^{]+)[{]|([^;{]+);)/) - if (match) { - symbol.name = `${match[1]} ${match[2]?.trim() ?? match[3]?.trim()}` - } - } - return symbol - }), - ), -) - -connection.onSelectionRanges(({ textDocument, positions }) => - withDocumentAndSettings(textDocument.uri, ({ document }) => - cssLanguageService.getSelectionRanges(document, positions, stylesheets.get(document)), - ), -) - -connection.onReferences(({ textDocument, position }) => - withDocumentAndSettings(textDocument.uri, ({ document }) => - cssLanguageService.findReferences(document, position, stylesheets.get(document)), - ), -) - -connection.onCodeAction(({ textDocument, range, context }) => - withDocumentAndSettings(textDocument.uri, ({ document }) => - cssLanguageService.doCodeActions2(document, range, context, stylesheets.get(document)), - ), -) - -connection.onDocumentLinks(({ textDocument }) => - withDocumentAndSettings(textDocument.uri, ({ document }) => - cssLanguageService.findDocumentLinks2( - document, - stylesheets.get(document), - getDocumentContext(document.uri, workspaceFolders), - ), - ), -) - -connection.onRenameRequest(({ textDocument, position, newName }) => - withDocumentAndSettings(textDocument.uri, ({ document }) => - cssLanguageService.doRename(document, position, newName, stylesheets.get(document)), - ), -) - -let documentSettings: { [key: string]: Thenable } = {} -documents.onDidClose((e) => { - delete documentSettings[e.document.uri] -}) -function getDocumentSettings(textDocument: TextDocument): Thenable { - let promise = documentSettings[textDocument.uri] - if (!promise) { - const configRequestParam = { - items: [{ scopeUri: textDocument.uri, section: 'css' }], - } - promise = connection - .sendRequest(ConfigurationRequest.type, configRequestParam) - .then((s) => s[0]) - documentSettings[textDocument.uri] = promise - } - return promise -} - -connection.onDidChangeConfiguration((change) => { - updateConfiguration(change.settings.css) -}) - -function updateConfiguration(settings: LanguageSettings) { - cssLanguageService.configure(settings) - // reset all document settings - documentSettings = {} - documents.all().forEach(triggerValidation) -} - -const pendingValidationRequests: { [uri: string]: Disposable } = {} -const validationDelayMs = 500 - -const timer = { - setTimeout(callback: (...args: any[]) => void, ms: number, ...args: any[]): Disposable { - const handle = setTimeout(callback, ms, ...args) - return { dispose: () => clearTimeout(handle) } - }, -} - -documents.onDidChangeContent((change) => { - triggerValidation(change.document) -}) - -documents.onDidClose((event) => { - cleanPendingValidation(event.document) - connection.sendDiagnostics({ uri: event.document.uri, diagnostics: [] }) -}) - -function cleanPendingValidation(textDocument: TextDocument): void { - const request = pendingValidationRequests[textDocument.uri] - if (request) { - request.dispose() - delete pendingValidationRequests[textDocument.uri] - } -} - -function triggerValidation(textDocument: TextDocument): void { - cleanPendingValidation(textDocument) - pendingValidationRequests[textDocument.uri] = timer.setTimeout(() => { - delete pendingValidationRequests[textDocument.uri] - validateTextDocument(textDocument) - }, validationDelayMs) -} - -function replace(delta = 0) { - return (_match: string, p1: string) => { - let lines = p1.split('\n') - if (lines.length > 1) { - return `@media(${MEDIA_MARKER})${'\n'.repeat(lines.length - 1)}${' '.repeat( - lines[lines.length - 1].length, - )}{` - } - return `@media(${MEDIA_MARKER})${' '.repeat(p1.length + delta)}{` - } -} -function replaceWithStyleRule(delta = 0) { - return (_match: string, p1: string) => { - let spaces = ' '.repeat(p1.length + delta) - return `.placeholder${spaces}{` - } -} - -function createVirtualCssDocument(textDocument: TextDocument): TextDocument { - let content = textDocument - .getText() - - // Remove inline `@layer` directives - // TODO: This should be unnecessary once we have updated the bundled CSS - // language service - .replace(/@layer\s+[^;{]+(;|$)/g, '') - - .replace(/@screen(\s+[^{]+){/g, replace(-2)) - .replace(/@variants(\s+[^{]+){/g, replace()) - .replace(/@responsive(\s*){/g, replace()) - .replace(/@utility(\s+[^{]+){/g, replaceWithStyleRule()) - .replace(/@custom-variant(\s+[^;{]+);/g, (match: string) => { - let spaces = ' '.repeat(match.length - 11) - return `@media (${MEDIA_MARKER})${spaces}{}` - }) - .replace(/@custom-variant(\s+[^{]+){/g, replaceWithStyleRule()) - .replace(/@variant(\s+[^{]+){/g, replaceWithStyleRule()) - .replace(/@layer(\s+[^{]{2,}){/g, replace(-3)) - .replace(/@reference\s*([^;]{2,})/g, '@import $1') - .replace( - /@media(\s+screen\s*\([^)]+\))/g, - (_match, screen) => `@media (${MEDIA_MARKER})${' '.repeat(screen.length - 4)}`, - ) - .replace( - /@import(\s*)("(?:[^"]+)"|'(?:[^']+)')\s*(.*?)(?=;|$)/g, - (_match, spaces, url, other) => { - // Remove`source(…)`, `theme(…)`, and `prefix(…)` from `@import`s - // otherwise we'll show syntax-error diagnostics which we don't want - other = other.replace(/((source|theme|prefix)\([^)]+\)\s*)+?/g, '') - - // We have to add the spaces here so the character positions line up - return `@import${spaces}"${url.slice(1, -1)}" ${other}` - }, - ) - .replace(/(?<=\b(?:theme|config)\([^)]*)[.[\]]/g, '_') - - return TextDocument.create( - textDocument.uri, - textDocument.languageId, - textDocument.version, - content, - ) -} - -async function validateTextDocument(textDocument: TextDocument): Promise { - textDocument = createVirtualCssDocument(textDocument) - - let settings = await getDocumentSettings(textDocument) - - // let stylesheet = cssLanguageService.parseStylesheet(textDocument) as any - // stylesheet.acceptVisitor({ - // visitNode(node) { - // if (node instanceof nodes.UnknownAtRule) { - // console.log( - // node.accept((node) => { - // console.log(node) - // }) - // ) - // } - // if (node.getText().includes('base')) { - // // console.log(node) - // } - // return true - // }, - // }) - - let diagnostics = cssLanguageService - .doValidation(textDocument, cssLanguageService.parseStylesheet(textDocument), settings) - .filter((diagnostic) => { - if ( - diagnostic.code === 'unknownAtRules' && - /Unknown at rule @(tailwind|apply|config|theme|plugin|source|utility|variant|custom-variant|slot)/.test( - diagnostic.message, - ) - ) { - return false - } - return true - }) - - connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }) -} - -documents.listen(connection) -connection.listen() diff --git a/packages/tailwindcss-language-server/src/language/rewriting.test.ts b/packages/tailwindcss-language-server/src/language/rewriting.test.ts new file mode 100644 index 00000000..3471d715 --- /dev/null +++ b/packages/tailwindcss-language-server/src/language/rewriting.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, test } from 'vitest' +import { rewriteCss } from './rewriting' +import dedent from 'dedent' + +// TODO: Remove once the bundled CSS language service is updated +test('@layer statements are removed', () => { + let input = [ + // + '@layer theme, base, components, utilities;', + '@import "tailwindcss";', + ] + + let output = [ + // + '', // wrong + '@import "tailwindcss" ;', + ] + + expect(rewriteCss(input.join('\n'))).toBe(output.join('\n')) +}) + +test('@layer blocks', () => { + let input = [ + // + '@layer utilities {', + ' .foo {', + ' color: red;', + ' }', + '}', + ] + + let output = [ + // + '@media(℘) {', + ' .foo {', + ' color: red;', + ' }', + '}', + ] + + expect(rewriteCss(input.join('\n'))).toBe(output.join('\n')) +}) + +test('@utility', () => { + let input = [ + // + '@utility foo {', + ' color: red;', + '}', + '@utility foo-* {', + ' color: red;', + '}', + ] + + let output = [ + // + '.placeholder {', // wrong + ' color: red;', + '}', + '.placeholder {', // wrong + ' color: red;', + '}', + ] + + expect(rewriteCss(input.join('\n'))).toBe(output.join('\n')) +}) + +test('@custom-variant', () => { + let input = [ + // + '@custom-variant foo (&:hover);', + '@custom-variant foo {', + ' &:hover {', + ' @slot;', + ' }', + '}', + ] + + let output = [ + // + '@media (℘) {}', // wrong + '.placeholder {', // wrong + ' &:hover {', + ' @slot;', + ' }', + '}', + ] + + expect(rewriteCss(input.join('\n'))).toBe(output.join('\n')) +}) + +test('@variant', () => { + let input = [ + // + '@variant foo {', + ' &:hover {', + ' @slot;', + ' }', + '}', + ] + + let output = [ + // + '.placeholder {', // wrong + ' &:hover {', + ' @slot;', + ' }', + '}', + ] + + expect(rewriteCss(input.join('\n'))).toBe(output.join('\n')) +}) + +test('@reference', () => { + let input = [ + // + '@reference "./app.css";', + '@reference "./app.css" source(none);', + ] + + let output = [ + // + '@import "./app.css" ;', // wrong + '@import "./app.css" ;', // wrong + ] + + expect(rewriteCss(input.join('\n'))).toBe(output.join('\n')) +}) + +test('@import', () => { + let input = [ + // + '@import "tailwindcss";', + '@import "tailwindcss" source(none);', + '@import "tailwindcss/utilities" layer(utilities);', + '@import "tailwindcss/utilities" layer(utilities) source(none);', + '@import "tailwindcss/utilities" layer(utilities) theme(inline);', + '@import "tailwindcss/utilities" layer(utilities) prefix(tw);', + '@import "tailwindcss/utilities" layer(utilities) source(none) theme(inline) prefix(tw);', + ] + + let output = [ + // + '@import "tailwindcss" ;', // wrong + '@import "tailwindcss" ;', // wrong + '@import "tailwindcss/utilities" layer(utilities);', + '@import "tailwindcss/utilities" layer(utilities) ;', // wrong + '@import "tailwindcss/utilities" layer(utilities) ;', // wrong + '@import "tailwindcss/utilities" layer(utilities) ;', // wrong + '@import "tailwindcss/utilities" layer(utilities) ;', // wrong + ] + + expect(rewriteCss(input.join('\n'))).toBe(output.join('\n')) +}) + +describe('v3', () => { + test('@screen', () => { + let input = [ + // + '@screen sm {', + ' .foo {', + ' color: red;', + ' }', + '}', + ] + + let output = [ + // + '@media(℘) {', + ' .foo {', + ' color: red;', + ' }', + '}', + ] + + expect(rewriteCss(input.join('\n'))).toBe(output.join('\n')) + }) + + test('@variants', () => { + let input = [ + // + '@variants focus, hover {', + ' .foo {', + ' color: red;', + ' }', + '}', + ] + + let output = [ + // + '@media(℘) {', + ' .foo {', + ' color: red;', + ' }', + '}', + ] + + expect(rewriteCss(input.join('\n'))).toBe(output.join('\n')) + }) + + test('@responsive', () => { + let input = [ + // + '@responsive {', + ' .foo {', + ' color: red;', + ' }', + '}', + ] + + let output = [ + // + '@media(℘) {', // todo wrong + ' .foo {', + ' color: red;', + ' }', + '}', + ] + + expect(rewriteCss(input.join('\n'))).toBe(output.join('\n')) + }) + + test('@responsive', () => { + let input = [ + // + '@responsive {', + ' .foo {', + ' color: red;', + ' }', + '}', + ] + + let output = [ + // + '@media(℘) {', // incorrect + ' .foo {', + ' color: red;', + ' }', + '}', + ] + + expect(rewriteCss(input.join('\n'))).toBe(output.join('\n')) + }) + + test('@media screen(…)', () => { + let input = [ + // + '@media screen(sm) {', + ' .foo {', + ' color: red;', + ' }', + '}', + ] + + let output = [ + // + '@media (℘) {', + ' .foo {', + ' color: red;', + ' }', + '}', + ] + + expect(rewriteCss(input.join('\n'))).toBe(output.join('\n')) + }) + + test('theme(keypath) + config(keypath)', () => { + let input = [ + // + '.foo {', + ' width: calc(1rem * theme(colors.red[500]));', + ' height: calc(1rem * config(screens.mobile.[sm]));', + '}', + ] + + let output = [ + // + '.foo {', + ' width: calc(1rem * theme(colors_red_500_));', + ' height: calc(1rem * config(screens_mobile__sm_));', + '}', + ] + + expect(rewriteCss(input.join('\n'))).toBe(output.join('\n')) + }) +}) diff --git a/packages/tailwindcss-language-server/src/language/rewriting.ts b/packages/tailwindcss-language-server/src/language/rewriting.ts new file mode 100644 index 00000000..35ae4bf7 --- /dev/null +++ b/packages/tailwindcss-language-server/src/language/rewriting.ts @@ -0,0 +1,71 @@ +const MEDIA_MARKER = '℘' + +function replaceWithAtRule(delta = 0) { + return (_match: string, p1: string) => { + let lines = p1.split('\n') + if (lines.length > 1) { + return `@media(${MEDIA_MARKER})${'\n'.repeat(lines.length - 1)}${' '.repeat( + lines[lines.length - 1].length, + )}{` + } + + return `@media(${MEDIA_MARKER})${' '.repeat(p1.length + delta)}{` + } +} + +function replaceWithStyleRule(delta = 0) { + return (_match: string, p1: string) => { + let spaces = ' '.repeat(p1.length + delta) + return `.placeholder${spaces}{` + } +} + +/** + * Rewrites the given CSS to be more compatible with the CSS language service + * + * The VSCode CSS language service doesn't understand our custom at-rules, nor + * our custom functions and minor syntax tweaks. This means it will show syntax + * errors for things that aren't actually errors. + */ +export function rewriteCss(css: string) { + // Remove inline `@layer` directives + // TODO: This should be unnecessary once we have updated the bundled CSS + // language service + css = css.replace(/@layer\s+[^;{]+(;|$)/g, '') + + css = css.replace(/@screen(\s+[^{]+){/g, replaceWithAtRule(-2)) + css = css.replace(/@variants(\s+[^{]+){/g, replaceWithAtRule()) + css = css.replace(/@responsive(\s*){/g, replaceWithAtRule()) + css = css.replace(/@utility(\s+[^{]+){/g, replaceWithStyleRule()) + + css = css.replace(/@custom-variant(\s+[^;{]+);/g, (match: string) => { + let spaces = ' '.repeat(match.length - 11) + return `@media (${MEDIA_MARKER})${spaces}{}` + }) + + css = css.replace(/@custom-variant(\s+[^{]+){/g, replaceWithStyleRule()) + css = css.replace(/@variant(\s+[^{]+){/g, replaceWithStyleRule()) + css = css.replace(/@layer(\s+[^{]{2,}){/g, replaceWithAtRule(-3)) + css = css.replace(/@reference\s*([^;]{2,})/g, '@import $1') + + css = css.replace( + /@media(\s+screen\s*\([^)]+\))/g, + (_match, screen) => `@media (${MEDIA_MARKER})${' '.repeat(screen.length - 4)}`, + ) + + css = css.replace( + /@import(\s*)("(?:[^"]+)"|'(?:[^']+)')\s*(.*?)(?=;|$)/g, + (_match, spaces, url, other) => { + // Remove`source(…)`, `theme(…)`, and `prefix(…)` from `@import`s + // otherwise we'll show syntax-error diagnostics which we don't want + other = other.replace(/((source|theme|prefix)\([^)]+\)\s*)+?/g, '') + + // We have to add the spaces here so the character positions line up + return `@import${spaces}"${url.slice(1, -1)}" ${other}` + }, + ) + + css = css.replace(/(?<=\b(?:theme|config)\([^)]*)[.[\]]/g, '_') + + return css +} diff --git a/packages/tailwindcss-language-server/tests/css/css-server.test.ts b/packages/tailwindcss-language-server/tests/css/css-server.test.ts new file mode 100644 index 00000000..b4093acf --- /dev/null +++ b/packages/tailwindcss-language-server/tests/css/css-server.test.ts @@ -0,0 +1,624 @@ +import { expect } from 'vitest' +import { css, defineTest } from '../../src/testing' +import { createClient } from '../utils/client' +import { SymbolKind } from 'vscode-languageserver' + +defineTest({ + name: '@custom-variant', + prepare: async ({ root }) => ({ + client: await createClient({ + server: 'css', + root, + }), + }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'tailwindcss', + name: 'file-1.css', + text: css` + @import 'tailwindcss'; + @custom-variant foo (&:hover); + @custom-variant bar { + &:hover { + @slot; + } + } + `, + }) + + // No errors + expect(await doc.diagnostics()).toEqual([]) + + // Symbols show up for @custom-variant + expect(await doc.symbols()).toMatchObject([ + { + kind: SymbolKind.Module, + name: '@custom-variant foo (&:hover)', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 31 }, + }, + }, + }, + { + kind: SymbolKind.Class, + name: '@custom-variant bar', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 2, character: 0 }, + end: { line: 6, character: 1 }, + }, + }, + }, + { + kind: SymbolKind.Class, + name: '&:hover', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 3, character: 2 }, + end: { line: 5, character: 3 }, + }, + }, + }, + ]) + }, +}) + +defineTest({ + name: '@variant', + prepare: async ({ root }) => ({ + client: await createClient({ + server: 'css', + root, + }), + }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'tailwindcss', + name: 'file-1.css', + text: css` + @import 'tailwindcss'; + @variant dark { + .foo { + color: black; + } + } + .bar { + @variant dark { + color: black; + } + } + `, + }) + + // No errors + expect(await doc.diagnostics()).toEqual([]) + + // Symbols show up for @custom-variant + expect(await doc.symbols()).toMatchObject([ + { + kind: SymbolKind.Class, + name: '@variant dark', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 1, character: 0 }, + end: { line: 5, character: 1 }, + }, + }, + }, + { + kind: SymbolKind.Class, + name: '.foo', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 2, character: 2 }, + end: { line: 4, character: 3 }, + }, + }, + }, + { + kind: SymbolKind.Class, + name: '.bar', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 6, character: 0 }, + end: { line: 10, character: 1 }, + }, + }, + }, + { + kind: SymbolKind.Class, + name: '@variant dark', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 7, character: 2 }, + end: { line: 9, character: 3 }, + }, + }, + }, + ]) + }, +}) + +defineTest({ + name: '@utility', + prepare: async ({ root }) => ({ + client: await createClient({ + server: 'css', + root, + }), + }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'tailwindcss', + name: 'file-1.css', + text: css` + @import 'tailwindcss'; + @utility example { + color: black; + } + @utility tab-size-* { + tab-size: --value(--tab-size); + } + `, + }) + + // No errors + expect(await doc.diagnostics()).toEqual([]) + + // Symbols show up for @custom-variant + expect(await doc.symbols()).toMatchObject([ + { + kind: SymbolKind.Class, + name: '@utility example', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 1, character: 0 }, + end: { line: 3, character: 1 }, + }, + }, + }, + { + kind: SymbolKind.Class, + name: '@utility tab-size-*', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 4, character: 0 }, + end: { line: 6, character: 1 }, + }, + }, + }, + ]) + }, +}) + +defineTest({ + name: '@layer statement', + prepare: async ({ root }) => ({ + client: await createClient({ + server: 'css', + root, + }), + }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'tailwindcss', + name: 'file-1.css', + text: css` + @layer theme, base, components, utilities; + @import 'tailwindcss'; + @theme { + --color-primary: #333; + } + `, + }) + + expect(await doc.diagnostics()).toEqual([]) + }, +}) + +defineTest({ + name: '@import functions', + prepare: async ({ root }) => ({ + client: await createClient({ + server: 'css', + root, + }), + }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'tailwindcss', + name: 'file-1.css', + text: css` + @import 'tailwindcss' layer(foo) source('../'); + @import 'tailwindcss' theme(inline); + @import 'tailwindcss' prefix(tw); + @import 'tailwindcss' layer(foo) source('../') theme(inline) prefix(tw); + `, + }) + + expect(await doc.diagnostics()).toEqual([]) + expect(await doc.links()).toEqual([ + { + target: '{workspace:default}/tailwindcss', + range: { + start: { line: 0, character: 8 }, + end: { line: 0, character: 21 }, + }, + }, + { + target: '{workspace:default}/tailwindcss', + range: { + start: { line: 1, character: 8 }, + end: { line: 1, character: 21 }, + }, + }, + { + target: '{workspace:default}/tailwindcss', + range: { + start: { line: 2, character: 8 }, + end: { line: 2, character: 21 }, + }, + }, + { + target: '{workspace:default}/tailwindcss', + range: { + start: { line: 3, character: 8 }, + end: { line: 3, character: 21 }, + }, + }, + ]) + }, +}) + +defineTest({ + name: '@reference', + prepare: async ({ root }) => ({ + client: await createClient({ + server: 'css', + root, + }), + }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'tailwindcss', + name: 'file-1.css', + text: css` + @reference 'tailwindcss'; + `, + }) + + expect(await doc.diagnostics()).toEqual([]) + expect(await doc.links()).toEqual([ + { + target: '{workspace:default}/tailwindcss', + range: { + start: { line: 0, character: 11 }, + end: { line: 0, character: 24 }, + }, + }, + ]) + }, +}) + +// Legacy +defineTest({ + name: '@screen', + prepare: async ({ root }) => ({ + client: await createClient({ + server: 'css', + root, + }), + }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'tailwindcss', + name: 'file-1.css', + text: css` + @screen sm { + .foo { + color: red; + } + } + `, + }) + + // No errors + expect(await doc.diagnostics()).toEqual([]) + + // Symbols show up for @custom-variant + expect(await doc.symbols()).toMatchObject([ + { + kind: SymbolKind.Module, + name: '@screen sm', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 0, character: 0 }, + end: { line: 4, character: 1 }, + }, + }, + }, + { + kind: SymbolKind.Class, + name: '.foo', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 1, character: 2 }, + end: { line: 3, character: 3 }, + }, + }, + }, + ]) + }, +}) + +defineTest({ + name: '@variants', + prepare: async ({ root }) => ({ + client: await createClient({ + server: 'css', + root, + }), + }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'tailwindcss', + name: 'file-1.css', + text: css` + @variants hover, focus { + .foo { + color: red; + } + } + `, + }) + + // No errors + expect(await doc.diagnostics()).toEqual([]) + + // Symbols show up for @custom-variant + expect(await doc.symbols()).toMatchObject([ + { + kind: SymbolKind.Module, + name: '@variants hover, focus', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 0, character: 0 }, + end: { line: 4, character: 1 }, + }, + }, + }, + { + kind: SymbolKind.Class, + name: '.foo', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 1, character: 2 }, + end: { line: 3, character: 3 }, + }, + }, + }, + ]) + }, +}) + +defineTest({ + name: '@responsive', + prepare: async ({ root }) => ({ + client: await createClient({ + server: 'css', + root, + }), + }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'tailwindcss', + name: 'file-1.css', + text: css` + @responsive { + .foo { + color: red; + } + } + `, + }) + + // No errors + expect(await doc.diagnostics()).toEqual([]) + + // Symbols show up for @custom-variant + expect(await doc.symbols()).toMatchObject([ + { + kind: SymbolKind.Module, + name: '@responsive ', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 0, character: 0 }, + end: { line: 4, character: 1 }, + }, + }, + }, + { + kind: SymbolKind.Class, + name: '.foo', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 1, character: 2 }, + end: { line: 3, character: 3 }, + }, + }, + }, + ]) + }, +}) + +defineTest({ + name: '@media screen(name)', + prepare: async ({ root }) => ({ + client: await createClient({ + server: 'css', + root, + }), + }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'tailwindcss', + name: 'file-1.css', + text: css` + @media screen(sm) { + .foo { + color: red; + } + } + `, + }) + + // No errors + expect(await doc.diagnostics()).toEqual([]) + + // Symbols show up for @custom-variant + expect(await doc.symbols()).toMatchObject([ + { + kind: SymbolKind.Module, + name: '@media screen(sm)', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 0, character: 0 }, + end: { line: 4, character: 1 }, + }, + }, + }, + { + kind: SymbolKind.Class, + name: '.foo', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 1, character: 2 }, + end: { line: 3, character: 3 }, + }, + }, + }, + ]) + }, +}) + +defineTest({ + name: '@layer', + prepare: async ({ root }) => ({ + client: await createClient({ + server: 'css', + root, + }), + }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'tailwindcss', + name: 'file-1.css', + text: css` + @layer base { + .foo { + color: red; + } + } + @layer utilities { + .bar { + color: red; + } + } + `, + }) + + // No errors + expect(await doc.diagnostics()).toEqual([]) + + // Symbols show up for @custom-variant + expect(await doc.symbols()).toMatchObject([ + { + kind: SymbolKind.Module, + name: '@layer base', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 0, character: 0 }, + end: { line: 4, character: 1 }, + }, + }, + }, + { + kind: SymbolKind.Class, + name: '.foo', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 1, character: 2 }, + end: { line: 3, character: 3 }, + }, + }, + }, + { + kind: SymbolKind.Module, + name: '@layer utilities', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 5, character: 0 }, + end: { line: 9, character: 1 }, + }, + }, + }, + { + kind: SymbolKind.Class, + name: '.bar', + location: { + uri: '{workspace:default}/file-1.css', + range: { + start: { line: 6, character: 2 }, + end: { line: 8, character: 3 }, + }, + }, + }, + ]) + }, +}) + +defineTest({ + name: 'theme(keypath) + config(keypath)', + prepare: async ({ root }) => ({ + client: await createClient({ + server: 'css', + root, + }), + }), + handle: async ({ client }) => { + let doc = await client.open({ + lang: 'tailwindcss', + name: 'file-1.css', + text: css` + .foo { + width: calc(1rem * theme(colors.red[500])); + height: calc(1rem * config(screens.mobile.[sm])); + } + `, + }) + + expect(await doc.diagnostics()).toEqual([]) + }, +}) diff --git a/packages/tailwindcss-language-server/tests/utils/connection.ts b/packages/tailwindcss-language-server/tests/utils/connection.ts index f0eaa999..510c44b7 100644 --- a/packages/tailwindcss-language-server/tests/utils/connection.ts +++ b/packages/tailwindcss-language-server/tests/utils/connection.ts @@ -3,6 +3,7 @@ import { createConnection } from 'vscode-languageserver/node' import type { ProtocolConnection } from 'vscode-languageclient/node' import { Duplex, type Readable, type Writable } from 'node:stream' import { TW } from '../../src/tw' +import { CssServer } from '../../src/language/css-server' class TestStream extends Duplex { _write(chunk: string, _encoding: string, done: () => void) { @@ -18,6 +19,10 @@ const SERVERS = { ServerClass: TW, binaryPath: './bin/tailwindcss-language-server', }, + css: { + ServerClass: CssServer, + binaryPath: './bin/css-language-server', + }, } export interface ConnectOptions { diff --git a/packages/vscode-tailwindcss/src/cssServer.ts b/packages/vscode-tailwindcss/src/cssServer.ts index ff5a7591..e49258db 100644 --- a/packages/vscode-tailwindcss/src/cssServer.ts +++ b/packages/vscode-tailwindcss/src/cssServer.ts @@ -1 +1 @@ -import '@tailwindcss/language-server/src/language/cssServer' +import '@tailwindcss/language-server/src/language/css'