diff --git a/packages/tailwindcss-language-server/src/lsp/diagnosticsProvider.ts b/packages/tailwindcss-language-server/src/lsp/diagnosticsProvider.ts
index 75fb87333..25d81a191 100644
--- a/packages/tailwindcss-language-server/src/lsp/diagnosticsProvider.ts
+++ b/packages/tailwindcss-language-server/src/lsp/diagnosticsProvider.ts
@@ -1,22 +1,21 @@
import type { TextDocument } from 'vscode-languageserver-textdocument'
import type { State } from '@tailwindcss/language-service/src/util/state'
-import { doValidate } from '@tailwindcss/language-service/src/diagnostics/diagnosticsProvider'
-import isExcluded from '../util/isExcluded'
+import type { LanguageService } from '@tailwindcss/language-service/src/service'
-export async function provideDiagnostics(state: State, document: TextDocument) {
- if (await isExcluded(state, document)) {
- clearDiagnostics(state, document)
- } else {
- state.editor?.connection.sendDiagnostics({
- uri: document.uri,
- diagnostics: await doValidate(state, document),
- })
- }
-}
+export async function provideDiagnostics(
+ service: LanguageService,
+ state: State,
+ document: TextDocument,
+) {
+ if (!state.enabled) return
+ let doc = await service.open(document.uri)
+ let report = await doc?.diagnostics()
+
+ // No need to send diagnostics if the document is unchanged
+ if (report.kind === 'unchanged') return
-export function clearDiagnostics(state: State, document: TextDocument): void {
state.editor?.connection.sendDiagnostics({
uri: document.uri,
- diagnostics: [],
+ diagnostics: report?.items ?? [],
})
}
diff --git a/packages/tailwindcss-language-server/src/projects.ts b/packages/tailwindcss-language-server/src/projects.ts
index 0c54942ca..de18b4984 100644
--- a/packages/tailwindcss-language-server/src/projects.ts
+++ b/packages/tailwindcss-language-server/src/projects.ts
@@ -36,13 +36,8 @@ import pkgUp from 'pkg-up'
import stackTrace from 'stack-trace'
import extractClassNames from './lib/extractClassNames'
import { klona } from 'klona/full'
-import { doHover } from '@tailwindcss/language-service/src/hoverProvider'
-import { getCodeLens } from '@tailwindcss/language-service/src/codeLensProvider'
+import { createLanguageService } from '@tailwindcss/language-service/src/service'
import { Resolver } from './resolver'
-import {
- doComplete,
- resolveCompletionItem,
-} from '@tailwindcss/language-service/src/completionProvider'
import type {
State,
FeatureFlags,
@@ -52,17 +47,12 @@ import type {
ClassEntry,
} from '@tailwindcss/language-service/src/util/state'
import { provideDiagnostics } from './lsp/diagnosticsProvider'
-import { doCodeActions } from '@tailwindcss/language-service/src/codeActions/codeActionProvider'
-import { getDocumentColors } from '@tailwindcss/language-service/src/documentColorProvider'
-import { getDocumentLinks } from '@tailwindcss/language-service/src/documentLinksProvider'
import { debounce } from 'debounce'
import { getModuleDependencies } from './util/getModuleDependencies'
import assert from 'node:assert'
// import postcssLoadConfig from 'postcss-load-config'
import { bigSign } from '@tailwindcss/language-service/src/util/jit'
import { getColor } from '@tailwindcss/language-service/src/util/color'
-import * as culori from 'culori'
-import namedColors from 'color-name'
import tailwindPlugins from './lib/plugins'
import isExcluded from './util/isExcluded'
import { getFileFsPath } from './util/uri'
@@ -72,7 +62,6 @@ import {
firstOptional,
withoutLogs,
clearRequireCache,
- withFallback,
isObject,
pathToFileURL,
changeAffectsFile,
@@ -85,8 +74,7 @@ import { supportedFeatures } from '@tailwindcss/language-service/src/features'
import { loadDesignSystem } from './util/v4'
import { readCssFile } from './util/css'
import type { DesignSystem } from '@tailwindcss/language-service/src/util/v4'
-
-const colorNames = Object.keys(namedColors)
+import type { File, FileType } from '@tailwindcss/language-service/src/fs'
function getConfigId(configPath: string, configDependencies: string[]): string {
return JSON.stringify(
@@ -102,7 +90,7 @@ export interface ProjectService {
state: State
tryInit: () => Promise
dispose: () => Promise
- onUpdateSettings: (settings: any) => void
+ onUpdateSettings: () => void
onFileEvents: (changes: Array<{ file: string; type: FileChangeType }>) => void
onHover(params: TextDocumentPositionParams): Promise
onCompletion(params: CompletionParams): Promise
@@ -234,36 +222,71 @@ export async function createProjectService(
getDocumentSymbols: (uri: string) => {
return connection.sendRequest('@/tailwindCSS/getDocumentSymbols', { uri })
},
- async readDirectory(document, directory) {
+ async readDirectory() {
+ // NOTE: This is overwritten in `createLanguageDocument`
+ throw new Error('Not implemented')
+ },
+ },
+ }
+
+ let service = createLanguageService({
+ state: () => state,
+ fs: {
+ async document(uri: string) {
+ return documentService.getDocument(uri)
+ },
+ async resolve(document: TextDocument, relativePath: string): Promise {
+ let documentPath = URI.parse(document.uri).fsPath
+ let baseDir = path.dirname(documentPath)
+
+ let resolved = await resolver.substituteId(relativePath, baseDir)
+ resolved ??= relativePath
+
+ return URI.file(path.resolve(baseDir, resolved)).toString()
+ },
+
+ async readDirectory(document: TextDocument, filepath: string): Promise {
try {
let baseDir = path.dirname(getFileFsPath(document.uri))
- directory = await resolver.substituteId(`${directory}/`, baseDir)
- directory = path.resolve(baseDir, directory)
-
- let dirents = await fs.promises.readdir(directory, { withFileTypes: true })
-
- let result: Array<[string, { isDirectory: boolean }] | null> = await Promise.all(
- dirents.map(async (dirent) => {
- let isDirectory = dirent.isDirectory()
- let shouldRemove = await isExcluded(
- state,
- document,
- path.join(directory, dirent.name, isDirectory ? '/' : ''),
- )
+ filepath = await resolver.substituteId(`${filepath}/`, baseDir)
+ filepath = path.resolve(baseDir, filepath)
- if (shouldRemove) return null
+ let dirents = await fs.promises.readdir(filepath, { withFileTypes: true })
- return [dirent.name, { isDirectory }]
- }),
- )
+ let results: File[] = []
+
+ for (let dirent of dirents) {
+ let isDirectory = dirent.isDirectory()
+ let shouldRemove = await isExcluded(
+ state,
+ document,
+ path.join(filepath, dirent.name, isDirectory ? '/' : ''),
+ )
+ if (shouldRemove) continue
+
+ let type: FileType = 'unknown'
- return result.filter((item) => item !== null)
+ if (dirent.isFile()) {
+ type = 'file'
+ } else if (dirent.isDirectory()) {
+ type = 'directory'
+ } else if (dirent.isSymbolicLink()) {
+ type = 'symbolic-link'
+ }
+
+ results.push({
+ name: dirent.name,
+ type,
+ })
+ }
+
+ return results
} catch {
return []
}
},
},
- }
+ })
if (projectConfig.configPath && projectConfig.config.source === 'js') {
let deps = []
@@ -1183,7 +1206,9 @@ export async function createProjectService(
;(await disposable).dispose()
}
},
- async onUpdateSettings(settings: any): Promise {
+ async onUpdateSettings(): Promise {
+ service.onUpdateSettings()
+
if (state.enabled) {
refreshDiagnostics()
}
@@ -1193,139 +1218,79 @@ export async function createProjectService(
},
onFileEvents,
async onHover(params: TextDocumentPositionParams): Promise {
- return withFallback(async () => {
- if (!state.enabled) return null
- let document = documentService.getDocument(params.textDocument.uri)
- if (!document) return null
- let settings = await state.editor.getConfiguration(document.uri)
- if (!settings.tailwindCSS.hovers) return null
- if (await isExcluded(state, document)) return null
- return doHover(state, document, params.position)
- }, null)
+ try {
+ let doc = await service.open(params.textDocument.uri)
+ if (!doc) return null
+ return doc.hover(params.position)
+ } catch {
+ return null
+ }
},
async onCodeLens(params: CodeLensParams): Promise {
- return withFallback(async () => {
- if (!state.enabled) return null
- let document = documentService.getDocument(params.textDocument.uri)
- if (!document) return null
- let settings = await state.editor.getConfiguration(document.uri)
- if (!settings.tailwindCSS.codeLens) return null
- if (await isExcluded(state, document)) return null
- return getCodeLens(state, document)
- }, null)
+ try {
+ let doc = await service.open(params.textDocument.uri)
+ if (!doc) return null
+ return doc.codeLenses()
+ } catch {
+ return []
+ }
},
async onCompletion(params: CompletionParams): Promise {
- return withFallback(async () => {
- if (!state.enabled) return null
- let document = documentService.getDocument(params.textDocument.uri)
- if (!document) return null
- let settings = await state.editor.getConfiguration(document.uri)
- if (!settings.tailwindCSS.suggestions) return null
- if (await isExcluded(state, document)) return null
- return doComplete(state, document, params.position, params.context)
- }, null)
+ try {
+ let doc = await service.open(params.textDocument.uri)
+ if (!doc) return null
+ return doc.completions(params.position)
+ } catch {
+ return null
+ }
},
- onCompletionResolve(item: CompletionItem): Promise {
- return withFallback(() => {
- if (!state.enabled) return null
- return resolveCompletionItem(state, item)
- }, null)
+ async onCompletionResolve(item: CompletionItem): Promise {
+ try {
+ return await service.resolveCompletion(item)
+ } catch {
+ return null
+ }
},
async onCodeAction(params: CodeActionParams): Promise {
- return withFallback(async () => {
- if (!state.enabled) return null
- let document = documentService.getDocument(params.textDocument.uri)
- if (!document) return null
- let settings = await state.editor.getConfiguration(document.uri)
- if (!settings.tailwindCSS.codeActions) return null
- return doCodeActions(state, params, document)
- }, null)
+ try {
+ let doc = await service.open(params.textDocument.uri)
+ if (!doc) return null
+ return doc.codeActions(params.range, params.context)
+ } catch {
+ return []
+ }
},
- onDocumentLinks(params: DocumentLinkParams): Promise {
- if (!state.enabled) return null
- let document = documentService.getDocument(params.textDocument.uri)
- if (!document) return null
-
- let documentPath = URI.parse(document.uri).fsPath
- let baseDir = path.dirname(documentPath)
-
- async function resolveTarget(linkPath: string) {
- linkPath = (await resolver.substituteId(linkPath, baseDir)) ?? linkPath
-
- return URI.file(path.resolve(baseDir, linkPath)).toString()
+ async onDocumentLinks(params: DocumentLinkParams): Promise {
+ try {
+ let doc = await service.open(params.textDocument.uri)
+ if (!doc) return null
+ return doc.documentLinks()
+ } catch {
+ return []
}
-
- return getDocumentLinks(state, document, resolveTarget)
},
provideDiagnostics: debounce(
- (document: TextDocument) => {
- if (!state.enabled) return
- provideDiagnostics(state, document)
- },
+ (document) => provideDiagnostics(service, state, document),
params.initializationOptions?.testMode ? 0 : 500,
),
- provideDiagnosticsForce: (document: TextDocument) => {
- if (!state.enabled) return
- provideDiagnostics(state, document)
- },
+ provideDiagnosticsForce: (document) => provideDiagnostics(service, state, document),
async onDocumentColor(params: DocumentColorParams): Promise {
- return withFallback(async () => {
- if (!state.enabled) return []
- let document = documentService.getDocument(params.textDocument.uri)
- if (!document) return []
- if (await isExcluded(state, document)) return null
- return getDocumentColors(state, document)
- }, null)
+ try {
+ let doc = await service.open(params.textDocument.uri)
+ if (!doc) return null
+ return doc.documentColors()
+ } catch {
+ return []
+ }
},
async onColorPresentation(params: ColorPresentationParams): Promise {
- let document = documentService.getDocument(params.textDocument.uri)
- if (!document) return []
- let className = document.getText(params.range)
- let match = className.match(
- new RegExp(`-\\[(${colorNames.join('|')}|(?:(?:#|rgba?\\(|hsla?\\())[^\\]]+)\\]$`, 'i'),
- )
- // let match = className.match(/-\[((?:#|rgba?\(|hsla?\()[^\]]+)\]$/i)
- if (match === null) return []
-
- let currentColor = match[1]
-
- let isNamedColor = colorNames.includes(currentColor)
-
- let color: culori.Color = {
- mode: 'rgb',
- r: params.color.red,
- g: params.color.green,
- b: params.color.blue,
- alpha: params.color.alpha,
- }
-
- let hexValue = culori.formatHex8(color)
-
- if (!isNamedColor && (currentColor.length === 4 || currentColor.length === 5)) {
- let [, ...chars] =
- hexValue.match(/^#([a-f\d])\1([a-f\d])\2([a-f\d])\3(?:([a-f\d])\4)?$/i) ?? []
- if (chars.length) {
- hexValue = `#${chars.filter(Boolean).join('')}`
- }
- }
-
- if (hexValue.length === 5) {
- hexValue = hexValue.replace(/f$/, '')
- } else if (hexValue.length === 9) {
- hexValue = hexValue.replace(/ff$/, '')
+ try {
+ let doc = await service.open(params.textDocument.uri)
+ if (!doc) return null
+ return doc.colorPresentation(params.color, params.range)
+ } catch {
+ return []
}
-
- let prefix = className.substr(0, match.index)
-
- return [
- hexValue,
- culori.formatRgb(color).replace(/ /g, ''),
- culori
- .formatHsl(color)
- .replace(/ /g, '')
- // round numbers
- .replace(/\d+\.\d+(%?)/g, (value, suffix) => `${Math.round(parseFloat(value))}${suffix}`),
- ].map((value) => ({ label: `${prefix}-[${value}]` }))
},
sortClassLists(classLists: string[]): string[] {
if (!state.jit) {
diff --git a/packages/tailwindcss-language-server/src/tw.ts b/packages/tailwindcss-language-server/src/tw.ts
index 245fa9e5a..f0f1a06fc 100644
--- a/packages/tailwindcss-language-server/src/tw.ts
+++ b/packages/tailwindcss-language-server/src/tw.ts
@@ -655,7 +655,7 @@ export class TW {
console.log(`[Global] Initialized ${enabledProjectCount} projects`)
this.disposables.push(
- this.connection.onDidChangeConfiguration(async ({ settings }) => {
+ this.connection.onDidChangeConfiguration(async () => {
let previousExclude = globalSettings.tailwindCSS.files.exclude
this.settingsCache.clear()
@@ -668,7 +668,7 @@ export class TW {
}
for (let [, project] of this.projects) {
- project.onUpdateSettings(settings)
+ project.onUpdateSettings()
}
}),
)
diff --git a/packages/tailwindcss-language-server/tests/code-actions/conflict.json b/packages/tailwindcss-language-server/tests/code-actions/conflict.json
index eccb14464..55fb35a77 100644
--- a/packages/tailwindcss-language-server/tests/code-actions/conflict.json
+++ b/packages/tailwindcss-language-server/tests/code-actions/conflict.json
@@ -14,7 +14,8 @@
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
- }
+ },
+ "span": [12, 31]
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
@@ -23,7 +24,8 @@
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 21 }
- }
+ },
+ "span": [12, 21]
},
"otherClassNames": [
{
@@ -33,7 +35,8 @@
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
- }
+ },
+ "span": [12, 31]
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
@@ -42,7 +45,8 @@
"range": {
"start": { "line": 0, "character": 22 },
"end": { "line": 0, "character": 31 }
- }
+ },
+ "span": [22, 31]
}
],
"range": {
@@ -92,7 +96,8 @@
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
- }
+ },
+ "span": [12, 31]
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
@@ -101,7 +106,8 @@
"range": {
"start": { "line": 0, "character": 22 },
"end": { "line": 0, "character": 31 }
- }
+ },
+ "span": [22, 31]
},
"otherClassNames": [
{
@@ -111,7 +117,8 @@
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
- }
+ },
+ "span": [12, 31]
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
@@ -120,7 +127,8 @@
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 21 }
- }
+ },
+ "span": [12, 21]
}
],
"range": {
diff --git a/packages/tailwindcss-language-server/tests/colors/colors.test.js b/packages/tailwindcss-language-server/tests/colors/colors.test.js
index 4780a4fb2..1bf3ad9a1 100644
--- a/packages/tailwindcss-language-server/tests/colors/colors.test.js
+++ b/packages/tailwindcss-language-server/tests/colors/colors.test.js
@@ -9,6 +9,7 @@ const range = (startLine, startCol, endLine, endCol) => ({
end: { line: endLine, character: endCol },
})
+// TODO: Find a way to test these in the language service
withFixture('basic', (c) => {
async function testColors(name, { text, expected }) {
test.concurrent(name, async ({ expect }) => {
@@ -159,6 +160,7 @@ withFixture('basic', (c) => {
})
})
+// TODO: Remove. These are all tested in the language service now
withFixture('v4/basic', (c) => {
async function testColors(name, { text, expected }) {
test.concurrent(name, async ({ expect }) => {
@@ -309,6 +311,7 @@ withFixture('v4/basic', (c) => {
})
})
+// TODO: Remove. These are all tested in the language service now
defineTest({
name: 'v4: colors are recursively resolved from the theme',
fs: {
@@ -354,6 +357,7 @@ defineTest({
},
})
+// TODO: Remove. These are all tested in the language service now
defineTest({
name: 'colors that use light-dark() resolve to their light color',
fs: {
diff --git a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/css.json b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/css.json
index da506bf12..d57066663 100644
--- a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/css.json
+++ b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/css.json
@@ -12,13 +12,15 @@
"start": { "line": 0, "character": 15 },
"end": { "line": 0, "character": 34 }
},
+ "span": [15, 34],
"important": false
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 9 }
},
- "range": { "start": { "line": 0, "character": 15 }, "end": { "line": 0, "character": 24 } }
+ "range": { "start": { "line": 0, "character": 15 }, "end": { "line": 0, "character": 24 } },
+ "span": [15, 24]
},
"otherClassNames": [
{
@@ -29,6 +31,7 @@
"start": { "line": 0, "character": 15 },
"end": { "line": 0, "character": 34 }
},
+ "span": [15, 34],
"important": false
},
"relativeRange": {
@@ -38,7 +41,8 @@
"range": {
"start": { "line": 0, "character": 25 },
"end": { "line": 0, "character": 34 }
- }
+ },
+ "span": [25, 34]
}
],
"range": { "start": { "line": 0, "character": 15 }, "end": { "line": 0, "character": 24 } },
@@ -67,13 +71,15 @@
"start": { "line": 0, "character": 15 },
"end": { "line": 0, "character": 34 }
},
+ "span": [15, 34],
"important": false
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
"end": { "line": 0, "character": 19 }
},
- "range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 34 } }
+ "range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 34 } },
+ "span": [25, 34]
},
"otherClassNames": [
{
@@ -84,6 +90,7 @@
"start": { "line": 0, "character": 15 },
"end": { "line": 0, "character": 34 }
},
+ "span": [15, 34],
"important": false
},
"relativeRange": {
@@ -93,7 +100,8 @@
"range": {
"start": { "line": 0, "character": 15 },
"end": { "line": 0, "character": 24 }
- }
+ },
+ "span": [15, 24]
}
],
"range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 34 } },
diff --git a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/jsx-concat-positive.json b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/jsx-concat-positive.json
index 39cbb515a..5561d773e 100644
--- a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/jsx-concat-positive.json
+++ b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/jsx-concat-positive.json
@@ -11,13 +11,15 @@
"range": {
"start": { "line": 0, "character": 17 },
"end": { "line": 0, "character": 36 }
- }
+ },
+ "span": [17, 36]
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 9 }
},
- "range": { "start": { "line": 0, "character": 17 }, "end": { "line": 0, "character": 26 } }
+ "range": { "start": { "line": 0, "character": 17 }, "end": { "line": 0, "character": 26 } },
+ "span": [17, 26]
},
"otherClassNames": [
{
@@ -27,7 +29,8 @@
"range": {
"start": { "line": 0, "character": 17 },
"end": { "line": 0, "character": 36 }
- }
+ },
+ "span": [17, 36]
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
@@ -36,7 +39,8 @@
"range": {
"start": { "line": 0, "character": 27 },
"end": { "line": 0, "character": 36 }
- }
+ },
+ "span": [27, 36]
}
],
"range": { "start": { "line": 0, "character": 17 }, "end": { "line": 0, "character": 26 } },
@@ -64,13 +68,15 @@
"range": {
"start": { "line": 0, "character": 17 },
"end": { "line": 0, "character": 36 }
- }
+ },
+ "span": [17, 36]
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
"end": { "line": 0, "character": 19 }
},
- "range": { "start": { "line": 0, "character": 27 }, "end": { "line": 0, "character": 36 } }
+ "range": { "start": { "line": 0, "character": 27 }, "end": { "line": 0, "character": 36 } },
+ "span": [27, 36]
},
"otherClassNames": [
{
@@ -80,7 +86,8 @@
"range": {
"start": { "line": 0, "character": 17 },
"end": { "line": 0, "character": 36 }
- }
+ },
+ "span": [17, 36]
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
@@ -89,7 +96,8 @@
"range": {
"start": { "line": 0, "character": 17 },
"end": { "line": 0, "character": 26 }
- }
+ },
+ "span": [17, 26]
}
],
"range": { "start": { "line": 0, "character": 27 }, "end": { "line": 0, "character": 36 } },
diff --git a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/simple.json b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/simple.json
index c98280a11..9f15fbcc2 100644
--- a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/simple.json
+++ b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/simple.json
@@ -10,13 +10,15 @@
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
- }
+ },
+ "span": [12, 31]
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 9 }
},
- "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 21 } }
+ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 21 } },
+ "span": [12, 21]
},
"otherClassNames": [
{
@@ -26,7 +28,8 @@
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
- }
+ },
+ "span": [12, 31]
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
@@ -35,7 +38,8 @@
"range": {
"start": { "line": 0, "character": 22 },
"end": { "line": 0, "character": 31 }
- }
+ },
+ "span": [22, 31]
}
],
"range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 21 } },
@@ -63,13 +67,15 @@
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
- }
+ },
+ "span": [12, 31]
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
"end": { "line": 0, "character": 19 }
},
- "range": { "start": { "line": 0, "character": 22 }, "end": { "line": 0, "character": 31 } }
+ "range": { "start": { "line": 0, "character": 22 }, "end": { "line": 0, "character": 31 } },
+ "span": [22, 31]
},
"otherClassNames": [
{
@@ -79,7 +85,8 @@
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 31 }
- }
+ },
+ "span": [12, 31]
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
@@ -88,7 +95,8 @@
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 21 }
- }
+ },
+ "span": [12, 21]
}
],
"range": { "start": { "line": 0, "character": 22 }, "end": { "line": 0, "character": 31 } },
diff --git a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/variants-positive.json b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/variants-positive.json
index 15fcb4572..5fbcb8aca 100644
--- a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/variants-positive.json
+++ b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/variants-positive.json
@@ -10,13 +10,15 @@
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 37 }
- }
+ },
+ "span": [12, 37]
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 12 }
},
- "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 24 } }
+ "range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 24 } },
+ "span": [12, 24]
},
"otherClassNames": [
{
@@ -26,7 +28,8 @@
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 37 }
- }
+ },
+ "span": [12, 37]
},
"relativeRange": {
"start": { "line": 0, "character": 13 },
@@ -35,7 +38,8 @@
"range": {
"start": { "line": 0, "character": 25 },
"end": { "line": 0, "character": 37 }
- }
+ },
+ "span": [25, 37]
}
],
"range": { "start": { "line": 0, "character": 12 }, "end": { "line": 0, "character": 24 } },
@@ -63,13 +67,15 @@
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 37 }
- }
+ },
+ "span": [12, 37]
},
"relativeRange": {
"start": { "line": 0, "character": 13 },
"end": { "line": 0, "character": 25 }
},
- "range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 37 } }
+ "range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 37 } },
+ "span": [25, 37]
},
"otherClassNames": [
{
@@ -79,7 +85,8 @@
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 37 }
- }
+ },
+ "span": [12, 37]
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
@@ -88,7 +95,8 @@
"range": {
"start": { "line": 0, "character": 12 },
"end": { "line": 0, "character": 24 }
- }
+ },
+ "span": [12, 24]
}
],
"range": { "start": { "line": 0, "character": 25 }, "end": { "line": 0, "character": 37 } },
diff --git a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/vue-style-lang-sass.json b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/vue-style-lang-sass.json
index 7e9da86be..b6a3b0f55 100644
--- a/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/vue-style-lang-sass.json
+++ b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/vue-style-lang-sass.json
@@ -12,13 +12,15 @@
"start": { "line": 2, "character": 9 },
"end": { "line": 2, "character": 28 }
},
+ "span": [34, 53],
"important": false
},
"relativeRange": {
"start": { "line": 0, "character": 0 },
"end": { "line": 0, "character": 9 }
},
- "range": { "start": { "line": 2, "character": 9 }, "end": { "line": 2, "character": 18 } }
+ "range": { "start": { "line": 2, "character": 9 }, "end": { "line": 2, "character": 18 } },
+ "span": [34, 43]
},
"otherClassNames": [
{
@@ -29,6 +31,7 @@
"start": { "line": 2, "character": 9 },
"end": { "line": 2, "character": 28 }
},
+ "span": [34, 53],
"important": false
},
"relativeRange": {
@@ -38,7 +41,8 @@
"range": {
"start": { "line": 2, "character": 19 },
"end": { "line": 2, "character": 28 }
- }
+ },
+ "span": [44, 53]
}
],
"range": { "start": { "line": 2, "character": 9 }, "end": { "line": 2, "character": 18 } },
@@ -67,13 +71,15 @@
"start": { "line": 2, "character": 9 },
"end": { "line": 2, "character": 28 }
},
+ "span": [34, 53],
"important": false
},
"relativeRange": {
"start": { "line": 0, "character": 10 },
"end": { "line": 0, "character": 19 }
},
- "range": { "start": { "line": 2, "character": 19 }, "end": { "line": 2, "character": 28 } }
+ "range": { "start": { "line": 2, "character": 19 }, "end": { "line": 2, "character": 28 } },
+ "span": [44, 53]
},
"otherClassNames": [
{
@@ -84,6 +90,7 @@
"start": { "line": 2, "character": 9 },
"end": { "line": 2, "character": 28 }
},
+ "span": [34, 53],
"important": false
},
"relativeRange": {
@@ -93,7 +100,8 @@
"range": {
"start": { "line": 2, "character": 9 },
"end": { "line": 2, "character": 18 }
- }
+ },
+ "span": [34, 43]
}
],
"range": { "start": { "line": 2, "character": 19 }, "end": { "line": 2, "character": 28 } },
diff --git a/packages/tailwindcss-language-server/tests/hover/hover.test.js b/packages/tailwindcss-language-server/tests/hover/hover.test.js
index 379f41996..693358872 100644
--- a/packages/tailwindcss-language-server/tests/hover/hover.test.js
+++ b/packages/tailwindcss-language-server/tests/hover/hover.test.js
@@ -3,6 +3,7 @@ import { withFixture } from '../common'
import { css, defineTest } from '../../src/testing'
import { createClient } from '../utils/client'
+// TODO: Find a way to test these in the language service
withFixture('basic', (c) => {
async function testHover(
name,
@@ -177,6 +178,7 @@ withFixture('basic', (c) => {
})
})
+// TODO: Remove. This are all tested in the language service now
withFixture('v4/basic', (c) => {
async function testHover(
name,
@@ -554,6 +556,7 @@ withFixture('v4/path-mappings', (c) => {
})
})
+// TODO: Remove. This is tested in the language service now
defineTest({
name: 'Can hover showing theme values used in var(…) and theme(…) functions',
fs: {
diff --git a/packages/tailwindcss-language-server/tests/utils/configuration.ts b/packages/tailwindcss-language-server/tests/utils/configuration.ts
index 8c08a5181..7ee08d34c 100644
--- a/packages/tailwindcss-language-server/tests/utils/configuration.ts
+++ b/packages/tailwindcss-language-server/tests/utils/configuration.ts
@@ -3,7 +3,7 @@ import {
type Settings,
} from '@tailwindcss/language-service/src/util/state'
import { URI } from 'vscode-uri'
-import type { DeepPartial } from './types'
+import type { DeepPartial } from '@tailwindcss/language-service/src/types'
import { CacheMap } from '../../src/cache-map'
import deepmerge from 'deepmerge'
diff --git a/packages/tailwindcss-language-service/package.json b/packages/tailwindcss-language-service/package.json
index 525bb4249..12f6956eb 100644
--- a/packages/tailwindcss-language-service/package.json
+++ b/packages/tailwindcss-language-service/package.json
@@ -46,14 +46,19 @@
"@types/dedent": "^0.7.2",
"@types/line-column": "^1.0.2",
"@types/node": "^18.19.33",
+ "@types/picomatch": "^2.3.3",
"@types/stringify-object": "^4.0.5",
"dedent": "^1.5.3",
+ "deepmerge": "4.2.2",
"esbuild": "^0.25.5",
"esbuild-node-externals": "^1.9.0",
"minimist": "^1.2.8",
+ "picomatch": "^4.0.1",
+ "tailwindcss-v4": "npm:tailwindcss@4.1.1",
"tslib": "2.2.0",
"typescript": "^5.8.3",
"vite": "^6.3.5",
- "vitest": "^3.2.1"
+ "vitest": "^3.2.1",
+ "vscode-uri": "3.0.2"
}
}
diff --git a/packages/tailwindcss-language-service/scripts/build.mjs b/packages/tailwindcss-language-service/scripts/build.mjs
index 68ba778bb..6ffb8d299 100644
--- a/packages/tailwindcss-language-service/scripts/build.mjs
+++ b/packages/tailwindcss-language-service/scripts/build.mjs
@@ -27,7 +27,7 @@ let build = await esbuild.context({
{
name: 'generate-types',
async setup(build) {
- build.onEnd(async (result) => {
+ build.onEnd(async () => {
// Call the tsc command to generate the types
spawnSync(
'tsc',
diff --git a/packages/tailwindcss-language-service/scripts/tsconfig.build.json b/packages/tailwindcss-language-service/scripts/tsconfig.build.json
index e80bb38fb..144d69e60 100644
--- a/packages/tailwindcss-language-service/scripts/tsconfig.build.json
+++ b/packages/tailwindcss-language-service/scripts/tsconfig.build.json
@@ -1,4 +1,7 @@
{
- "extends": "../tsconfig.json",
- "exclude": ["../src/**/*.test.ts"]
-}
\ No newline at end of file
+ "extends": "../tsconfig.json",
+ "exclude": ["../src/**/*.test.ts", "../tests/**/*.ts"],
+ "compilerOptions": {
+ "rootDir": "../src"
+ }
+}
diff --git a/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts b/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts
index 7ebf79cb1..4e1c6c77a 100644
--- a/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts
+++ b/packages/tailwindcss-language-service/src/codeActions/codeActionProvider.ts
@@ -5,8 +5,8 @@ import { doValidate } from '../diagnostics/diagnosticsProvider'
import { rangesEqual } from '../util/rangesEqual'
import {
type DiagnosticKind,
- isInvalidApplyDiagnostic,
type AugmentedDiagnostic,
+ isInvalidApplyDiagnostic,
isCssConflictDiagnostic,
isInvalidConfigPathDiagnostic,
isInvalidTailwindDirectiveDiagnostic,
@@ -26,7 +26,8 @@ async function getDiagnosticsFromCodeActionParams(
only?: DiagnosticKind[],
): Promise {
if (!document) return []
- let diagnostics = await doValidate(state, document, only)
+ let report = await doValidate(state, document, only)
+ let diagnostics = report.items as AugmentedDiagnostic[]
return params.context.diagnostics
.map((diagnostic) => {
diff --git a/packages/tailwindcss-language-service/src/colorPresentationProvider.ts b/packages/tailwindcss-language-service/src/colorPresentationProvider.ts
new file mode 100644
index 000000000..d551345b0
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/colorPresentationProvider.ts
@@ -0,0 +1,60 @@
+import type { State } from './util/state'
+import type { Range, TextDocument } from 'vscode-languageserver-textdocument'
+import type { Color, ColorPresentation, ColorPresentationParams } from 'vscode-languageserver'
+import * as culori from 'culori'
+import namedColors from 'color-name'
+
+const colorNames = Object.keys(namedColors)
+
+export async function provideColorPresentation(
+ state: State,
+ document: TextDocument,
+ lscolor: Color,
+ range: Range,
+): Promise {
+ let className = document.getText(range)
+ let match = className.match(
+ new RegExp(`-\\[(${colorNames.join('|')}|(?:(?:#|rgba?\\(|hsla?\\())[^\\]]+)\\]$`, 'i'),
+ )
+ // let match = className.match(/-\[((?:#|rgba?\(|hsla?\()[^\]]+)\]$/i)
+ if (match === null) return []
+
+ let currentColor = match[1]
+
+ let isNamedColor = colorNames.includes(currentColor)
+
+ let color: culori.Color = {
+ mode: 'rgb',
+ r: lscolor.red,
+ g: lscolor.green,
+ b: lscolor.blue,
+ alpha: lscolor.alpha,
+ }
+
+ let hexValue = culori.formatHex8(color)
+
+ if (!isNamedColor && (currentColor.length === 4 || currentColor.length === 5)) {
+ let [, ...chars] = hexValue.match(/^#([a-f\d])\1([a-f\d])\2([a-f\d])\3(?:([a-f\d])\4)?$/i) ?? []
+ if (chars.length) {
+ hexValue = `#${chars.filter(Boolean).join('')}`
+ }
+ }
+
+ if (hexValue.length === 5) {
+ hexValue = hexValue.replace(/f$/, '')
+ } else if (hexValue.length === 9) {
+ hexValue = hexValue.replace(/ff$/, '')
+ }
+
+ let prefix = className.substr(0, match.index)
+
+ return [
+ hexValue,
+ culori.formatRgb(color).replace(/ /g, ''),
+ culori
+ .formatHsl(color)
+ .replace(/ /g, '')
+ // round numbers
+ .replace(/\d+\.\d+(%?)/g, (value, suffix) => `${Math.round(parseFloat(value))}${suffix}`),
+ ].map((value) => ({ label: `${prefix}-[${value}]` }))
+}
diff --git a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts
index 075cea38e..d4dc2e35e 100644
--- a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts
+++ b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts
@@ -1,6 +1,10 @@
import type { TextDocument } from 'vscode-languageserver-textdocument'
+import {
+ DocumentDiagnosticReportKind,
+ type FullDocumentDiagnosticReport,
+} from 'vscode-languageserver'
import type { State } from '../util/state'
-import { DiagnosticKind, type AugmentedDiagnostic } from './types'
+import { DiagnosticKind } from './types'
import { getCssConflictDiagnostics } from './getCssConflictDiagnostics'
import { getInvalidApplyDiagnostics } from './getInvalidApplyDiagnostics'
import { getInvalidScreenDiagnostics } from './getInvalidScreenDiagnostics'
@@ -25,10 +29,10 @@ export async function doValidate(
DiagnosticKind.RecommendedVariantOrder,
DiagnosticKind.UsedBlocklistedClass,
],
-): Promise {
- const settings = await state.editor.getConfiguration(document.uri)
+): Promise {
+ let settings = await state.editor.getConfiguration(document.uri)
- return settings.tailwindCSS.validate
+ let items = settings.tailwindCSS.validate
? [
...(only.includes(DiagnosticKind.CssConflict)
? await getCssConflictDiagnostics(state, document, settings)
@@ -59,4 +63,9 @@ export async function doValidate(
: []),
]
: []
+
+ return {
+ kind: DocumentDiagnosticReportKind.Full,
+ items,
+ }
}
diff --git a/packages/tailwindcss-language-service/src/documentColorProvider.ts b/packages/tailwindcss-language-service/src/documentColorProvider.ts
index cef0729f0..f9603661b 100644
--- a/packages/tailwindcss-language-service/src/documentColorProvider.ts
+++ b/packages/tailwindcss-language-service/src/documentColorProvider.ts
@@ -1,51 +1,40 @@
-import type { State } from './util/state'
-import {
- findClassListsInDocument,
- getClassNamesInClassList,
- findHelperFunctionsInDocument,
-} from './util/find'
+import type { ColorInformation } from 'vscode-languageserver'
+import type { Document } from './documents/document'
import { getColor, getColorFromValue, culoriColorToVscodeColor } from './util/color'
import { stringToPath } from './util/stringToPath'
-import type { ColorInformation } from 'vscode-languageserver'
-import type { TextDocument } from 'vscode-languageserver-textdocument'
import dlv from 'dlv'
import { dedupeByRange } from './util/array'
-export async function getDocumentColors(
- state: State,
- document: TextDocument,
-): Promise {
+export function getDocumentColors(doc: Document): ColorInformation[] {
let colors: ColorInformation[] = []
- if (!state.enabled) return colors
- let settings = await state.editor.getConfiguration(document.uri)
- if (settings.tailwindCSS.colorDecorators === false) return colors
+ for (let className of doc.classNames()) {
+ let color = getColor(doc.state, className.className)
+ if (!color) continue
+ if (typeof color === 'string') continue
+ if ((color.alpha ?? 1) === 0) continue
- let classLists = await findClassListsInDocument(state, document)
- classLists.forEach((classList) => {
- let classNames = getClassNamesInClassList(classList, state.blocklist)
- classNames.forEach((className) => {
- let color = getColor(state, className.className)
- if (color === null || typeof color === 'string' || (color.alpha ?? 1) === 0) {
- return
- }
- colors.push({
- range: className.range,
- color: culoriColorToVscodeColor(color),
- })
+ colors.push({
+ range: className.range,
+ color: culoriColorToVscodeColor(color),
})
- })
+ }
- let helperFns = findHelperFunctionsInDocument(state, document)
- helperFns.forEach((fn) => {
+ for (let fn of doc.helperFns()) {
let keys = stringToPath(fn.path)
let base = fn.helper === 'theme' ? ['theme'] : []
- let value = dlv(state.config, [...base, ...keys])
+ let value = dlv(doc.state.config, [...base, ...keys])
+
let color = getColorFromValue(value)
- if (color && typeof color !== 'string' && (color.alpha ?? 1) !== 0) {
- colors.push({ range: fn.ranges.path, color: culoriColorToVscodeColor(color) })
- }
- })
+ if (!color) continue
+ if (typeof color === 'string') continue
+ if ((color.alpha ?? 1) === 0) continue
+
+ colors.push({
+ range: fn.ranges.path,
+ color: culoriColorToVscodeColor(color),
+ })
+ }
return dedupeByRange(colors)
}
diff --git a/packages/tailwindcss-language-service/src/documents/document.ts b/packages/tailwindcss-language-service/src/documents/document.ts
new file mode 100644
index 000000000..f1a313548
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/documents/document.ts
@@ -0,0 +1,190 @@
+import type { Position, TextDocument } from 'vscode-languageserver-textdocument'
+import type {
+ DocumentClassList,
+ DocumentClassName,
+ DocumentHelperFunction,
+ Settings,
+ State,
+} from '../util/state'
+import type { ServiceOptions } from '../service'
+import { isWithinRange } from '../util/isWithinRange'
+import { getDocumentBlocks, type LanguageBlock } from '../util/language-blocks'
+import {
+ findClassListsInCssRange,
+ findClassListsInHtmlRange,
+ findCustomClassLists,
+ findHelperFunctionsInDocument,
+ findHelperFunctionsInRange,
+ getClassNamesInClassList,
+} from '../util/find'
+import { dedupeBySpan } from '../util/array'
+
+export interface Document {
+ readonly state: State
+ readonly version: number
+ readonly uri: string
+ readonly settings: Settings
+ readonly storage: TextDocument
+
+ /**
+ * Find the language block that contains the cursor
+ */
+ blockAt(cursor: Position): LanguageBlock | null
+
+ /**
+ * Find all class lists in the document
+ */
+ classLists(): Iterable
+
+ /**
+ * Find all class lists at a given cursor position
+ */
+ classListsAt(cursor: Position): Iterable
+
+ /**
+ * Find all class names in the document
+ */
+ classNames(): Iterable
+
+ /**
+ * Find all class names at a given cursor position
+ *
+ * Theoretically, this function should only ever contain one entry
+ * but the presence of custom regexes may produce multiple entries
+ */
+ classNamesAt(cursor: Position): Iterable
+
+ /**
+ * Find all helper functions in the document
+ *
+ * This only applies to CSS contexts. Other document types will produce
+ * zero entries.
+ */
+ helperFns(): Iterable
+
+ /**
+ * Find all helper functions at a given cursor position
+ */
+ helperFnsAt(cursor: Position): Iterable
+}
+
+export async function createVirtualDocument(
+ opts: ServiceOptions,
+ storage: TextDocument,
+): Promise {
+ /**
+ * The state of the server at the time of creation
+ */
+ let state = opts.state()
+
+ /**
+ * The current settings for this document
+ */
+ let settings = await state.editor.getConfiguration(storage.uri)
+
+ /**
+ * Conceptual boundaries of the document where different languages are used
+ *
+ * This is used to determine how the document is structured and what parts
+ * are relevant to the current operation.
+ */
+ let blocks = getDocumentBlocks(state, storage)
+
+ /**
+ * All class lists in the document
+ */
+ let classLists: DocumentClassList[] = []
+
+ for (let block of blocks) {
+ if (block.context === 'css') {
+ classLists.push(...findClassListsInCssRange(state, storage, block.range, block.lang))
+ } else if (block.context === 'html') {
+ classLists.push(...(await findClassListsInHtmlRange(state, storage, 'html', block.range)))
+ } else if (block.context === 'js') {
+ classLists.push(...(await findClassListsInHtmlRange(state, storage, 'jsx', block.range)))
+ }
+ }
+
+ classLists.push(...(await findCustomClassLists(state, storage)))
+
+ classLists.sort((a, b) => a.span[0] - b.span[0] || b.span[1] - a.span[1])
+ classLists = dedupeBySpan(classLists)
+
+ /**
+ * All class names in the document
+ */
+ let classNames: DocumentClassName[] = []
+
+ for (let classList of classLists) {
+ classNames.push(...getClassNamesInClassList(classList, state.blocklist ?? []))
+ }
+
+ classNames.sort((a, b) => a.span[0] - b.span[0] || b.span[1] - a.span[1])
+ classNames = dedupeBySpan(classNames)
+
+ /**
+ * Helper functions in CSS
+ */
+ let helperFns: DocumentHelperFunction[] = []
+
+ for (let block of blocks) {
+ if (block.context === 'css') {
+ helperFns.push(...findHelperFunctionsInRange(storage, block.range))
+ }
+ }
+
+ function blockAt(cursor: Position): LanguageBlock | null {
+ for (let block of blocks) {
+ if (isWithinRange(cursor, block.range)) {
+ return block
+ }
+ }
+
+ return null
+ }
+
+ /**
+ * Find all class lists at a given cursor position
+ */
+ function classListsAt(cursor: Position): DocumentClassList[] {
+ return classLists.filter((classList) => isWithinRange(cursor, classList.range))
+ }
+
+ /**
+ * Find all class names at a given cursor position
+ */
+ function classNamesAt(cursor: Position): DocumentClassName[] {
+ return classNames.filter((className) => isWithinRange(cursor, className.range))
+ }
+
+ /**
+ * Find all class names at a given cursor position
+ */
+ function helperFnsAt(cursor: Position): DocumentHelperFunction[] {
+ return helperFns.filter((fn) => isWithinRange(cursor, fn.ranges.full))
+ }
+
+ return {
+ settings,
+ storage,
+ uri: storage.uri,
+
+ get version() {
+ return storage.version
+ },
+
+ get state() {
+ return opts.state()
+ },
+
+ blockAt,
+
+ classLists: () => classLists.slice(),
+ classListsAt,
+ classNames: () => classNames.slice(),
+ classNamesAt,
+
+ helperFns: () => helperFns.slice(),
+ helperFnsAt,
+ }
+}
diff --git a/packages/tailwindcss-language-service/src/documents/store.ts b/packages/tailwindcss-language-service/src/documents/store.ts
new file mode 100644
index 000000000..904e09dc1
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/documents/store.ts
@@ -0,0 +1,25 @@
+import type { TextDocument } from 'vscode-languageserver-textdocument'
+import type { ServiceOptions } from '../service'
+import { createVirtualDocument, type Document } from './document'
+
+export function createDocumentStore(opts: ServiceOptions) {
+ let documents = new Map()
+
+ return {
+ clear: () => documents.clear(),
+
+ async parse(uri: string | TextDocument) {
+ let textDoc = typeof uri === 'string' ? await opts.fs.document(uri) : uri
+
+ // Return from the cache if the document has not changed
+ let found = documents.get(textDoc.uri)
+ if (found && found[0] === textDoc.version) return found[1]
+
+ let doc = await createVirtualDocument(opts, textDoc)
+
+ documents.set(textDoc.uri, [textDoc.version, doc])
+
+ return doc
+ },
+ }
+}
diff --git a/packages/tailwindcss-language-service/src/fs.ts b/packages/tailwindcss-language-service/src/fs.ts
new file mode 100644
index 000000000..fca75e099
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/fs.ts
@@ -0,0 +1,25 @@
+import { TextDocument } from 'vscode-languageserver-textdocument'
+
+export interface FileSystem {
+ /**
+ * Get the document with the given URI
+ */
+ document(uri: string): Promise
+
+ /**
+ * Resolve the path relative to the given document
+ */
+ resolve(document: TextDocument, relativePath: string): Promise
+
+ /**
+ * List the files and directories in the given directory
+ */
+ readDirectory(document: TextDocument, path: string): Promise
+}
+
+export type FileType = 'unknown' | 'file' | 'directory' | 'symbolic-link'
+
+export interface File {
+ name: string
+ type: FileType
+}
diff --git a/packages/tailwindcss-language-service/src/hoverProvider.ts b/packages/tailwindcss-language-service/src/hoverProvider.ts
index 583cc80f9..5e6675a06 100644
--- a/packages/tailwindcss-language-service/src/hoverProvider.ts
+++ b/packages/tailwindcss-language-service/src/hoverProvider.ts
@@ -3,60 +3,36 @@ import type { Hover, MarkupContent, Position, Range } from 'vscode-languageserve
import { stringifyCss, stringifyConfigValue } from './util/stringify'
import dlv from 'dlv'
import { isCssContext } from './util/css'
-import {
- findAll,
- findClassNameAtPosition,
- findHelperFunctionsInRange,
- indexToPosition,
-} from './util/find'
+import { findAll, indexToPosition } from './util/find'
import { validateApply } from './util/validateApply'
import { getClassNameParts } from './util/getClassNameAtPosition'
import * as jit from './util/jit'
import { validateConfigPath } from './diagnostics/getInvalidConfigPathDiagnostics'
import { isWithinRange } from './util/isWithinRange'
-import type { TextDocument } from 'vscode-languageserver-textdocument'
import { addPixelEquivalentsToValue } from './util/pixelEquivalents'
import { getTextWithoutComments } from './util/doc'
import braces from 'braces'
import { absoluteRange } from './util/absoluteRange'
import { segment } from './util/segment'
+import type { Document } from './documents/document'
-export async function doHover(
- state: State,
- document: TextDocument,
- position: Position,
-): Promise {
+export async function doHover(doc: Document, position: Position): Promise {
return (
- (await provideClassNameHover(state, document, position)) ||
- (await provideThemeDirectiveHover(state, document, position)) ||
- (await provideCssHelperHover(state, document, position)) ||
- (await provideSourceGlobHover(state, document, position))
+ (await provideClassNameHover(doc, position)) ||
+ (await provideThemeDirectiveHover(doc, position)) ||
+ (await provideCssHelperHover(doc, position)) ||
+ (await provideSourceGlobHover(doc, position))
)
}
-async function provideCssHelperHover(
- state: State,
- document: TextDocument,
- position: Position,
-): Promise {
- if (!isCssContext(state, document, position)) {
- return null
- }
-
- const settings = await state.editor.getConfiguration(document.uri)
-
- let helperFns = findHelperFunctionsInRange(document, {
- start: { line: position.line, character: 0 },
- end: { line: position.line + 1, character: 0 },
- })
-
- for (let helperFn of helperFns) {
+async function provideCssHelperHover(doc: Document, position: Position): Promise {
+ for (let helperFn of doc.helperFnsAt(position)) {
if (!isWithinRange(position, helperFn.ranges.path)) continue
- if (helperFn.helper === 'var' && !state.v4) continue
+ if (helperFn.helper === 'var' && !doc.state.v4) continue
let validated = validateConfigPath(
- state,
+ doc.state,
helperFn.path,
helperFn.helper === 'theme' ? ['theme'] : [],
)
@@ -65,13 +41,13 @@ async function provideCssHelperHover(
let value = validated.isValid ? stringifyConfigValue(validated.value) : null
if (value === null) return null
- if (settings.tailwindCSS.showPixelEquivalents) {
- value = addPixelEquivalentsToValue(value, settings.tailwindCSS.rootFontSize)
+ if (doc.settings.tailwindCSS.showPixelEquivalents) {
+ value = addPixelEquivalentsToValue(value, doc.settings.tailwindCSS.rootFontSize)
}
let lines = ['```plaintext', value, '```']
- if (state.v4 && helperFn.path.startsWith('--')) {
+ if (doc.state.v4 && helperFn.path.startsWith('--')) {
lines = [
//
'```css',
@@ -91,14 +67,11 @@ async function provideCssHelperHover(
return null
}
-async function provideClassNameHover(
- state: State,
- document: TextDocument,
- position: Position,
-): Promise {
- let className = await findClassNameAtPosition(state, document, position)
- if (className === null) return null
+async function provideClassNameHover(doc: Document, position: Position): Promise {
+ let className = Array.from(doc.classNamesAt(position))[0]
+ if (!className) return null
+ let state = doc.state
if (state.v4) {
let root = state.designSystem.compile([className.className])[0]
@@ -109,7 +82,7 @@ async function provideClassNameHover(
return {
contents: {
language: 'css',
- value: await jit.stringifyRoot(state, root, document.uri),
+ value: await jit.stringifyRoot(state, root, doc.uri),
},
range: className.range,
}
@@ -125,7 +98,7 @@ async function provideClassNameHover(
return {
contents: {
language: 'css',
- value: await jit.stringifyRoot(state, root, document.uri),
+ value: await jit.stringifyRoot(state, root, doc.uri),
},
range: className.range,
}
@@ -134,14 +107,14 @@ async function provideClassNameHover(
const parts = getClassNameParts(state, className.className)
if (!parts) return null
- if (isCssContext(state, document, position)) {
+ if (isCssContext(state, doc.storage, position)) {
let validated = validateApply(state, parts)
if (validated === null || validated.isApplyable === false) {
return null
}
}
- const settings = await state.editor.getConfiguration(document.uri)
+ const settings = await state.editor.getConfiguration(doc.uri)
const css = stringifyCss(
className.className,
@@ -167,11 +140,10 @@ function markdown(lines: string[]): MarkupContent {
}
}
-async function provideSourceGlobHover(
- state: State,
- document: TextDocument,
- position: Position,
-): Promise {
+async function provideSourceGlobHover(doc: Document, position: Position): Promise {
+ let state = doc.state
+ let document = doc.storage
+
if (!isCssContext(state, document, position)) {
return null
}
@@ -230,11 +202,10 @@ async function provideSourceGlobHover(
const PATTERN_AT_THEME = /@(?theme)\s+(?[^{]+)\s*\{/dg
const PATTERN_IMPORT_THEME = /@(?import)\s*[^;]+?theme\((?[^)]+)\)/dg
-async function provideThemeDirectiveHover(
- state: State,
- document: TextDocument,
- position: Position,
-): Promise {
+async function provideThemeDirectiveHover(doc: Document, position: Position): Promise {
+ let state = doc.state
+ let document = doc.storage
+
if (!state.v4) return null
let range = {
diff --git a/packages/tailwindcss-language-service/src/index.ts b/packages/tailwindcss-language-service/src/index.ts
index b8ad02f69..5c5737dfe 100644
--- a/packages/tailwindcss-language-service/src/index.ts
+++ b/packages/tailwindcss-language-service/src/index.ts
@@ -1,9 +1,4 @@
-export { doComplete, resolveCompletionItem, completionsFromClassList } from './completionProvider'
-export { doValidate } from './diagnostics/diagnosticsProvider'
-export { doHover } from './hoverProvider'
-export { doCodeActions } from './codeActions/codeActionProvider'
-export { getDocumentColors } from './documentColorProvider'
-export { getDocumentLinks } from './documentLinksProvider'
export * from './util/state'
export * from './diagnostics/types'
export * from './util/color'
+export * from './service'
diff --git a/packages/tailwindcss-language-service/src/service.ts b/packages/tailwindcss-language-service/src/service.ts
new file mode 100644
index 000000000..e6fc04c23
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/service.ts
@@ -0,0 +1,193 @@
+import type { Position, Range, TextDocument } from 'vscode-languageserver-textdocument'
+import {
+ type Color,
+ type CodeAction,
+ type CodeActionContext,
+ type CodeActionParams,
+ type CodeLens,
+ type ColorInformation,
+ type CompletionContext,
+ type CompletionItem,
+ type CompletionList,
+ type DocumentLink,
+ type Hover,
+ type ColorPresentation,
+ type DocumentDiagnosticReport,
+} from 'vscode-languageserver'
+import type { State } from './util/state'
+import type { DiagnosticKind } from './diagnostics/types'
+import type { FileSystem } from './fs'
+import picomatch from 'picomatch'
+import { doHover } from './hoverProvider'
+import { getDocumentLinks } from './documentLinksProvider'
+import { getDocumentColors } from './documentColorProvider'
+import { getCodeLens } from './codeLensProvider'
+import { doComplete, resolveCompletionItem, completionsFromClassList } from './completionProvider'
+import { doValidate } from './diagnostics/diagnosticsProvider'
+import { doCodeActions } from './codeActions/codeActionProvider'
+import { provideColorPresentation } from './colorPresentationProvider'
+import { getColor, KeywordColor } from './util/color'
+import * as culori from 'culori'
+import { Document } from './documents/document'
+import { createDocumentStore } from './documents/store'
+
+export interface ServiceOptions {
+ fs: FileSystem
+ state: () => State
+}
+
+export interface LanguageDocument {
+ hover(position: Position): Promise
+ colorPresentation(color: Color, range: Range): Promise
+ documentLinks(): Promise
+ documentColors(): Promise
+ codeLenses(): Promise
+ diagnostics(kinds?: DiagnosticKind[]): Promise
+ codeActions(range: Range, context: CodeActionContext): Promise
+ completions(position: Position, ctx?: CompletionContext): Promise
+ resolveCompletion(item: CompletionItem): Promise
+}
+
+export interface LanguageService {
+ open(doc: TextDocument | string): Promise
+ resolveCompletion(item: CompletionItem): Promise
+ onUpdateSettings(): Promise
+
+ /** @internal */
+ getColor(className: string): Promise
+
+ /** @internal */
+ completionsFromClassList(classList: string, range: Range): Promise
+}
+
+export function createLanguageService(opts: ServiceOptions): LanguageService {
+ let store = createDocumentStore(opts)
+
+ async function open(doc: TextDocument | string) {
+ return createLanguageDocument(opts, await store.parse(doc))
+ }
+
+ return {
+ open,
+ async getColor(className: string) {
+ return getColor(opts.state(), className)
+ },
+ async completionsFromClassList(classList: string, range: Range) {
+ return completionsFromClassList(opts.state(), classList, range, 16)
+ },
+ async resolveCompletion(item: CompletionItem) {
+ // Figure out what document this completion item belongs to
+ let uri = item.data?.uri
+ if (!uri) return Promise.resolve(item)
+
+ let textDoc = await opts.fs.document(uri)
+ if (!textDoc) return Promise.resolve(item)
+
+ let doc = await open(textDoc)
+
+ return doc.resolveCompletion(item)
+ },
+
+ async onUpdateSettings() {
+ store.clear()
+ },
+ }
+}
+
+async function createLanguageDocument(
+ opts: ServiceOptions,
+ doc: Document,
+): Promise {
+ let state = opts.state()
+ if (!state.enabled) return null
+ if (!state.editor) throw new Error('No editor provided')
+
+ state.editor.readDirectory = async (doc, filepath) => {
+ let files = await opts.fs.readDirectory(doc, filepath)
+
+ return files.map((file) => [file.name, { isDirectory: file.type === 'directory' }])
+ }
+
+ // Get the settings for the current document
+ let settings = await state.editor.getConfiguration(doc.uri)
+ if (!settings) throw new Error('Unable to get the settings for the current document')
+
+ // Should we ignore this file?
+ let exclusions = settings.tailwindCSS.files.exclude.map((pattern) => {
+ return picomatch(`${state.editor.folder}/${pattern}`)
+ })
+
+ for (let isExcluded of exclusions) {
+ if (isExcluded(doc.uri)) return null
+ }
+
+ return {
+ async hover(position: Position) {
+ if (!state.enabled || !settings.tailwindCSS.hovers) return null
+
+ return doHover(doc, position)
+ },
+
+ async documentLinks() {
+ if (!state.enabled) return []
+
+ return getDocumentLinks(state, doc.storage, (path) => {
+ return opts.fs.resolve(doc.storage, path)
+ })
+ },
+
+ async documentColors() {
+ if (!state.enabled || !settings.tailwindCSS.colorDecorators) return []
+
+ return getDocumentColors(doc)
+ },
+
+ async colorPresentation(color: Color, range: Range) {
+ if (!state.enabled || !settings.tailwindCSS.colorDecorators) return []
+
+ return provideColorPresentation(state, doc.storage, color, range)
+ },
+
+ async codeLenses() {
+ if (!state.enabled || !settings.tailwindCSS.codeLens) return []
+
+ return getCodeLens(state, doc.storage)
+ },
+
+ async diagnostics(kinds?: DiagnosticKind[]) {
+ if (!state.enabled || !settings.tailwindCSS.validate) {
+ return {
+ kind: 'full',
+ items: [],
+ }
+ }
+
+ return doValidate(state, doc.storage, kinds)
+ },
+
+ async codeActions(range: Range, context: CodeActionContext) {
+ if (!state.enabled || !settings.tailwindCSS.codeActions) return []
+
+ let params: CodeActionParams = {
+ textDocument: { uri: doc.uri },
+ range,
+ context,
+ }
+
+ return doCodeActions(state, params, doc.storage)
+ },
+
+ async completions(position: Position, ctx?: CompletionContext) {
+ if (!state.enabled || !settings.tailwindCSS.suggestions) return null
+
+ state.completionItemData.uri = doc.uri
+ return doComplete(state, doc.storage, position, ctx)
+ },
+
+ async resolveCompletion(item: CompletionItem) {
+ if (!state.enabled || !settings.tailwindCSS.suggestions) return item
+
+ return resolveCompletionItem(state, item)
+ },
+ }
+}
diff --git a/packages/tailwindcss-language-service/src/util/array.ts b/packages/tailwindcss-language-service/src/util/array.ts
index 9c9826405..52379e347 100644
--- a/packages/tailwindcss-language-service/src/util/array.ts
+++ b/packages/tailwindcss-language-service/src/util/array.ts
@@ -1,5 +1,7 @@
import type { Range } from 'vscode-languageserver'
import { rangesEqual } from './rangesEqual'
+import { Span } from './state'
+import { spansEqual } from './spans-equal'
export function dedupe(arr: Array): Array {
return arr.filter((value, index, self) => self.indexOf(value) === index)
@@ -16,6 +18,13 @@ export function dedupeByRange(arr: Array): Array<
)
}
+export function dedupeBySpan(arr: Array): Array {
+ return arr.filter(
+ (classList, classListIndex) =>
+ classListIndex === arr.findIndex((c) => spansEqual(c.span, classList.span)),
+ )
+}
+
export function ensureArray(value: T | T[]): T[] {
return Array.isArray(value) ? value : [value]
}
diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts
index 9c65f23d3..fcfddcb7d 100644
--- a/packages/tailwindcss-language-service/src/util/find.test.ts
+++ b/packages/tailwindcss-language-service/src/util/find.test.ts
@@ -39,6 +39,7 @@ test('class regex works in astro', async ({ expect }) => {
expect(classLists).toEqual([
{
classList: 'p-4 sm:p-2 $',
+ span: [10, 22],
range: {
start: { line: 0, character: 10 },
end: { line: 0, character: 22 },
@@ -46,6 +47,7 @@ test('class regex works in astro', async ({ expect }) => {
},
{
classList: 'underline',
+ span: [33, 42],
range: {
start: { line: 0, character: 33 },
end: { line: 0, character: 42 },
@@ -53,6 +55,7 @@ test('class regex works in astro', async ({ expect }) => {
},
{
classList: 'line-through',
+ span: [46, 58],
range: {
start: { line: 0, character: 46 },
end: { line: 0, character: 58 },
@@ -111,6 +114,7 @@ test('find class lists in functions', async ({ expect }) => {
// from clsx(…)
{
classList: 'flex p-4',
+ span: [45, 53],
range: {
start: { line: 2, character: 3 },
end: { line: 2, character: 11 },
@@ -118,6 +122,7 @@ test('find class lists in functions', async ({ expect }) => {
},
{
classList: 'block sm:p-0',
+ span: [59, 71],
range: {
start: { line: 3, character: 3 },
end: { line: 3, character: 15 },
@@ -125,6 +130,7 @@ test('find class lists in functions', async ({ expect }) => {
},
{
classList: 'text-white',
+ span: [96, 106],
range: {
start: { line: 4, character: 22 },
end: { line: 4, character: 32 },
@@ -132,6 +138,7 @@ test('find class lists in functions', async ({ expect }) => {
},
{
classList: 'text-black',
+ span: [111, 121],
range: {
start: { line: 4, character: 37 },
end: { line: 4, character: 47 },
@@ -141,6 +148,7 @@ test('find class lists in functions', async ({ expect }) => {
// from cva(…)
{
classList: 'flex p-4',
+ span: [171, 179],
range: {
start: { line: 9, character: 3 },
end: { line: 9, character: 11 },
@@ -148,6 +156,7 @@ test('find class lists in functions', async ({ expect }) => {
},
{
classList: 'block sm:p-0',
+ span: [185, 197],
range: {
start: { line: 10, character: 3 },
end: { line: 10, character: 15 },
@@ -155,6 +164,7 @@ test('find class lists in functions', async ({ expect }) => {
},
{
classList: 'text-white',
+ span: [222, 232],
range: {
start: { line: 11, character: 22 },
end: { line: 11, character: 32 },
@@ -162,6 +172,7 @@ test('find class lists in functions', async ({ expect }) => {
},
{
classList: 'text-black',
+ span: [237, 247],
range: {
start: { line: 11, character: 37 },
end: { line: 11, character: 47 },
@@ -219,6 +230,7 @@ test('find class lists in nested fn calls', async ({ expect }) => {
expect(classLists).toMatchObject([
{
classList: 'flex',
+ span: [193, 197],
range: {
start: { line: 3, character: 3 },
end: { line: 3, character: 7 },
@@ -228,6 +240,7 @@ test('find class lists in nested fn calls', async ({ expect }) => {
// TODO: This should be ignored because they're inside cn(…)
{
classList: 'bg-red-500',
+ span: [212, 222],
range: {
start: { line: 5, character: 5 },
end: { line: 5, character: 15 },
@@ -237,6 +250,7 @@ test('find class lists in nested fn calls', async ({ expect }) => {
// TODO: This should be ignored because they're inside cn(…)
{
classList: 'text-white',
+ span: [236, 246],
range: {
start: { line: 6, character: 5 },
end: { line: 6, character: 15 },
@@ -245,6 +259,7 @@ test('find class lists in nested fn calls', async ({ expect }) => {
{
classList: 'fixed',
+ span: [286, 291],
range: {
start: { line: 9, character: 5 },
end: { line: 9, character: 10 },
@@ -252,6 +267,7 @@ test('find class lists in nested fn calls', async ({ expect }) => {
},
{
classList: 'absolute inset-0',
+ span: [299, 315],
range: {
start: { line: 10, character: 5 },
end: { line: 10, character: 21 },
@@ -259,6 +275,7 @@ test('find class lists in nested fn calls', async ({ expect }) => {
},
{
classList: 'bottom-0',
+ span: [335, 343],
range: {
start: { line: 13, character: 6 },
end: { line: 13, character: 14 },
@@ -266,6 +283,7 @@ test('find class lists in nested fn calls', async ({ expect }) => {
},
{
classList: 'border',
+ span: [347, 353],
range: {
start: { line: 13, character: 18 },
end: { line: 13, character: 24 },
@@ -273,6 +291,7 @@ test('find class lists in nested fn calls', async ({ expect }) => {
},
{
classList: 'bottom-0 left-0',
+ span: [419, 434],
range: {
start: { line: 17, character: 20 },
end: { line: 17, character: 35 },
@@ -280,6 +299,7 @@ test('find class lists in nested fn calls', async ({ expect }) => {
},
{
classList: `inset-0\n rounded-none\n `,
+ span: [468, 500],
range: {
start: { line: 19, character: 12 },
// TODO: Fix the range calculation. Its wrong on this one
@@ -321,6 +341,7 @@ test('find class lists in nested fn calls (only nested matches)', async ({ expec
expect(classLists).toMatchObject([
{
classList: 'fixed',
+ span: [228, 233],
range: {
start: { line: 9, character: 5 },
end: { line: 9, character: 10 },
@@ -328,6 +349,7 @@ test('find class lists in nested fn calls (only nested matches)', async ({ expec
},
{
classList: 'absolute inset-0',
+ span: [241, 257],
range: {
start: { line: 10, character: 5 },
end: { line: 10, character: 21 },
@@ -386,6 +408,7 @@ test('find class lists in tagged template literals', async ({ expect }) => {
// from clsx`…`
{
classList: 'flex p-4\n block sm:p-0\n $',
+ span: [44, 71],
range: {
start: { line: 2, character: 2 },
end: { line: 4, character: 3 },
@@ -393,6 +416,7 @@ test('find class lists in tagged template literals', async ({ expect }) => {
},
{
classList: 'text-white',
+ span: [92, 102],
range: {
start: { line: 4, character: 24 },
end: { line: 4, character: 34 },
@@ -400,6 +424,7 @@ test('find class lists in tagged template literals', async ({ expect }) => {
},
{
classList: 'text-black',
+ span: [107, 117],
range: {
start: { line: 4, character: 39 },
end: { line: 4, character: 49 },
@@ -409,6 +434,7 @@ test('find class lists in tagged template literals', async ({ expect }) => {
// from cva`…`
{
classList: 'flex p-4\n block sm:p-0\n $',
+ span: [166, 193],
range: {
start: { line: 9, character: 2 },
end: { line: 11, character: 3 },
@@ -416,6 +442,7 @@ test('find class lists in tagged template literals', async ({ expect }) => {
},
{
classList: 'text-white',
+ span: [214, 224],
range: {
start: { line: 11, character: 24 },
end: { line: 11, character: 34 },
@@ -423,6 +450,7 @@ test('find class lists in tagged template literals', async ({ expect }) => {
},
{
classList: 'text-black',
+ span: [229, 239],
range: {
start: { line: 11, character: 39 },
end: { line: 11, character: 49 },
@@ -467,6 +495,7 @@ test('classFunctions can be a regex', async ({ expect }) => {
expect(classListsA).toEqual([
{
classList: 'flex p-4',
+ span: [22, 30],
range: {
start: { line: 0, character: 22 },
end: { line: 0, character: 30 },
@@ -522,6 +551,7 @@ test('Finds consecutive instances of a class function', async ({ expect }) => {
expect(classLists).toEqual([
{
classList: 'relative flex bg-red-500',
+ span: [28, 52],
range: {
start: { line: 1, character: 6 },
end: { line: 1, character: 30 },
@@ -529,6 +559,7 @@ test('Finds consecutive instances of a class function', async ({ expect }) => {
},
{
classList: 'relative flex bg-red-500',
+ span: [62, 86],
range: {
start: { line: 2, character: 6 },
end: { line: 2, character: 30 },
@@ -536,6 +567,7 @@ test('Finds consecutive instances of a class function', async ({ expect }) => {
},
{
classList: 'relative flex bg-red-500',
+ span: [96, 120],
range: {
start: { line: 3, character: 6 },
end: { line: 3, character: 30 },
@@ -585,6 +617,7 @@ test('classFunctions & classAttributes should not duplicate matches', async ({ e
expect(classLists).toEqual([
{
classList: 'relative flex',
+ span: [74, 87],
range: {
start: { line: 3, character: 7 },
end: { line: 3, character: 20 },
@@ -592,6 +625,7 @@ test('classFunctions & classAttributes should not duplicate matches', async ({ e
},
{
classList: 'inset-0 md:h-[calc(100%-2rem)]',
+ span: [97, 127],
range: {
start: { line: 4, character: 7 },
end: { line: 4, character: 37 },
@@ -599,6 +633,7 @@ test('classFunctions & classAttributes should not duplicate matches', async ({ e
},
{
classList: 'rounded-none bg-blue-700',
+ span: [142, 166],
range: {
start: { line: 5, character: 12 },
end: { line: 5, character: 36 },
@@ -606,6 +641,7 @@ test('classFunctions & classAttributes should not duplicate matches', async ({ e
},
{
classList: 'relative flex',
+ span: [294, 307],
range: {
start: { line: 14, character: 7 },
end: { line: 14, character: 20 },
@@ -613,6 +649,7 @@ test('classFunctions & classAttributes should not duplicate matches', async ({ e
},
{
classList: 'inset-0 md:h-[calc(100%-2rem)]',
+ span: [317, 347],
range: {
start: { line: 15, character: 7 },
end: { line: 15, character: 37 },
@@ -620,6 +657,7 @@ test('classFunctions & classAttributes should not duplicate matches', async ({ e
},
{
classList: 'rounded-none bg-blue-700',
+ span: [362, 386],
range: {
start: { line: 16, character: 12 },
end: { line: 16, character: 36 },
@@ -664,6 +702,7 @@ test('classFunctions should only match in JS-like contexts', async ({ expect })
expect(classLists).toEqual([
{
classList: 'relative flex',
+ span: [130, 143],
range: {
start: { line: 5, character: 16 },
end: { line: 5, character: 29 },
@@ -671,6 +710,7 @@ test('classFunctions should only match in JS-like contexts', async ({ expect })
},
{
classList: 'relative flex',
+ span: [162, 175],
range: {
start: { line: 6, character: 16 },
end: { line: 6, character: 29 },
@@ -678,6 +718,7 @@ test('classFunctions should only match in JS-like contexts', async ({ expect })
},
{
classList: 'relative flex',
+ span: [325, 338],
range: {
start: { line: 14, character: 16 },
end: { line: 14, character: 29 },
@@ -685,6 +726,7 @@ test('classFunctions should only match in JS-like contexts', async ({ expect })
},
{
classList: 'relative flex',
+ span: [357, 370],
range: {
start: { line: 15, character: 16 },
end: { line: 15, character: 29 },
@@ -724,6 +766,7 @@ test('classAttributes find class lists inside variables in JS(X)/TS(X)', async (
expect(classLists).toEqual([
{
classList: 'relative flex',
+ span: [24, 37],
range: {
start: { line: 1, character: 6 },
end: { line: 1, character: 19 },
@@ -731,6 +774,7 @@ test('classAttributes find class lists inside variables in JS(X)/TS(X)', async (
},
{
classList: 'relative flex',
+ span: [60, 73],
range: {
start: { line: 3, character: 8 },
end: { line: 3, character: 21 },
@@ -738,6 +782,7 @@ test('classAttributes find class lists inside variables in JS(X)/TS(X)', async (
},
{
classList: 'relative flex',
+ span: [102, 115],
range: {
start: { line: 6, character: 8 },
end: { line: 6, character: 21 },
@@ -765,6 +810,7 @@ test('classAttributes find class lists inside pug', async ({ expect }) => {
expect(classLists).toEqual([
{
classList: 'relative flex',
+ span: [15, 28],
range: {
start: { line: 0, character: 15 },
end: { line: 0, character: 28 },
@@ -794,6 +840,7 @@ test('classAttributes find class lists inside Vue bindings', async ({ expect })
expect(classLists).toEqual([
{
classList: 'relative flex',
+ span: [28, 41],
range: {
start: { line: 1, character: 17 },
end: { line: 1, character: 30 },
diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts
index 11f5946fb..303abfab3 100644
--- a/packages/tailwindcss-language-service/src/util/find.ts
+++ b/packages/tailwindcss-language-service/src/util/find.ts
@@ -33,7 +33,7 @@ export function findLast(re: RegExp, str: string): RegExpMatchArray {
}
export function getClassNamesInClassList(
- { classList, range, important }: DocumentClassList,
+ { classList, span, range, important }: DocumentClassList,
blocklist: State['blocklist'],
): DocumentClassName[] {
const parts = classList.split(/(\s+)/)
@@ -41,13 +41,16 @@ export function getClassNamesInClassList(
let index = 0
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0 && !blocklist.includes(parts[i])) {
+ const classNameSpan = [index, index + parts[i].length]
const start = indexToPosition(classList, index)
const end = indexToPosition(classList, index + parts[i].length)
names.push({
className: parts[i],
+ span: [span[0] + classNameSpan[0], span[0] + classNameSpan[1]],
classList: {
classList,
range,
+ span,
important,
},
relativeRange: {
@@ -107,11 +110,19 @@ export function findClassListsInCssRange(
const matches = findAll(regex, text)
const globalStart: Position = range ? range.start : { line: 0, character: 0 }
+ const rangeStartOffset = doc.offsetAt(globalStart)
+
return matches.map((match) => {
- const start = indexToPosition(text, match.index + match[1].length)
- const end = indexToPosition(text, match.index + match[1].length + match.groups.classList.length)
+ let span = [
+ match.index + match[1].length,
+ match.index + match[1].length + match.groups.classList.length,
+ ] as [number, number]
+
+ const start = indexToPosition(text, span[0])
+ const end = indexToPosition(text, span[1])
return {
classList: match.groups.classList,
+ span: [rangeStartOffset + span[0], rangeStartOffset + span[1]],
important: Boolean(match.groups.important),
range: {
start: {
@@ -127,7 +138,7 @@ export function findClassListsInCssRange(
})
}
-async function findCustomClassLists(
+export async function findCustomClassLists(
state: State,
doc: TextDocument,
range?: Range,
@@ -143,6 +154,7 @@ async function findCustomClassLists(
for (let match of customClassesIn({ text, filters: regexes })) {
result.push({
classList: match.classList,
+ span: match.range,
range: {
start: doc.positionAt(match.range[0]),
end: doc.positionAt(match.range[1]),
@@ -225,6 +237,8 @@ export async function findClassListsInHtmlRange(
const existingResultSet = new Set()
const results: DocumentClassList[] = []
+ const rangeStartOffset = doc.offsetAt(range?.start || { line: 0, character: 0 })
+
matches.forEach((match) => {
const subtext = text.substr(match.index + match[0].length - 1)
@@ -278,13 +292,16 @@ export async function findClassListsInHtmlRange(
const after = value.match(/\s*$/)
const afterOffset = after === null ? 0 : -after[0].length
- const start = indexToPosition(text, match.index + match[0].length - 1 + offset + beforeOffset)
- const end = indexToPosition(
- text,
+ let span = [
+ match.index + match[0].length - 1 + offset + beforeOffset,
match.index + match[0].length - 1 + offset + value.length + afterOffset,
- )
+ ]
+
+ const start = indexToPosition(text, span[0])
+ const end = indexToPosition(text, span[1])
const result: DocumentClassList = {
+ span: [rangeStartOffset + span[0], rangeStartOffset + span[1]] as [number, number],
classList: value.substr(beforeOffset, value.length + afterOffset),
range: {
start: {
@@ -405,6 +422,8 @@ export function findHelperFunctionsInRange(
): DocumentHelperFunction[] {
let text = getTextWithoutComments(doc, 'css', range)
+ let rangeStartOffset = range?.start ? doc.offsetAt(range.start) : 0
+
// Find every instance of a helper function
let matches = findAll(/\b(?config|theme|--theme|var)\(/g, text)
@@ -573,6 +592,16 @@ export function findHelperFunctionsInRange(
range,
),
},
+ spans: {
+ full: [
+ rangeStartOffset + startIndex,
+ rangeStartOffset + startIndex + match.groups.path.length,
+ ],
+ path: [
+ rangeStartOffset + startIndex + quotesBefore.length,
+ rangeStartOffset + startIndex + quotesBefore.length + path.length,
+ ],
+ },
})
}
diff --git a/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts b/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts
index 786cc2f71..cacd8eaff 100644
--- a/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts
+++ b/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts
@@ -4,7 +4,7 @@ import { isVueDoc, isHtmlDoc, isSvelteDoc } from './html'
import type { State } from './state'
import { indexToPosition } from './find'
import { isJsDoc } from './js'
-import moo from 'moo'
+import moo, { type Rules } from 'moo'
import Cache from 'tmp-cache'
import { getTextWithoutComments } from './doc'
import { isCssLanguage } from './css'
@@ -12,6 +12,7 @@ import { isCssLanguage } from './css'
export type LanguageBoundary = {
type: 'html' | 'js' | 'jsx' | 'css' | (string & {})
range: Range
+ span: [number, number]
lang?: string
}
@@ -29,9 +30,11 @@ let jsxScriptTypes = [
'text/babel',
]
+type States = { [x: string]: Rules }
+
let text = { text: { match: /[^]/, lineBreaks: true } }
-let states = {
+let states: States = {
main: {
cssBlockStart: { match: /
+ `,
+ })
+
+ expect(await doc.hover({ line: 2, character: 13 })).toEqual({
+ range: range(2, 11, 2, 20),
+ contents: {
+ language: 'css',
+ value: '.underline {\n' + ' text-decoration-line: underline;\n' + '}',
+ },
+ })
+ })
+
+ test('@source glob expansion', async () => {
+ let doc = await client.open({
+ lang: 'css',
+ text: css`
+ @source "../{app,components}/**/*.jsx";
+ `,
+ })
+
+ expect(await doc.hover({ line: 0, character: 23 })).toEqual({
+ range: range(0, 8, 0, 38),
+ contents: {
+ kind: 'markdown',
+ value: [
+ '**Expansion**',
+ '```plaintext',
+ '- ../app/**/*.jsx',
+ '- ../components/**/*.jsx',
+ '```',
+ ].join('\n'),
+ },
+ })
+ })
+
+ test('@source not glob expansion', async () => {
+ let doc = await client.open({
+ lang: 'css',
+ text: css`
+ @source not "../{app,components}/**/*.jsx";
+ `,
+ })
+
+ expect(await doc.hover({ line: 0, character: 23 })).toEqual({
+ range: range(0, 12, 0, 42),
+ contents: {
+ kind: 'markdown',
+ value: [
+ '**Expansion**',
+ '```plaintext',
+ '- ../app/**/*.jsx',
+ '- ../components/**/*.jsx',
+ '```',
+ ].join('\n'),
+ },
+ })
+ })
+
+ test('@source inline(…) glob expansion', async () => {
+ let doc = await client.open({
+ lang: 'css',
+ text: css`
+ @source inline("{,hover:,active:}m-{1,2,3}");
+ `,
+ })
+
+ expect(await doc.hover({ line: 0, character: 23 })).toEqual({
+ range: range(0, 15, 0, 43),
+ contents: {
+ kind: 'markdown',
+ value: [
+ '**Expansion**',
+ '```plaintext',
+ '- m-1',
+ '- m-2',
+ '- m-3',
+ '- hover:m-1',
+ '- hover:m-2',
+ '- hover:m-3',
+ '- active:m-1',
+ '- active:m-2',
+ '- active:m-3',
+ '```',
+ ].join('\n'),
+ },
+ })
+ })
+
+ test('@source not inline(…) glob expansion', async () => {
+ let doc = await client.open({
+ lang: 'css',
+ text: css`
+ @source not inline("{,hover:,active:}m-{1,2,3}");
+ `,
+ })
+
+ expect(await doc.hover({ line: 0, character: 23 })).toEqual({
+ range: range(0, 19, 0, 47),
+ contents: {
+ kind: 'markdown',
+ value: [
+ '**Expansion**',
+ '```plaintext',
+ '- m-1',
+ '- m-2',
+ '- m-3',
+ '- hover:m-1',
+ '- hover:m-2',
+ '- hover:m-3',
+ '- active:m-1',
+ '- active:m-2',
+ '- active:m-3',
+ '```',
+ ].join('\n'),
+ },
+ })
+ })
+
+ test('--theme() inside media query', async () => {
+ let doc = await client.open({
+ lang: 'css',
+ text: css`
+ @media (width>=--theme(--breakpoint-xl)) {
+ .foo {
+ color: red;
+ }
+ }
+ `,
+ })
+
+ expect(await doc.hover({ line: 0, character: 23 })).toEqual({
+ range: range(0, 23, 0, 38),
+ contents: {
+ kind: 'markdown',
+ value: [
+ //
+ '```css',
+ '@theme {',
+ ' --breakpoint-xl: 80rem /* 1280px */;',
+ '}',
+ '```',
+ ].join('\n'),
+ },
+ })
+ })
+
+ test('var(…) and theme(…) show theme values', async () => {
+ let doc = await client.open({
+ lang: 'css',
+ text: css`
+ .foo {
+ color: theme(--color-black);
+ }
+ .bar {
+ color: var(--color-black);
+ }
+ `,
+ })
+
+ // color: theme(--color-black);
+ // ^
+ let hoverTheme = await doc.hover({ line: 1, character: 18 })
+
+ // color: var(--color-black);
+ // ^
+ let hoverVar = await doc.hover({ line: 4, character: 16 })
+
+ expect(hoverTheme).toEqual({
+ range: range(1, 15, 1, 28),
+ contents: {
+ kind: 'markdown',
+ value: [
+ //
+ '```css',
+ '@theme {',
+ ' --color-black: #000;',
+ '}',
+ '```',
+ ].join('\n'),
+ },
+ })
+
+ expect(hoverVar).toEqual({
+ range: range(4, 13, 4, 26),
+ contents: {
+ kind: 'markdown',
+ value: [
+ //
+ '```css',
+ '@theme {',
+ ' --color-black: #000;',
+ '}',
+ '```',
+ ].join('\n'),
+ },
+ })
+ })
+})
diff --git a/packages/tailwindcss-language-service/tsconfig.json b/packages/tailwindcss-language-service/tsconfig.json
index 10bf6a9f2..26197eb6d 100644
--- a/packages/tailwindcss-language-service/tsconfig.json
+++ b/packages/tailwindcss-language-service/tsconfig.json
@@ -1,5 +1,5 @@
{
- "include": ["src", "../../types"],
+ "include": ["src", "tests", "../../types"],
"compilerOptions": {
"module": "ES2022",
"lib": ["ES2022"],
@@ -7,7 +7,6 @@
"importHelpers": true,
"declaration": true,
"sourceMap": true,
- "rootDir": "./src",
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0783099ad..46a3762d4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -345,12 +345,18 @@ importers:
'@types/node':
specifier: ^18.19.33
version: 18.19.43
+ '@types/picomatch':
+ specifier: ^2.3.3
+ version: 2.3.4
'@types/stringify-object':
specifier: ^4.0.5
version: 4.0.5
dedent:
specifier: ^1.5.3
version: 1.5.3
+ deepmerge:
+ specifier: 4.2.2
+ version: 4.2.2
esbuild:
specifier: ^0.25.5
version: 0.25.5
@@ -360,6 +366,12 @@ importers:
minimist:
specifier: ^1.2.8
version: 1.2.8
+ picomatch:
+ specifier: ^4.0.1
+ version: 4.0.2
+ tailwindcss-v4:
+ specifier: npm:tailwindcss@4.1.1
+ version: tailwindcss@4.1.1
tslib:
specifier: 2.2.0
version: 2.2.0
@@ -372,6 +384,9 @@ importers:
vitest:
specifier: ^3.2.1
version: 3.2.1(@types/node@18.19.43)(jiti@2.3.3)(yaml@2.5.0)
+ vscode-uri:
+ specifier: 3.0.2
+ version: 3.0.2
packages/tailwindcss-language-syntax:
devDependencies: