Add Tailwind CSS language mode (#518)
* Use `esbuild` * Replace direct `eval` * add initial language mode * Update capabilities, add rename provider * Update vscode types * Add completion middleware to match built-in CSS provider * Update language config to match built-in CSS language * Add folding region completion provider * Add stylesheet cache * Add log and error handling * Update settings handling, debounce validation * Fix response flakiness by always augmenting CSS * Respect folding range limit * Use uncommon symbol as `@media` placeholder * Update readme * Use `esbuild` for language server build * Add `theme()` completion item * Avoid errors when using `@media screen()` * Update readme * Add CSS server to build script * Rename language mode server file in prod * Update VS Code `engines` and types versions * Update grammarmaster
parent
0a6e5def00
commit
fbbd4bc2d0
File diff suppressed because it is too large
Load Diff
|
@ -29,7 +29,7 @@
|
||||||
"@tailwindcss/typography": "0.5.0",
|
"@tailwindcss/typography": "0.5.0",
|
||||||
"@types/debounce": "1.2.0",
|
"@types/debounce": "1.2.0",
|
||||||
"@types/node": "14.14.34",
|
"@types/node": "14.14.34",
|
||||||
"@types/vscode": "1.52.0",
|
"@types/vscode": "1.60.0",
|
||||||
"builtin-modules": "3.2.0",
|
"builtin-modules": "3.2.0",
|
||||||
"chokidar": "3.5.1",
|
"chokidar": "3.5.1",
|
||||||
"color-name": "1.1.4",
|
"color-name": "1.1.4",
|
||||||
|
@ -57,6 +57,7 @@
|
||||||
"tailwindcss": "3.0.11",
|
"tailwindcss": "3.0.11",
|
||||||
"terser": "4.6.12",
|
"terser": "4.6.12",
|
||||||
"typescript": "4.2.4",
|
"typescript": "4.2.4",
|
||||||
|
"vscode-css-languageservice": "5.4.1",
|
||||||
"vscode-languageserver": "7.0.0",
|
"vscode-languageserver": "7.0.0",
|
||||||
"vscode-languageserver-textdocument": "1.0.1",
|
"vscode-languageserver-textdocument": "1.0.1",
|
||||||
"vscode-uri": "3.0.2"
|
"vscode-uri": "3.0.2"
|
||||||
|
|
|
@ -0,0 +1,401 @@
|
||||||
|
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'
|
||||||
|
|
||||||
|
let connection = createConnection(ProposedFeatures.all)
|
||||||
|
|
||||||
|
console.log = connection.console.log.bind(connection.console)
|
||||||
|
console.error = connection.console.error.bind(connection.console)
|
||||||
|
|
||||||
|
function formatError(message: string, err: any): string {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
let error = <Error>err
|
||||||
|
return `${message}: ${error.message}\n${error.stack}`
|
||||||
|
} else if (typeof err === 'string') {
|
||||||
|
return `${message}: ${err}`
|
||||||
|
} else if (err) {
|
||||||
|
return `${message}: ${err.toString()}`
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (e: any) => {
|
||||||
|
connection.console.error(formatError(`Unhandled exception`, e))
|
||||||
|
})
|
||||||
|
|
||||||
|
let documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument)
|
||||||
|
|
||||||
|
let cssLanguageService = getCSSLanguageService()
|
||||||
|
|
||||||
|
let workspaceFolders: WorkspaceFolder[]
|
||||||
|
|
||||||
|
let foldingRangeLimit = Number.MAX_VALUE
|
||||||
|
const MEDIA_MARKER = '℘'
|
||||||
|
|
||||||
|
const stylesheets = getLanguageModelCache<Stylesheet>(10, 60, (document) =>
|
||||||
|
cssLanguageService.parseStylesheet(document)
|
||||||
|
)
|
||||||
|
documents.onDidClose(({ document }) => {
|
||||||
|
stylesheets.onDocumentRemoved(document)
|
||||||
|
})
|
||||||
|
connection.onShutdown(() => {
|
||||||
|
stylesheets.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
connection.onInitialize((params: InitializeParams) => {
|
||||||
|
workspaceFolders = (<any>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<T>(
|
||||||
|
uri: string,
|
||||||
|
callback: (result: {
|
||||||
|
document: TextDocument
|
||||||
|
settings: LanguageSettings | undefined
|
||||||
|
}) => T | Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
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()',
|
||||||
|
documentation: {
|
||||||
|
kind: 'markdown',
|
||||||
|
value:
|
||||||
|
'Use the `theme()` function to access your Tailwind config values using dot notation.',
|
||||||
|
},
|
||||||
|
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()}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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<LanguageSettings | undefined> } = {}
|
||||||
|
documents.onDidClose((e) => {
|
||||||
|
delete documentSettings[e.document.uri]
|
||||||
|
})
|
||||||
|
function getDocumentSettings(textDocument: TextDocument): Thenable<LanguageSettings | undefined> {
|
||||||
|
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(<LanguageSettings>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 createVirtualCssDocument(textDocument: TextDocument): TextDocument {
|
||||||
|
return TextDocument.create(
|
||||||
|
textDocument.uri,
|
||||||
|
textDocument.languageId,
|
||||||
|
textDocument.version,
|
||||||
|
textDocument
|
||||||
|
.getText()
|
||||||
|
.replace(/@screen(\s+[^{]+){/g, replace(-2))
|
||||||
|
.replace(/@variants(\s+[^{]+){/g, replace())
|
||||||
|
.replace(/@responsive(\s*){/g, replace())
|
||||||
|
.replace(/@layer(\s+[^{]{2,}){/g, replace(-3))
|
||||||
|
.replace(
|
||||||
|
/@media(\s+screen\s*\([^)]+\))/g,
|
||||||
|
(_match, screen) => `@media (${MEDIA_MARKER})${' '.repeat(screen.length - 4)}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateTextDocument(textDocument: TextDocument): Promise<void> {
|
||||||
|
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)/.test(diagnostic.message)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
connection.sendDiagnostics({ uri: textDocument.uri, diagnostics })
|
||||||
|
}
|
||||||
|
|
||||||
|
documents.listen(connection)
|
||||||
|
connection.listen()
|
|
@ -0,0 +1,91 @@
|
||||||
|
/*---------------------------------------------------------------------------------------------
|
||||||
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||||
|
* Licensed under the MIT License.
|
||||||
|
*--------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
import { TextDocument } from 'vscode-css-languageservice'
|
||||||
|
|
||||||
|
export interface LanguageModelCache<T> {
|
||||||
|
get(document: TextDocument): T
|
||||||
|
onDocumentRemoved(document: TextDocument): void
|
||||||
|
dispose(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLanguageModelCache<T>(
|
||||||
|
maxEntries: number,
|
||||||
|
cleanupIntervalTimeInSec: number,
|
||||||
|
parse: (document: TextDocument) => T
|
||||||
|
): LanguageModelCache<T> {
|
||||||
|
let languageModels: {
|
||||||
|
[uri: string]: { version: number; languageId: string; cTime: number; languageModel: T }
|
||||||
|
} = {}
|
||||||
|
let nModels = 0
|
||||||
|
|
||||||
|
let cleanupInterval: NodeJS.Timer | undefined = undefined
|
||||||
|
if (cleanupIntervalTimeInSec > 0) {
|
||||||
|
cleanupInterval = setInterval(() => {
|
||||||
|
let cutoffTime = Date.now() - cleanupIntervalTimeInSec * 1000
|
||||||
|
let uris = Object.keys(languageModels)
|
||||||
|
for (let uri of uris) {
|
||||||
|
let languageModelInfo = languageModels[uri]
|
||||||
|
if (languageModelInfo.cTime < cutoffTime) {
|
||||||
|
delete languageModels[uri]
|
||||||
|
nModels--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, cleanupIntervalTimeInSec * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get(document: TextDocument): T {
|
||||||
|
let version = document.version
|
||||||
|
let languageId = document.languageId
|
||||||
|
let languageModelInfo = languageModels[document.uri]
|
||||||
|
if (
|
||||||
|
languageModelInfo &&
|
||||||
|
languageModelInfo.version === version &&
|
||||||
|
languageModelInfo.languageId === languageId
|
||||||
|
) {
|
||||||
|
languageModelInfo.cTime = Date.now()
|
||||||
|
return languageModelInfo.languageModel
|
||||||
|
}
|
||||||
|
let languageModel = parse(document)
|
||||||
|
languageModels[document.uri] = { languageModel, version, languageId, cTime: Date.now() }
|
||||||
|
if (!languageModelInfo) {
|
||||||
|
nModels++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nModels === maxEntries) {
|
||||||
|
let oldestTime = Number.MAX_VALUE
|
||||||
|
let oldestUri = null
|
||||||
|
for (let uri in languageModels) {
|
||||||
|
let languageModelInfo = languageModels[uri]
|
||||||
|
if (languageModelInfo.cTime < oldestTime) {
|
||||||
|
oldestUri = uri
|
||||||
|
oldestTime = languageModelInfo.cTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldestUri) {
|
||||||
|
delete languageModels[oldestUri]
|
||||||
|
nModels--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return languageModel
|
||||||
|
},
|
||||||
|
onDocumentRemoved(document: TextDocument) {
|
||||||
|
let uri = document.uri
|
||||||
|
if (languageModels[uri]) {
|
||||||
|
delete languageModels[uri]
|
||||||
|
nModels--
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
if (typeof cleanupInterval !== 'undefined') {
|
||||||
|
clearInterval(cleanupInterval)
|
||||||
|
cleanupInterval = undefined
|
||||||
|
languageModels = {}
|
||||||
|
nModels = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -32,7 +32,16 @@ export const htmlLanguages = [
|
||||||
'twig',
|
'twig',
|
||||||
]
|
]
|
||||||
|
|
||||||
export const cssLanguages = ['css', 'less', 'postcss', 'sass', 'scss', 'stylus', 'sugarss']
|
export const cssLanguages = [
|
||||||
|
'css',
|
||||||
|
'less',
|
||||||
|
'postcss',
|
||||||
|
'sass',
|
||||||
|
'scss',
|
||||||
|
'stylus',
|
||||||
|
'sugarss',
|
||||||
|
'tailwindcss',
|
||||||
|
]
|
||||||
|
|
||||||
export const jsLanguages = [
|
export const jsLanguages = [
|
||||||
'javascript',
|
'javascript',
|
||||||
|
|
|
@ -28,18 +28,24 @@ See the complete CSS for a Tailwind class name by hovering over it.
|
||||||
|
|
||||||
<img src="https://raw.githubusercontent.com/bradlc/vscode-tailwindcss/master/packages/vscode-tailwindcss/.github/hover.png" alt="" />
|
<img src="https://raw.githubusercontent.com/bradlc/vscode-tailwindcss/master/packages/vscode-tailwindcss/.github/hover.png" alt="" />
|
||||||
|
|
||||||
### CSS Syntax Highlighting
|
### Tailwind CSS Language Mode
|
||||||
|
|
||||||
Provides syntax definitions so that Tailwind features are highlighted correctly.
|
An alternative to VS Code's built-in CSS language mode which maintains full CSS IntelliSense support even when using Tailwind-specific at-rules. Syntax definitions are also provided so that Tailwind-specific syntax is highlighted correctly in all CSS contexts.
|
||||||
|
|
||||||
## Recommended VS Code Settings
|
## Recommended VS Code Settings
|
||||||
|
|
||||||
VS Code has built-in CSS validation which may display errors when using Tailwind-specific syntax, such as `@apply`. You can disable this with the `css.validate` setting:
|
### `files.associations`
|
||||||
|
|
||||||
|
Use the `files.associations` setting to tell VS Code to always open `.css` files in Tailwind CSS mode:
|
||||||
|
|
||||||
```
|
```
|
||||||
"css.validate": false
|
"files.associations": {
|
||||||
|
"*.css": "tailwindcss"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `editor.quickSuggestions`
|
||||||
|
|
||||||
By default VS Code will not trigger completions when editing "string" content, for example within JSX attribute values. Updating the `editor.quickSuggestions` setting may improve your experience:
|
By default VS Code will not trigger completions when editing "string" content, for example within JSX attribute values. Updating the `editor.quickSuggestions` setting may improve your experience:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
@ -25,7 +25,7 @@
|
||||||
"vscode"
|
"vscode"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"vscode": "^1.52.0"
|
"vscode": "^1.60.0"
|
||||||
},
|
},
|
||||||
"categories": [
|
"categories": [
|
||||||
"Linters",
|
"Linters",
|
||||||
|
@ -43,6 +43,21 @@
|
||||||
"virtualWorkspaces": false
|
"virtualWorkspaces": false
|
||||||
},
|
},
|
||||||
"contributes": {
|
"contributes": {
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"id": "tailwindcss",
|
||||||
|
"aliases": [
|
||||||
|
"Tailwind CSS"
|
||||||
|
],
|
||||||
|
"extensions": [
|
||||||
|
".css"
|
||||||
|
],
|
||||||
|
"mimetypes": [
|
||||||
|
"text/css"
|
||||||
|
],
|
||||||
|
"configuration": "./tailwindcss.language.configuration.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
"commands": [
|
"commands": [
|
||||||
{
|
{
|
||||||
"command": "tailwindCSS.showOutput",
|
"command": "tailwindCSS.showOutput",
|
||||||
|
@ -51,6 +66,14 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grammars": [
|
"grammars": [
|
||||||
|
{
|
||||||
|
"language": "tailwindcss",
|
||||||
|
"scopeName": "source.css.tailwind",
|
||||||
|
"path": "./syntaxes/source.css.tailwind.tmLanguage.json",
|
||||||
|
"tokenTypes": {
|
||||||
|
"meta.function.url string.quoted": "other"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"scopeName": "tailwindcss.at-rules.injection",
|
"scopeName": "tailwindcss.at-rules.injection",
|
||||||
"path": "./syntaxes/at-rules.tmLanguage.json",
|
"path": "./syntaxes/at-rules.tmLanguage.json",
|
||||||
|
@ -258,10 +281,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"_esbuild": "node ../../esbuild.js src/extension.ts src/server.ts --outdir=dist --external=pnpapi --external=vscode",
|
"_esbuild": "node ../../esbuild.js src/extension.ts src/server.ts src/cssServer.ts --outdir=dist --external=pnpapi --external=vscode",
|
||||||
"dev": "concurrently --raw --kill-others \"npm run watch\" \"npm run check -- --watch\"",
|
"dev": "concurrently --raw --kill-others \"npm run watch\" \"npm run check -- --watch\"",
|
||||||
"watch": "npm run clean && npm run _esbuild -- --watch",
|
"watch": "npm run clean && npm run _esbuild -- --watch",
|
||||||
"build": "npm run check && npm run clean && npm run _esbuild -- --minify && mv dist/server.js dist/tailwindServer.js",
|
"build": "npm run check && npm run clean && npm run _esbuild -- --minify && mv dist/server.js dist/tailwindServer.js && mv dist/cssServer.js dist/tailwindModeServer.js",
|
||||||
"package": "vsce package",
|
"package": "vsce package",
|
||||||
"publish": "vsce publish",
|
"publish": "vsce publish",
|
||||||
"copy:notices": "cp ../tailwindcss-language-server/ThirdPartyNotices.txt ./dist/ThirdPartyNotices.txt",
|
"copy:notices": "cp ../tailwindcss-language-server/ThirdPartyNotices.txt ./dist/ThirdPartyNotices.txt",
|
||||||
|
@ -271,7 +294,7 @@
|
||||||
"check": "tsc --noEmit"
|
"check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/vscode": "1.52.0",
|
"@types/vscode": "1.60.0",
|
||||||
"color-name": "1.1.4",
|
"color-name": "1.1.4",
|
||||||
"concurrently": "7.0.0",
|
"concurrently": "7.0.0",
|
||||||
"rimraf": "3.0.2",
|
"rimraf": "3.0.2",
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
import 'tailwindcss-language-server/src/language/cssServer'
|
|
@ -6,6 +6,7 @@ import * as path from 'path'
|
||||||
import {
|
import {
|
||||||
workspace as Workspace,
|
workspace as Workspace,
|
||||||
window as Window,
|
window as Window,
|
||||||
|
languages as Languages,
|
||||||
ExtensionContext,
|
ExtensionContext,
|
||||||
TextDocument,
|
TextDocument,
|
||||||
OutputChannel,
|
OutputChannel,
|
||||||
|
@ -19,6 +20,12 @@ import {
|
||||||
RelativePattern,
|
RelativePattern,
|
||||||
ConfigurationScope,
|
ConfigurationScope,
|
||||||
WorkspaceConfiguration,
|
WorkspaceConfiguration,
|
||||||
|
CompletionItem,
|
||||||
|
CompletionItemKind,
|
||||||
|
CompletionList,
|
||||||
|
ProviderResult,
|
||||||
|
SnippetString,
|
||||||
|
TextEdit,
|
||||||
} from 'vscode'
|
} from 'vscode'
|
||||||
import {
|
import {
|
||||||
LanguageClient,
|
LanguageClient,
|
||||||
|
@ -27,6 +34,7 @@ import {
|
||||||
TransportKind,
|
TransportKind,
|
||||||
State as LanguageClientState,
|
State as LanguageClientState,
|
||||||
RevealOutputChannelOn,
|
RevealOutputChannelOn,
|
||||||
|
Disposable,
|
||||||
} from 'vscode-languageclient/node'
|
} from 'vscode-languageclient/node'
|
||||||
import { languages as defaultLanguages } from 'tailwindcss-language-service/src/util/languages'
|
import { languages as defaultLanguages } from 'tailwindcss-language-service/src/util/languages'
|
||||||
import isObject from 'tailwindcss-language-service/src/util/isObject'
|
import isObject from 'tailwindcss-language-service/src/util/isObject'
|
||||||
|
@ -184,6 +192,117 @@ export async function activate(context: ExtensionContext) {
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
client.onReady().then(() => {
|
||||||
|
context.subscriptions.push(initCompletionProvider())
|
||||||
|
})
|
||||||
|
|
||||||
|
context.subscriptions.push(client.start())
|
||||||
|
|
||||||
|
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) {
|
function bootWorkspaceClient(folder: WorkspaceFolder) {
|
||||||
if (clients.has(folder.uri.toString())) {
|
if (clients.has(folder.uri.toString())) {
|
||||||
return
|
return
|
||||||
|
@ -282,7 +401,8 @@ export async function activate(context: ExtensionContext) {
|
||||||
return { range, newText: result.additionalTextEdits[0].newText }
|
return { range, newText: result.additionalTextEdits[0].newText }
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
result.insertText = result.label
|
result.insertText =
|
||||||
|
typeof result.label === 'string' ? result.label : result.label.label
|
||||||
result.additionalTextEdits = []
|
result.additionalTextEdits = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -401,6 +521,10 @@ export async function activate(context: ExtensionContext) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function didOpenTextDocument(document: TextDocument): Promise<void> {
|
async function didOpenTextDocument(document: TextDocument): Promise<void> {
|
||||||
|
if (document.languageId === 'tailwindcss') {
|
||||||
|
bootCssServer()
|
||||||
|
}
|
||||||
|
|
||||||
// We are only interested in language mode text
|
// We are only interested in language mode text
|
||||||
if (document.uri.scheme !== 'file') {
|
if (document.uri.scheme !== 'file') {
|
||||||
return
|
return
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "TailwindCSS",
|
||||||
|
"scopeName": "source.css.tailwind",
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"include": "source.css"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"comments": {
|
||||||
|
"blockComment": ["/*", "*/"]
|
||||||
|
},
|
||||||
|
"brackets": [
|
||||||
|
["{", "}"],
|
||||||
|
["[", "]"],
|
||||||
|
["(", ")"]
|
||||||
|
],
|
||||||
|
"autoClosingPairs": [
|
||||||
|
{ "open": "{", "close": "}", "notIn": ["string", "comment"] },
|
||||||
|
{ "open": "[", "close": "]", "notIn": ["string", "comment"] },
|
||||||
|
{ "open": "(", "close": ")", "notIn": ["string", "comment"] },
|
||||||
|
{ "open": "\"", "close": "\"", "notIn": ["string", "comment"] },
|
||||||
|
{ "open": "'", "close": "'", "notIn": ["string", "comment"] }
|
||||||
|
],
|
||||||
|
"surroundingPairs": [
|
||||||
|
["{", "}"],
|
||||||
|
["[", "]"],
|
||||||
|
["(", ")"],
|
||||||
|
["\"", "\""],
|
||||||
|
["'", "'"]
|
||||||
|
],
|
||||||
|
"folding": {
|
||||||
|
"markers": {
|
||||||
|
"start": "^\\s*\\/\\*\\s*#region\\b\\s*(.*?)\\s*\\*\\/",
|
||||||
|
"end": "^\\s*\\/\\*\\s*#endregion\\b.*\\*\\/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indentationRules": {
|
||||||
|
"increaseIndentPattern": "(^.*\\{[^}]*$)",
|
||||||
|
"decreaseIndentPattern": "^\\s*\\}"
|
||||||
|
},
|
||||||
|
"wordPattern": "(#?-?\\d*\\.\\d\\w*%?)|(::?[\\w-]*(?=[^,{;]*[,{]))|(([@#.!])?[\\w-?]+%?|[@#!.])"
|
||||||
|
}
|
Loading…
Reference in New Issue