|
| 1 | +import { |
| 2 | + getCSSLanguageService, |
| 3 | + LanguageSettings, |
| 4 | + DocumentContext, |
| 5 | +} from 'vscode-css-languageservice/lib/esm/cssLanguageService' |
| 6 | +import { |
| 7 | + InitializeParams, |
| 8 | + TextDocuments, |
| 9 | + TextDocumentSyncKind, |
| 10 | + WorkspaceFolder, |
| 11 | + Disposable, |
| 12 | + ConfigurationRequest, |
| 13 | + CompletionItemKind, |
| 14 | + Connection, |
| 15 | +} from 'vscode-languageserver/node' |
| 16 | +import { TextDocument } from 'vscode-languageserver-textdocument' |
| 17 | +import { Utils, URI } from 'vscode-uri' |
| 18 | +import { getLanguageModelCache } from './languageModelCache' |
| 19 | +import { Stylesheet } from 'vscode-css-languageservice' |
| 20 | +import dlv from 'dlv' |
| 21 | +import { rewriteCss } from './rewriting' |
| 22 | + |
| 23 | +export class CssServer { |
| 24 | + private documents: TextDocuments<TextDocument> |
| 25 | + constructor(private connection: Connection) { |
| 26 | + this.documents = new TextDocuments(TextDocument) |
| 27 | + } |
| 28 | + |
| 29 | + setup() { |
| 30 | + let connection = this.connection |
| 31 | + let documents = this.documents |
| 32 | + |
| 33 | + let cssLanguageService = getCSSLanguageService() |
| 34 | + |
| 35 | + let workspaceFolders: WorkspaceFolder[] |
| 36 | + |
| 37 | + let foldingRangeLimit = Number.MAX_VALUE |
| 38 | + const MEDIA_MARKER = '℘' |
| 39 | + |
| 40 | + const stylesheets = getLanguageModelCache<Stylesheet>(10, 60, (document) => |
| 41 | + cssLanguageService.parseStylesheet(document), |
| 42 | + ) |
| 43 | + documents.onDidOpen(({ document }) => { |
| 44 | + connection.sendNotification('@/tailwindCSS/documentReady', { |
| 45 | + uri: document.uri, |
| 46 | + }) |
| 47 | + }) |
| 48 | + documents.onDidClose(({ document }) => { |
| 49 | + stylesheets.onDocumentRemoved(document) |
| 50 | + }) |
| 51 | + connection.onShutdown(() => { |
| 52 | + stylesheets.dispose() |
| 53 | + }) |
| 54 | + |
| 55 | + connection.onInitialize((params: InitializeParams) => { |
| 56 | + workspaceFolders = (<any>params).workspaceFolders |
| 57 | + if (!Array.isArray(workspaceFolders)) { |
| 58 | + workspaceFolders = [] |
| 59 | + if (params.rootPath) { |
| 60 | + workspaceFolders.push({ name: '', uri: URI.file(params.rootPath).toString() }) |
| 61 | + } |
| 62 | + } |
| 63 | + |
| 64 | + foldingRangeLimit = dlv( |
| 65 | + params.capabilities, |
| 66 | + 'textDocument.foldingRange.rangeLimit', |
| 67 | + Number.MAX_VALUE, |
| 68 | + ) |
| 69 | + |
| 70 | + return { |
| 71 | + capabilities: { |
| 72 | + textDocumentSync: TextDocumentSyncKind.Full, |
| 73 | + completionProvider: { resolveProvider: false, triggerCharacters: ['/', '-', ':'] }, |
| 74 | + hoverProvider: true, |
| 75 | + foldingRangeProvider: true, |
| 76 | + colorProvider: {}, |
| 77 | + definitionProvider: true, |
| 78 | + documentHighlightProvider: true, |
| 79 | + documentSymbolProvider: true, |
| 80 | + selectionRangeProvider: true, |
| 81 | + referencesProvider: true, |
| 82 | + codeActionProvider: true, |
| 83 | + documentLinkProvider: { resolveProvider: false }, |
| 84 | + renameProvider: true, |
| 85 | + }, |
| 86 | + } |
| 87 | + }) |
| 88 | + |
| 89 | + function getDocumentContext( |
| 90 | + documentUri: string, |
| 91 | + workspaceFolders: WorkspaceFolder[], |
| 92 | + ): DocumentContext { |
| 93 | + function getRootFolder(): string | undefined { |
| 94 | + for (let folder of workspaceFolders) { |
| 95 | + let folderURI = folder.uri |
| 96 | + if (!folderURI.endsWith('/')) { |
| 97 | + folderURI = folderURI + '/' |
| 98 | + } |
| 99 | + if (documentUri.startsWith(folderURI)) { |
| 100 | + return folderURI |
| 101 | + } |
| 102 | + } |
| 103 | + return undefined |
| 104 | + } |
| 105 | + |
| 106 | + return { |
| 107 | + resolveReference: (ref: string, base = documentUri) => { |
| 108 | + if (ref[0] === '/') { |
| 109 | + // resolve absolute path against the current workspace folder |
| 110 | + let folderUri = getRootFolder() |
| 111 | + if (folderUri) { |
| 112 | + return folderUri + ref.substr(1) |
| 113 | + } |
| 114 | + } |
| 115 | + base = base.substr(0, base.lastIndexOf('/') + 1) |
| 116 | + return Utils.resolvePath(URI.parse(base), ref).toString() |
| 117 | + }, |
| 118 | + } |
| 119 | + } |
| 120 | + |
| 121 | + async function withDocumentAndSettings<T>( |
| 122 | + uri: string, |
| 123 | + callback: (result: { |
| 124 | + document: TextDocument |
| 125 | + settings: LanguageSettings | undefined |
| 126 | + }) => T | Promise<T>, |
| 127 | + ): Promise<T> { |
| 128 | + let document = documents.get(uri) |
| 129 | + if (!document) { |
| 130 | + return null |
| 131 | + } |
| 132 | + return await callback({ |
| 133 | + document: createVirtualCssDocument(document), |
| 134 | + settings: await getDocumentSettings(document), |
| 135 | + }) |
| 136 | + } |
| 137 | + |
| 138 | + connection.onCompletion(async ({ textDocument, position }, _token) => |
| 139 | + withDocumentAndSettings(textDocument.uri, async ({ document, settings }) => { |
| 140 | + let result = await cssLanguageService.doComplete2( |
| 141 | + document, |
| 142 | + position, |
| 143 | + stylesheets.get(document), |
| 144 | + getDocumentContext(document.uri, workspaceFolders), |
| 145 | + settings?.completion, |
| 146 | + ) |
| 147 | + return { |
| 148 | + isIncomplete: result.isIncomplete, |
| 149 | + items: result.items.flatMap((item) => { |
| 150 | + // Add the `theme()` function |
| 151 | + if (item.kind === CompletionItemKind.Function && item.label === 'calc()') { |
| 152 | + return [ |
| 153 | + item, |
| 154 | + { |
| 155 | + ...item, |
| 156 | + label: 'theme()', |
| 157 | + filterText: 'theme', |
| 158 | + documentation: { |
| 159 | + kind: 'markdown', |
| 160 | + value: |
| 161 | + 'Use the `theme()` function to access your Tailwind config values using dot notation.', |
| 162 | + }, |
| 163 | + command: { |
| 164 | + title: '', |
| 165 | + command: 'editor.action.triggerSuggest', |
| 166 | + }, |
| 167 | + textEdit: { |
| 168 | + ...item.textEdit, |
| 169 | + newText: item.textEdit.newText.replace(/^calc\(/, 'theme('), |
| 170 | + }, |
| 171 | + }, |
| 172 | + ] |
| 173 | + } |
| 174 | + return item |
| 175 | + }), |
| 176 | + } |
| 177 | + }), |
| 178 | + ) |
| 179 | + |
| 180 | + connection.onHover(({ textDocument, position }, _token) => |
| 181 | + withDocumentAndSettings(textDocument.uri, ({ document, settings }) => |
| 182 | + cssLanguageService.doHover(document, position, stylesheets.get(document), settings?.hover), |
| 183 | + ), |
| 184 | + ) |
| 185 | + |
| 186 | + connection.onFoldingRanges(({ textDocument }, _token) => |
| 187 | + withDocumentAndSettings(textDocument.uri, ({ document }) => |
| 188 | + cssLanguageService.getFoldingRanges(document, { rangeLimit: foldingRangeLimit }), |
| 189 | + ), |
| 190 | + ) |
| 191 | + |
| 192 | + connection.onDocumentColor(({ textDocument }) => |
| 193 | + withDocumentAndSettings(textDocument.uri, ({ document }) => |
| 194 | + cssLanguageService.findDocumentColors(document, stylesheets.get(document)), |
| 195 | + ), |
| 196 | + ) |
| 197 | + |
| 198 | + connection.onColorPresentation(({ textDocument, color, range }) => |
| 199 | + withDocumentAndSettings(textDocument.uri, ({ document }) => |
| 200 | + cssLanguageService.getColorPresentations(document, stylesheets.get(document), color, range), |
| 201 | + ), |
| 202 | + ) |
| 203 | + |
| 204 | + connection.onDefinition(({ textDocument, position }) => |
| 205 | + withDocumentAndSettings(textDocument.uri, ({ document }) => |
| 206 | + cssLanguageService.findDefinition(document, position, stylesheets.get(document)), |
| 207 | + ), |
| 208 | + ) |
| 209 | + |
| 210 | + connection.onDocumentHighlight(({ textDocument, position }) => |
| 211 | + withDocumentAndSettings(textDocument.uri, ({ document }) => |
| 212 | + cssLanguageService.findDocumentHighlights(document, position, stylesheets.get(document)), |
| 213 | + ), |
| 214 | + ) |
| 215 | + |
| 216 | + connection.onDocumentSymbol(({ textDocument }) => |
| 217 | + withDocumentAndSettings(textDocument.uri, ({ document }) => |
| 218 | + cssLanguageService |
| 219 | + .findDocumentSymbols(document, stylesheets.get(document)) |
| 220 | + .map((symbol) => { |
| 221 | + if (symbol.name === `@media (${MEDIA_MARKER})`) { |
| 222 | + let doc = documents.get(symbol.location.uri) |
| 223 | + let text = doc.getText(symbol.location.range) |
| 224 | + let match = text.trim().match(/^(@[^\s]+)(?:([^{]+)[{]|([^;{]+);)/) |
| 225 | + if (match) { |
| 226 | + symbol.name = `${match[1]} ${match[2]?.trim() ?? match[3]?.trim()}` |
| 227 | + } |
| 228 | + } else if (symbol.name === `.placeholder`) { |
| 229 | + let doc = documents.get(symbol.location.uri) |
| 230 | + let text = doc.getText(symbol.location.range) |
| 231 | + let match = text.trim().match(/^(@[^\s]+)(?:([^{]+)[{]|([^;{]+);)/) |
| 232 | + if (match) { |
| 233 | + symbol.name = `${match[1]} ${match[2]?.trim() ?? match[3]?.trim()}` |
| 234 | + } |
| 235 | + } |
| 236 | + return symbol |
| 237 | + }), |
| 238 | + ), |
| 239 | + ) |
| 240 | + |
| 241 | + connection.onSelectionRanges(({ textDocument, positions }) => |
| 242 | + withDocumentAndSettings(textDocument.uri, ({ document }) => |
| 243 | + cssLanguageService.getSelectionRanges(document, positions, stylesheets.get(document)), |
| 244 | + ), |
| 245 | + ) |
| 246 | + |
| 247 | + connection.onReferences(({ textDocument, position }) => |
| 248 | + withDocumentAndSettings(textDocument.uri, ({ document }) => |
| 249 | + cssLanguageService.findReferences(document, position, stylesheets.get(document)), |
| 250 | + ), |
| 251 | + ) |
| 252 | + |
| 253 | + connection.onCodeAction(({ textDocument, range, context }) => |
| 254 | + withDocumentAndSettings(textDocument.uri, ({ document }) => |
| 255 | + cssLanguageService.doCodeActions2(document, range, context, stylesheets.get(document)), |
| 256 | + ), |
| 257 | + ) |
| 258 | + |
| 259 | + connection.onDocumentLinks(({ textDocument }) => |
| 260 | + withDocumentAndSettings(textDocument.uri, ({ document }) => |
| 261 | + cssLanguageService.findDocumentLinks2( |
| 262 | + document, |
| 263 | + stylesheets.get(document), |
| 264 | + getDocumentContext(document.uri, workspaceFolders), |
| 265 | + ), |
| 266 | + ), |
| 267 | + ) |
| 268 | + |
| 269 | + connection.onRenameRequest(({ textDocument, position, newName }) => |
| 270 | + withDocumentAndSettings(textDocument.uri, ({ document }) => |
| 271 | + cssLanguageService.doRename(document, position, newName, stylesheets.get(document)), |
| 272 | + ), |
| 273 | + ) |
| 274 | + |
| 275 | + let documentSettings: { [key: string]: Thenable<LanguageSettings | undefined> } = {} |
| 276 | + documents.onDidClose((e) => { |
| 277 | + delete documentSettings[e.document.uri] |
| 278 | + }) |
| 279 | + function getDocumentSettings( |
| 280 | + textDocument: TextDocument, |
| 281 | + ): Thenable<LanguageSettings | undefined> { |
| 282 | + let promise = documentSettings[textDocument.uri] |
| 283 | + if (!promise) { |
| 284 | + const configRequestParam = { |
| 285 | + items: [{ scopeUri: textDocument.uri, section: 'css' }], |
| 286 | + } |
| 287 | + promise = connection |
| 288 | + .sendRequest(ConfigurationRequest.type, configRequestParam) |
| 289 | + .then((s) => s[0]) |
| 290 | + documentSettings[textDocument.uri] = promise |
| 291 | + } |
| 292 | + return promise |
| 293 | + } |
| 294 | + |
| 295 | + connection.onDidChangeConfiguration((change) => { |
| 296 | + updateConfiguration(<LanguageSettings>change.settings.css) |
| 297 | + }) |
| 298 | + |
| 299 | + function updateConfiguration(settings: LanguageSettings) { |
| 300 | + cssLanguageService.configure(settings) |
| 301 | + // reset all document settings |
| 302 | + documentSettings = {} |
| 303 | + documents.all().forEach(triggerValidation) |
| 304 | + } |
| 305 | + |
| 306 | + const pendingValidationRequests: { [uri: string]: Disposable } = {} |
| 307 | + const validationDelayMs = 500 |
| 308 | + |
| 309 | + const timer = { |
| 310 | + setTimeout(callback: (...args: any[]) => void, ms: number, ...args: any[]): Disposable { |
| 311 | + const handle = setTimeout(callback, ms, ...args) |
| 312 | + return { dispose: () => clearTimeout(handle) } |
| 313 | + }, |
| 314 | + } |
| 315 | + |
| 316 | + documents.onDidChangeContent((change) => { |
| 317 | + triggerValidation(change.document) |
| 318 | + }) |
| 319 | + |
| 320 | + documents.onDidClose((event) => { |
| 321 | + cleanPendingValidation(event.document) |
| 322 | + connection.sendDiagnostics({ uri: event.document.uri, diagnostics: [] }) |
| 323 | + }) |
| 324 | + |
| 325 | + function cleanPendingValidation(textDocument: TextDocument): void { |
| 326 | + const request = pendingValidationRequests[textDocument.uri] |
| 327 | + if (request) { |
| 328 | + request.dispose() |
| 329 | + delete pendingValidationRequests[textDocument.uri] |
| 330 | + } |
| 331 | + } |
| 332 | + |
| 333 | + function triggerValidation(textDocument: TextDocument): void { |
| 334 | + cleanPendingValidation(textDocument) |
| 335 | + pendingValidationRequests[textDocument.uri] = timer.setTimeout(() => { |
| 336 | + delete pendingValidationRequests[textDocument.uri] |
| 337 | + validateTextDocument(textDocument) |
| 338 | + }, validationDelayMs) |
| 339 | + } |
| 340 | + |
| 341 | + function createVirtualCssDocument(textDocument: TextDocument): TextDocument { |
| 342 | + let content = rewriteCss(textDocument.getText()) |
| 343 | + |
| 344 | + return TextDocument.create( |
| 345 | + textDocument.uri, |
| 346 | + textDocument.languageId, |
| 347 | + textDocument.version, |
| 348 | + content, |
| 349 | + ) |
| 350 | + } |
| 351 | + |
| 352 | + async function validateTextDocument(textDocument: TextDocument): Promise<void> { |
| 353 | + textDocument = createVirtualCssDocument(textDocument) |
| 354 | + |
| 355 | + let settings = await getDocumentSettings(textDocument) |
| 356 | + |
| 357 | + let diagnostics = cssLanguageService |
| 358 | + .doValidation(textDocument, cssLanguageService.parseStylesheet(textDocument), settings) |
| 359 | + .filter((diagnostic) => { |
| 360 | + if ( |
| 361 | + diagnostic.code === 'unknownAtRules' && |
| 362 | + /Unknown at rule @(tailwind|apply|config|theme|plugin|source|utility|variant|custom-variant|slot)/.test( |
| 363 | + diagnostic.message, |
| 364 | + ) |
| 365 | + ) { |
| 366 | + return false |
| 367 | + } |
| 368 | + return true |
| 369 | + }) |
| 370 | + |
| 371 | + connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }) |
| 372 | + } |
| 373 | + } |
| 374 | + |
| 375 | + listen() { |
| 376 | + this.documents.listen(this.connection) |
| 377 | + this.connection.listen() |
| 378 | + } |
| 379 | +} |
0 commit comments