Skip to content

Commit f37a144

Browse files
Add Tailwind CSS language mode tests (tailwindlabs#1216)
This PR does a small bit of refactoring on the bundled CSS language server and adds tests for it to ensure we don't break stuff
1 parent 4bf0e13 commit f37a144

File tree

9 files changed

+1387
-439
lines changed

9 files changed

+1387
-439
lines changed

packages/tailwindcss-language-server/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
},
1414
"homepage": "https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme",
1515
"scripts": {
16-
"build": "pnpm run clean && pnpm run _esbuild && pnpm run hashbang",
16+
"build": "pnpm run clean && pnpm run _esbuild && pnpm run _esbuild:css && pnpm run hashbang",
1717
"_esbuild": "node ../../esbuild.mjs src/server.ts --outfile=bin/tailwindcss-language-server --minify",
18+
"_esbuild:css": "node ../../esbuild.mjs src/language/css.ts --outfile=bin/css-language-server --minify",
1819
"clean": "rimraf bin",
1920
"hashbang": "node scripts/hashbang.mjs",
2021
"create-notices-file": "node scripts/createNoticesFile.mjs",
Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
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

Comments
 (0)