diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index af79a0082..2b25480a0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,8 +26,11 @@ jobs:
- name: Install dependencies
run: pnpm install
- - name: Run tests
+ - name: Run service tests
+ working-directory: packages/tailwindcss-language-service
+ run: pnpm run build && pnpm run test
+
+ - name: Run server tests
+ working-directory: packages/tailwindcss-language-server
run: |
- cd packages/tailwindcss-language-server &&
- pnpm run build &&
- pnpm run test
+ pnpm run build && pnpm run test
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 04160569e..2a71e9610 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 = []
@@ -1186,7 +1209,9 @@ export async function createProjectService(
;(await disposable).dispose()
}
},
- async onUpdateSettings(settings: any): Promise {
+ async onUpdateSettings(): Promise {
+ service.onUpdateSettings()
+
if (state.enabled) {
refreshDiagnostics()
}
@@ -1196,139 +1221,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 20ac01581..5a4807c76 100644
--- a/packages/tailwindcss-language-server/src/tw.ts
+++ b/packages/tailwindcss-language-server/src/tw.ts
@@ -643,7 +643,7 @@ export class TW {
this.setupLSPHandlers()
this.disposables.push(
- this.connection.onDidChangeConfiguration(async ({ settings }) => {
+ this.connection.onDidChangeConfiguration(async () => {
let previousExclude = globalSettings.tailwindCSS.files.exclude
this.settingsCache.clear()
@@ -656,7 +656,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 2a1e3079e..2bf38ba11 100644
--- a/packages/tailwindcss-language-service/package.json
+++ b/packages/tailwindcss-language-service/package.json
@@ -45,13 +45,21 @@
"@types/dedent": "^0.7.2",
"@types/line-column": "^1.0.2",
"@types/node": "^18.19.33",
+ "@types/normalize-path": "^3.0.2",
+ "@types/picomatch": "^2.3.3",
"@types/stringify-object": "^4.0.5",
"dedent": "^1.5.3",
+ "deepmerge": "4.2.2",
"esbuild": "^0.25.0",
"esbuild-node-externals": "^1.9.0",
+ "memfs": "^4.17.0",
"minimist": "^1.2.8",
+ "normalize-path": "3.0.0",
+ "picomatch": "^4.0.1",
+ "tailwindcss-v4": "npm:tailwindcss@4.1.1",
"tslib": "2.2.0",
"typescript": "^5.3.3",
- "vitest": "^3.0.9"
+ "vitest": "^3.0.9",
+ "vscode-uri": "3.0.2"
}
}
diff --git a/packages/tailwindcss-language-service/scripts/build.mjs b/packages/tailwindcss-language-service/scripts/build.mjs
index 128426bed..6ffb8d299 100644
--- a/packages/tailwindcss-language-service/scripts/build.mjs
+++ b/packages/tailwindcss-language-service/scripts/build.mjs
@@ -3,8 +3,9 @@ import { spawnSync } from 'node:child_process'
import esbuild from 'esbuild'
import minimist from 'minimist'
import { nodeExternalsPlugin } from 'esbuild-node-externals'
+import { fileURLToPath } from 'node:url'
-const __dirname = new URL('.', import.meta.url).pathname
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
const args = minimist(process.argv.slice(2), {
boolean: ['watch', 'minify'],
@@ -26,11 +27,17 @@ 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',
- ['-p', path.resolve(__dirname, './tsconfig.build.json'), '--emitDeclarationOnly', '--outDir', path.resolve(__dirname, '../dist')],
+ [
+ '-p',
+ path.resolve(__dirname, './tsconfig.build.json'),
+ '--emitDeclarationOnly',
+ '--outDir',
+ path.resolve(__dirname, '../dist'),
+ ],
{
stdio: 'inherit',
},
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/codeLensProvider.ts b/packages/tailwindcss-language-service/src/codeLensProvider.ts
index b00df983d..66c3b5780 100644
--- a/packages/tailwindcss-language-service/src/codeLensProvider.ts
+++ b/packages/tailwindcss-language-service/src/codeLensProvider.ts
@@ -19,15 +19,15 @@ export async function getCodeLens(state: State, doc: TextDocument): Promise'[^']+'|"[^"]+")/dg
+const countFormatter = new Intl.NumberFormat('en', {
+ maximumFractionDigits: 2,
+})
+
async function sourceInlineCodeLens(state: State, doc: TextDocument): Promise {
if (!state.features.includes('source-inline')) return []
let text = doc.getText()
- let countFormatter = new Intl.NumberFormat('en', {
- maximumFractionDigits: 2,
- })
-
let lenses: CodeLens[] = []
for (let match of findAll(SOURCE_INLINE_PATTERN, text)) {
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/css/ast.ts b/packages/tailwindcss-language-service/src/css/ast.ts
new file mode 100644
index 000000000..39b49c431
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/css/ast.ts
@@ -0,0 +1,117 @@
+import { parseAtRule } from './parse'
+import type { SourceLocation } from './source'
+
+const AT_SIGN = 0x40
+
+export type StyleRule = {
+ kind: 'rule'
+ selector: string
+ nodes: AstNode[]
+
+ src?: SourceLocation
+ dst?: SourceLocation
+}
+
+export type AtRule = {
+ kind: 'at-rule'
+ name: string
+ params: string
+ nodes: AstNode[]
+
+ src?: SourceLocation
+ dst?: SourceLocation
+}
+
+export type Declaration = {
+ kind: 'declaration'
+ property: string
+ value: string | undefined
+ important: boolean
+
+ src?: SourceLocation
+ dst?: SourceLocation
+}
+
+export type Comment = {
+ kind: 'comment'
+ value: string
+
+ src?: SourceLocation
+ dst?: SourceLocation
+}
+
+export type Context = {
+ kind: 'context'
+ context: Record
+ nodes: AstNode[]
+
+ src?: undefined
+ dst?: undefined
+}
+
+export type AtRoot = {
+ kind: 'at-root'
+ nodes: AstNode[]
+
+ src?: undefined
+ dst?: undefined
+}
+
+export type Rule = StyleRule | AtRule
+export type AstNode = StyleRule | AtRule | Declaration | Comment | Context | AtRoot
+
+export function styleRule(selector: string, nodes: AstNode[] = []): StyleRule {
+ return {
+ kind: 'rule',
+ selector,
+ nodes,
+ }
+}
+
+export function atRule(name: string, params: string = '', nodes: AstNode[] = []): AtRule {
+ return {
+ kind: 'at-rule',
+ name,
+ params,
+ nodes,
+ }
+}
+
+export function rule(selector: string, nodes: AstNode[] = []): StyleRule | AtRule {
+ if (selector.charCodeAt(0) === AT_SIGN) {
+ return parseAtRule(selector, nodes)
+ }
+
+ return styleRule(selector, nodes)
+}
+
+export function decl(property: string, value: string | undefined, important = false): Declaration {
+ return {
+ kind: 'declaration',
+ property,
+ value,
+ important,
+ }
+}
+
+export function comment(value: string): Comment {
+ return {
+ kind: 'comment',
+ value: value,
+ }
+}
+
+export function context(context: Record, nodes: AstNode[]): Context {
+ return {
+ kind: 'context',
+ context,
+ nodes,
+ }
+}
+
+export function atRoot(nodes: AstNode[]): AtRoot {
+ return {
+ kind: 'at-root',
+ nodes,
+ }
+}
diff --git a/packages/tailwindcss-language-service/src/css/parse.ts b/packages/tailwindcss-language-service/src/css/parse.ts
new file mode 100644
index 000000000..ddd83d0b8
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/css/parse.ts
@@ -0,0 +1,633 @@
+import {
+ atRule,
+ comment,
+ decl,
+ rule,
+ type AstNode,
+ type AtRule,
+ type Comment,
+ type Declaration,
+ type Rule,
+} from './ast'
+import { createInputSource } from './source'
+
+const BACKSLASH = 0x5c
+const SLASH = 0x2f
+const ASTERISK = 0x2a
+const DOUBLE_QUOTE = 0x22
+const SINGLE_QUOTE = 0x27
+const COLON = 0x3a
+const SEMICOLON = 0x3b
+const LINE_BREAK = 0x0a
+const SPACE = 0x20
+const TAB = 0x09
+const OPEN_CURLY = 0x7b
+const CLOSE_CURLY = 0x7d
+const OPEN_PAREN = 0x28
+const CLOSE_PAREN = 0x29
+const OPEN_BRACKET = 0x5b
+const CLOSE_BRACKET = 0x5d
+const DASH = 0x2d
+const AT_SIGN = 0x40
+const EXCLAMATION_MARK = 0x21
+
+export interface ParseOptions {
+ from?: string
+}
+
+export function parse(input: string, opts?: ParseOptions) {
+ let source = opts?.from ? createInputSource(opts.from, input) : null
+
+ // Note: it is important that any transformations of the input string
+ // *before* processing do NOT change the length of the string. This
+ // would invalidate the mechanism used to track source locations.
+ if (input[0] === '\uFEFF') input = ' ' + input.slice(1)
+ input = input.replaceAll('\r\n', ' \n')
+
+ let ast: AstNode[] = []
+ let licenseComments: Comment[] = []
+
+ let stack: (Rule | null)[] = []
+
+ let parent = null as Rule | null
+ let node = null as AstNode | null
+
+ let buffer = ''
+ let closingBracketStack = ''
+
+ // The start of the first non-whitespace character in the buffer
+ let bufferStart = 0
+
+ let peekChar
+
+ for (let i = 0; i < input.length; i++) {
+ let currentChar = input.charCodeAt(i)
+
+ // Current character is a `\` therefore the next character is escaped,
+ // consume it together with the next character and continue.
+ //
+ // E.g.:
+ //
+ // ```css
+ // .hover\:foo:hover {}
+ // ^
+ // ```
+ //
+ if (currentChar === BACKSLASH) {
+ if (buffer === '') bufferStart = i
+ buffer += input.slice(i, i + 2)
+ i += 1
+ }
+
+ // Start of a comment.
+ //
+ // E.g.:
+ //
+ // ```css
+ // /* Example */
+ // ^^^^^^^^^^^^^
+ // .foo {
+ // color: red; /* Example */
+ // ^^^^^^^^^^^^^
+ // }
+ // .bar {
+ // color: /* Example */ red;
+ // ^^^^^^^^^^^^^
+ // }
+ // ```
+ else if (currentChar === SLASH && input.charCodeAt(i + 1) === ASTERISK) {
+ let start = i
+
+ for (let j = i + 2; j < input.length; j++) {
+ peekChar = input.charCodeAt(j)
+
+ // Current character is a `\` therefore the next character is escaped.
+ if (peekChar === BACKSLASH) {
+ j += 1
+ }
+
+ // End of the comment
+ else if (peekChar === ASTERISK && input.charCodeAt(j + 1) === SLASH) {
+ i = j + 1
+ break
+ }
+ }
+
+ let commentString = input.slice(start, i + 1)
+
+ // Collect all license comments so that we can hoist them to the top of
+ // the AST.
+ if (commentString.charCodeAt(2) === EXCLAMATION_MARK) {
+ let node = comment(commentString.slice(2, -2))
+ licenseComments.push(node)
+
+ if (source) {
+ node.src = [source, start, i + 1]
+ node.dst = [source, start, i + 1]
+ }
+ }
+ }
+
+ // Start of a string.
+ else if (currentChar === SINGLE_QUOTE || currentChar === DOUBLE_QUOTE) {
+ let start = i
+
+ // We need to ensure that the closing quote is the same as the opening
+ // quote.
+ //
+ // E.g.:
+ //
+ // ```css
+ // .foo {
+ // content: "This is a string with a 'quote' in it";
+ // ^ ^ -> These are not the end of the string.
+ // }
+ // ```
+ for (let j = i + 1; j < input.length; j++) {
+ peekChar = input.charCodeAt(j)
+ // Current character is a `\` therefore the next character is escaped.
+ if (peekChar === BACKSLASH) {
+ j += 1
+ }
+
+ // End of the string.
+ else if (peekChar === currentChar) {
+ i = j
+ break
+ }
+
+ // End of the line without ending the string but with a `;` at the end.
+ //
+ // E.g.:
+ //
+ // ```css
+ // .foo {
+ // content: "This is a string with a;
+ // ^ Missing "
+ // }
+ // ```
+ else if (peekChar === SEMICOLON && input.charCodeAt(j + 1) === LINE_BREAK) {
+ throw new Error(
+ `Unterminated string: ${input.slice(start, j + 1) + String.fromCharCode(currentChar)}`,
+ )
+ }
+
+ // End of the line without ending the string.
+ //
+ // E.g.:
+ //
+ // ```css
+ // .foo {
+ // content: "This is a string with a
+ // ^ Missing "
+ // }
+ // ```
+ else if (peekChar === LINE_BREAK) {
+ throw new Error(
+ `Unterminated string: ${input.slice(start, j) + String.fromCharCode(currentChar)}`,
+ )
+ }
+ }
+
+ // Adjust `buffer` to include the string.
+ buffer += input.slice(start, i + 1)
+ }
+
+ // Skip whitespace if the next character is also whitespace. This allows us
+ // to reduce the amount of whitespace in the AST.
+ else if (
+ (currentChar === SPACE || currentChar === LINE_BREAK || currentChar === TAB) &&
+ (peekChar = input.charCodeAt(i + 1)) &&
+ (peekChar === SPACE || peekChar === LINE_BREAK || peekChar === TAB)
+ ) {
+ continue
+ }
+
+ // Replace new lines with spaces.
+ else if (currentChar === LINE_BREAK) {
+ if (buffer.length === 0) continue
+
+ peekChar = buffer.charCodeAt(buffer.length - 1)
+ if (peekChar !== SPACE && peekChar !== LINE_BREAK && peekChar !== TAB) {
+ buffer += ' '
+ }
+ }
+
+ // Start of a custom property.
+ //
+ // Custom properties are very permissive and can contain almost any
+ // character, even `;` and `}`. Therefore we have to make sure that we are
+ // at the correct "end" of the custom property by making sure everything is
+ // balanced.
+ else if (currentChar === DASH && input.charCodeAt(i + 1) === DASH && buffer.length === 0) {
+ let closingBracketStack = ''
+
+ let start = i
+ let colonIdx = -1
+
+ for (let j = i + 2; j < input.length; j++) {
+ peekChar = input.charCodeAt(j)
+
+ // Current character is a `\` therefore the next character is escaped.
+ if (peekChar === BACKSLASH) {
+ j += 1
+ }
+
+ // Start of a comment.
+ else if (peekChar === SLASH && input.charCodeAt(j + 1) === ASTERISK) {
+ for (let k = j + 2; k < input.length; k++) {
+ peekChar = input.charCodeAt(k)
+ // Current character is a `\` therefore the next character is escaped.
+ if (peekChar === BACKSLASH) {
+ k += 1
+ }
+
+ // End of the comment
+ else if (peekChar === ASTERISK && input.charCodeAt(k + 1) === SLASH) {
+ j = k + 1
+ break
+ }
+ }
+ }
+
+ // End of the "property" of the property-value pair.
+ else if (colonIdx === -1 && peekChar === COLON) {
+ colonIdx = buffer.length + j - start
+ }
+
+ // End of the custom property.
+ else if (peekChar === SEMICOLON && closingBracketStack.length === 0) {
+ buffer += input.slice(start, j)
+ i = j
+ break
+ }
+
+ // Start of a block.
+ else if (peekChar === OPEN_PAREN) {
+ closingBracketStack += ')'
+ } else if (peekChar === OPEN_BRACKET) {
+ closingBracketStack += ']'
+ } else if (peekChar === OPEN_CURLY) {
+ closingBracketStack += '}'
+ }
+
+ // End of the custom property if didn't use a `;` to end the custom
+ // property.
+ //
+ // E.g.:
+ //
+ // ```css
+ // .foo {
+ // --custom: value
+ // ^
+ // }
+ // ```
+ else if (
+ (peekChar === CLOSE_CURLY || input.length - 1 === j) &&
+ closingBracketStack.length === 0
+ ) {
+ i = j - 1
+ buffer += input.slice(start, j)
+ break
+ }
+
+ // End of a block.
+ else if (
+ peekChar === CLOSE_PAREN ||
+ peekChar === CLOSE_BRACKET ||
+ peekChar === CLOSE_CURLY
+ ) {
+ if (
+ closingBracketStack.length > 0 &&
+ input[j] === closingBracketStack[closingBracketStack.length - 1]
+ ) {
+ closingBracketStack = closingBracketStack.slice(0, -1)
+ }
+ }
+ }
+
+ let declaration = parseDeclaration(buffer, colonIdx)
+ if (!declaration) throw new Error(`Invalid custom property, expected a value`)
+
+ if (source) {
+ declaration.src = [source, start, i]
+ declaration.dst = [source, start, i]
+ }
+
+ if (parent) {
+ parent.nodes.push(declaration)
+ } else {
+ ast.push(declaration)
+ }
+
+ buffer = ''
+ }
+
+ // End of a body-less at-rule.
+ //
+ // E.g.:
+ //
+ // ```css
+ // @charset "UTF-8";
+ // ^
+ // ```
+ else if (currentChar === SEMICOLON && buffer.charCodeAt(0) === AT_SIGN) {
+ node = parseAtRule(buffer)
+
+ if (source) {
+ node.src = [source, bufferStart, i]
+ node.dst = [source, bufferStart, i]
+ }
+
+ // At-rule is nested inside of a rule, attach it to the parent.
+ if (parent) {
+ parent.nodes.push(node)
+ }
+
+ // We are the root node which means we are done with the current node.
+ else {
+ ast.push(node)
+ }
+
+ // Reset the state for the next node.
+ buffer = ''
+ node = null
+ }
+
+ // End of a declaration.
+ //
+ // E.g.:
+ //
+ // ```css
+ // .foo {
+ // color: red;
+ // ^
+ // }
+ // ```
+ //
+ else if (
+ currentChar === SEMICOLON &&
+ closingBracketStack[closingBracketStack.length - 1] !== ')'
+ ) {
+ let declaration = parseDeclaration(buffer)
+ if (!declaration) {
+ if (buffer.length === 0) throw new Error('Unexpected semicolon')
+ throw new Error(`Invalid declaration: \`${buffer.trim()}\``)
+ }
+
+ if (source) {
+ declaration.src = [source, bufferStart, i]
+ declaration.dst = [source, bufferStart, i]
+ }
+
+ if (parent) {
+ parent.nodes.push(declaration)
+ } else {
+ ast.push(declaration)
+ }
+
+ buffer = ''
+ }
+
+ // Start of a block.
+ else if (
+ currentChar === OPEN_CURLY &&
+ closingBracketStack[closingBracketStack.length - 1] !== ')'
+ ) {
+ closingBracketStack += '}'
+
+ // At this point `buffer` should resemble a selector or an at-rule.
+ node = rule(buffer.trim())
+
+ // Track the source location for source maps
+ if (source) {
+ node.src = [source, bufferStart, i]
+ node.dst = [source, bufferStart, i]
+ }
+
+ // Attach the rule to the parent in case it's nested.
+ if (parent) {
+ parent.nodes.push(node)
+ }
+
+ // Push the parent node to the stack, so that we can go back once the
+ // nested nodes are done.
+ stack.push(parent)
+
+ // Make the current node the new parent, so that nested nodes can be
+ // attached to it.
+ parent = node
+
+ // Reset the state for the next node.
+ buffer = ''
+ node = null
+ }
+
+ // End of a block.
+ else if (
+ currentChar === CLOSE_CURLY &&
+ closingBracketStack[closingBracketStack.length - 1] !== ')'
+ ) {
+ if (closingBracketStack === '') {
+ throw new Error('Missing opening {')
+ }
+
+ closingBracketStack = closingBracketStack.slice(0, -1)
+
+ // When we hit a `}` and `buffer` is filled in, then it means that we did
+ // not complete the previous node yet. This means that we hit a
+ // declaration without a `;` at the end.
+ if (buffer.length > 0) {
+ // This can happen for nested at-rules.
+ //
+ // E.g.:
+ //
+ // ```css
+ // @layer foo {
+ // @tailwind utilities
+ // ^
+ // }
+ // ```
+ if (buffer.charCodeAt(0) === AT_SIGN) {
+ node = parseAtRule(buffer)
+
+ // Track the source location for source maps
+ if (source) {
+ node.src = [source, bufferStart, i]
+ node.dst = [source, bufferStart, i]
+ }
+
+ // At-rule is nested inside of a rule, attach it to the parent.
+ if (parent) {
+ parent.nodes.push(node)
+ }
+
+ // We are the root node which means we are done with the current node.
+ else {
+ ast.push(node)
+ }
+
+ // Reset the state for the next node.
+ buffer = ''
+ node = null
+ }
+
+ // But it can also happen for declarations.
+ //
+ // E.g.:
+ //
+ // ```css
+ // .foo {
+ // color: red
+ // ^
+ // }
+ // ```
+ else {
+ // Split `buffer` into a `property` and a `value`. At this point the
+ // comments are already removed which means that we don't have to worry
+ // about `:` inside of comments.
+ let colonIdx = buffer.indexOf(':')
+
+ // Attach the declaration to the parent.
+ if (parent) {
+ let node = parseDeclaration(buffer, colonIdx)
+ if (!node) throw new Error(`Invalid declaration: \`${buffer.trim()}\``)
+
+ if (source) {
+ node.src = [source, bufferStart, i]
+ node.dst = [source, bufferStart, i]
+ }
+
+ parent.nodes.push(node)
+ }
+ }
+ }
+
+ // We are done with the current node, which means we can go up one level
+ // in the stack.
+ let grandParent = stack.pop() ?? null
+
+ // We are the root node which means we are done and continue with the next
+ // node.
+ if (grandParent === null && parent) {
+ ast.push(parent)
+ }
+
+ // Go up one level in the stack.
+ parent = grandParent
+
+ // Reset the state for the next node.
+ buffer = ''
+ node = null
+ }
+
+ // `(`
+ else if (currentChar === OPEN_PAREN) {
+ closingBracketStack += ')'
+ buffer += '('
+ }
+
+ // `)`
+ else if (currentChar === CLOSE_PAREN) {
+ if (closingBracketStack[closingBracketStack.length - 1] !== ')') {
+ throw new Error('Missing opening (')
+ }
+
+ closingBracketStack = closingBracketStack.slice(0, -1)
+ buffer += ')'
+ }
+
+ // Any other character is part of the current node.
+ else {
+ // Skip whitespace at the start of a new node.
+ if (
+ buffer.length === 0 &&
+ (currentChar === SPACE || currentChar === LINE_BREAK || currentChar === TAB)
+ ) {
+ continue
+ }
+
+ if (buffer === '') bufferStart = i
+
+ buffer += String.fromCharCode(currentChar)
+ }
+ }
+
+ // If we have a leftover `buffer` that happens to start with an `@` then it
+ // means that we have an at-rule that is not terminated with a semicolon at
+ // the end of the input.
+ if (buffer.charCodeAt(0) === AT_SIGN) {
+ let node = parseAtRule(buffer)
+
+ // Track the source location for source maps
+ if (source) {
+ node.src = [source, bufferStart, input.length]
+ node.dst = [source, bufferStart, input.length]
+ }
+
+ ast.push(node)
+ }
+
+ // When we are done parsing then everything should be balanced. If we still
+ // have a leftover `parent`, then it means that we have an unterminated block.
+ if (closingBracketStack.length > 0 && parent) {
+ if (parent.kind === 'rule') {
+ throw new Error(`Missing closing } at ${parent.selector}`)
+ }
+ if (parent.kind === 'at-rule') {
+ throw new Error(`Missing closing } at ${parent.name} ${parent.params}`)
+ }
+ }
+
+ if (licenseComments.length > 0) {
+ return (licenseComments as AstNode[]).concat(ast)
+ }
+
+ return ast
+}
+
+export function parseAtRule(buffer: string, nodes: AstNode[] = []): AtRule {
+ let name = buffer
+ let params = ''
+
+ // Assumption: The smallest at-rule in CSS right now is `@page`, this means
+ // that we can always skip the first 5 characters and start at the
+ // sixth (at index 5).
+ //
+ // There is a chance someone is using a shorter at-rule, in that case we have
+ // to adjust this number back to 2, e.g.: `@x`.
+ //
+ // This issue can only occur if somebody does the following things:
+ //
+ // 1. Uses a shorter at-rule than `@page`
+ // 2. Disables Lightning CSS from `@tailwindcss/postcss` (because Lightning
+ // CSS doesn't handle custom at-rules properly right now)
+ // 3. Sandwiches the `@tailwindcss/postcss` plugin between two other plugins
+ // that can handle the shorter at-rule
+ //
+ // Let's use the more common case as the default and we can adjust this
+ // behavior if necessary.
+ for (let i = 5 /* '@page'.length */; i < buffer.length; i++) {
+ let currentChar = buffer.charCodeAt(i)
+ if (currentChar === SPACE || currentChar === OPEN_PAREN) {
+ name = buffer.slice(0, i)
+ params = buffer.slice(i)
+ break
+ }
+ }
+
+ return atRule(name.trim(), params.trim(), nodes)
+}
+
+function parseDeclaration(
+ buffer: string,
+ colonIdx: number = buffer.indexOf(':'),
+): Declaration | null {
+ if (colonIdx === -1) return null
+ let importantIdx = buffer.indexOf('!important', colonIdx + 1)
+ return decl(
+ buffer.slice(0, colonIdx).trim(),
+ buffer.slice(colonIdx + 1, importantIdx === -1 ? buffer.length : importantIdx).trim(),
+ importantIdx !== -1,
+ )
+}
diff --git a/packages/tailwindcss-language-service/src/css/source.ts b/packages/tailwindcss-language-service/src/css/source.ts
new file mode 100644
index 000000000..9fe53e202
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/css/source.ts
@@ -0,0 +1,37 @@
+/**
+ * The source code for one or more nodes in the AST
+ *
+ * This generally corresponds to a stylesheet
+ */
+export interface Source {
+ /**
+ * The path to the file that contains the referenced source code
+ *
+ * If this references the *output* source code, this is `null`.
+ */
+ file: string | null
+
+ /**
+ * The referenced source code
+ */
+ code: string
+}
+
+/**
+ * The file and offsets within it that this node covers
+ *
+ * This can represent either:
+ * - A location in the original CSS which caused this node to be created
+ * - A location in the output CSS where this node resides
+ */
+export type SourceLocation = [source: Source, start: number, end: number]
+
+/**
+ * The file and offsets within it that this node covers
+ */
+export function createInputSource(file: string, code: string): Source {
+ return {
+ file,
+ code,
+ }
+}
diff --git a/packages/tailwindcss-language-service/src/css/to-css.ts b/packages/tailwindcss-language-service/src/css/to-css.ts
new file mode 100644
index 000000000..d38a707ec
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/css/to-css.ts
@@ -0,0 +1,209 @@
+import { AstNode } from './ast'
+import { Source } from './source'
+
+export function toCss(ast: AstNode[], track?: boolean) {
+ let pos = 0
+
+ let source: Source = {
+ file: null,
+ code: '',
+ }
+
+ function stringify(node: AstNode, depth = 0): string {
+ let css = ''
+ let indent = ' '.repeat(depth)
+
+ // Declaration
+ if (node.kind === 'declaration') {
+ css += `${indent}${node.property}: ${node.value}${node.important ? ' !important' : ''};\n`
+
+ if (track) {
+ // indent
+ pos += indent.length
+
+ // node.property
+ let start = pos
+ pos += node.property.length
+
+ // `: `
+ pos += 2
+
+ // node.value
+ pos += node.value?.length ?? 0
+
+ // !important
+ if (node.important) {
+ pos += 11
+ }
+
+ let end = pos
+
+ // `;\n`
+ pos += 2
+
+ node.dst = [source, start, end]
+ }
+ }
+
+ // Rule
+ else if (node.kind === 'rule') {
+ css += `${indent}${node.selector} {\n`
+
+ if (track) {
+ // indent
+ pos += indent.length
+
+ // node.selector
+ let start = pos
+ pos += node.selector.length
+
+ // ` `
+ pos += 1
+
+ let end = pos
+ node.dst = [source, start, end]
+
+ // `{\n`
+ pos += 2
+ }
+
+ for (let child of node.nodes) {
+ css += stringify(child, depth + 1)
+ }
+
+ css += `${indent}}\n`
+
+ if (track) {
+ // indent
+ pos += indent.length
+
+ // `}\n`
+ pos += 2
+ }
+ }
+
+ // AtRule
+ else if (node.kind === 'at-rule') {
+ // Print at-rules without nodes with a `;` instead of an empty block.
+ //
+ // E.g.:
+ //
+ // ```css
+ // @layer base, components, utilities;
+ // ```
+ if (node.nodes.length === 0) {
+ let css = `${indent}${node.name} ${node.params};\n`
+
+ if (track) {
+ // indent
+ pos += indent.length
+
+ // node.name
+ let start = pos
+ pos += node.name.length
+
+ // ` `
+ pos += 1
+
+ // node.params
+ pos += node.params.length
+ let end = pos
+
+ // `;\n`
+ pos += 2
+
+ node.dst = [source, start, end]
+ }
+
+ return css
+ }
+
+ css += `${indent}${node.name}${node.params ? ` ${node.params} ` : ' '}{\n`
+
+ if (track) {
+ // indent
+ pos += indent.length
+
+ // node.name
+ let start = pos
+ pos += node.name.length
+
+ if (node.params) {
+ // ` `
+ pos += 1
+
+ // node.params
+ pos += node.params.length
+ }
+
+ // ` `
+ pos += 1
+
+ let end = pos
+ node.dst = [source, start, end]
+
+ // `{\n`
+ pos += 2
+ }
+
+ for (let child of node.nodes) {
+ css += stringify(child, depth + 1)
+ }
+
+ css += `${indent}}\n`
+
+ if (track) {
+ // indent
+ pos += indent.length
+
+ // `}\n`
+ pos += 2
+ }
+ }
+
+ // Comment
+ else if (node.kind === 'comment') {
+ css += `${indent}/*${node.value}*/\n`
+
+ if (track) {
+ // indent
+ pos += indent.length
+
+ // The comment itself. We do this instead of just the inside because
+ // it seems more useful to have the entire comment span tracked.
+ let start = pos
+ pos += 2 + node.value.length + 2
+ let end = pos
+
+ node.dst = [source, start, end]
+
+ // `\n`
+ pos += 1
+ }
+ }
+
+ // These should've been handled already by `optimizeAst` which
+ // means we can safely ignore them here. We return an empty string
+ // immediately to signal that something went wrong.
+ else if (node.kind === 'context' || node.kind === 'at-root') {
+ return ''
+ }
+
+ // Unknown
+ else {
+ node satisfies never
+ }
+
+ return css
+ }
+
+ let css = ''
+
+ for (let node of ast) {
+ css += stringify(node, 0)
+ }
+
+ source.code = css
+
+ return css
+}
diff --git a/packages/tailwindcss-language-service/src/css/walk.ts b/packages/tailwindcss-language-service/src/css/walk.ts
new file mode 100644
index 000000000..748daa9f2
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/css/walk.ts
@@ -0,0 +1,79 @@
+import { AstNode } from './ast'
+
+export const enum WalkAction {
+ /** Continue walking, which is the default */
+ Continue,
+
+ /** Skip visiting the children of this node */
+ Skip,
+
+ /** Stop the walk entirely */
+ Stop,
+}
+
+export interface VisitorMeta {
+ path: AstNode[]
+ parent: AstNode | null
+ context: Record
+
+ replaceWith(newNode: AstNode | AstNode[]): void
+}
+
+export interface Visitor {
+ enter?(node: AstNode, meta: VisitorMeta): WalkAction
+ exit?(node: AstNode, meta: VisitorMeta): WalkAction
+}
+
+export function walk(
+ ast: AstNode[],
+ visit: Visitor,
+ path: AstNode[] = [],
+ context: Record = {},
+) {
+ for (let i = 0; i < ast.length; i++) {
+ let node = ast[i]
+ let parent = path[path.length - 1] ?? null
+
+ let meta: VisitorMeta = {
+ parent,
+ context,
+ path,
+ replaceWith(newNode) {
+ ast[i] = {
+ kind: 'context',
+ context: {},
+ nodes: Array.isArray(newNode) ? newNode : [newNode],
+ }
+ },
+ }
+
+ path.push(node)
+ let status = visit.enter?.(node, meta) ?? WalkAction.Continue
+ path.pop()
+
+ // Stop the walk entirely
+ if (status === WalkAction.Stop) return WalkAction.Stop
+
+ // Skip visiting the children of this node
+ if (status === WalkAction.Skip) continue
+
+ // These nodes do not have children
+ if (node.kind === 'comment' || node.kind === 'declaration') continue
+
+ let nodeContext = node.kind === 'context' ? { ...context, ...node.context } : context
+
+ path.push(node)
+ status = walk(node.nodes, visit, path, nodeContext)
+ path.pop()
+
+ if (status === WalkAction.Stop) return WalkAction.Stop
+
+ path.push(node)
+ status = visit.exit?.(node, meta) ?? WalkAction.Continue
+ path.pop()
+
+ if (status === WalkAction.Stop) return WalkAction.Stop
+ }
+
+ return WalkAction.Continue
+}
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/paths.ts b/packages/tailwindcss-language-service/src/paths.ts
new file mode 100644
index 000000000..97eab6134
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/paths.ts
@@ -0,0 +1,51 @@
+import picomatch from 'picomatch'
+import normalizePathBase from 'normalize-path'
+
+export function createPathMatcher(base: string, patterns: string[]) {
+ // Should we ignore this file?
+ // TODO: Do we need to normalize windows paths?
+ let matchers = patterns.map((pattern) => {
+ pattern = `${base}/${pattern}`
+ pattern = normalizePath(pattern)
+ pattern = normalizeDriveLetter(pattern)
+
+ return picomatch(`${base}/${pattern}`)
+ })
+
+ return (file: string) => {
+ file = normalizePath(file)
+ file = normalizeDriveLetter(file)
+
+ return matchers.some((isMatch) => isMatch(file))
+ }
+}
+
+const WIN_DRIVE_LETTER = /^([a-zA-Z]):/
+const POSIX_DRIVE_LETTER = /^\/([a-zA-Z]):/
+
+/**
+ * Windows drive letters are case-insensitive and we may get them as either
+ * lower or upper case. This function normalizes the drive letter to uppercase
+ * to be consistent with the rest of the codebase.
+ */
+export function normalizeDriveLetter(filepath: string) {
+ return filepath
+ .replace(WIN_DRIVE_LETTER, (_, letter) => `${letter.toUpperCase()}:`)
+ .replace(POSIX_DRIVE_LETTER, (_, letter) => `/${letter.toUpperCase()}:`)
+}
+
+export function normalizePath(originalPath: string) {
+ let normalized = normalizePathBase(originalPath)
+
+ // This is Windows network share but the normalize path had one of the leading
+ // slashes stripped so we need to add it back
+ if (
+ originalPath.startsWith('\\\\') &&
+ normalized.startsWith('/') &&
+ !normalized.startsWith('//')
+ ) {
+ return `/${normalized}`
+ }
+
+ return normalized
+}
diff --git a/packages/tailwindcss-language-service/src/project/color.ts b/packages/tailwindcss-language-service/src/project/color.ts
new file mode 100644
index 000000000..66360d11d
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/project/color.ts
@@ -0,0 +1,15 @@
+// FIXME: This is a performance optimization and not strictly correct
+let isNegative = /^-/
+let isNumericUtility =
+ /^-?((min-|max-)?[wh]|z|start|order|opacity|rounded|row|col|size|basis|end|duration|ease|font|top|left|bottom|right|inset|leading|cursor|(space|scale|skew|rotate)-[xyz]|gap(-[xy])?|(scroll-)?[pm][trblxyse]?)-/
+let isMaskUtility = /^-?mask-/
+
+export function mayContainColors(className: string) {
+ if (isNegative.test(className)) return false
+ // TODO: This is **not** correct but is intentional because there are 5k mask utilities and a LOT of them are colors
+ // This causes a massive slowdown when building the design system
+ if (isMaskUtility.test(className)) return false
+ if (isNumericUtility.test(className)) return false
+
+ return true
+}
diff --git a/packages/tailwindcss-language-service/src/project/project.ts b/packages/tailwindcss-language-service/src/project/project.ts
new file mode 100644
index 000000000..faae9086e
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/project/project.ts
@@ -0,0 +1,125 @@
+import type { Feature } from '../features'
+import type { ResolvedClass, ResolvedDesignToken, ResolvedVariant } from './tokens'
+import { createProjectV4, type ProjectDescriptorV4 } from './v4'
+
+export interface Project {
+ /**
+ * The version of Tailwind CSS used by this project
+ */
+ readonly version: string
+
+ /**
+ * The features supported by this version of Tailwind CSS
+ *
+ * @internal These values are not stable and may change at any point
+ */
+ readonly features: Feature[]
+
+ /**
+ * A list of files this project depends on. If any of these files change the
+ * project must be re-created.
+ *
+ * These are normalized URIs
+ */
+ depdendencies: string[]
+
+ /**
+ * A list of glob patterns that represent known source / template paths
+ *
+ * v4.x, inferred from:
+ * - `@source "…"`
+ * - `@import "…" source(…)`
+ * - `@tailwind utilities source(…)`
+ * - `@config` -> `content` (compat)
+ * - `@plugin` -> `content` (compat)
+ *
+ * v3.x, inferred from:
+ * - `content`
+ *
+ * v2.x: always empty
+ * v1.x: always empty
+ * v0.x: always empty
+ */
+ sources(): ProjectSource[]
+
+ /**
+ * Get information about a given list of classes.
+ *
+ * - Postcondition: The returned list is the same length and in the same order as `classes`.
+ * - Postcondition: Unknown classes have .source = 'unknown'
+ */
+ resolveClasses(classes: string[]): Promise
+
+ /**
+ * Get information about a list of registered design tokens.
+ *
+ * - Postcondition: The returned list is the same length and in the same order as `tokens`.
+ * - Postcondition: Unknown tokens have .source = 'unknown'
+ */
+ resolveDesignTokens(tokens: string[]): Promise
+
+ /**
+ * Get information about a given list of variants.
+ *
+ * - Postcondition: The returned list is the same length and in the same order as `variants`.
+ * - Postcondition: Unknown classes have .source = 'unknown'
+ */
+ resolveVariants(variants: string[]): Promise
+
+ /**
+ * Return a list of classes that may match the given query
+ * This is generally a prefix search on a given class part (e.g. "bg-" or "red-")
+ *
+ * - Postcondition: Only known classes are returned.
+ */
+ searchClasses(query: string): Promise
+
+ /**
+ * Return a list of properties that may match the given query
+ *
+ * - Postcondition: Only known design tokens are returned.
+ */
+ searchDesignTokens(query: string): Promise
+
+ /**
+ * Return a list of variants that may match the given query
+ * This is generally a prefix search on a given variant part (e.g. "data-" or "red-")
+ *
+ * - Postcondition: Only known variants are returned.
+ */
+ searchVariants(query: string): Promise
+
+ /**
+ * Sort the given list of classes.
+ *
+ * - Postcondition: The returned list is the same length as `classes`.
+ * - Postcondition: Unknown classes are kept in their original, relative order
+ * but are moved to the beginning of the list.
+ */
+ sortClasses(classes: string[]): Promise
+}
+
+export interface ProjectSource {
+ /**
+ * The base (file) URI for this pattern
+ */
+ readonly base: string
+
+ /**
+ * The glob pattern to match against the base URI
+ */
+ readonly pattern: string
+
+ /**
+ * Whether or not this is a inverted/negative pattern
+ */
+ readonly negated: boolean
+}
+
+export type ProjectDescriptor = ProjectDescriptorV4
+
+export function createProject(desc: ProjectDescriptor): Promise {
+ if (desc.kind === 'v4') return createProjectV4(desc)
+
+ throw new Error('Unknown project kind')
+}
diff --git a/packages/tailwindcss-language-service/src/project/tokens.ts b/packages/tailwindcss-language-service/src/project/tokens.ts
new file mode 100644
index 000000000..9906f7a0a
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/project/tokens.ts
@@ -0,0 +1,121 @@
+import type * as culori from 'culori'
+import { AstNode } from '../css/ast'
+
+export type ResolvedColor = culori.Color
+
+export type ToenSource =
+ // A class not known to the language server
+ | 'unknown'
+
+ // A class that was generated by Tailwind CSS
+ | 'generated'
+
+ // A class that exists in a stylesheet
+ | 'authored'
+
+export interface ResolvedDesignToken {
+ readonly kind: 'design-token'
+ readonly source: ToenSource
+
+ /**
+ * The name of this token as authored by the user
+ *
+ * It can represent one of the following:
+ *
+ * - a legacy-style config keypath (theme.colors.red.500)
+ * - a CSS property (--some-color, authored in CSS)
+ * - a theme namespace (--color-*, authored in `@theme`)
+ * - a theme value (--color-red-500, authored in `@theme`)
+ */
+ readonly name: string
+
+ /**
+ * The value of the given design token
+ */
+ value(): string
+}
+
+export interface ResolvedClass {
+ readonly kind: 'class'
+ readonly source: ToenSource
+
+ /**
+ * The name of this class as authored by the user
+ */
+ readonly name: string
+
+ /**
+ * The variants present in this class
+ *
+ * These are not guaranteed to be valid. This is a syntactic check only.
+ */
+ readonly variants: readonly string[]
+
+ /**
+ * A list of known, allowed modifiers for this class
+ * This list is empty when:
+ * - Modifiers are not supported by the given version of Tailwind
+ * - The class does not support any modifiers
+ * - The class _already_ includes a modifier
+ *
+ * This list being empty *does not mean* that thie class cannot take any
+ * modifiers. Only that we don't know what modifiers could be supported.
+ */
+ readonly modifiers: readonly string[]
+
+ /**
+ * Whether or not this class can be used with @apply
+ */
+ readonly apply: { allowed: true } | { allowed: false; reason: string }
+
+ /**
+ * The CSS AST this class generates
+ */
+ nodes(): AstNode[]
+
+ /**
+ * A list of representative color swatches for this class
+ *
+ * The first color swatch must be the "primary" swatch presented to the user
+ * as it is used for suggestions.
+ *
+ * Most utilities will have at most one color
+ *
+ * Computing colors can be potentially costly as it requires resolving the
+ * class into an AST.
+ */
+ colors(force?: boolean): ResolvedColor[]
+}
+
+export interface ResolvedVariant {
+ readonly kind: 'variant'
+ readonly source: ToenSource
+
+ /**
+ * The name of this variant as authored by the user
+ */
+ readonly name: string
+
+ /**
+ * A list of known, allowed modifiers for this variant
+ * This list is empty when:
+ * - Modifiers are not supported by the given version of Tailwind
+ * - The variant does not support any modifiers
+ * - The variant _already_ includes a modifier
+ *
+ * This list being empty *does not mean* that thie variant cannot take any
+ * modifiers. Only that we don't know what modifiers could be supported.
+ */
+ readonly modifiers: readonly string[]
+
+ /**
+ * The list of CSS selectors or at-rules produced by this variant
+ *
+ * - Sibling selectors/at-rules introduce an entry into the array
+ * - Nested selectors/at-rules produce a string like this:
+ * ```css
+ * selector1 { selector2 { some-at-rule { &:hover } } }
+ * ```
+ */
+ selectors(): string[]
+}
diff --git a/packages/tailwindcss-language-service/src/project/v4.test.ts b/packages/tailwindcss-language-service/src/project/v4.test.ts
new file mode 100644
index 000000000..cbe7c68f5
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/project/v4.test.ts
@@ -0,0 +1,597 @@
+import { test, expect } from 'vitest'
+import { createProject } from './project'
+import dedent from 'dedent'
+import { decl, rule } from '../css/ast'
+const css = dedent
+
+test('can create a v4 project', async () => {
+ let content = css`
+ @theme default {
+ --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
+ 'Segoe UI Symbol', 'Noto Color Emoji';
+ --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
+ 'Courier New', monospace;
+
+ --color-red: #f00;
+ --color-red-50: oklch(97.1% 0.013 17.38);
+ --color-red-100: oklch(93.6% 0.032 17.717);
+ --color-red-200: oklch(88.5% 0.062 18.334);
+ --color-red-300: oklch(80.8% 0.114 19.571);
+ --color-red-400: oklch(70.4% 0.191 22.216);
+ --color-red-500: oklch(63.7% 0.237 25.331);
+ --color-red-600: oklch(57.7% 0.245 27.325);
+ --color-red-700: oklch(50.5% 0.213 27.518);
+ --color-red-800: oklch(44.4% 0.177 26.899);
+ --color-red-900: oklch(39.6% 0.141 25.723);
+ --color-red-950: oklch(25.8% 0.092 26.042);
+
+ --color-orange-50: oklch(98% 0.016 73.684);
+ --color-orange-100: oklch(95.4% 0.038 75.164);
+ --color-orange-200: oklch(90.1% 0.076 70.697);
+ --color-orange-300: oklch(83.7% 0.128 66.29);
+ --color-orange-400: oklch(75% 0.183 55.934);
+ --color-orange-500: oklch(70.5% 0.213 47.604);
+ --color-orange-600: oklch(64.6% 0.222 41.116);
+ --color-orange-700: oklch(55.3% 0.195 38.402);
+ --color-orange-800: oklch(47% 0.157 37.304);
+ --color-orange-900: oklch(40.8% 0.123 38.172);
+ --color-orange-950: oklch(26.6% 0.079 36.259);
+
+ --color-amber-50: oklch(98.7% 0.022 95.277);
+ --color-amber-100: oklch(96.2% 0.059 95.617);
+ --color-amber-200: oklch(92.4% 0.12 95.746);
+ --color-amber-300: oklch(87.9% 0.169 91.605);
+ --color-amber-400: oklch(82.8% 0.189 84.429);
+ --color-amber-500: oklch(76.9% 0.188 70.08);
+ --color-amber-600: oklch(66.6% 0.179 58.318);
+ --color-amber-700: oklch(55.5% 0.163 48.998);
+ --color-amber-800: oklch(47.3% 0.137 46.201);
+ --color-amber-900: oklch(41.4% 0.112 45.904);
+ --color-amber-950: oklch(27.9% 0.077 45.635);
+
+ --color-yellow-50: oklch(98.7% 0.026 102.212);
+ --color-yellow-100: oklch(97.3% 0.071 103.193);
+ --color-yellow-200: oklch(94.5% 0.129 101.54);
+ --color-yellow-300: oklch(90.5% 0.182 98.111);
+ --color-yellow-400: oklch(85.2% 0.199 91.936);
+ --color-yellow-500: oklch(79.5% 0.184 86.047);
+ --color-yellow-600: oklch(68.1% 0.162 75.834);
+ --color-yellow-700: oklch(55.4% 0.135 66.442);
+ --color-yellow-800: oklch(47.6% 0.114 61.907);
+ --color-yellow-900: oklch(42.1% 0.095 57.708);
+ --color-yellow-950: oklch(28.6% 0.066 53.813);
+
+ --color-lime-50: oklch(98.6% 0.031 120.757);
+ --color-lime-100: oklch(96.7% 0.067 122.328);
+ --color-lime-200: oklch(93.8% 0.127 124.321);
+ --color-lime-300: oklch(89.7% 0.196 126.665);
+ --color-lime-400: oklch(84.1% 0.238 128.85);
+ --color-lime-500: oklch(76.8% 0.233 130.85);
+ --color-lime-600: oklch(64.8% 0.2 131.684);
+ --color-lime-700: oklch(53.2% 0.157 131.589);
+ --color-lime-800: oklch(45.3% 0.124 130.933);
+ --color-lime-900: oklch(40.5% 0.101 131.063);
+ --color-lime-950: oklch(27.4% 0.072 132.109);
+
+ --color-green-50: oklch(98.2% 0.018 155.826);
+ --color-green-100: oklch(96.2% 0.044 156.743);
+ --color-green-200: oklch(92.5% 0.084 155.995);
+ --color-green-300: oklch(87.1% 0.15 154.449);
+ --color-green-400: oklch(79.2% 0.209 151.711);
+ --color-green-500: oklch(72.3% 0.219 149.579);
+ --color-green-600: oklch(62.7% 0.194 149.214);
+ --color-green-700: oklch(52.7% 0.154 150.069);
+ --color-green-800: oklch(44.8% 0.119 151.328);
+ --color-green-900: oklch(39.3% 0.095 152.535);
+ --color-green-950: oklch(26.6% 0.065 152.934);
+
+ --color-emerald-50: oklch(97.9% 0.021 166.113);
+ --color-emerald-100: oklch(95% 0.052 163.051);
+ --color-emerald-200: oklch(90.5% 0.093 164.15);
+ --color-emerald-300: oklch(84.5% 0.143 164.978);
+ --color-emerald-400: oklch(76.5% 0.177 163.223);
+ --color-emerald-500: oklch(69.6% 0.17 162.48);
+ --color-emerald-600: oklch(59.6% 0.145 163.225);
+ --color-emerald-700: oklch(50.8% 0.118 165.612);
+ --color-emerald-800: oklch(43.2% 0.095 166.913);
+ --color-emerald-900: oklch(37.8% 0.077 168.94);
+ --color-emerald-950: oklch(26.2% 0.051 172.552);
+
+ --color-teal-50: oklch(98.4% 0.014 180.72);
+ --color-teal-100: oklch(95.3% 0.051 180.801);
+ --color-teal-200: oklch(91% 0.096 180.426);
+ --color-teal-300: oklch(85.5% 0.138 181.071);
+ --color-teal-400: oklch(77.7% 0.152 181.912);
+ --color-teal-500: oklch(70.4% 0.14 182.503);
+ --color-teal-600: oklch(60% 0.118 184.704);
+ --color-teal-700: oklch(51.1% 0.096 186.391);
+ --color-teal-800: oklch(43.7% 0.078 188.216);
+ --color-teal-900: oklch(38.6% 0.063 188.416);
+ --color-teal-950: oklch(27.7% 0.046 192.524);
+
+ --color-cyan-50: oklch(98.4% 0.019 200.873);
+ --color-cyan-100: oklch(95.6% 0.045 203.388);
+ --color-cyan-200: oklch(91.7% 0.08 205.041);
+ --color-cyan-300: oklch(86.5% 0.127 207.078);
+ --color-cyan-400: oklch(78.9% 0.154 211.53);
+ --color-cyan-500: oklch(71.5% 0.143 215.221);
+ --color-cyan-600: oklch(60.9% 0.126 221.723);
+ --color-cyan-700: oklch(52% 0.105 223.128);
+ --color-cyan-800: oklch(45% 0.085 224.283);
+ --color-cyan-900: oklch(39.8% 0.07 227.392);
+ --color-cyan-950: oklch(30.2% 0.056 229.695);
+
+ --color-sky-50: oklch(97.7% 0.013 236.62);
+ --color-sky-100: oklch(95.1% 0.026 236.824);
+ --color-sky-200: oklch(90.1% 0.058 230.902);
+ --color-sky-300: oklch(82.8% 0.111 230.318);
+ --color-sky-400: oklch(74.6% 0.16 232.661);
+ --color-sky-500: oklch(68.5% 0.169 237.323);
+ --color-sky-600: oklch(58.8% 0.158 241.966);
+ --color-sky-700: oklch(50% 0.134 242.749);
+ --color-sky-800: oklch(44.3% 0.11 240.79);
+ --color-sky-900: oklch(39.1% 0.09 240.876);
+ --color-sky-950: oklch(29.3% 0.066 243.157);
+
+ --color-blue-50: oklch(97% 0.014 254.604);
+ --color-blue-100: oklch(93.2% 0.032 255.585);
+ --color-blue-200: oklch(88.2% 0.059 254.128);
+ --color-blue-300: oklch(80.9% 0.105 251.813);
+ --color-blue-400: oklch(70.7% 0.165 254.624);
+ --color-blue-500: oklch(62.3% 0.214 259.815);
+ --color-blue-600: oklch(54.6% 0.245 262.881);
+ --color-blue-700: oklch(48.8% 0.243 264.376);
+ --color-blue-800: oklch(42.4% 0.199 265.638);
+ --color-blue-900: oklch(37.9% 0.146 265.522);
+ --color-blue-950: oklch(28.2% 0.091 267.935);
+
+ --color-indigo-50: oklch(96.2% 0.018 272.314);
+ --color-indigo-100: oklch(93% 0.034 272.788);
+ --color-indigo-200: oklch(87% 0.065 274.039);
+ --color-indigo-300: oklch(78.5% 0.115 274.713);
+ --color-indigo-400: oklch(67.3% 0.182 276.935);
+ --color-indigo-500: oklch(58.5% 0.233 277.117);
+ --color-indigo-600: oklch(51.1% 0.262 276.966);
+ --color-indigo-700: oklch(45.7% 0.24 277.023);
+ --color-indigo-800: oklch(39.8% 0.195 277.366);
+ --color-indigo-900: oklch(35.9% 0.144 278.697);
+ --color-indigo-950: oklch(25.7% 0.09 281.288);
+
+ --color-violet-50: oklch(96.9% 0.016 293.756);
+ --color-violet-100: oklch(94.3% 0.029 294.588);
+ --color-violet-200: oklch(89.4% 0.057 293.283);
+ --color-violet-300: oklch(81.1% 0.111 293.571);
+ --color-violet-400: oklch(70.2% 0.183 293.541);
+ --color-violet-500: oklch(60.6% 0.25 292.717);
+ --color-violet-600: oklch(54.1% 0.281 293.009);
+ --color-violet-700: oklch(49.1% 0.27 292.581);
+ --color-violet-800: oklch(43.2% 0.232 292.759);
+ --color-violet-900: oklch(38% 0.189 293.745);
+ --color-violet-950: oklch(28.3% 0.141 291.089);
+
+ --color-purple-50: oklch(97.7% 0.014 308.299);
+ --color-purple-100: oklch(94.6% 0.033 307.174);
+ --color-purple-200: oklch(90.2% 0.063 306.703);
+ --color-purple-300: oklch(82.7% 0.119 306.383);
+ --color-purple-400: oklch(71.4% 0.203 305.504);
+ --color-purple-500: oklch(62.7% 0.265 303.9);
+ --color-purple-600: oklch(55.8% 0.288 302.321);
+ --color-purple-700: oklch(49.6% 0.265 301.924);
+ --color-purple-800: oklch(43.8% 0.218 303.724);
+ --color-purple-900: oklch(38.1% 0.176 304.987);
+ --color-purple-950: oklch(29.1% 0.149 302.717);
+
+ --color-fuchsia-50: oklch(97.7% 0.017 320.058);
+ --color-fuchsia-100: oklch(95.2% 0.037 318.852);
+ --color-fuchsia-200: oklch(90.3% 0.076 319.62);
+ --color-fuchsia-300: oklch(83.3% 0.145 321.434);
+ --color-fuchsia-400: oklch(74% 0.238 322.16);
+ --color-fuchsia-500: oklch(66.7% 0.295 322.15);
+ --color-fuchsia-600: oklch(59.1% 0.293 322.896);
+ --color-fuchsia-700: oklch(51.8% 0.253 323.949);
+ --color-fuchsia-800: oklch(45.2% 0.211 324.591);
+ --color-fuchsia-900: oklch(40.1% 0.17 325.612);
+ --color-fuchsia-950: oklch(29.3% 0.136 325.661);
+
+ --color-pink-50: oklch(97.1% 0.014 343.198);
+ --color-pink-100: oklch(94.8% 0.028 342.258);
+ --color-pink-200: oklch(89.9% 0.061 343.231);
+ --color-pink-300: oklch(82.3% 0.12 346.018);
+ --color-pink-400: oklch(71.8% 0.202 349.761);
+ --color-pink-500: oklch(65.6% 0.241 354.308);
+ --color-pink-600: oklch(59.2% 0.249 0.584);
+ --color-pink-700: oklch(52.5% 0.223 3.958);
+ --color-pink-800: oklch(45.9% 0.187 3.815);
+ --color-pink-900: oklch(40.8% 0.153 2.432);
+ --color-pink-950: oklch(28.4% 0.109 3.907);
+
+ --color-rose-50: oklch(96.9% 0.015 12.422);
+ --color-rose-100: oklch(94.1% 0.03 12.58);
+ --color-rose-200: oklch(89.2% 0.058 10.001);
+ --color-rose-300: oklch(81% 0.117 11.638);
+ --color-rose-400: oklch(71.2% 0.194 13.428);
+ --color-rose-500: oklch(64.5% 0.246 16.439);
+ --color-rose-600: oklch(58.6% 0.253 17.585);
+ --color-rose-700: oklch(51.4% 0.222 16.935);
+ --color-rose-800: oklch(45.5% 0.188 13.697);
+ --color-rose-900: oklch(41% 0.159 10.272);
+ --color-rose-950: oklch(27.1% 0.105 12.094);
+
+ --color-slate-50: oklch(98.4% 0.003 247.858);
+ --color-slate-100: oklch(96.8% 0.007 247.896);
+ --color-slate-200: oklch(92.9% 0.013 255.508);
+ --color-slate-300: oklch(86.9% 0.022 252.894);
+ --color-slate-400: oklch(70.4% 0.04 256.788);
+ --color-slate-500: oklch(55.4% 0.046 257.417);
+ --color-slate-600: oklch(44.6% 0.043 257.281);
+ --color-slate-700: oklch(37.2% 0.044 257.287);
+ --color-slate-800: oklch(27.9% 0.041 260.031);
+ --color-slate-900: oklch(20.8% 0.042 265.755);
+ --color-slate-950: oklch(12.9% 0.042 264.695);
+
+ --color-gray-50: oklch(98.5% 0.002 247.839);
+ --color-gray-100: oklch(96.7% 0.003 264.542);
+ --color-gray-200: oklch(92.8% 0.006 264.531);
+ --color-gray-300: oklch(87.2% 0.01 258.338);
+ --color-gray-400: oklch(70.7% 0.022 261.325);
+ --color-gray-500: oklch(55.1% 0.027 264.364);
+ --color-gray-600: oklch(44.6% 0.03 256.802);
+ --color-gray-700: oklch(37.3% 0.034 259.733);
+ --color-gray-800: oklch(27.8% 0.033 256.848);
+ --color-gray-900: oklch(21% 0.034 264.665);
+ --color-gray-950: oklch(13% 0.028 261.692);
+
+ --color-zinc-50: oklch(98.5% 0 0);
+ --color-zinc-100: oklch(96.7% 0.001 286.375);
+ --color-zinc-200: oklch(92% 0.004 286.32);
+ --color-zinc-300: oklch(87.1% 0.006 286.286);
+ --color-zinc-400: oklch(70.5% 0.015 286.067);
+ --color-zinc-500: oklch(55.2% 0.016 285.938);
+ --color-zinc-600: oklch(44.2% 0.017 285.786);
+ --color-zinc-700: oklch(37% 0.013 285.805);
+ --color-zinc-800: oklch(27.4% 0.006 286.033);
+ --color-zinc-900: oklch(21% 0.006 285.885);
+ --color-zinc-950: oklch(14.1% 0.005 285.823);
+
+ --color-neutral-50: oklch(98.5% 0 0);
+ --color-neutral-100: oklch(97% 0 0);
+ --color-neutral-200: oklch(92.2% 0 0);
+ --color-neutral-300: oklch(87% 0 0);
+ --color-neutral-400: oklch(70.8% 0 0);
+ --color-neutral-500: oklch(55.6% 0 0);
+ --color-neutral-600: oklch(43.9% 0 0);
+ --color-neutral-700: oklch(37.1% 0 0);
+ --color-neutral-800: oklch(26.9% 0 0);
+ --color-neutral-900: oklch(20.5% 0 0);
+ --color-neutral-950: oklch(14.5% 0 0);
+
+ --color-stone-50: oklch(98.5% 0.001 106.423);
+ --color-stone-100: oklch(97% 0.001 106.424);
+ --color-stone-200: oklch(92.3% 0.003 48.717);
+ --color-stone-300: oklch(86.9% 0.005 56.366);
+ --color-stone-400: oklch(70.9% 0.01 56.259);
+ --color-stone-500: oklch(55.3% 0.013 58.071);
+ --color-stone-600: oklch(44.4% 0.011 73.639);
+ --color-stone-700: oklch(37.4% 0.01 67.558);
+ --color-stone-800: oklch(26.8% 0.007 34.298);
+ --color-stone-900: oklch(21.6% 0.006 56.043);
+ --color-stone-950: oklch(14.7% 0.004 49.25);
+
+ --color-black: #000;
+ --color-white: #fff;
+
+ --spacing: 0.25rem;
+
+ --breakpoint-sm: 40rem;
+ --breakpoint-md: 48rem;
+ --breakpoint-lg: 64rem;
+ --breakpoint-xl: 80rem;
+ --breakpoint-2xl: 96rem;
+
+ --container-3xs: 16rem;
+ --container-2xs: 18rem;
+ --container-xs: 20rem;
+ --container-sm: 24rem;
+ --container-md: 28rem;
+ --container-lg: 32rem;
+ --container-xl: 36rem;
+ --container-2xl: 42rem;
+ --container-3xl: 48rem;
+ --container-4xl: 56rem;
+ --container-5xl: 64rem;
+ --container-6xl: 72rem;
+ --container-7xl: 80rem;
+
+ --text-xs: 0.75rem;
+ --text-xs--line-height: calc(1 / 0.75);
+ --text-sm: 0.875rem;
+ --text-sm--line-height: calc(1.25 / 0.875);
+ --text-base: 1rem;
+ --text-base--line-height: calc(1.5 / 1);
+ --text-lg: 1.125rem;
+ --text-lg--line-height: calc(1.75 / 1.125);
+ --text-xl: 1.25rem;
+ --text-xl--line-height: calc(1.75 / 1.25);
+ --text-2xl: 1.5rem;
+ --text-2xl--line-height: calc(2 / 1.5);
+ --text-3xl: 1.875rem;
+ --text-3xl--line-height: calc(2.25 / 1.875);
+ --text-4xl: 2.25rem;
+ --text-4xl--line-height: calc(2.5 / 2.25);
+ --text-5xl: 3rem;
+ --text-5xl--line-height: 1;
+ --text-6xl: 3.75rem;
+ --text-6xl--line-height: 1;
+ --text-7xl: 4.5rem;
+ --text-7xl--line-height: 1;
+ --text-8xl: 6rem;
+ --text-8xl--line-height: 1;
+ --text-9xl: 8rem;
+ --text-9xl--line-height: 1;
+
+ --font-weight-thin: 100;
+ --font-weight-extralight: 200;
+ --font-weight-light: 300;
+ --font-weight-normal: 400;
+ --font-weight-medium: 500;
+ --font-weight-semibold: 600;
+ --font-weight-bold: 700;
+ --font-weight-extrabold: 800;
+ --font-weight-black: 900;
+
+ --tracking-tighter: -0.05em;
+ --tracking-tight: -0.025em;
+ --tracking-normal: 0em;
+ --tracking-wide: 0.025em;
+ --tracking-wider: 0.05em;
+ --tracking-widest: 0.1em;
+
+ --leading-tight: 1.25;
+ --leading-snug: 1.375;
+ --leading-normal: 1.5;
+ --leading-relaxed: 1.625;
+ --leading-loose: 2;
+
+ --radius-xs: 0.125rem;
+ --radius-sm: 0.25rem;
+ --radius-md: 0.375rem;
+ --radius-lg: 0.5rem;
+ --radius-xl: 0.75rem;
+ --radius-2xl: 1rem;
+ --radius-3xl: 1.5rem;
+ --radius-4xl: 2rem;
+
+ --shadow-2xs: 0 1px rgb(0 0 0 / 0.05);
+ --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+ --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+ --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
+ --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
+
+ --inset-shadow-2xs: inset 0 1px rgb(0 0 0 / 0.05);
+ --inset-shadow-xs: inset 0 1px 1px rgb(0 0 0 / 0.05);
+ --inset-shadow-sm: inset 0 2px 4px rgb(0 0 0 / 0.05);
+
+ --drop-shadow-xs: 0 1px 1px rgb(0 0 0 / 0.05);
+ --drop-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.15);
+ --drop-shadow-md: 0 3px 3px rgb(0 0 0 / 0.12);
+ --drop-shadow-lg: 0 4px 4px rgb(0 0 0 / 0.15);
+ --drop-shadow-xl: 0 9px 7px rgb(0 0 0 / 0.1);
+ --drop-shadow-2xl: 0 25px 25px rgb(0 0 0 / 0.15);
+
+ --text-shadow-2xs: 0px 1px 0px rgb(0 0 0 / 0.15);
+ --text-shadow-xs: 0px 1px 1px rgb(0 0 0 / 0.2);
+ --text-shadow-sm: 0px 1px 0px rgb(0 0 0 / 0.075), 0px 1px 1px rgb(0 0 0 / 0.075),
+ 0px 2px 2px rgb(0 0 0 / 0.075);
+ --text-shadow-md: 0px 1px 1px rgb(0 0 0 / 0.1), 0px 1px 2px rgb(0 0 0 / 0.1),
+ 0px 2px 4px rgb(0 0 0 / 0.1);
+ --text-shadow-lg: 0px 1px 2px rgb(0 0 0 / 0.1), 0px 3px 2px rgb(0 0 0 / 0.1),
+ 0px 4px 8px rgb(0 0 0 / 0.1);
+
+ --ease-in: cubic-bezier(0.4, 0, 1, 1);
+ --ease-out: cubic-bezier(0, 0, 0.2, 1);
+ --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
+
+ --animate-spin: spin 1s linear infinite;
+ --animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
+ --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+ --animate-bounce: bounce 1s infinite;
+
+ @keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+ }
+
+ @keyframes ping {
+ 75%,
+ 100% {
+ transform: scale(2);
+ opacity: 0;
+ }
+ }
+
+ @keyframes pulse {
+ 50% {
+ opacity: 0.5;
+ }
+ }
+
+ @keyframes bounce {
+ 0%,
+ 100% {
+ transform: translateY(-25%);
+ animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
+ }
+
+ 50% {
+ transform: none;
+ animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
+ }
+ }
+
+ --blur-xs: 4px;
+ --blur-sm: 8px;
+ --blur-md: 12px;
+ --blur-lg: 16px;
+ --blur-xl: 24px;
+ --blur-2xl: 40px;
+ --blur-3xl: 64px;
+
+ --perspective-dramatic: 100px;
+ --perspective-near: 300px;
+ --perspective-normal: 500px;
+ --perspective-midrange: 800px;
+ --perspective-distant: 1200px;
+
+ --aspect-video: 16 / 9;
+
+ --default-transition-duration: 150ms;
+ --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ --default-font-family: --theme(--font-sans, initial);
+ --default-font-feature-settings: --theme(--font-sans--font-feature-settings, initial);
+ --default-font-variation-settings: --theme(--font-sans--font-variation-settings, initial);
+ --default-mono-font-family: --theme(--font-mono, initial);
+ --default-mono-font-feature-settings: --theme(--font-mono--font-feature-settings, initial);
+ --default-mono-font-variation-settings: --theme(
+ --font-mono--font-variation-settings,
+ initial
+ );
+ }
+
+ /* Deprecated */
+ @theme default inline reference {
+ --blur: 8px;
+ --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
+ --shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
+ --drop-shadow: 0 1px 2px rgb(0 0 0 / 0.1), 0 1px 1px rgb(0 0 0 / 0.06);
+ --radius: 0.25rem;
+ --max-width-prose: 65ch;
+ }
+ `
+
+ let { version } = require('tailwindcss-v4/package.json')
+ let tailwindcss = await import('tailwindcss-v4')
+ let design: any = await tailwindcss.__unstable__loadDesignSystem(content)
+
+ let now = process.hrtime.bigint()
+ let project = await createProject({
+ kind: 'v4',
+ version,
+ design,
+ tailwindcss,
+ })
+ let elapsed = process.hrtime.bigint() - now
+ console.log(`${Number(elapsed) / 1e6}ms`)
+
+ expect(project.version).toEqual('4.1.1')
+ expect(project.features).toEqual([
+ 'css-at-theme',
+ 'layer:base',
+ 'content-list',
+ 'source-inline',
+ 'source-not',
+ ])
+
+ expect(project.depdendencies).toEqual([])
+ expect(project.sources()).toEqual([])
+
+ let classes = await project.resolveClasses([
+ // Unknown utility
+ 'does-not-exist',
+
+ // static
+ 'underline',
+
+ // functional
+ 'text-red',
+
+ // with variants
+ 'active:accent-[#fc3]',
+ ])
+
+ expect(classes[0]).toMatchObject({
+ kind: 'class',
+ source: 'unknown',
+
+ name: 'does-not-exist',
+ modifiers: [],
+ variants: [],
+
+ apply: { allowed: false },
+ })
+
+ expect(classes[1]).toMatchObject({
+ kind: 'class',
+ source: 'generated',
+
+ name: 'underline',
+ variants: [],
+ modifiers: [],
+
+ apply: { allowed: true },
+ })
+
+ expect(classes[2]).toMatchObject({
+ kind: 'class',
+ source: 'generated',
+
+ name: 'text-red',
+ variants: [],
+ modifiers: [
+ '0',
+ '5',
+ '10',
+ '15',
+ '20',
+ '25',
+ '30',
+ '35',
+ '40',
+ '45',
+ '50',
+ '55',
+ '60',
+ '65',
+ '70',
+ '75',
+ '80',
+ '85',
+ '90',
+ '95',
+ '100',
+ ],
+
+ apply: { allowed: true },
+ })
+
+ expect(classes[3]).toMatchObject({
+ kind: 'class',
+ source: 'generated',
+
+ name: 'active:accent-[#fc3]',
+ variants: ['active'],
+
+ // TODO: Core needs to provide an API to produce modifiers for this
+ modifiers: [],
+
+ apply: { allowed: true },
+ })
+
+ expect(classes.map((cls) => cls.nodes())).toEqual([
+ [],
+ [rule('.underline', [decl('text-decoration-line', 'underline')])],
+ [rule('.text-red', [decl('color', 'var(--color-red)')])],
+ [rule('.active\\:accent-\\[\\#fc3\\]', [rule('&:active', [decl('accent-color', '#fc3')])])],
+ ])
+
+ expect(classes.map((cls) => cls.colors())).toEqual([
+ //
+ [],
+ [],
+ [{ mode: 'rgb', r: 1, g: 0, b: 0 }],
+ [{ mode: 'rgb', r: 1, g: 0.8, b: 0.2 }],
+ ])
+})
diff --git a/packages/tailwindcss-language-service/src/project/v4.ts b/packages/tailwindcss-language-service/src/project/v4.ts
new file mode 100644
index 000000000..08dd98719
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/project/v4.ts
@@ -0,0 +1,143 @@
+import { supportedFeatures } from '../features'
+import { DesignSystem } from '../util/v4'
+import { Project } from './project'
+import { ResolvedClass } from './tokens'
+import { bigSign } from '../util/big-sign'
+import { lazy } from '../util/lazy'
+import { segment } from '../util/segment'
+import * as CSS from '../css/parse'
+import { colorsInAst } from '../util/color'
+import { AstNode } from '../css/ast'
+import { mayContainColors } from './color'
+
+export interface ProjectDescriptorV4 {
+ kind: 'v4'
+
+ /**
+ * The version of Tailwind CSS in use
+ */
+ version: string
+
+ /**
+ * The module returned by import("tailwindcss")
+ */
+ tailwindcss: unknown
+
+ /**
+ * The design system returned by `__unstable_loadDesignSystem(…)`
+ */
+ design: DesignSystem
+}
+
+export async function createProjectV4(desc: ProjectDescriptorV4): Promise {
+ let classCache = new Map()
+ let modifierCache = new Map()
+
+ async function resolveClasses(classes: string[]) {
+ // Compile anything not in the cache
+ let uncached = classes.filter((className) => !classCache.has(className))
+ let results = compileClasses(uncached)
+
+ // Populate the class cache
+ for (let result of results) classCache.set(result.name, result)
+
+ // Collect the results in order
+ let resolved = classes.map((name) => classCache.get(name) ?? null)
+
+ // Remove unknown classes from the cache otherwise these jsut waste memory
+ for (let result of results) {
+ if (result.source !== 'unknown') continue
+ classCache.delete(result.name)
+ }
+
+ return resolved
+ }
+
+ function compileClasses(classes: string[]): ResolvedClass[] {
+ let errors: any[] = []
+
+ let css = desc.design.candidatesToCss(classes)
+
+ let parsed = css.map((str) =>
+ lazy(() => {
+ let ast: AstNode[] = []
+ let colors: ReturnType = []
+
+ if (str) {
+ try {
+ ast = CSS.parse(str)
+ colors = colorsInAst(state, ast)
+ } catch (err) {
+ errors.push(err)
+ }
+ }
+
+ return { ast, colors }
+ }),
+ )
+
+ if (errors.length > 0) {
+ console.error(JSON.stringify(errors))
+ }
+
+ return classes.map((name, idx) => ({
+ kind: 'class',
+ source: css[idx] ? 'generated' : 'unknown',
+ name,
+ variants: css[idx] ? segment(name, ':').slice(0, -1) : [],
+ modifiers: modifierCache.get(name) ?? [],
+ apply: css[idx] ? { allowed: true } : { allowed: false, reason: 'class does not exist' },
+ nodes: () => parsed[idx]().ast,
+ colors: (force?: boolean) => {
+ let res = parsed[idx]
+ if (res.status === 'pending' && !force) {
+ return []
+ }
+
+ return parsed[idx]().colors
+ },
+ }))
+ }
+
+ async function sortClasses(classes: string[]) {
+ return desc.design
+ .getClassOrder(classes)
+ .sort(([, a], [, z]) => {
+ if (a === z) return 0
+ if (a === null) return -1
+ if (z === null) return 1
+ return bigSign(a - z)
+ })
+ .map(([className]) => className)
+ }
+
+ let classList = desc.design.getClassList()
+
+ for (let [className, meta] of classList) {
+ modifierCache.set(className, meta.modifiers)
+ }
+
+ // Pre-compute color values
+ let state = { designSystem: desc.design } as any
+ let colors = classList.map((entry) => entry[0]).filter(mayContainColors)
+ let resolved = await resolveClasses(colors)
+ for (let cls of resolved) cls.colors(true)
+
+ return {
+ version: desc.version,
+ features: supportedFeatures(desc.version, desc.tailwindcss),
+ depdendencies: [],
+
+ sources: () => [],
+
+ resolveClasses,
+ resolveDesignTokens: async () => [],
+ resolveVariants: async () => [],
+
+ searchClasses: async () => [],
+ searchDesignTokens: async () => [],
+ searchVariants: async () => [],
+
+ sortClasses,
+ }
+}
diff --git a/packages/tailwindcss-language-service/src/service.ts b/packages/tailwindcss-language-service/src/service.ts
new file mode 100644
index 000000000..3f8466412
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/service.ts
@@ -0,0 +1,189 @@
+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 { 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'
+import { createPathMatcher } from './paths'
+
+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?
+ // TODO: Do we need to normalize windows paths?
+ let isExcludedPath = createPathMatcher(state.editor.folder, settings.tailwindCSS.files.exclude)
+ if (isExcludedPath(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/big-sign.ts b/packages/tailwindcss-language-service/src/util/big-sign.ts
new file mode 100644
index 000000000..e29ce3c00
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/util/big-sign.ts
@@ -0,0 +1,3 @@
+export function bigSign(value: bigint) {
+ return Number(value > 0n) - Number(value < 0n)
+}
diff --git a/packages/tailwindcss-language-service/src/util/color.ts b/packages/tailwindcss-language-service/src/util/color.ts
index a1a99d661..ab7a612ac 100644
--- a/packages/tailwindcss-language-service/src/util/color.ts
+++ b/packages/tailwindcss-language-service/src/util/color.ts
@@ -9,6 +9,8 @@ import * as culori from 'culori'
import namedColors from 'color-name'
import postcss from 'postcss'
import { replaceCssVarsWithFallbacks } from './rewriting'
+import { AstNode } from '../css/ast'
+import { walk, WalkAction } from '../css/walk'
const COLOR_PROPS = [
'accent-color',
@@ -34,7 +36,7 @@ const COLOR_PROPS = [
'text-decoration-color',
]
-export type KeywordColor = 'transparent' | 'currentColor'
+export type KeywordColor = 'transparent' | 'currentColor' | 'inherit'
function getKeywordColor(value: unknown): KeywordColor | null {
if (typeof value !== 'string') return null
@@ -315,3 +317,32 @@ const LIGHT_DARK_REGEX = /light-dark\(\s*(.*?)\s*,\s*.*?\s*\)/g
function resolveLightDark(str: string) {
return str.replace(LIGHT_DARK_REGEX, (_, lightColor) => lightColor)
}
+
+export function colorsInAst(state: State, ast: AstNode[]) {
+ let decls: Record = {}
+
+ walk(ast, {
+ enter(node) {
+ if (node.kind === 'at-rule') {
+ if (node.name === '@property') {
+ return WalkAction.Skip
+ }
+
+ // Tailwind CSS v4 alpha and beta
+ if (node.name === '@supports' && node.params === '(-moz-orient: inline)') {
+ return WalkAction.Skip
+ }
+ } else if (node.kind === 'declaration') {
+ decls[node.property] ??= []
+ decls[node.property].push(node.value)
+ }
+
+ return WalkAction.Continue
+ },
+ })
+
+ let color = getColorFromDecls(state, decls)
+ if (!color) return []
+ if (typeof color === 'string') return []
+ return [color]
+}
diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts
index 839fb6d09..bd60281a0 100644
--- a/packages/tailwindcss-language-service/src/util/find.test.ts
+++ b/packages/tailwindcss-language-service/src/util/find.test.ts
@@ -1,8 +1,8 @@
-import { test } from 'vitest'
+import { expect, test } from 'vitest'
import { findClassListsInHtmlRange } from './find'
import { js, html, pug, createDocument } from './test-utils'
-test('class regex works in astro', async ({ expect }) => {
+test('class regex works in astro', async () => {
let file = createDocument({
name: 'file.astro',
lang: 'astro',
@@ -29,6 +29,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 },
@@ -36,6 +37,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 },
@@ -43,6 +45,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 },
@@ -51,7 +54,7 @@ test('class regex works in astro', async ({ expect }) => {
])
})
-test('find class lists in functions', async ({ expect }) => {
+test('find class lists in functions', async () => {
let fileA = createDocument({
name: 'file.jsx',
lang: 'javascriptreact',
@@ -101,6 +104,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 },
@@ -108,6 +112,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 },
@@ -115,6 +120,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 },
@@ -122,6 +128,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 },
@@ -131,6 +138,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 },
@@ -138,6 +146,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 },
@@ -145,6 +154,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 },
@@ -152,6 +162,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 },
@@ -163,7 +174,7 @@ test('find class lists in functions', async ({ expect }) => {
expect(classListsB).toEqual([])
})
-test('find class lists in nested fn calls', async ({ expect }) => {
+test('find class lists in nested fn calls', async () => {
let file = createDocument({
name: 'file.jsx',
lang: 'javascriptreact',
@@ -209,6 +220,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 },
@@ -218,6 +230,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 },
@@ -227,6 +240,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 },
@@ -235,6 +249,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 },
@@ -242,6 +257,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 },
@@ -249,6 +265,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 },
@@ -256,6 +273,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 },
@@ -263,6 +281,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 },
@@ -270,6 +289,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
@@ -279,7 +299,7 @@ test('find class lists in nested fn calls', async ({ expect }) => {
])
})
-test('find class lists in nested fn calls (only nested matches)', async ({ expect }) => {
+test('find class lists in nested fn calls (only nested matches)', async () => {
let file = createDocument({
name: 'file.jsx',
lang: 'javascriptreact',
@@ -311,6 +331,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 },
@@ -318,6 +339,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 },
@@ -326,7 +348,7 @@ test('find class lists in nested fn calls (only nested matches)', async ({ expec
])
})
-test('find class lists in tagged template literals', async ({ expect }) => {
+test('find class lists in tagged template literals', async () => {
let fileA = createDocument({
name: 'file.jsx',
lang: 'javascriptreact',
@@ -376,6 +398,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 },
@@ -383,6 +406,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 },
@@ -390,6 +414,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 },
@@ -399,6 +424,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 },
@@ -406,6 +432,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 },
@@ -413,6 +440,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 },
@@ -424,7 +452,7 @@ test('find class lists in tagged template literals', async ({ expect }) => {
expect(classListsB).toEqual([])
})
-test('classFunctions can be a regex', async ({ expect }) => {
+test('classFunctions can be a regex', async () => {
let fileA = createDocument({
name: 'file.jsx',
lang: 'javascriptreact',
@@ -457,6 +485,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 },
@@ -468,7 +497,7 @@ test('classFunctions can be a regex', async ({ expect }) => {
expect(classListsB).toEqual([])
})
-test('classFunctions regexes only match on function names', async ({ expect }) => {
+test('classFunctions regexes only match on function names', async () => {
let file = createDocument({
name: 'file.jsx',
lang: 'javascriptreact',
@@ -489,7 +518,7 @@ test('classFunctions regexes only match on function names', async ({ expect }) =
expect(classLists).toEqual([])
})
-test('Finds consecutive instances of a class function', async ({ expect }) => {
+test('Finds consecutive instances of a class function', async () => {
let file = createDocument({
name: 'file.js',
lang: 'javascript',
@@ -512,6 +541,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 },
@@ -519,6 +549,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 },
@@ -526,6 +557,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 },
@@ -534,7 +566,7 @@ test('Finds consecutive instances of a class function', async ({ expect }) => {
])
})
-test('classFunctions & classAttributes should not duplicate matches', async ({ expect }) => {
+test('classFunctions & classAttributes should not duplicate matches', async () => {
let file = createDocument({
name: 'file.jsx',
lang: 'javascriptreact',
@@ -575,6 +607,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 },
@@ -582,6 +615,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 },
@@ -589,6 +623,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 },
@@ -596,6 +631,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 },
@@ -603,6 +639,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 },
@@ -610,6 +647,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 },
@@ -618,7 +656,7 @@ test('classFunctions & classAttributes should not duplicate matches', async ({ e
])
})
-test('classFunctions should only match in JS-like contexts', async ({ expect }) => {
+test('classFunctions should only match in JS-like contexts', async () => {
let file = createDocument({
name: 'file.html',
lang: 'html',
@@ -654,6 +692,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 },
@@ -661,6 +700,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 },
@@ -668,6 +708,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 },
@@ -675,6 +716,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 },
@@ -683,7 +725,7 @@ test('classFunctions should only match in JS-like contexts', async ({ expect })
])
})
-test('classAttributes find class lists inside variables in JS(X)/TS(X)', async ({ expect }) => {
+test('classAttributes find class lists inside variables in JS(X)/TS(X)', async () => {
let file = createDocument({
name: 'file.html',
lang: 'javascript',
@@ -714,6 +756,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 },
@@ -721,6 +764,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 },
@@ -728,6 +772,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 },
@@ -736,7 +781,7 @@ test('classAttributes find class lists inside variables in JS(X)/TS(X)', async (
])
})
-test('classAttributes find class lists inside pug', async ({ expect }) => {
+test('classAttributes find class lists inside pug', async () => {
let file = createDocument({
name: 'file.pug',
lang: 'pug',
@@ -755,6 +800,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 },
@@ -763,7 +809,7 @@ test('classAttributes find class lists inside pug', async ({ expect }) => {
])
})
-test('classAttributes find class lists inside Vue bindings', async ({ expect }) => {
+test('classAttributes find class lists inside Vue bindings', async () => {
let file = createDocument({
name: 'file.pug',
lang: 'vue',
@@ -784,6 +830,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 9118403da..0fd6d25db 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: {
@@ -409,6 +426,8 @@ export function findHelperFunctionsInRange(
text,
)
+ let rangeStartOffset = range?.start ? doc.offsetAt(range.start) : 0
+
// Eliminate matches that are on an `@import`
matches = matches.filter((match) => {
// Scan backwards to see if we're in an `@import` statement
@@ -477,6 +496,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 42a4a4950..f794d2b4a 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: /`,
+ expected: [
+ {
+ code: DiagnosticKind.CssConflict,
+ range: range(2, 9, 2, 18),
+ severity: 2,
+ message: "'uppercase' applies the same CSS properties as 'lowercase'.",
+ relatedInformation: [
+ {
+ message: 'lowercase',
+ location: {
+ uri: expect.stringContaining('file://projects/root/'),
+ range: range(2, 19, 2, 28),
+ },
+ },
+ ],
+ className: {
+ className: 'uppercase',
+ classList: {
+ classList: 'uppercase lowercase',
+ range: range(2, 9, 2, 28),
+ span: [34, 53],
+ important: false,
+ },
+ relativeRange: range(0, 0, 0, 9),
+ range: range(2, 9, 2, 18),
+ span: [34, 43],
+ },
+ otherClassNames: [
+ {
+ className: 'lowercase',
+ classList: {
+ classList: 'uppercase lowercase',
+ range: range(2, 9, 2, 28),
+ span: [34, 53],
+ important: false,
+ },
+ relativeRange: range(0, 10, 0, 19),
+ range: range(2, 19, 2, 28),
+ span: [44, 53],
+ },
+ ],
+ },
+ {
+ code: DiagnosticKind.CssConflict,
+ range: range(2, 19, 2, 28),
+ severity: 2,
+ message: "'lowercase' applies the same CSS properties as 'uppercase'.",
+ relatedInformation: [
+ {
+ message: 'uppercase',
+ location: {
+ uri: expect.stringContaining('file://projects/root/'),
+ range: range(2, 9, 2, 18),
+ },
+ },
+ ],
+ className: {
+ className: 'lowercase',
+ classList: {
+ classList: 'uppercase lowercase',
+ range: range(2, 9, 2, 28),
+ span: [34, 53],
+ important: false,
+ },
+ relativeRange: range(0, 10, 0, 19),
+ range: range(2, 19, 2, 28),
+ span: [44, 53],
+ },
+ otherClassNames: [
+ {
+ className: 'uppercase',
+ classList: {
+ classList: 'uppercase lowercase',
+ range: range(2, 9, 2, 28),
+ span: [34, 53],
+ important: false,
+ },
+ relativeRange: range(0, 0, 0, 9),
+ range: range(2, 9, 2, 18),
+ span: [34, 43],
+ },
+ ],
+ },
+ ],
+ })
+
+ runTest({
+ name: 'conflict in @apply',
+ lang: 'css',
+ text: '.test { @apply uppercase lowercase }',
+ expected: [
+ {
+ code: DiagnosticKind.CssConflict,
+ range: range(0, 15, 0, 24),
+ severity: 2,
+ message: "'uppercase' applies the same CSS properties as 'lowercase'.",
+ relatedInformation: [
+ {
+ message: 'lowercase',
+ location: {
+ uri: expect.stringContaining('file://projects/root/'),
+ range: range(0, 25, 0, 34),
+ },
+ },
+ ],
+ className: {
+ className: 'uppercase',
+ classList: {
+ classList: 'uppercase lowercase',
+ range: range(0, 15, 0, 34),
+ span: [15, 34],
+ important: false,
+ },
+ relativeRange: range(0, 0, 0, 9),
+ range: range(0, 15, 0, 24),
+ span: [15, 24],
+ },
+ otherClassNames: [
+ {
+ className: 'lowercase',
+ classList: {
+ classList: 'uppercase lowercase',
+ range: range(0, 15, 0, 34),
+ span: [15, 34],
+ important: false,
+ },
+ relativeRange: range(0, 10, 0, 19),
+ range: range(0, 25, 0, 34),
+ span: [25, 34],
+ },
+ ],
+ },
+ {
+ code: DiagnosticKind.CssConflict,
+ range: range(0, 25, 0, 34),
+ severity: 2,
+ message: "'lowercase' applies the same CSS properties as 'uppercase'.",
+ relatedInformation: [
+ {
+ message: 'uppercase',
+ location: {
+ uri: expect.stringContaining('file://projects/root/'),
+ range: range(0, 15, 0, 24),
+ },
+ },
+ ],
+ className: {
+ className: 'lowercase',
+ classList: {
+ classList: 'uppercase lowercase',
+ range: range(0, 15, 0, 34),
+ span: [15, 34],
+ important: false,
+ },
+ relativeRange: range(0, 10, 0, 19),
+ range: range(0, 25, 0, 34),
+ span: [25, 34],
+ },
+ otherClassNames: [
+ {
+ className: 'uppercase',
+ classList: {
+ classList: 'uppercase lowercase',
+ range: range(0, 15, 0, 34),
+ span: [15, 34],
+ important: false,
+ },
+ relativeRange: range(0, 0, 0, 9),
+ range: range(0, 15, 0, 24),
+ span: [15, 24],
+ },
+ ],
+ },
+ ],
+ })
+
+ runTest({
+ name: 'multiple @apply rules do not conflict with each other (multiple rules)',
+ lang: 'css',
+ text: '.test { @apply uppercase }\n.test { @apply lowercase }',
+ expected: [],
+ })
+
+ runTest({
+ name: 'multiple @apply rules do not conflict with each other (multiple props)',
+ lang: 'css',
+ text: '.test { @apply uppercase; color: red; @apply lowercase }',
+ expected: [],
+ })
+
+ //
+ // @source
+ //
+ runTest({
+ name: 'Source directives require paths',
+ lang: 'css',
+ text: css`
+ @import 'tailwindcss' source();
+ @import 'tailwindcss' source('');
+ @import 'tailwindcss' source('');
+ @tailwind utilities source();
+ @tailwind utilities source('');
+ @tailwind utilities source("");
+ `,
+ expected: [
+ {
+ code: DiagnosticKind.InvalidSourceDirective,
+ range: range(0, 29, 0, 29),
+ severity: 1,
+ message: 'The source directive requires a path to a directory.',
+ },
+ {
+ code: DiagnosticKind.InvalidSourceDirective,
+ range: range(1, 29, 1, 31),
+ severity: 1,
+ message: 'The source directive requires a path to a directory.',
+ },
+ {
+ code: DiagnosticKind.InvalidSourceDirective,
+ range: range(2, 29, 2, 31),
+ severity: 1,
+ message: 'The source directive requires a path to a directory.',
+ },
+ {
+ code: DiagnosticKind.InvalidSourceDirective,
+ range: range(3, 27, 3, 27),
+ severity: 1,
+ message: 'The source directive requires a path to a directory.',
+ },
+ {
+ code: DiagnosticKind.InvalidSourceDirective,
+ range: range(4, 27, 4, 29),
+ severity: 1,
+ message: 'The source directive requires a path to a directory.',
+ },
+ {
+ code: DiagnosticKind.InvalidSourceDirective,
+ range: range(5, 27, 5, 29),
+ severity: 1,
+ message: 'The source directive requires a path to a directory.',
+ },
+ ],
+ })
+
+ runTest({
+ name: 'source(none) must not be misspelled',
+ lang: 'css',
+ text: css`
+ @import 'tailwindcss' source(no);
+ @tailwind utilities source(no);
+ `,
+ expected: [
+ {
+ code: DiagnosticKind.InvalidSourceDirective,
+ severity: 1,
+ message: '`source(no)` is invalid. Did you mean `source(none)`?',
+ range: range(0, 29, 0, 31),
+ },
+ {
+ code: DiagnosticKind.InvalidSourceDirective,
+ severity: 1,
+ message: '`source(no)` is invalid. Did you mean `source(none)`?',
+ range: range(1, 27, 1, 29),
+ },
+ ],
+ })
+
+ runTest({
+ name: 'source("…") does not produce diagnostics',
+ lang: 'css',
+ text: css`
+ @import 'tailwindcss' source('../app');
+ @tailwind utilities source('../app');
+ @import 'tailwindcss' source('../app');
+ @tailwind utilities source("../app");
+ `,
+ expected: [],
+ })
+
+ runTest({
+ name: 'paths given to source("…") must error when not POSIX',
+ lang: 'css',
+ text: css`
+ @import 'tailwindcss' source('C:\\absolute\\path');
+ @import 'tailwindcss' source('C:relative.txt');
+ `,
+ expected: [
+ {
+ code: DiagnosticKind.InvalidSourceDirective,
+ severity: 1,
+ range: range(0, 29, 0, 49),
+ message:
+ 'POSIX-style paths are required with `source(…)` but `C:\\absolute\\path` is a Windows-style path.',
+ },
+ {
+ code: DiagnosticKind.InvalidSourceDirective,
+ severity: 1,
+ range: range(1, 29, 1, 45),
+ message:
+ 'POSIX-style paths are required with `source(…)` but `C:relative.txt` is a Windows-style path.',
+ },
+ ],
+ })
+})
diff --git a/packages/tailwindcss-language-service/tests/document-links.test.ts b/packages/tailwindcss-language-service/tests/document-links.test.ts
new file mode 100644
index 000000000..527b8b5d9
--- /dev/null
+++ b/packages/tailwindcss-language-service/tests/document-links.test.ts
@@ -0,0 +1,212 @@
+import { test, expect, describe } from 'vitest'
+import { ClientOptions, createClient } from './utils/client'
+import { css, range } from './utils/utils'
+import { DocumentLink } from 'vscode-languageserver'
+
+interface DocumentLinkFixture {
+ name: string
+ client?: ClientOptions
+ lang?: string
+ text: string
+ expected: DocumentLink[]
+}
+
+describe('v4', async () => {
+ let client = await createClient({
+ config: {
+ kind: 'css',
+ content: css`
+ /* */
+ `,
+ },
+ })
+
+ function runTest({
+ client: clientOpts,
+ name,
+ lang = 'html',
+ text,
+ expected,
+ }: DocumentLinkFixture) {
+ test(name, async () => {
+ let testClient = clientOpts ? await createClient(clientOpts) : client
+ let doc = await testClient.open({ lang, text })
+ expect(await doc.documentLinks()).toEqual(expected)
+ })
+ }
+
+ runTest({
+ name: 'config: file exists',
+ lang: 'css',
+ text: css`
+ @config "tailwind.config.js";
+ `,
+ expected: [
+ {
+ target: 'file:///projects/root/tailwind.config.js',
+ range: range(0, 8, 0, 28),
+ },
+ ],
+ })
+
+ runTest({
+ name: 'config: file does not exist',
+ lang: 'css',
+ text: css`
+ @config "does-not-exist.js";
+ `,
+ expected: [
+ {
+ target: 'file:///projects/root/does-not-exist.js',
+ range: range(0, 8, 0, 27),
+ },
+ ],
+ })
+
+ runTest({
+ name: 'plugin: file exists',
+ lang: 'css',
+ text: css`
+ @plugin "plugin.js";
+ `,
+ expected: [
+ {
+ target: 'file:///projects/root/plugin.js',
+ range: range(0, 8, 0, 19),
+ },
+ ],
+ })
+
+ runTest({
+ name: 'plugin: file does not exist',
+ lang: 'css',
+ text: css`
+ @plugin "does-not-exist.js";
+ `,
+ expected: [
+ {
+ target: 'file:///projects/root/does-not-exist.js',
+ range: range(0, 8, 0, 27),
+ },
+ ],
+ })
+
+ runTest({
+ name: 'source: file exists',
+ lang: 'css',
+ text: css`
+ @source "index.html";
+ `,
+ expected: [
+ {
+ target: 'file:///projects/root/index.html',
+ range: range(0, 8, 0, 20),
+ },
+ ],
+ })
+
+ runTest({
+ name: 'source: file does not exist',
+ lang: 'css',
+ text: css`
+ @source "does-not-exist.html";
+ `,
+ expected: [
+ {
+ target: 'file:///projects/root/does-not-exist.html',
+ range: range(0, 8, 0, 29),
+ },
+ ],
+ })
+
+ runTest({
+ name: 'source not: file exists',
+ lang: 'css',
+ text: css`
+ @source not "index.html";
+ `,
+ expected: [
+ {
+ target: 'file:///projects/root/index.html',
+ range: range(0, 12, 0, 24),
+ },
+ ],
+ })
+
+ runTest({
+ name: 'source not: file does not exist',
+ lang: 'css',
+ text: css`
+ @source not "does-not-exist.html";
+ `,
+ expected: [
+ {
+ target: 'file:///projects/root/does-not-exist.html',
+ range: range(0, 12, 0, 33),
+ },
+ ],
+ })
+
+ runTest({
+ name: '@source inline(…)',
+ lang: 'css',
+ text: css`
+ @source inline("m-{1,2,3}");
+ `,
+ expected: [],
+ })
+
+ runTest({
+ name: '@source not inline(…)',
+ lang: 'css',
+ text: css`
+ @source not inline("m-{1,2,3}");
+ `,
+ expected: [],
+ })
+
+ runTest({
+ name: 'Directories in source(…) show links',
+ lang: 'css',
+ text: css`
+ @import 'tailwindcss' source('../../');
+ @tailwind utilities source("../../");
+ `,
+ expected: [
+ {
+ target: 'file:///',
+ range: range(0, 29, 0, 37),
+ },
+ {
+ target: 'file:///',
+ range: range(1, 27, 1, 35),
+ },
+ ],
+ })
+
+ runTest({
+ name: 'Globs in source(…) do not show links',
+ lang: 'css',
+ text: css`
+ @import 'tailwindcss' source('../{a,b,c}');
+ @tailwind utilities source("../{a,b,c}");
+ `,
+ expected: [],
+ })
+
+ runTest({
+ name: 'Windows paths in source(…) do not show links',
+ lang: 'css',
+ text: css`
+ @import 'tailwindcss' source('..\foo\bar');
+ @tailwind utilities source("..\foo\bar");
+
+ @import 'tailwindcss' source('C:\foo\bar');
+ @tailwind utilities source("C:\foo\bar");
+
+ @import 'tailwindcss' source('C:foo');
+ @tailwind utilities source("C:bar");
+ `,
+ expected: [],
+ })
+})
diff --git a/packages/tailwindcss-language-service/tests/hovers.test.ts b/packages/tailwindcss-language-service/tests/hovers.test.ts
new file mode 100644
index 000000000..4bf9ed7cc
--- /dev/null
+++ b/packages/tailwindcss-language-service/tests/hovers.test.ts
@@ -0,0 +1,311 @@
+import { test, expect, describe } from 'vitest'
+import { createClient } from './utils/client'
+import { css, html, range } from './utils/utils'
+
+describe('v4', async () => {
+ let client = await createClient({
+ config: {
+ kind: 'css',
+ content: css`
+ @theme {
+ --spacing-4: 1rem;
+ --breakpoint-xl: 80rem;
+ --color-black: #000;
+ }
+ `,
+ },
+ })
+
+ test('disabled', async () => {
+ let doc = await client.open({
+ lang: 'html',
+ text: '',
+ settings: {
+ tailwindCSS: { hovers: false },
+ },
+ })
+
+ expect(await doc.hover({ line: 0, character: 13 })).toEqual(null)
+ })
+
+ test('named', async () => {
+ let doc = await client.open({
+ lang: 'html',
+ text: '
',
+ })
+
+ expect(await doc.hover({ line: 0, character: 13 })).toEqual({
+ range: range(0, 12, 0, 20),
+ contents: {
+ language: 'css',
+ value: '.bg-black {\n background-color: var(--color-black) /* #000 = #000000 */;\n}',
+ },
+ })
+ })
+
+ test('arbitrary value', async () => {
+ let doc = await client.open({
+ lang: 'html',
+ text: '
',
+ })
+
+ expect(await doc.hover({ line: 0, character: 13 })).toEqual({
+ range: range(0, 12, 0, 19),
+ contents: {
+ language: 'css',
+ value: '.p-\\[3px\\] {\n padding: 3px;\n}',
+ },
+ })
+ })
+
+ test('arbitrary value + theme fn (modern)', async () => {
+ let doc = await client.open({
+ lang: 'html',
+ text: '
',
+ })
+
+ expect(await doc.hover({ line: 0, character: 13 })).toEqual({
+ range: range(0, 12, 0, 34),
+ contents: {
+ language: 'css',
+ value: '.p-\\[theme\\(--spacing-4\\)\\] {\n' + ' padding: 1rem /* 16px */;\n' + '}',
+ },
+ })
+ })
+
+ test('arbitrary value + theme fn (legacy)', async () => {
+ let doc = await client.open({
+ lang: 'html',
+ text: '
',
+ })
+
+ expect(await doc.hover({ line: 0, character: 13 })).toEqual({
+ range: range(0, 12, 0, 32),
+ contents: {
+ language: 'css',
+ value: '.p-\\[theme\\(spacing\\.4\\)\\] {\n' + ' padding: 1rem /* 16px */;\n' + '}',
+ },
+ })
+ })
+
+ test('arbitrary property', async () => {
+ let doc = await client.open({
+ lang: 'html',
+ text: '
',
+ })
+
+ expect(await doc.hover({ line: 0, character: 13 })).toEqual({
+ range: range(0, 12, 0, 31),
+ contents: {
+ language: 'css',
+ value: '.\\[text-wrap\\:balance\\] {\n text-wrap: balance;\n}',
+ },
+ })
+ })
+
+ test('named + apply + vue
+ `,
+ })
+
+ 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/tests/utils/cache-map.ts b/packages/tailwindcss-language-service/tests/utils/cache-map.ts
new file mode 100644
index 000000000..6091842fb
--- /dev/null
+++ b/packages/tailwindcss-language-service/tests/utils/cache-map.ts
@@ -0,0 +1,10 @@
+export class CacheMap extends Map {
+ remember(key: TKey, factory: (key: TKey) => TValue): TValue {
+ let value = super.get(key)
+ if (!value) {
+ value = factory(key)
+ this.set(key, value)
+ }
+ return value!
+ }
+}
diff --git a/packages/tailwindcss-language-service/tests/utils/client.ts b/packages/tailwindcss-language-service/tests/utils/client.ts
new file mode 100644
index 000000000..63e055401
--- /dev/null
+++ b/packages/tailwindcss-language-service/tests/utils/client.ts
@@ -0,0 +1,147 @@
+import { Settings, State } from '../../src'
+import postcss from 'postcss'
+import { createLanguageService, createState } from '../../src'
+import { supportedFeatures } from '../../src/features'
+import { TextDocument } from 'vscode-languageserver-textdocument'
+import { URI, Utils as URIUtils } from 'vscode-uri'
+import { createConfiguration } from './configuration'
+import { DeepPartial } from '../../src/types'
+import { createFileSystem, Storage } from './fs'
+
+export interface ClientOptions {
+ config:
+ | { kind: 'css'; content: string }
+ | { kind: 'module'; content: any }
+ | { kind: 'custom'; content: (state: State) => State }
+
+ /**
+ * In-memory filesystem structure
+ */
+ fs?: Storage
+}
+
+export interface DocumentDescriptor {
+ /**
+ * The language the document is written in
+ */
+ lang: string
+
+ /**
+ * The content of the document
+ */
+ text: string
+
+ /**
+ * The name or file path to the document
+ *
+ * By default a unique path is generated at the root of the workspace
+ */
+ name?: string
+
+ /**
+ * Custom settings / config for this document
+ */
+ settings?: DeepPartial
+}
+
+export async function createClient(options: ClientOptions) {
+ if (options.config.kind !== 'css') {
+ throw new Error('unsupported')
+ }
+
+ let { version } = require('tailwindcss-v4/package.json')
+ let tailwindcss = await import('tailwindcss-v4')
+
+ let design = await tailwindcss.__unstable__loadDesignSystem(options.config.content)
+
+ // Step 4: Augment the design system with some additional APIs that the LSP needs
+ Object.assign(design, {
+ dependencies: () => [],
+
+ // TODOs:
+ //
+ // 1. Remove PostCSS parsing — its roughly 60% of the processing time
+ // ex: compiling 19k classes take 650ms and 400ms of that is PostCSS
+ //
+ // - Replace `candidatesToCss` with a `candidatesToAst` API
+ // First step would be to convert to a PostCSS AST by transforming the nodes directly
+ // Then it would be to drop the PostCSS AST representation entirely in all v4 code paths
+ compile(classes: string[]): (postcss.Root | null)[] {
+ let css = design.candidatesToCss(classes)
+ let errors: any[] = []
+
+ let roots = css.map((str) => {
+ if (str === null) return postcss.root()
+
+ try {
+ return postcss.parse(str.trimEnd())
+ } catch (err) {
+ errors.push(err)
+ return postcss.root()
+ }
+ })
+
+ if (errors.length > 0) {
+ console.error(JSON.stringify(errors))
+ }
+
+ return roots
+ },
+
+ toCss(nodes: postcss.Root | postcss.Node[]): string {
+ return Array.isArray(nodes)
+ ? postcss.root({ nodes }).toString().trim()
+ : nodes.toString().trim()
+ },
+ })
+
+ let config = createConfiguration()
+
+ let state = createState({
+ v4: true,
+ version,
+ designSystem: design as any,
+ // TODO: This should not be necessary
+ blocklist: Array.from(design.invalidCandidates),
+ features: supportedFeatures(version, tailwindcss),
+ editor: {
+ getConfiguration: async (uri) => config.get(uri),
+ },
+ })
+
+ let service = createLanguageService({
+ state: () => state,
+ fs: createFileSystem(options.fs ?? {}),
+ })
+
+ state.separator = ':'
+ state.variants = design.getVariants()
+ state.classList = await Promise.all(
+ design.getClassList().map(async (entry) => [
+ entry[0],
+ {
+ ...entry[1],
+ color: await service.getColor(entry[0]),
+ },
+ ]),
+ )
+
+ let index = 0
+ function open(desc: DocumentDescriptor) {
+ let uri = URIUtils.resolvePath(
+ URI.parse('file://projects/root'),
+ desc.name ? desc.name : `file-${++index}.${desc.lang}`,
+ ).toString()
+
+ if (desc.settings) {
+ config.set(uri, desc.settings)
+ }
+
+ return service.open(TextDocument.create(uri, desc.lang, 1, desc.text))
+ }
+
+ return {
+ ...service,
+ open,
+ }
+}
diff --git a/packages/tailwindcss-language-service/tests/utils/configuration.ts b/packages/tailwindcss-language-service/tests/utils/configuration.ts
new file mode 100644
index 000000000..e125c22c0
--- /dev/null
+++ b/packages/tailwindcss-language-service/tests/utils/configuration.ts
@@ -0,0 +1,56 @@
+import { getDefaultTailwindSettings, type Settings } from '../../src/util/state'
+import { URI } from 'vscode-uri'
+import type { DeepPartial } from '../../src/types'
+import { CacheMap } from './cache-map'
+import deepmerge from 'deepmerge'
+
+export interface Configuration {
+ get(uri: string | null): Settings
+ set(uri: string | null, value: DeepPartial): void
+}
+
+export function createConfiguration(): Configuration {
+ let defaults = getDefaultTailwindSettings()
+
+ /**
+ * Settings per file or directory URI
+ */
+ let cache = new CacheMap()
+
+ function compute(uri: URI | null) {
+ let groups: Partial[] = [
+ // 1. Extension defaults
+ structuredClone(defaults),
+
+ // 2. "Global" settings
+ cache.get(null) ?? {},
+ ]
+
+ // 3. Workspace and per-file settings
+ let components = uri ? uri.path.split('/') : []
+
+ for (let i = 0; i <= components.length; i++) {
+ let parts = components.slice(0, i)
+ if (parts.length === 0) continue
+ let path = parts.join('/')
+ let cached = cache.get(uri!.with({ path }).toString())
+ if (!cached) continue
+ groups.push(cached)
+ }
+
+ // Merge all the settings together
+ return deepmerge.all(groups, {
+ arrayMerge: (_target, source) => source,
+ })
+ }
+
+ function get(uri: string | null) {
+ return compute(uri ? URI.parse(uri) : null)
+ }
+
+ function set(uri: string | null, value: Settings) {
+ cache.set(uri ? URI.parse(uri).toString() : null, value)
+ }
+
+ return { get, set }
+}
diff --git a/packages/tailwindcss-language-service/tests/utils/fs.ts b/packages/tailwindcss-language-service/tests/utils/fs.ts
new file mode 100644
index 000000000..4b956fbd6
--- /dev/null
+++ b/packages/tailwindcss-language-service/tests/utils/fs.ts
@@ -0,0 +1,61 @@
+import { memfs } from 'memfs'
+import { URI } from 'vscode-uri'
+import { File, FileSystem, FileType } from '../../src/fs'
+import * as path from 'node:path'
+import { IDirent } from 'memfs/lib/node/types/misc'
+
+export interface Storage {
+ /** A list of files and their content */
+ [filePath: string]: string | Buffer | null
+}
+
+export function createFileSystem(storage: Storage): FileSystem {
+ let { fs } = memfs(storage, '/projects/root')
+
+ return {
+ document: async () => null,
+ resolve: async (doc, relativePath) => {
+ let documentPath = URI.parse(doc.uri).fsPath
+ let baseDir = path.dirname(documentPath)
+ return URI.file(path.resolve(baseDir, relativePath)).toString()
+ },
+ async readDirectory(document, filepath): Promise {
+ let uri = URI.parse(document.uri)
+
+ let baseDir = path.dirname(uri.fsPath)
+ filepath = path.resolve(baseDir, filepath)
+
+ let dirents = await fs.promises.readdir(filepath, { withFileTypes: true })
+
+ let results: File[] = []
+
+ for (let dirent of dirents as IDirent[]) {
+ // TODO:
+ // let isDirectory = dirent.isDirectory()
+ // let shouldRemove = await isExcluded(
+ // state,
+ // document,
+ // path.join(filepath, dirent.name.toString(), isDirectory ? '/' : ''),
+ // )
+ // if (shouldRemove) continue
+
+ let type: FileType = 'unknown'
+
+ if (dirent.isFile()) {
+ type = 'file'
+ } else if (dirent.isDirectory()) {
+ type = 'directory'
+ } else if (dirent.isSymbolicLink()) {
+ type = 'symbolic-link'
+ }
+
+ results.push({
+ name: dirent.name.toString(),
+ type,
+ })
+ }
+
+ return results
+ },
+ }
+}
diff --git a/packages/tailwindcss-language-service/tests/utils/utils.ts b/packages/tailwindcss-language-service/tests/utils/utils.ts
new file mode 100644
index 000000000..732c485a9
--- /dev/null
+++ b/packages/tailwindcss-language-service/tests/utils/utils.ts
@@ -0,0 +1,17 @@
+import dedent from 'dedent'
+
+export const js = dedent
+export const css = dedent
+export const html = dedent
+
+export const range = (startLine: number, startCol: number, endLine: number, endCol: number) => ({
+ start: { line: startLine, character: startCol },
+ end: { line: endLine, character: endCol },
+})
+
+export const rgb = (red: number, green: number, blue: number, alpha: number = 1) => ({
+ red,
+ green,
+ blue,
+ alpha,
+})
diff --git a/packages/tailwindcss-language-service/tsconfig.json b/packages/tailwindcss-language-service/tsconfig.json
index 883356e77..a7625b22e 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": "NodeNext",
"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 2255d6ad5..af09102e3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -339,21 +339,42 @@ importers:
'@types/node':
specifier: ^18.19.33
version: 18.19.43
+ '@types/normalize-path':
+ specifier: ^3.0.2
+ version: 3.0.2
+ '@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.0
version: 0.25.0
esbuild-node-externals:
specifier: ^1.9.0
version: 1.14.0(esbuild@0.25.0)
+ memfs:
+ specifier: ^4.17.0
+ version: 4.17.0
minimist:
specifier: ^1.2.8
version: 1.2.8
+ normalize-path:
+ specifier: 3.0.0
+ version: 3.0.0
+ 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
@@ -363,6 +384,9 @@ importers:
vitest:
specifier: ^3.0.9
version: 3.0.9(@types/node@18.19.43)
+ vscode-uri:
+ specifier: 3.0.2
+ version: 3.0.2
packages/vscode-tailwindcss:
devDependencies:
@@ -767,6 +791,24 @@ packages:
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
+ '@jsonjoy.com/base64@1.1.2':
+ resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==}
+ engines: {node: '>=10.0'}
+ peerDependencies:
+ tslib: '2'
+
+ '@jsonjoy.com/json-pack@1.2.0':
+ resolution: {integrity: sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==}
+ engines: {node: '>=10.0'}
+ peerDependencies:
+ tslib: '2'
+
+ '@jsonjoy.com/util@1.5.0':
+ resolution: {integrity: sha512-ojoNsrIuPI9g6o8UxhraZQSyF2ByJanAY4cTFbc8Mf2AXEF4aQRGY1dJxyJpuyav8r9FGflEt/Ff3u5Nt6YMPA==}
+ engines: {node: '>=10.0'}
+ peerDependencies:
+ tslib: '2'
+
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -1723,6 +1765,10 @@ packages:
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
+ hyperdyperid@1.2.0:
+ resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==}
+ engines: {node: '>=10.18'}
+
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -1918,6 +1964,10 @@ packages:
mdurl@1.0.1:
resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==}
+ memfs@4.17.0:
+ resolution: {integrity: sha512-4eirfZ7thblFmqFjywlTmuWVSvccHAJbn1r8qQLzmTO11qcqpohOjmY2mFce6x7x7WtskzRqApPD0hv+Oa74jg==}
+ engines: {node: '>= 4.0.0'}
+
meow@10.1.5:
resolution: {integrity: sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -2564,6 +2614,12 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
+ thingies@1.21.0:
+ resolution: {integrity: sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==}
+ engines: {node: '>=10.18'}
+ peerDependencies:
+ tslib: ^2
+
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2598,6 +2654,12 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
+ tree-dump@1.0.2:
+ resolution: {integrity: sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==}
+ engines: {node: '>=10.0'}
+ peerDependencies:
+ tslib: '2'
+
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@@ -3089,6 +3151,22 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
+ '@jsonjoy.com/base64@1.1.2(tslib@2.2.0)':
+ dependencies:
+ tslib: 2.2.0
+
+ '@jsonjoy.com/json-pack@1.2.0(tslib@2.2.0)':
+ dependencies:
+ '@jsonjoy.com/base64': 1.1.2(tslib@2.2.0)
+ '@jsonjoy.com/util': 1.5.0(tslib@2.2.0)
+ hyperdyperid: 1.2.0
+ thingies: 1.21.0(tslib@2.2.0)
+ tslib: 2.2.0
+
+ '@jsonjoy.com/util@1.5.0(tslib@2.2.0)':
+ dependencies:
+ tslib: 2.2.0
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -4010,6 +4088,8 @@ snapshots:
domutils: 3.1.0
entities: 4.5.0
+ hyperdyperid@1.2.0: {}
+
ieee754@1.2.1:
optional: true
@@ -4179,6 +4259,13 @@ snapshots:
mdurl@1.0.1: {}
+ memfs@4.17.0:
+ dependencies:
+ '@jsonjoy.com/json-pack': 1.2.0(tslib@2.2.0)
+ '@jsonjoy.com/util': 1.5.0(tslib@2.2.0)
+ tree-dump: 1.0.2(tslib@2.2.0)
+ tslib: 2.2.0
+
meow@10.1.5:
dependencies:
'@types/minimist': 1.2.5
@@ -4883,6 +4970,10 @@ snapshots:
dependencies:
any-promise: 1.3.0
+ thingies@1.21.0(tslib@2.2.0):
+ dependencies:
+ tslib: 2.2.0
+
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
@@ -4906,6 +4997,10 @@ snapshots:
dependencies:
is-number: 7.0.0
+ tree-dump@1.0.2(tslib@2.2.0):
+ dependencies:
+ tslib: 2.2.0
+
tree-kill@1.2.2: {}
treeify@1.1.0: {}