From 8ec45eb46888d21829a1a250604cac487d0a9376 Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Mon, 31 Mar 2025 18:03:55 -0400
Subject: [PATCH 01/34] Simplify
---
packages/tailwindcss-language-server/src/projects.ts | 4 ++--
packages/tailwindcss-language-server/src/tw.ts | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/packages/tailwindcss-language-server/src/projects.ts b/packages/tailwindcss-language-server/src/projects.ts
index 04160569..08b26b39 100644
--- a/packages/tailwindcss-language-server/src/projects.ts
+++ b/packages/tailwindcss-language-server/src/projects.ts
@@ -102,7 +102,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
@@ -1186,7 +1186,7 @@ export async function createProjectService(
;(await disposable).dispose()
}
},
- async onUpdateSettings(settings: any): Promise {
+ async onUpdateSettings(): Promise {
if (state.enabled) {
refreshDiagnostics()
}
diff --git a/packages/tailwindcss-language-server/src/tw.ts b/packages/tailwindcss-language-server/src/tw.ts
index 20ac0158..5a4807c7 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()
}
}),
)
From 08a200d8bcec52ac7105e3c965c9dc5264f5b778 Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Mon, 31 Mar 2025 17:11:22 -0400
Subject: [PATCH 02/34] Refactor
---
.../src/util/language-blocks.ts | 86 ++++++++++++-------
1 file changed, 57 insertions(+), 29 deletions(-)
diff --git a/packages/tailwindcss-language-service/src/util/language-blocks.ts b/packages/tailwindcss-language-service/src/util/language-blocks.ts
index 10a3fe14..1f5ed97d 100644
--- a/packages/tailwindcss-language-service/src/util/language-blocks.ts
+++ b/packages/tailwindcss-language-service/src/util/language-blocks.ts
@@ -1,45 +1,73 @@
import type { State } from '../util/state'
-import { type Range } from 'vscode-languageserver'
-import type { TextDocument } from 'vscode-languageserver-textdocument'
+import type { TextDocument, Range } from 'vscode-languageserver-textdocument'
import { getLanguageBoundaries } from '../util/getLanguageBoundaries'
import { isCssDoc } from '../util/css'
import { getTextWithoutComments } from './doc'
+import { isHtmlDoc } from './html'
+import { isJsDoc } from './js'
export interface LanguageBlock {
- document: TextDocument
+ context: 'html' | 'js' | 'css' | 'other'
range: Range | undefined
lang: string
- readonly text: string
+ text: string
}
-export function* getCssBlocks(
- state: State,
- document: TextDocument,
-): Iterable {
- if (isCssDoc(state, document)) {
- yield {
- document,
- range: undefined,
- lang: document.languageId,
- get text() {
- return getTextWithoutComments(document, 'css')
- },
- }
- } else {
- let boundaries = getLanguageBoundaries(state, document)
- if (!boundaries) return []
+export function getDocumentBlocks(state: State, doc: TextDocument): LanguageBlock[] {
+ let text = doc.getText()
- for (let boundary of boundaries) {
- if (boundary.type !== 'css') continue
+ let boundaries = getLanguageBoundaries(state, doc, text)
+ if (boundaries && boundaries.length > 0) {
+ return boundaries.map((boundary) => {
+ let context: 'html' | 'js' | 'css' | 'other'
+
+ if (boundary.type === 'html') {
+ context = 'html'
+ } else if (boundary.type === 'css') {
+ context = 'css'
+ } else if (boundary.type === 'js' || boundary.type === 'jsx') {
+ context = 'js'
+ } else {
+ context = 'other'
+ }
- yield {
- document,
+ let text = doc.getText(boundary.range)
+
+ return {
+ context,
range: boundary.range,
- lang: boundary.lang ?? document.languageId,
- get text() {
- return getTextWithoutComments(document, 'css', boundary.range)
- },
+ lang: boundary.lang ?? doc.languageId,
+ text: context === 'other' ? text : getTextWithoutComments(text, context),
}
- }
+ })
}
+
+ // If we get here we most likely have non-HTML document in a single language
+ let context: 'html' | 'js' | 'css' | 'other'
+
+ if (isHtmlDoc(state, doc)) {
+ context = 'html'
+ } else if (isCssDoc(state, doc)) {
+ context = 'css'
+ } else if (isJsDoc(state, doc)) {
+ context = 'js'
+ } else {
+ context = 'other'
+ }
+
+ return [
+ {
+ context,
+ range: {
+ start: doc.positionAt(0),
+ end: doc.positionAt(text.length),
+ },
+ lang: doc.languageId,
+ text: context === 'other' ? text : getTextWithoutComments(text, context),
+ },
+ ]
+}
+
+export function getCssBlocks(state: State, document: TextDocument): LanguageBlock[] {
+ return getDocumentBlocks(state, document).filter((block) => block.context === 'css')
}
From 30912438588aa718e1f3b3f5249bb83aa992b765 Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Wed, 5 Feb 2025 21:02:58 -0500
Subject: [PATCH 03/34] Collect offset-based range information
---
.../tests/code-actions/conflict.json | 24 ++++++----
.../tests/diagnostics/css-conflict/css.json | 16 +++++--
.../css-conflict/jsx-concat-positive.json | 24 ++++++----
.../diagnostics/css-conflict/simple.json | 24 ++++++----
.../css-conflict/variants-positive.json | 24 ++++++----
.../css-conflict/vue-style-lang-sass.json | 16 +++++--
.../src/util/array.ts | 9 ++++
.../src/util/find.test.ts | 47 +++++++++++++++++++
.../src/util/find.ts | 43 ++++++++++++++---
.../src/util/getLanguageBoundaries.ts | 33 +++++++++++--
.../src/util/language-blocks.ts | 5 +-
.../src/util/language-boundaries.test.ts | 14 ++++++
.../src/util/spans-equal.ts | 5 ++
.../src/util/state.ts | 13 +++++
14 files changed, 244 insertions(+), 53 deletions(-)
create mode 100644 packages/tailwindcss-language-service/src/util/spans-equal.ts
diff --git a/packages/tailwindcss-language-server/tests/code-actions/conflict.json b/packages/tailwindcss-language-server/tests/code-actions/conflict.json
index eccb1446..55fb35a7 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/diagnostics/css-conflict/css.json b/packages/tailwindcss-language-server/tests/diagnostics/css-conflict/css.json
index da506bf1..d5706666 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 39cbb515..5561d773 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 c98280a1..9f15fbcc 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 15fcb457..5fbcb8ac 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 7e9da86b..b6a3b0f5 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-service/src/util/array.ts b/packages/tailwindcss-language-service/src/util/array.ts
index 9c982640..52379e34 100644
--- a/packages/tailwindcss-language-service/src/util/array.ts
+++ b/packages/tailwindcss-language-service/src/util/array.ts
@@ -1,5 +1,7 @@
import type { Range } from 'vscode-languageserver'
import { rangesEqual } from './rangesEqual'
+import { Span } from './state'
+import { spansEqual } from './spans-equal'
export function dedupe(arr: Array): Array {
return arr.filter((value, index, self) => self.indexOf(value) === index)
@@ -16,6 +18,13 @@ export function dedupeByRange(arr: Array): Array<
)
}
+export function dedupeBySpan(arr: Array): Array {
+ return arr.filter(
+ (classList, classListIndex) =>
+ classListIndex === arr.findIndex((c) => spansEqual(c.span, classList.span)),
+ )
+}
+
export function ensureArray(value: T | T[]): T[] {
return Array.isArray(value) ? value : [value]
}
diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts
index 839fb6d0..3befc1d4 100644
--- a/packages/tailwindcss-language-service/src/util/find.test.ts
+++ b/packages/tailwindcss-language-service/src/util/find.test.ts
@@ -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 },
@@ -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 },
@@ -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
@@ -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 },
@@ -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 },
@@ -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 },
@@ -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 },
@@ -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 },
@@ -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 },
@@ -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 },
@@ -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 },
@@ -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 9118403d..f7f081d9 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: {
@@ -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 42a4a495..f794d2b4 100644
--- a/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts
+++ b/packages/tailwindcss-language-service/src/util/getLanguageBoundaries.ts
@@ -4,7 +4,7 @@ import { isVueDoc, isHtmlDoc, isSvelteDoc } from './html'
import type { State } from './state'
import { indexToPosition } from './find'
import { isJsDoc } from './js'
-import moo from 'moo'
+import moo, { type Rules } from 'moo'
import Cache from 'tmp-cache'
import { getTextWithoutComments } from './doc'
import { isCssLanguage } from './css'
@@ -12,6 +12,7 @@ import { isCssLanguage } from './css'
export type LanguageBoundary = {
type: 'html' | 'js' | 'jsx' | 'css' | (string & {})
range: Range
+ span: [number, number]
lang?: string
}
@@ -29,9 +30,11 @@ let jsxScriptTypes = [
'text/babel',
]
+type States = { [x: string]: Rules }
+
let text = { text: { match: /[^]/, lineBreaks: true } }
-let states = {
+let states: States = {
main: {
cssBlockStart: { match: /
+ `,
+ })
+
+ expect(await doc.hover({ line: 2, character: 13 })).toEqual({
+ range: range(2, 11, 2, 20),
+ contents: {
+ language: 'css',
+ value: '.underline {\n' + ' text-decoration-line: underline;\n' + '}',
+ },
+ })
+ })
+
+ test('@source glob expansion', async () => {
+ let doc = await client.open({
+ lang: 'css',
+ text: css`
+ @source "../{app,components}/**/*.jsx";
+ `,
+ })
+
+ expect(await doc.hover({ line: 0, character: 23 })).toEqual({
+ range: range(0, 8, 0, 38),
+ contents: {
+ kind: 'markdown',
+ value: [
+ '**Expansion**',
+ '```plaintext',
+ '- ../app/**/*.jsx',
+ '- ../components/**/*.jsx',
+ '```',
+ ].join('\n'),
+ },
+ })
+ })
+
+ test('@source not glob expansion', async () => {
+ let doc = await client.open({
+ lang: 'css',
+ text: css`
+ @source not "../{app,components}/**/*.jsx";
+ `,
+ })
+
+ expect(await doc.hover({ line: 0, character: 23 })).toEqual({
+ range: range(0, 12, 0, 42),
+ contents: {
+ kind: 'markdown',
+ value: [
+ '**Expansion**',
+ '```plaintext',
+ '- ../app/**/*.jsx',
+ '- ../components/**/*.jsx',
+ '```',
+ ].join('\n'),
+ },
+ })
+ })
+
+ test('@source inline(…) glob expansion', async () => {
+ let doc = await client.open({
+ lang: 'css',
+ text: css`
+ @source inline("{,hover:,active:}m-{1,2,3}");
+ `,
+ })
+
+ expect(await doc.hover({ line: 0, character: 23 })).toEqual({
+ range: range(0, 15, 0, 43),
+ contents: {
+ kind: 'markdown',
+ value: [
+ '**Expansion**',
+ '```plaintext',
+ '- m-1',
+ '- m-2',
+ '- m-3',
+ '- hover:m-1',
+ '- hover:m-2',
+ '- hover:m-3',
+ '- active:m-1',
+ '- active:m-2',
+ '- active:m-3',
+ '```',
+ ].join('\n'),
+ },
+ })
+ })
+
+ test('@source not inline(…) glob expansion', async () => {
+ let doc = await client.open({
+ lang: 'css',
+ text: css`
+ @source not inline("{,hover:,active:}m-{1,2,3}");
+ `,
+ })
+
+ expect(await doc.hover({ line: 0, character: 23 })).toEqual({
+ range: range(0, 19, 0, 47),
+ contents: {
+ kind: 'markdown',
+ value: [
+ '**Expansion**',
+ '```plaintext',
+ '- m-1',
+ '- m-2',
+ '- m-3',
+ '- hover:m-1',
+ '- hover:m-2',
+ '- hover:m-3',
+ '- active:m-1',
+ '- active:m-2',
+ '- active:m-3',
+ '```',
+ ].join('\n'),
+ },
+ })
+ })
+
+ test('--theme() inside media query', async () => {
+ let doc = await client.open({
+ lang: 'css',
+ text: css`
+ @media (width>=--theme(--breakpoint-xl)) {
+ .foo {
+ color: red;
+ }
+ }
+ `,
+ })
+
+ expect(await doc.hover({ line: 0, character: 23 })).toEqual({
+ range: range(0, 23, 0, 38),
+ contents: {
+ kind: 'markdown',
+ value: [
+ //
+ '```css',
+ '@theme {',
+ ' --breakpoint-xl: 80rem /* 1280px */;',
+ '}',
+ '```',
+ ].join('\n'),
+ },
+ })
+ })
+
+ test('var(…) and theme(…) show theme values', async () => {
+ let doc = await client.open({
+ lang: 'css',
+ text: css`
+ .foo {
+ color: theme(--color-black);
+ }
+ .bar {
+ color: var(--color-black);
+ }
+ `,
+ })
+
+ // color: theme(--color-black);
+ // ^
+ let hoverTheme = await doc.hover({ line: 1, character: 18 })
+
+ // color: var(--color-black);
+ // ^
+ let hoverVar = await doc.hover({ line: 4, character: 16 })
+
+ expect(hoverTheme).toEqual({
+ range: range(1, 15, 1, 28),
+ contents: {
+ kind: 'markdown',
+ value: [
+ //
+ '```css',
+ '@theme {',
+ ' --color-black: #000;',
+ '}',
+ '```',
+ ].join('\n'),
+ },
+ })
+
+ expect(hoverVar).toEqual({
+ range: range(4, 13, 4, 26),
+ contents: {
+ kind: 'markdown',
+ value: [
+ //
+ '```css',
+ '@theme {',
+ ' --color-black: #000;',
+ '}',
+ '```',
+ ].join('\n'),
+ },
+ })
+ })
+})
diff --git a/packages/tailwindcss-language-service/tsconfig.json b/packages/tailwindcss-language-service/tsconfig.json
index 883356e7..a7625b22 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 bf281fe2..f8f588af 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -348,6 +348,9 @@ importers:
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
@@ -360,6 +363,9 @@ importers:
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
@@ -369,6 +375,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:
From 10b1e76f092caec6973c0c09e42c4d695419b63e Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Tue, 15 Apr 2025 12:08:15 -0400
Subject: [PATCH 11/34] Add todos
---
.../tailwindcss-language-server/tests/colors/colors.test.js | 4 ++++
.../tailwindcss-language-server/tests/hover/hover.test.js | 3 +++
2 files changed, 7 insertions(+)
diff --git a/packages/tailwindcss-language-server/tests/colors/colors.test.js b/packages/tailwindcss-language-server/tests/colors/colors.test.js
index 4780a4fb..1bf3ad9a 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/hover/hover.test.js b/packages/tailwindcss-language-server/tests/hover/hover.test.js
index 379f4199..69335887 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: {
From 0f172a4af53c916a5270b85ba959a438af153261 Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Tue, 15 Apr 2025 12:39:28 -0400
Subject: [PATCH 12/34] Update CI workflow
---
.github/workflows/ci.yml | 11 +++++++----
.../tailwindcss-language-service/scripts/build.mjs | 13 ++++++++++---
2 files changed, 17 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index af79a008..2b25480a 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-service/scripts/build.mjs b/packages/tailwindcss-language-service/scripts/build.mjs
index 128426be..6ffb8d29 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',
},
From 007b8e2e8269a96f16bb2894cc4b8eb62ea5ef72 Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Tue, 15 Apr 2025 14:13:44 -0400
Subject: [PATCH 13/34] wip
---
.../scripts/tsconfig.build.json | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/packages/tailwindcss-language-service/scripts/tsconfig.build.json b/packages/tailwindcss-language-service/scripts/tsconfig.build.json
index e80bb38f..144d69e6 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"
+ }
+}
From fcdb939b7724ef8f78d3b0c643052203d6e984ad Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Tue, 15 Apr 2025 18:30:43 -0400
Subject: [PATCH 14/34] Refactor
wip
---
.../tests/colors.test.ts | 18 ++----------------
.../tests/hovers.test.ts | 12 ++----------
.../tests/{ => utils}/cache-map.ts | 0
.../tests/{ => utils}/client.ts | 11 +++++++----
.../tests/{ => utils}/configuration.ts | 4 ++--
.../tests/utils/utils.ts | 16 ++++++++++++++++
6 files changed, 29 insertions(+), 32 deletions(-)
rename packages/tailwindcss-language-service/tests/{ => utils}/cache-map.ts (100%)
rename packages/tailwindcss-language-service/tests/{ => utils}/client.ts (91%)
rename packages/tailwindcss-language-service/tests/{ => utils}/configuration.ts (91%)
create mode 100644 packages/tailwindcss-language-service/tests/utils/utils.ts
diff --git a/packages/tailwindcss-language-service/tests/colors.test.ts b/packages/tailwindcss-language-service/tests/colors.test.ts
index 46895011..76cad281 100644
--- a/packages/tailwindcss-language-service/tests/colors.test.ts
+++ b/packages/tailwindcss-language-service/tests/colors.test.ts
@@ -1,20 +1,6 @@
import { test, expect, describe } from 'vitest'
-import dedent from 'dedent'
-import { createClient } from './client'
-
-const css = dedent
-
-const rgb = (red: number, green: number, blue: number, alpha: number = 1) => ({
- red,
- green,
- blue,
- alpha,
-})
-
-const range = (startLine: number, startCol: number, endLine: number, endCol: number) => ({
- start: { line: startLine, character: startCol },
- end: { line: endLine, character: endCol },
-})
+import { createClient } from './utils/client'
+import { css, rgb, range } from './utils/utils'
describe('v4', async () => {
let client = await createClient({
diff --git a/packages/tailwindcss-language-service/tests/hovers.test.ts b/packages/tailwindcss-language-service/tests/hovers.test.ts
index 4b001225..4bf9ed7c 100644
--- a/packages/tailwindcss-language-service/tests/hovers.test.ts
+++ b/packages/tailwindcss-language-service/tests/hovers.test.ts
@@ -1,14 +1,6 @@
import { test, expect, describe } from 'vitest'
-import dedent from 'dedent'
-import { createClient } from './client'
-
-const css = dedent
-const html = dedent
-
-const range = (startLine: number, startCol: number, endLine: number, endCol: number) => ({
- start: { line: startLine, character: startCol },
- end: { line: endLine, character: endCol },
-})
+import { createClient } from './utils/client'
+import { css, html, range } from './utils/utils'
describe('v4', async () => {
let client = await createClient({
diff --git a/packages/tailwindcss-language-service/tests/cache-map.ts b/packages/tailwindcss-language-service/tests/utils/cache-map.ts
similarity index 100%
rename from packages/tailwindcss-language-service/tests/cache-map.ts
rename to packages/tailwindcss-language-service/tests/utils/cache-map.ts
diff --git a/packages/tailwindcss-language-service/tests/client.ts b/packages/tailwindcss-language-service/tests/utils/client.ts
similarity index 91%
rename from packages/tailwindcss-language-service/tests/client.ts
rename to packages/tailwindcss-language-service/tests/utils/client.ts
index e1451795..f7c38049 100644
--- a/packages/tailwindcss-language-service/tests/client.ts
+++ b/packages/tailwindcss-language-service/tests/utils/client.ts
@@ -1,11 +1,11 @@
-import { Settings, State } from '../src'
+import { Settings, State } from '../../src'
import postcss from 'postcss'
-import { createLanguageService, createState, getDefaultTailwindSettings } from '../src'
-import { supportedFeatures } from '../src/features'
+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 { DeepPartial } from '../../src/types'
export interface ClientOptions {
config:
@@ -93,7 +93,10 @@ export async function createClient(options: ClientOptions) {
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),
diff --git a/packages/tailwindcss-language-service/tests/configuration.ts b/packages/tailwindcss-language-service/tests/utils/configuration.ts
similarity index 91%
rename from packages/tailwindcss-language-service/tests/configuration.ts
rename to packages/tailwindcss-language-service/tests/utils/configuration.ts
index 99592bca..e125c22c 100644
--- a/packages/tailwindcss-language-service/tests/configuration.ts
+++ b/packages/tailwindcss-language-service/tests/utils/configuration.ts
@@ -1,6 +1,6 @@
-import { getDefaultTailwindSettings, type Settings } from '../src/util/state'
+import { getDefaultTailwindSettings, type Settings } from '../../src/util/state'
import { URI } from 'vscode-uri'
-import type { DeepPartial } from '../src/types'
+import type { DeepPartial } from '../../src/types'
import { CacheMap } from './cache-map'
import deepmerge from 'deepmerge'
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 00000000..d96a539c
--- /dev/null
+++ b/packages/tailwindcss-language-service/tests/utils/utils.ts
@@ -0,0 +1,16 @@
+import dedent from '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,
+})
From 152f9e7beaf676162080efd5b81a473c78c66f70 Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Tue, 15 Apr 2025 21:47:19 -0400
Subject: [PATCH 15/34] Add fs support
---
.../tailwindcss-language-service/package.json | 1 +
.../tests/utils/client.ts | 12 ++++++----
.../tests/utils/fs.ts | 23 +++++++++++++++++++
3 files changed, 31 insertions(+), 5 deletions(-)
create mode 100644 packages/tailwindcss-language-service/tests/utils/fs.ts
diff --git a/packages/tailwindcss-language-service/package.json b/packages/tailwindcss-language-service/package.json
index 84b79230..deba7474 100644
--- a/packages/tailwindcss-language-service/package.json
+++ b/packages/tailwindcss-language-service/package.json
@@ -51,6 +51,7 @@
"deepmerge": "4.2.2",
"esbuild": "^0.25.0",
"esbuild-node-externals": "^1.9.0",
+ "memfs": "^4.17.0",
"minimist": "^1.2.8",
"picomatch": "^4.0.1",
"tailwindcss-v4": "npm:tailwindcss@4.1.1",
diff --git a/packages/tailwindcss-language-service/tests/utils/client.ts b/packages/tailwindcss-language-service/tests/utils/client.ts
index f7c38049..108badb9 100644
--- a/packages/tailwindcss-language-service/tests/utils/client.ts
+++ b/packages/tailwindcss-language-service/tests/utils/client.ts
@@ -6,12 +6,18 @@ 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 {
@@ -105,11 +111,7 @@ export async function createClient(options: ClientOptions) {
let service = createLanguageService({
state: () => state,
- fs: {
- document: async () => null,
- resolve: async () => null,
- readDirectory: async () => [],
- },
+ fs: createFileSystem(options.fs ?? {}),
})
let index = 0
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 00000000..579b7827
--- /dev/null
+++ b/packages/tailwindcss-language-service/tests/utils/fs.ts
@@ -0,0 +1,23 @@
+import { memfs } from 'memfs'
+import { URI, Utils as URIUtils } from 'vscode-uri'
+import { FileSystem } from '../../src/fs'
+import * as path from 'node:path'
+
+export interface Storage {
+ /** A list of files and their content */
+ [filePath: string]: string | Buffer
+}
+
+export function createFileSystem(storage: Storage): FileSystem {
+ let { fs, vol } = memfs(storage)
+
+ 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()
+ },
+ readDirectory: async () => [],
+ }
+}
From 004e44e7d84a498b2303aaaa6570ff2c909506bc Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Tue, 15 Apr 2025 18:30:50 -0400
Subject: [PATCH 16/34] Add color presentation tests
---
.../tests/color-presentation.test.ts | 113 ++++++++++++++++++
1 file changed, 113 insertions(+)
create mode 100644 packages/tailwindcss-language-service/tests/color-presentation.test.ts
diff --git a/packages/tailwindcss-language-service/tests/color-presentation.test.ts b/packages/tailwindcss-language-service/tests/color-presentation.test.ts
new file mode 100644
index 00000000..e4860ac7
--- /dev/null
+++ b/packages/tailwindcss-language-service/tests/color-presentation.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, test } from 'vitest'
+import { createClient } from './utils/client'
+import { html, rgb, css, range } from './utils/utils'
+
+describe('v4', async () => {
+ let client = await createClient({
+ config: {
+ kind: 'css',
+ content: css`
+ @theme {
+ --color-black: #000;
+ }
+ `,
+ },
+ })
+
+ test('color keyword', async () => {
+ let doc = await client.open({
+ lang: 'html',
+ text: html``,
+ })
+
+ let presentation = await doc.colorPresentation(rgb(1, 0, 0), range(0, 12, 0, 20))
+
+ expect(presentation).toEqual([
+ { label: 'bg-[#ff0000]' },
+ { label: 'bg-[rgb(255,0,0)]' },
+ { label: 'bg-[hsl(0,100%,50%)]' },
+ ])
+ })
+
+ test('short hex', async () => {
+ let doc = await client.open({
+ lang: 'html',
+ text: html``,
+ })
+
+ let presentation = await doc.colorPresentation(rgb(1, 0, 0), range(0, 12, 0, 21))
+
+ expect(presentation).toEqual([
+ { label: 'bg-[#f00]' },
+ { label: 'bg-[rgb(255,0,0)]' },
+ { label: 'bg-[hsl(0,100%,50%)]' },
+ ])
+ })
+
+ test('long hex', async () => {
+ let doc = await client.open({
+ lang: 'html',
+ text: html``,
+ })
+
+ let presentation = await doc.colorPresentation(rgb(1, 0, 0), range(0, 12, 0, 24))
+
+ expect(presentation).toEqual([
+ { label: 'bg-[#ff0000]' },
+ { label: 'bg-[rgb(255,0,0)]' },
+ { label: 'bg-[hsl(0,100%,50%)]' },
+ ])
+ })
+
+ test('rgb', async () => {
+ let doc = await client.open({
+ lang: 'html',
+ text: html``,
+ })
+
+ let presentation = await doc.colorPresentation(rgb(1, 0, 0), range(0, 12, 0, 29))
+
+ expect(presentation).toEqual([
+ { label: 'bg-[#ff0000]' },
+ { label: 'bg-[rgb(255,0,0)]' },
+ { label: 'bg-[hsl(0,100%,50%)]' },
+ ])
+ })
+
+ test('hsl', async () => {
+ let doc = await client.open({
+ lang: 'html',
+ text: html``,
+ })
+
+ let presentation = await doc.colorPresentation(rgb(1, 0, 0), range(0, 12, 0, 32))
+
+ expect(presentation).toEqual([
+ { label: 'bg-[#ff0000]' },
+ { label: 'bg-[rgb(255,0,0)]' },
+ { label: 'bg-[hsl(0,100%,50%)]' },
+ ])
+ })
+
+ test('oklch colors are ignored', async () => {
+ let doc = await client.open({
+ lang: 'html',
+ text: html``,
+ })
+
+ let presentation = await doc.colorPresentation(rgb(1, 0, 0), range(0, 12, 0, 39))
+
+ expect(presentation).toEqual([])
+ })
+
+ test('named color utilities are ignored', async () => {
+ let doc = await client.open({
+ lang: 'html',
+ text: html``,
+ })
+
+ let presentation = await doc.colorPresentation(rgb(0, 0, 0), range(0, 12, 0, 20))
+
+ expect(presentation).toEqual([])
+ })
+})
From 8645e4b33d6cc320a91be80bbfc20099afe171f5 Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Tue, 15 Apr 2025 18:30:53 -0400
Subject: [PATCH 17/34] Add code lens tests
---
.../tests/code-lens.test.ts | 72 +++++++++++++++++++
1 file changed, 72 insertions(+)
create mode 100644 packages/tailwindcss-language-service/tests/code-lens.test.ts
diff --git a/packages/tailwindcss-language-service/tests/code-lens.test.ts b/packages/tailwindcss-language-service/tests/code-lens.test.ts
new file mode 100644
index 00000000..92facd43
--- /dev/null
+++ b/packages/tailwindcss-language-service/tests/code-lens.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, test } from 'vitest'
+import { createClient } from './utils/client'
+import { css, range } from './utils/utils'
+
+describe('v4', async () => {
+ let client = await createClient({
+ config: {
+ kind: 'css',
+ content: css`
+ @theme {
+ --color-black: #000;
+ --color-primary: #f00;
+ --color-light-dark: light-dark(#ff0000, #0000ff);
+ }
+ `,
+ },
+ })
+
+ test('code lenses are displayed for @source inline(…)', async () => {
+ let doc = await client.open({
+ lang: 'css',
+ text: css`
+ @import 'tailwindcss';
+ @source inline("{,{hover,focus}:}{flex,underline,bg-red-{50,{100..900.100},950}}");
+ `,
+ })
+
+ expect(await doc.codeLenses()).toEqual([
+ {
+ range: range(1, 15, 1, 81),
+ command: {
+ command: '',
+ title: 'Generates 15 classes',
+ },
+ },
+ ])
+ })
+
+ test('the user is warned when @source inline(…) generates a lerge amount of CSS', async () => {
+ let doc = await client.open({
+ lang: 'css',
+ text: css`
+ @import 'tailwindcss';
+ @source inline("{,dark:}{,{sm,md,lg,xl,2xl}:}{,{hover,focus,active}:}{flex,underline,bg-red-{50,{100..900.100},950}{,/{0..100}}}");
+ `,
+ })
+
+ expect(await doc.codeLenses()).toEqual([
+ {
+ range: range(1, 15, 1, 129),
+ command: {
+ command: '',
+ title: 'Generates 14,784 classes',
+ },
+ },
+ {
+ range: range(1, 15, 1, 129),
+ command: {
+ command: '',
+ title: 'At least 3MB of CSS',
+ },
+ },
+ {
+ range: range(1, 15, 1, 129),
+ command: {
+ command: '',
+ title: 'This may slow down your bundler/browser',
+ },
+ },
+ ])
+ })
+})
From 4352417b4f68113b65ec363da29c3060d600b3a7 Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Tue, 15 Apr 2025 19:41:45 -0400
Subject: [PATCH 18/34] Add diagnostic tests
wip
wip
---
.../src/util/validateApply.ts | 4 +
.../tests/diagnostics.test.ts | 894 ++++++++++++++++++
.../tests/utils/utils.ts | 1 +
3 files changed, 899 insertions(+)
create mode 100644 packages/tailwindcss-language-service/tests/diagnostics.test.ts
diff --git a/packages/tailwindcss-language-service/src/util/validateApply.ts b/packages/tailwindcss-language-service/src/util/validateApply.ts
index 4e3d8f42..ff0ba1dd 100644
--- a/packages/tailwindcss-language-service/src/util/validateApply.ts
+++ b/packages/tailwindcss-language-service/src/util/validateApply.ts
@@ -7,6 +7,10 @@ export function validateApply(
state: State,
classNameOrParts: string | string[],
): { isApplyable: true } | { isApplyable: false; reason: string } | null {
+ if (state.v4) {
+ return { isApplyable: true }
+ }
+
if (state.jit) {
return { isApplyable: true }
}
diff --git a/packages/tailwindcss-language-service/tests/diagnostics.test.ts b/packages/tailwindcss-language-service/tests/diagnostics.test.ts
new file mode 100644
index 00000000..f70d5c20
--- /dev/null
+++ b/packages/tailwindcss-language-service/tests/diagnostics.test.ts
@@ -0,0 +1,894 @@
+import { test, expect, describe } from 'vitest'
+import { ClientOptions, createClient } from './utils/client'
+import { css, html, js, range } from './utils/utils'
+import { AugmentedDiagnostic } from '../src'
+import { DiagnosticKind } from '../src/diagnostics/types'
+
+interface DiagnosticFixture {
+ name: string
+ client?: ClientOptions
+ lang?: string
+ text: string
+ expected: AugmentedDiagnostic[]
+}
+
+describe('v4', async () => {
+ let client = await createClient({
+ config: {
+ kind: 'css',
+ content: css`
+ @theme {
+ --color-red-900: #f00;
+ --breakpoint-sm: 40rem;
+ }
+ `,
+ },
+ })
+
+ function runTest({ client: clientOpts, name, lang = 'html', text, expected }: DiagnosticFixture) {
+ test(name, async () => {
+ let testClient = clientOpts ? await createClient(clientOpts) : client
+ let doc = await testClient.open({ lang, text })
+ expect(await doc.diagnostics()).toEqual({
+ kind: 'full',
+ items: expected,
+ })
+ })
+ }
+
+ runTest({
+ name: 'simple typos in theme keys (in key)',
+ lang: 'css',
+ text: css`
+ .test {
+ color: theme(--color-red-901);
+ }
+ `,
+ expected: [
+ {
+ code: DiagnosticKind.InvalidConfigPath,
+ range: range(1, 15, 1, 30),
+ severity: 1,
+ message: "'--color-red-901' does not exist in your theme. Did you mean '--color-red-900'?",
+ suggestions: ['--color-red-900'],
+ },
+ ],
+ })
+
+ runTest({
+ name: 'simple typos in theme keys (in namespace)',
+ lang: 'css',
+ text: css`
+ .test {
+ color: theme(--colors-red-900);
+ }
+ `,
+ expected: [
+ {
+ code: DiagnosticKind.InvalidConfigPath,
+ range: range(1, 15, 1, 31),
+ severity: 1,
+ message: "'--colors-red-900' does not exist in your theme. Did you mean '--color-red-900'?",
+ suggestions: ['--color-red-900'],
+ },
+ ],
+ })
+
+ runTest({
+ name: 'unknown theme key',
+ lang: 'css',
+ text: css`
+ .test {
+ color: theme(--font-obliqueness-90);
+ }
+ `,
+ expected: [
+ {
+ code: DiagnosticKind.InvalidConfigPath,
+ range: range(1, 15, 1, 36),
+ severity: 1,
+ message: "'--font-obliqueness-90' does not exist in your theme.",
+ suggestions: [],
+ },
+ ],
+ })
+
+ runTest({
+ name: 'valid theme keys dont produce diagnostics',
+ lang: 'css',
+ text: css`
+ .test {
+ color: theme(--color-red-900);
+ }
+ `,
+ expected: [],
+ })
+
+ runTest({
+ name: 'typos in legacy theme config paths',
+ lang: 'css',
+ text: css`
+ .test {
+ color: theme(colors.red.901);
+ }
+ `,
+ expected: [
+ {
+ code: DiagnosticKind.InvalidConfigPath,
+ range: range(1, 15, 1, 29),
+ severity: 1,
+ message: "'colors.red.901' does not exist in your theme config.",
+ suggestions: [],
+ },
+ ],
+ })
+
+ runTest({
+ name: 'valid legacy theme keys dont issue diagnostics',
+ lang: 'css',
+ text: css`
+ .test {
+ color: theme(colors.red.900);
+ }
+ `,
+ expected: [],
+ })
+
+ runTest({
+ name: 'shows warning when using blocklisted classes',
+
+ client: {
+ config: {
+ kind: 'css',
+ content: css`
+ @source not inline("{,hover:}flex");
+ `,
+ },
+ },
+
+ lang: 'html',
+ text: html``,
+ expected: [
+ {
+ code: DiagnosticKind.UsedBlocklistedClass,
+ range: range(0, 12, 0, 16),
+ severity: 2,
+ message: 'The class "flex" will not be generated as it has been blocklisted',
+ },
+ {
+ code: DiagnosticKind.UsedBlocklistedClass,
+ range: range(0, 27, 0, 37),
+ severity: 2,
+ message: 'The class "hover:flex" will not be generated as it has been blocklisted',
+ },
+ ],
+ })
+
+ runTest({
+ name: 'conflicts show even when unknown classes are present',
+ lang: 'html',
+ text: html`testing
`,
+ expected: [
+ {
+ code: DiagnosticKind.CssConflict,
+ range: range(0, 16, 0, 20),
+ severity: 2,
+ message: "'flex' applies the same CSS properties as 'block'.",
+ relatedInformation: [
+ {
+ message: 'block',
+ location: {
+ uri: expect.stringContaining('file://projects/root/'),
+ range: range(0, 21, 0, 26),
+ },
+ },
+ ],
+ className: {
+ className: 'flex',
+ span: [16, 20],
+ range: range(0, 16, 0, 20),
+ relativeRange: range(0, 4, 0, 8),
+ classList: {
+ classList: 'foo flex block hover:underline',
+ important: undefined,
+ span: [12, 42],
+ range: range(0, 12, 0, 42),
+ },
+ },
+ otherClassNames: [
+ {
+ className: 'block',
+ span: [21, 26],
+ range: range(0, 21, 0, 26),
+ relativeRange: range(0, 9, 0, 14),
+ classList: {
+ classList: 'foo flex block hover:underline',
+ important: undefined,
+ span: [12, 42],
+ range: range(0, 12, 0, 42),
+ },
+ },
+ ],
+ },
+ {
+ code: DiagnosticKind.CssConflict,
+ range: range(0, 21, 0, 26),
+ severity: 2,
+ message: "'block' applies the same CSS properties as 'flex'.",
+ relatedInformation: [
+ {
+ message: 'flex',
+ location: {
+ uri: expect.stringContaining('file://projects/root/'),
+ range: range(0, 16, 0, 20),
+ },
+ },
+ ],
+ className: {
+ className: 'block',
+ span: [21, 26],
+ range: range(0, 21, 0, 26),
+ relativeRange: range(0, 9, 0, 14),
+ classList: {
+ classList: 'foo flex block hover:underline',
+ important: undefined,
+ span: [12, 42],
+ range: range(0, 12, 0, 42),
+ },
+ },
+ otherClassNames: [
+ {
+ className: 'flex',
+ span: [16, 20],
+ range: range(0, 16, 0, 20),
+ relativeRange: range(0, 4, 0, 8),
+ classList: {
+ classList: 'foo flex block hover:underline',
+ important: undefined,
+ span: [12, 42],
+ range: range(0, 12, 0, 42),
+ },
+ },
+ ],
+ },
+ ],
+ })
+
+ runTest({
+ name: 'old @tailwind directives warn when used',
+ lang: 'css',
+ text: css`
+ @tailwind base;
+ @tailwind preflight;
+ @tailwind components;
+ @tailwind screens;
+ @tailwind variants;
+ `,
+ expected: [
+ {
+ code: DiagnosticKind.InvalidTailwindDirective,
+ range: range(0, 10, 0, 14),
+ severity: 1,
+ message:
+ "'@tailwind base' is no longer available in v4. Use '@import \"tailwindcss/preflight\"' instead.",
+ suggestions: [],
+ },
+ {
+ code: DiagnosticKind.InvalidTailwindDirective,
+ range: range(1, 10, 1, 19),
+ severity: 1,
+ message:
+ "'@tailwind preflight' is no longer available in v4. Use '@import \"tailwindcss/preflight\"' instead.",
+ suggestions: [],
+ },
+ {
+ code: DiagnosticKind.InvalidTailwindDirective,
+ range: range(2, 10, 2, 20),
+ severity: 1,
+ message:
+ "'@tailwind components' is no longer available in v4. Use '@tailwind utilities' instead.",
+ suggestions: ['utilities'],
+ },
+ {
+ code: DiagnosticKind.InvalidTailwindDirective,
+ range: range(3, 10, 3, 17),
+ severity: 1,
+ message:
+ "'@tailwind screens' is no longer available in v4. Use '@tailwind utilities' instead.",
+ suggestions: ['utilities'],
+ },
+ {
+ code: DiagnosticKind.InvalidTailwindDirective,
+ range: range(4, 10, 4, 18),
+ severity: 1,
+ message:
+ "'@tailwind variants' is no longer available in v4. Use '@tailwind utilities' instead.",
+ suggestions: ['utilities'],
+ },
+ ],
+ })
+
+ runTest({
+ name: 'conflicting classes',
+ lang: 'html',
+ text: html``,
+ expected: [
+ {
+ code: DiagnosticKind.CssConflict,
+ range: range(0, 12, 0, 21),
+ severity: 2,
+ message: "'uppercase' applies the same CSS properties as 'lowercase'.",
+ relatedInformation: [
+ {
+ message: 'lowercase',
+ location: {
+ uri: expect.stringContaining('file://projects/root/'),
+ range: range(0, 22, 0, 31),
+ },
+ },
+ ],
+ className: {
+ className: 'uppercase',
+ classList: {
+ classList: 'uppercase lowercase',
+ range: range(0, 12, 0, 31),
+ span: [12, 31],
+ },
+ relativeRange: range(0, 0, 0, 9),
+ range: range(0, 12, 0, 21),
+ span: [12, 21],
+ },
+ otherClassNames: [
+ {
+ className: 'lowercase',
+ classList: {
+ classList: 'uppercase lowercase',
+ range: range(0, 12, 0, 31),
+ span: [12, 31],
+ },
+ relativeRange: range(0, 10, 0, 19),
+ range: range(0, 22, 0, 31),
+ span: [22, 31],
+ },
+ ],
+ },
+ {
+ code: DiagnosticKind.CssConflict,
+ range: range(0, 22, 0, 31),
+ severity: 2,
+ message: "'lowercase' applies the same CSS properties as 'uppercase'.",
+ relatedInformation: [
+ {
+ message: 'uppercase',
+ location: {
+ uri: expect.stringContaining('file://projects/root/'),
+ range: range(0, 12, 0, 21),
+ },
+ },
+ ],
+ className: {
+ className: 'lowercase',
+ classList: {
+ classList: 'uppercase lowercase',
+ range: range(0, 12, 0, 31),
+ span: [12, 31],
+ },
+ relativeRange: range(0, 10, 0, 19),
+ range: range(0, 22, 0, 31),
+ span: [22, 31],
+ },
+ otherClassNames: [
+ {
+ className: 'uppercase',
+ classList: {
+ classList: 'uppercase lowercase',
+ range: range(0, 12, 0, 31),
+ span: [12, 31],
+ },
+ relativeRange: range(0, 0, 0, 9),
+ range: range(0, 12, 0, 21),
+ span: [12, 21],
+ },
+ ],
+ },
+ ],
+ })
+
+ runTest({
+ name: 'base + variant no conflict',
+ lang: 'html',
+ text: html``,
+ expected: [],
+ })
+
+ runTest({
+ name: 'variant + variant conflict',
+ lang: 'html',
+ text: html``,
+ expected: [
+ {
+ code: DiagnosticKind.CssConflict,
+ range: range(0, 12, 0, 24),
+ severity: 2,
+ message: "'sm:uppercase' applies the same CSS properties as 'sm:lowercase'.",
+ relatedInformation: [
+ {
+ message: 'sm:lowercase',
+ location: {
+ uri: expect.stringContaining('file://projects/root/'),
+ range: range(0, 25, 0, 37),
+ },
+ },
+ ],
+ className: {
+ className: 'sm:uppercase',
+ classList: {
+ classList: 'sm:uppercase sm:lowercase',
+ range: range(0, 12, 0, 37),
+ span: [12, 37],
+ },
+ relativeRange: range(0, 0, 0, 12),
+ range: range(0, 12, 0, 24),
+ span: [12, 24],
+ },
+ otherClassNames: [
+ {
+ className: 'sm:lowercase',
+ classList: {
+ classList: 'sm:uppercase sm:lowercase',
+ range: range(0, 12, 0, 37),
+ span: [12, 37],
+ },
+ relativeRange: range(0, 13, 0, 25),
+ range: range(0, 25, 0, 37),
+ span: [25, 37],
+ },
+ ],
+ },
+ {
+ code: DiagnosticKind.CssConflict,
+ range: range(0, 25, 0, 37),
+ severity: 2,
+ message: "'sm:lowercase' applies the same CSS properties as 'sm:uppercase'.",
+ relatedInformation: [
+ {
+ message: 'sm:uppercase',
+ location: {
+ uri: expect.stringContaining('file://projects/root/'),
+ range: range(0, 12, 0, 24),
+ },
+ },
+ ],
+ className: {
+ className: 'sm:lowercase',
+ classList: {
+ classList: 'sm:uppercase sm:lowercase',
+ range: range(0, 12, 0, 37),
+ span: [12, 37],
+ },
+ relativeRange: range(0, 13, 0, 25),
+ range: range(0, 25, 0, 37),
+ span: [25, 37],
+ },
+ otherClassNames: [
+ {
+ className: 'sm:uppercase',
+ classList: {
+ classList: 'sm:uppercase sm:lowercase',
+ range: range(0, 12, 0, 37),
+ span: [12, 37],
+ },
+ relativeRange: range(0, 0, 0, 12),
+ range: range(0, 12, 0, 24),
+ span: [12, 24],
+ },
+ ],
+ },
+ ],
+ })
+
+ runTest({
+ name: 'jsx concat does not conflict',
+ lang: 'javascriptreact',
+ text: js`
+
+ `,
+ expected: [],
+ })
+
+ runTest({
+ name: 'conflicts in single jsx string',
+ lang: 'javascriptreact',
+ text: js`
+
+ `,
+ expected: [
+ {
+ code: DiagnosticKind.CssConflict,
+ range: range(0, 17, 0, 26),
+ severity: 2,
+ message: "'lowercase' applies the same CSS properties as 'uppercase'.",
+ relatedInformation: [
+ {
+ message: 'uppercase',
+ location: {
+ uri: expect.stringContaining('file://projects/root/'),
+ range: range(0, 27, 0, 36),
+ },
+ },
+ ],
+ className: {
+ className: 'lowercase',
+ classList: {
+ classList: 'lowercase uppercase',
+ range: range(0, 17, 0, 36),
+ span: [17, 36],
+ },
+ relativeRange: range(0, 0, 0, 9),
+ range: range(0, 17, 0, 26),
+ span: [17, 26],
+ },
+ otherClassNames: [
+ {
+ className: 'uppercase',
+ classList: {
+ classList: 'lowercase uppercase',
+ range: range(0, 17, 0, 36),
+ span: [17, 36],
+ },
+ relativeRange: range(0, 10, 0, 19),
+ range: range(0, 27, 0, 36),
+ span: [27, 36],
+ },
+ ],
+ },
+ {
+ code: DiagnosticKind.CssConflict,
+ range: range(0, 27, 0, 36),
+ severity: 2,
+ message: "'uppercase' applies the same CSS properties as 'lowercase'.",
+ relatedInformation: [
+ {
+ message: 'lowercase',
+ location: {
+ uri: expect.stringContaining('file://projects/root/'),
+ range: range(0, 17, 0, 26),
+ },
+ },
+ ],
+ className: {
+ className: 'uppercase',
+ classList: {
+ classList: 'lowercase uppercase',
+ range: range(0, 17, 0, 36),
+ span: [17, 36],
+ },
+ relativeRange: range(0, 10, 0, 19),
+ range: range(0, 27, 0, 36),
+ span: [27, 36],
+ },
+ otherClassNames: [
+ {
+ className: 'lowercase',
+ classList: {
+ classList: 'lowercase uppercase',
+ range: range(0, 17, 0, 36),
+ span: [17, 36],
+ },
+ relativeRange: range(0, 0, 0, 9),
+ range: range(0, 17, 0, 26),
+ span: [17, 26],
+ },
+ ],
+ },
+ ],
+ })
+
+ runTest({
+ name: 'vue + `,
+ 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/utils/utils.ts b/packages/tailwindcss-language-service/tests/utils/utils.ts
index d96a539c..732c485a 100644
--- a/packages/tailwindcss-language-service/tests/utils/utils.ts
+++ b/packages/tailwindcss-language-service/tests/utils/utils.ts
@@ -1,5 +1,6 @@
import dedent from 'dedent'
+export const js = dedent
export const css = dedent
export const html = dedent
From 2fdec45a2735bea79fd1f0ba16ff54224cf25ecc Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Tue, 15 Apr 2025 21:08:26 -0400
Subject: [PATCH 19/34] allow tests to run with `bun test`
---
.../src/util/find.test.ts | 28 +++++++++----------
.../src/util/language-boundaries.test.ts | 10 +++----
2 files changed, 19 insertions(+), 19 deletions(-)
diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts
index 3befc1d4..bd60281a 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',
@@ -54,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',
@@ -174,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',
@@ -299,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',
@@ -348,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',
@@ -452,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',
@@ -497,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',
@@ -518,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',
@@ -566,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',
@@ -656,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',
@@ -725,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',
@@ -781,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',
@@ -809,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',
diff --git a/packages/tailwindcss-language-service/src/util/language-boundaries.test.ts b/packages/tailwindcss-language-service/src/util/language-boundaries.test.ts
index 89da4416..72fc3cde 100644
--- a/packages/tailwindcss-language-service/src/util/language-boundaries.test.ts
+++ b/packages/tailwindcss-language-service/src/util/language-boundaries.test.ts
@@ -1,8 +1,8 @@
-import { test } from 'vitest'
+import { expect, test } from 'vitest'
import { getLanguageBoundaries } from './getLanguageBoundaries'
import { jsx, createDocument, html } from './test-utils'
-test('regex literals are ignored when determining language boundaries', ({ expect }) => {
+test('regex literals are ignored when determining language boundaries', () => {
let file = createDocument({
name: 'file.js',
lang: 'javascript',
@@ -28,7 +28,7 @@ test('regex literals are ignored when determining language boundaries', ({ expec
])
})
-test('style tags in HTML are treated as a separate boundary', ({ expect }) => {
+test('style tags in HTML are treated as a separate boundary', () => {
let file = createDocument({
name: 'file.html',
lang: 'html',
@@ -74,7 +74,7 @@ test('style tags in HTML are treated as a separate boundary', ({ expect }) => {
])
})
-test('script tags in HTML are treated as a separate boundary', ({ expect }) => {
+test('script tags in HTML are treated as a separate boundary', () => {
let file = createDocument({
name: 'file.html',
lang: 'html',
@@ -120,7 +120,7 @@ test('script tags in HTML are treated as a separate boundary', ({ expect }) => {
])
})
-test('Vue files detect ,
+// `,
+// })
+
+// // let classes = clsx('')
+// // ^
+// let completion = await document.completions({ line: 1, character: 22 })
+
+// expect(completion?.items.length).toBe(19236)
+// },
+// })
+
+// defineTest({
+// name: 'v4: Theme key completions show in var(…)',
+// fs: {
+// 'app.css': css`
+// @import 'tailwindcss';
+
+// @theme {
+// --color-custom: #000;
+// }
+// `,
+// },
+// prepare: async ({ root }) => ({ client: await createClient({ root }) }),
+// handle: async ({ client }) => {
+// let document = await client.open({
+// settings: {
+// tailwindCSS: {
+// classFunctions: ['clsx'],
+// },
+// },
+// lang: 'css',
+// text: css`
+// .foo {
+// color: var();
+// }
+// `,
+// })
+
+// // color: var();
+// // ^
+// let completion = await document.completions({ line: 1, character: 13 })
+
+// expect(completion).toEqual({
+// isIncomplete: false,
+// items: expect.arrayContaining([
+// // From the default theme
+// expect.objectContaining({ label: '--font-sans' }),
+
+// // From the `@theme` block in the CSS file
+// expect.objectContaining({
+// label: '--color-custom',
+
+// // And it's shown as a color
+// kind: CompletionItemKind.Color,
+// documentation: '#000000',
+// }),
+// ]),
+// })
+// },
+// })
+
+// defineTest({
+// name: 'v4: class function completions mixed with class attribute completions work',
+// fs: {
+// 'app.css': css`
+// @import 'tailwindcss';
+// `,
+// },
+// prepare: async ({ root }) => ({ client: await createClient({ root }) }),
+// handle: async ({ client }) => {
+// let document = await client.open({
+// settings: {
+// tailwindCSS: {
+// classAttributes: ['className'],
+// classFunctions: ['cn', 'cva'],
+// },
+// },
+// lang: 'javascriptreact',
+// text: js`
+// let x = cva("")
+
+// export function Button() {
+// return
+// }
+
+// export function Button2() {
+// return
+// }
+
+// let y = cva("")
+// `,
+// })
+
+// // let x = cva("");
+// // ^
+// let completionA = await document.completions({ line: 0, character: 13 })
+
+// expect(completionA?.items.length).toBe(19236)
+
+// // return ;
+// // ^
+// let completionB = await document.completions({ line: 3, character: 30 })
+
+// expect(completionB?.items.length).toBe(19236)
+
+// // return ;
+// // ^
+// let completionC = await document.completions({ line: 7, character: 30 })
+
+// expect(completionC?.items.length).toBe(19236)
+
+// // let y = cva("");
+// // ^
+// let completionD = await document.completions({ line: 10, character: 13 })
+
+// expect(completionD?.items.length).toBe(19236)
+// },
+// })
diff --git a/packages/tailwindcss-language-service/tests/utils/client.ts b/packages/tailwindcss-language-service/tests/utils/client.ts
index 108badb9..63e05540 100644
--- a/packages/tailwindcss-language-service/tests/utils/client.ts
+++ b/packages/tailwindcss-language-service/tests/utils/client.ts
@@ -114,6 +114,18 @@ export async function createClient(options: ClientOptions) {
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(
From 3682218fcc10cb034a8aa4dd748a49e6caf7172b Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Sun, 20 Apr 2025 16:08:47 -0400
Subject: [PATCH 29/34] Simplify code
---
.../src/util/lazy.ts | 29 ++++++++++---------
1 file changed, 15 insertions(+), 14 deletions(-)
diff --git a/packages/tailwindcss-language-service/src/util/lazy.ts b/packages/tailwindcss-language-service/src/util/lazy.ts
index 858dac58..87b0f77e 100644
--- a/packages/tailwindcss-language-service/src/util/lazy.ts
+++ b/packages/tailwindcss-language-service/src/util/lazy.ts
@@ -1,19 +1,20 @@
-// https://www.codementor.io/@agustinchiappeberrini/lazy-evaluation-and-javascript-a5m7g8gs3
-
export interface Lazy {
- (): T
- isLazy: boolean
+ status: 'pending' | 'fulfilled'
+
+ (...args: unknown[]): T
}
-export const lazy = (getter: () => T): Lazy => {
- let evaluated: boolean = false
- let _res: T = null
- const res = >function (): T {
- if (evaluated) return _res
- _res = getter.apply(this, arguments)
- evaluated = true
- return _res
+export function lazy(getter: () => T): Lazy {
+ let result: { value: T } | undefined
+
+ function get(): T {
+ result ??= { value: getter() }
+ return result.value
}
- res.isLazy = true
- return res
+
+ return Object.defineProperties(get as Lazy, {
+ status: {
+ get: () => (result ? 'fulfilled' : 'pending'),
+ },
+ })
}
From f6037576973c3d77dd365328db73fe8c57acfb1c Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Sun, 20 Apr 2025 16:08:54 -0400
Subject: [PATCH 30/34] Add CSS parser
---
.../src/css/ast.ts | 117 ++++
.../src/css/parse.ts | 633 ++++++++++++++++++
.../src/css/source.ts | 37 +
.../src/css/to-css.ts | 209 ++++++
.../src/css/walk.ts | 79 +++
5 files changed, 1075 insertions(+)
create mode 100644 packages/tailwindcss-language-service/src/css/ast.ts
create mode 100644 packages/tailwindcss-language-service/src/css/parse.ts
create mode 100644 packages/tailwindcss-language-service/src/css/source.ts
create mode 100644 packages/tailwindcss-language-service/src/css/to-css.ts
create mode 100644 packages/tailwindcss-language-service/src/css/walk.ts
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 00000000..39b49c43
--- /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 00000000..ddd83d0b
--- /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 00000000..9fe53e20
--- /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 00000000..d38a707e
--- /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 00000000..748daa9f
--- /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
+}
From adc317737c35b39a80b85d901fc52b8ce8f7a4e8 Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Sun, 20 Apr 2025 16:08:58 -0400
Subject: [PATCH 31/34] wip
---
packages/tailwindcss-language-service/src/util/big-sign.ts | 3 +++
1 file changed, 3 insertions(+)
create mode 100644 packages/tailwindcss-language-service/src/util/big-sign.ts
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 00000000..e29ce3c0
--- /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)
+}
From c565337a56ce57d15ed6bf9328b5d2d6c3e6b448 Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Sun, 20 Apr 2025 16:09:03 -0400
Subject: [PATCH 32/34] css parser
---
.../src/util/v4/ast.ts | 33 -------------------
1 file changed, 33 deletions(-)
delete mode 100644 packages/tailwindcss-language-service/src/util/v4/ast.ts
diff --git a/packages/tailwindcss-language-service/src/util/v4/ast.ts b/packages/tailwindcss-language-service/src/util/v4/ast.ts
deleted file mode 100644
index 452f35ff..00000000
--- a/packages/tailwindcss-language-service/src/util/v4/ast.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-export type Rule = {
- kind: 'rule'
- selector: string
- nodes: AstNode[]
-}
-
-export type Declaration = {
- kind: 'declaration'
- property: string
- value: string
- important: boolean
-}
-
-export type Comment = {
- kind: 'comment'
- value: string
-}
-
-export type AstNode = Rule | Declaration | Comment
-
-export function visit(
- nodes: AstNode[],
- cb: (node: AstNode, path: AstNode[]) => void,
- path: AstNode[] = [],
-) {
- for (let child of nodes) {
- path = [...path, child]
- cb(child, path)
- if (child.kind === 'rule') {
- visit(child.nodes, cb, path)
- }
- }
-}
From 487f89abb74e37e9f3abf5ed26a0ec322f125306 Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Sun, 20 Apr 2025 16:09:09 -0400
Subject: [PATCH 33/34] wip
---
.../src/util/color.ts | 33 ++++++++++++++++++-
1 file changed, 32 insertions(+), 1 deletion(-)
diff --git a/packages/tailwindcss-language-service/src/util/color.ts b/packages/tailwindcss-language-service/src/util/color.ts
index a1a99d66..ab7a612a 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]
+}
From 84a1a1294c5adaed9d8782c64b4b5fa72b1e7b64 Mon Sep 17 00:00:00 2001
From: Jordan Pittman
Date: Sun, 20 Apr 2025 16:09:16 -0400
Subject: [PATCH 34/34] add project api stub
---
.../src/project/color.ts | 15 +
.../src/project/project.ts | 125 ++++
.../src/project/tokens.ts | 121 ++++
.../src/project/v4.test.ts | 597 ++++++++++++++++++
.../src/project/v4.ts | 143 +++++
5 files changed, 1001 insertions(+)
create mode 100644 packages/tailwindcss-language-service/src/project/color.ts
create mode 100644 packages/tailwindcss-language-service/src/project/project.ts
create mode 100644 packages/tailwindcss-language-service/src/project/tokens.ts
create mode 100644 packages/tailwindcss-language-service/src/project/v4.test.ts
create mode 100644 packages/tailwindcss-language-service/src/project/v4.ts
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 00000000..66360d11
--- /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 00000000..faae9086
--- /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 00000000..9906f7a0
--- /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 00000000..cbe7c68f
--- /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 00000000..08dd9871
--- /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,
+ }
+}