diff --git a/packages/tailwindcss-language-server/src/config.ts b/packages/tailwindcss-language-server/src/config.ts
index 2e417fce..90fb3207 100644
--- a/packages/tailwindcss-language-server/src/config.ts
+++ b/packages/tailwindcss-language-server/src/config.ts
@@ -27,6 +27,7 @@ function getDefaultSettings(): Settings {
invalidVariant: 'error',
invalidConfigPath: 'error',
invalidTailwindDirective: 'error',
+ invalidSourceDirective: 'error',
recommendedVariantOrder: 'warning',
},
showPixelEquivalents: true,
diff --git a/packages/tailwindcss-language-server/src/language/cssServer.ts b/packages/tailwindcss-language-server/src/language/cssServer.ts
index 11caad45..c33e3ebd 100644
--- a/packages/tailwindcss-language-server/src/language/cssServer.ts
+++ b/packages/tailwindcss-language-server/src/language/cssServer.ts
@@ -336,11 +336,7 @@ function replace(delta = 0) {
}
function createVirtualCssDocument(textDocument: TextDocument): TextDocument {
- return TextDocument.create(
- textDocument.uri,
- textDocument.languageId,
- textDocument.version,
- textDocument
+ let content = textDocument
.getText()
.replace(/@screen(\s+[^{]+){/g, replace(-2))
.replace(/@variants(\s+[^{]+){/g, replace())
@@ -350,7 +346,19 @@ function createVirtualCssDocument(textDocument: TextDocument): TextDocument {
/@media(\s+screen\s*\([^)]+\))/g,
(_match, screen) => `@media (${MEDIA_MARKER})${' '.repeat(screen.length - 4)}`,
)
- .replace(/(?<=\b(?:theme|config)\([^)]*)[.[\]]/g, '_'),
+ // Remove`source(…)`, `theme(…)`, and `prefix(…)` from `@import`s
+ // otherwise we'll show syntax-error diagnostics which we don't want
+ .replace(
+ /@import\s*("(?:[^"]+)"|'(?:[^']+)')\s*((source|theme|prefix)\([^)]+\)\s*)+/g,
+ (_match, url) => `@import "${url.slice(1, -1)}"`,
+ )
+ .replace(/(?<=\b(?:theme|config)\([^)]*)[.[\]]/g, '_')
+
+ return TextDocument.create(
+ textDocument.uri,
+ textDocument.languageId,
+ textDocument.version,
+ content,
)
}
diff --git a/packages/tailwindcss-language-server/tests/completions/at-config.test.js b/packages/tailwindcss-language-server/tests/completions/at-config.test.js
index b97d1873..15d99ac6 100644
--- a/packages/tailwindcss-language-server/tests/completions/at-config.test.js
+++ b/packages/tailwindcss-language-server/tests/completions/at-config.test.js
@@ -296,4 +296,90 @@ withFixture('v4/dependencies', (c) => {
],
})
})
+
+ test.concurrent('@import "…" source(…)', async ({ expect }) => {
+ let result = await completion({
+ text: '@import "tailwindcss" source("',
+ lang: 'css',
+ position: {
+ line: 0,
+ character: 30,
+ },
+ })
+
+ expect(result).toEqual({
+ isIncomplete: false,
+ items: [
+ {
+ label: 'sub-dir/',
+ kind: 19,
+ command: { command: 'editor.action.triggerSuggest', title: '' },
+ data: expect.anything(),
+ textEdit: {
+ newText: 'sub-dir/',
+ range: { start: { line: 0, character: 30 }, end: { line: 0, character: 30 } },
+ },
+ },
+ ],
+ })
+ })
+
+ test.concurrent('@tailwind utilities source(…)', async ({ expect }) => {
+ let result = await completion({
+ text: '@tailwind utilities source("',
+ lang: 'css',
+ position: {
+ line: 0,
+ character: 28,
+ },
+ })
+
+ expect(result).toEqual({
+ isIncomplete: false,
+ items: [
+ {
+ label: 'sub-dir/',
+ kind: 19,
+ command: { command: 'editor.action.triggerSuggest', title: '' },
+ data: expect.anything(),
+ textEdit: {
+ newText: 'sub-dir/',
+ range: { start: { line: 0, character: 28 }, end: { line: 0, character: 28 } },
+ },
+ },
+ ],
+ })
+ })
+
+ test.concurrent('@import "…" source(…) directory', async ({ expect }) => {
+ let result = await completion({
+ text: '@import "tailwindcss" source("sub-dir/',
+ lang: 'css',
+ position: {
+ line: 0,
+ character: 38,
+ },
+ })
+
+ expect(result).toEqual({
+ isIncomplete: false,
+ items: [],
+ })
+ })
+
+ test.concurrent('@tailwind utilities source(…) directory', async ({ expect }) => {
+ let result = await completion({
+ text: '@tailwind utilities source("sub-dir/',
+ lang: 'css',
+ position: {
+ line: 0,
+ character: 36,
+ },
+ })
+
+ expect(result).toEqual({
+ isIncomplete: false,
+ items: [],
+ })
+ })
})
diff --git a/packages/tailwindcss-language-server/tests/completions/completions.test.js b/packages/tailwindcss-language-server/tests/completions/completions.test.js
index 767a3102..cd678845 100644
--- a/packages/tailwindcss-language-server/tests/completions/completions.test.js
+++ b/packages/tailwindcss-language-server/tests/completions/completions.test.js
@@ -507,6 +507,40 @@ withFixture('v4/basic', (c) => {
)
})
+ test.concurrent('@theme suggests options', async ({ expect }) => {
+ let result = await completion({
+ lang: 'css',
+ text: '@theme ',
+ position: { line: 0, character: 7 },
+ })
+
+ expect(result.items.length).toBe(3)
+ expect(result.items).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ label: 'reference' }),
+ expect.objectContaining({ label: 'inline' }),
+ expect.objectContaining({ label: 'default' }),
+ ]),
+ )
+ })
+
+ test.concurrent('@import "…" theme(…) suggests options', async ({ expect }) => {
+ let result = await completion({
+ lang: 'css',
+ text: '@import "tailwindcss/theme" theme()',
+ position: { line: 0, character: 34 },
+ })
+
+ expect(result.items.length).toBe(3)
+ expect(result.items).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ label: 'reference' }),
+ expect.objectContaining({ label: 'inline' }),
+ expect.objectContaining({ label: 'default' }),
+ ]),
+ )
+ })
+
test.concurrent('resolve', async ({ expect }) => {
let result = await completion({
text: '
',
diff --git a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js
index 48cdd4f7..56fe9f7f 100644
--- a/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js
+++ b/packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js
@@ -314,4 +314,72 @@ withFixture('v4/basic', (c) => {
},
],
})
+
+ testMatch('Old Tailwind directives warn when used in a v4 project', {
+ language: 'css',
+ code: `
+ @tailwind base;
+ @tailwind preflight;
+ @tailwind components;
+ @tailwind screens;
+ @tailwind variants;
+ `,
+ expected: [
+ {
+ code: 'invalidTailwindDirective',
+ message:
+ "'@tailwind base' is no longer available in v4. Use '@import \"tailwindcss/preflight\"' instead.",
+ suggestions: [],
+ range: {
+ start: { line: 1, character: 16 },
+ end: { line: 1, character: 20 },
+ },
+ severity: 1,
+ },
+ {
+ code: 'invalidTailwindDirective',
+ message:
+ "'@tailwind preflight' is no longer available in v4. Use '@import \"tailwindcss/preflight\"' instead.",
+ suggestions: [],
+ range: {
+ start: { line: 2, character: 16 },
+ end: { line: 2, character: 25 },
+ },
+ severity: 1,
+ },
+ {
+ code: 'invalidTailwindDirective',
+ message:
+ "'@tailwind components' is no longer available in v4. Use '@tailwind utilities' instead.",
+ suggestions: ['utilities'],
+ range: {
+ start: { line: 3, character: 16 },
+ end: { line: 3, character: 26 },
+ },
+ severity: 1,
+ },
+ {
+ code: 'invalidTailwindDirective',
+ message:
+ "'@tailwind screens' is no longer available in v4. Use '@tailwind utilities' instead.",
+ suggestions: ['utilities'],
+ range: {
+ start: { line: 4, character: 16 },
+ end: { line: 4, character: 23 },
+ },
+ severity: 1,
+ },
+ {
+ code: 'invalidTailwindDirective',
+ message:
+ "'@tailwind variants' is no longer available in v4. Use '@tailwind utilities' instead.",
+ suggestions: ['utilities'],
+ range: {
+ start: { line: 5, character: 16 },
+ end: { line: 5, character: 24 },
+ },
+ severity: 1,
+ },
+ ],
+ })
})
diff --git a/packages/tailwindcss-language-server/tests/diagnostics/source-diagnostics.test.js b/packages/tailwindcss-language-server/tests/diagnostics/source-diagnostics.test.js
new file mode 100644
index 00000000..f6287b63
--- /dev/null
+++ b/packages/tailwindcss-language-server/tests/diagnostics/source-diagnostics.test.js
@@ -0,0 +1,148 @@
+import { expect, test } from 'vitest'
+import { withFixture } from '../common'
+
+withFixture('v4/basic', (c) => {
+ function runTest(name, { code, expected, language }) {
+ test(name, async () => {
+ let promise = new Promise((resolve) => {
+ c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => {
+ resolve(diagnostics)
+ })
+ })
+
+ let doc = await c.openDocument({ text: code, lang: language })
+ let diagnostics = await promise
+
+ expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', doc.uri))
+
+ expect(diagnostics).toMatchObject(expected)
+ })
+ }
+
+ runTest('Source directives require paths', {
+ language: 'css',
+ code: `
+ @import 'tailwindcss' source();
+ @import 'tailwindcss' source('');
+ @import 'tailwindcss' source("");
+ @tailwind utilities source();
+ @tailwind utilities source('');
+ @tailwind utilities source("");
+ `,
+ expected: [
+ {
+ code: 'invalidSourceDirective',
+ message: 'The source directive requires a path to a directory.',
+ range: {
+ start: { line: 1, character: 35 },
+ end: { line: 1, character: 35 },
+ },
+ },
+ {
+ code: 'invalidSourceDirective',
+ message: 'The source directive requires a path to a directory.',
+ range: {
+ start: { line: 2, character: 35 },
+ end: { line: 2, character: 37 },
+ },
+ },
+ {
+ code: 'invalidSourceDirective',
+ message: 'The source directive requires a path to a directory.',
+ range: {
+ start: { line: 3, character: 35 },
+ end: { line: 3, character: 37 },
+ },
+ },
+ {
+ code: 'invalidSourceDirective',
+ message: 'The source directive requires a path to a directory.',
+ range: {
+ start: { line: 4, character: 33 },
+ end: { line: 4, character: 33 },
+ },
+ },
+ {
+ code: 'invalidSourceDirective',
+ message: 'The source directive requires a path to a directory.',
+ range: {
+ start: { line: 5, character: 33 },
+ end: { line: 5, character: 35 },
+ },
+ },
+ {
+ code: 'invalidSourceDirective',
+ message: 'The source directive requires a path to a directory.',
+ range: {
+ start: { line: 6, character: 33 },
+ end: { line: 6, character: 35 },
+ },
+ },
+ ],
+ })
+
+ runTest('source(none) must not be misspelled', {
+ language: 'css',
+ code: `
+ @import 'tailwindcss' source(no);
+ @tailwind utilities source(no);
+ `,
+ expected: [
+ {
+ code: 'invalidSourceDirective',
+ message: '`source(no)` is invalid. Did you mean `source(none)`?',
+ range: {
+ start: { line: 1, character: 35 },
+ end: { line: 1, character: 37 },
+ },
+ },
+ {
+ code: 'invalidSourceDirective',
+ message: '`source(no)` is invalid. Did you mean `source(none)`?',
+ range: {
+ start: { line: 2, character: 33 },
+ end: { line: 2, character: 35 },
+ },
+ },
+ ],
+ })
+
+ runTest('source("…") does not produce diagnostics', {
+ language: 'css',
+ code: `
+ @import 'tailwindcss' source('../app');
+ @tailwind utilities source('../app');
+ @import 'tailwindcss' source("../app");
+ @tailwind utilities source("../app");
+ `,
+ expected: [],
+ })
+
+ runTest('paths given to source("…") must error when not POSIX', {
+ language: 'css',
+ code: String.raw`
+ @import 'tailwindcss' source('C:\\absolute\\path');
+ @import 'tailwindcss' source('C:relative.txt');
+ `,
+ expected: [
+ {
+ code: 'invalidSourceDirective',
+ message:
+ 'POSIX-style paths are required with `source(…)` but `C:\\absolute\\path` is a Windows-style path.',
+ range: {
+ start: { line: 1, character: 35 },
+ end: { line: 1, character: 55 },
+ },
+ },
+ {
+ code: 'invalidSourceDirective',
+ message:
+ 'POSIX-style paths are required with `source(…)` but `C:relative.txt` is a Windows-style path.',
+ range: {
+ start: { line: 2, character: 35 },
+ end: { line: 2, character: 51 },
+ },
+ },
+ ],
+ })
+})
diff --git a/packages/tailwindcss-language-server/tests/document-links/document-links.test.js b/packages/tailwindcss-language-server/tests/document-links/document-links.test.js
index 595eeec2..861f74c9 100644
--- a/packages/tailwindcss-language-server/tests/document-links/document-links.test.js
+++ b/packages/tailwindcss-language-server/tests/document-links/document-links.test.js
@@ -130,4 +130,46 @@ withFixture('v4/basic', (c) => {
},
],
})
+
+ testDocumentLinks('Directories in source(…) show links', {
+ text: `
+ @import "tailwindcss" source("../../");
+ @tailwind utilities source("../../");
+ `,
+ lang: 'css',
+ expected: [
+ {
+ target: `file://${path.resolve('./tests/fixtures').replace(/@/g, '%40')}`,
+ range: { start: { line: 1, character: 35 }, end: { line: 1, character: 43 } },
+ },
+ {
+ target: `file://${path.resolve('./tests/fixtures').replace(/@/g, '%40')}`,
+ range: { start: { line: 2, character: 33 }, end: { line: 2, character: 41 } },
+ },
+ ],
+ })
+
+ testDocumentLinks('Globs in source(…) do not show links', {
+ text: `
+ @import "tailwindcss" source("../{a,b,c}");
+ @tailwind utilities source("../{a,b,c}");
+ `,
+ lang: 'css',
+ expected: [],
+ })
+
+ testDocumentLinks('Windows paths in source(…) do not show links', {
+ text: String.raw`
+ @import "tailwindcss" source("..\foo\bar");
+ @tailwind utilities source("..\foo\bar");
+
+ @import "tailwindcss" source("C:\foo\bar");
+ @tailwind utilities source("C:\foo\bar");
+
+ @import "tailwindcss" source("C:foo");
+ @tailwind utilities source("C:bar");
+ `,
+ lang: 'css',
+ expected: [],
+ })
})
diff --git a/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/file.d.ts b/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/file.d.ts
new file mode 100644
index 00000000..39a995a9
--- /dev/null
+++ b/packages/tailwindcss-language-server/tests/fixtures/v4/dependencies/file.d.ts
@@ -0,0 +1 @@
+export type ColorSpace = 'srgb' | 'display-p3'
diff --git a/packages/tailwindcss-language-server/tests/hover/hover.test.js b/packages/tailwindcss-language-server/tests/hover/hover.test.js
index ce271963..878fed21 100644
--- a/packages/tailwindcss-language-server/tests/hover/hover.test.js
+++ b/packages/tailwindcss-language-server/tests/hover/hover.test.js
@@ -157,7 +157,10 @@ withFixture('basic', (c) => {
})
withFixture('v4/basic', (c) => {
- async function testHover(name, { text, lang, position, expected, expectedRange, settings }) {
+ async function testHover(
+ name,
+ { text, exact = false, lang, position, expected, expectedRange, settings },
+ ) {
test.concurrent(name, async ({ expect }) => {
let textDocument = await c.openDocument({ text, lang, settings })
let res = await c.sendRequest('textDocument/hover', {
@@ -165,17 +168,17 @@ withFixture('v4/basic', (c) => {
position,
})
- expect(res).toEqual(
- expected
- ? {
- contents: {
- language: 'css',
- value: expected,
- },
- range: expectedRange,
- }
- : expected,
- )
+ if (!exact && expected) {
+ expected = {
+ contents: {
+ language: 'css',
+ value: expected,
+ },
+ range: expectedRange,
+ }
+ }
+
+ expect(res).toEqual(expected)
})
}
@@ -242,6 +245,33 @@ withFixture('v4/basic', (c) => {
end: { line: 2, character: 18 },
},
})
+
+ testHover('css @source glob expansion', {
+ exact: true,
+ lang: 'css',
+ text: `@source "../{app,components}/**/*.jsx"`,
+ position: { line: 0, character: 23 },
+ expected: {
+ contents: {
+ kind: 'markdown',
+ value: [
+ '**Expansion**',
+ '```plaintext',
+ '- ../app/**/*.jsx',
+ '- ../components/**/*.jsx',
+ '```',
+ ].join('\n'),
+ },
+ range: {
+ start: { line: 0, character: 8 },
+ end: { line: 0, character: 38 },
+ },
+ },
+ expectedRange: {
+ start: { line: 2, character: 9 },
+ end: { line: 2, character: 18 },
+ },
+ })
})
withFixture('v4/css-loading-js', (c) => {
diff --git a/packages/tailwindcss-language-service/package.json b/packages/tailwindcss-language-service/package.json
index 3c66323d..29034fb0 100644
--- a/packages/tailwindcss-language-service/package.json
+++ b/packages/tailwindcss-language-service/package.json
@@ -19,6 +19,7 @@
"@types/culori": "^2.1.0",
"@types/moo": "0.5.3",
"@types/semver": "7.3.10",
+ "braces": "3.0.3",
"color-name": "1.1.4",
"css.escape": "1.5.1",
"culori": "^4.0.1",
diff --git a/packages/tailwindcss-language-service/src/completionProvider.ts b/packages/tailwindcss-language-service/src/completionProvider.ts
index a8f1f21a..157c4610 100644
--- a/packages/tailwindcss-language-service/src/completionProvider.ts
+++ b/packages/tailwindcss-language-service/src/completionProvider.ts
@@ -39,6 +39,7 @@ import {
import { customClassesIn } from './util/classes'
import { IS_SCRIPT_SOURCE, IS_TEMPLATE_SOURCE } from './metadata/extensions'
import * as postcss from 'postcss'
+import { findFileDirective } from './completions/file-paths'
let isUtil = (className) =>
Array.isArray(className.__info)
@@ -1599,6 +1600,95 @@ function isInsideAtRule(name: string, document: TextDocument, position: Position
return braceLevel(text.slice(block)) > 0
}
+// Provide completions for directives that take file paths
+const PATTERN_AT_THEME = /@(?
theme)\s+(?:(?[^{]+)\s$|$)/
+const PATTERN_IMPORT_THEME = /@(?import)\s*[^;]+?theme\((?:(?[^)]+)\s$|$)/
+
+async function provideThemeDirectiveCompletions(
+ state: State,
+ document: TextDocument,
+ position: Position,
+): Promise {
+ if (!state.v4) return null
+
+ let text = document.getText({ start: { line: position.line, character: 0 }, end: position })
+
+ let match = text.match(PATTERN_AT_THEME) ?? text.match(PATTERN_IMPORT_THEME)
+
+ // Are we in a context where suggesting theme(…) stuff makes sense?
+ if (!match) return null
+
+ let directive = match.groups.directive
+ let parts = new Set(
+ (match.groups.parts ?? '')
+ .trim()
+ .split(/\s+/)
+ .map((part) => part.trim())
+ .filter((part) => part !== ''),
+ )
+
+ let items: CompletionItem[] = [
+ {
+ label: 'reference',
+ documentation: {
+ kind: 'markdown',
+ value:
+ directive === 'import'
+ ? `Don't emit CSS variables for imported theme values.`
+ : `Don't emit CSS variables for these theme values.`,
+ },
+ sortText: '-000000',
+ },
+ {
+ label: 'inline',
+ documentation: {
+ kind: 'markdown',
+ value:
+ directive === 'import'
+ ? `Inline imported theme values into generated utilities instead of using \`var(…)\`.`
+ : `Inline these theme values into generated utilities instead of using \`var(…)\`.`,
+ },
+ sortText: '-000001',
+ },
+ {
+ label: 'default',
+ documentation: {
+ kind: 'markdown',
+ value:
+ directive === 'import'
+ ? `Allow imported theme values to be overriden by JS configs and plugins.`
+ : `Allow these theme values to be overriden by JS configs and plugins.`,
+ },
+ sortText: '-000003',
+ },
+ ]
+
+ items = items.filter((item) => !parts.has(item.label))
+
+ if (items.length === 0) return null
+
+ return withDefaults(
+ {
+ isIncomplete: false,
+ items,
+ },
+ {
+ data: {
+ ...(state.completionItemData ?? {}),
+ _type: 'filesystem',
+ },
+ range: {
+ start: {
+ line: position.line,
+ character: position.character,
+ },
+ end: position,
+ },
+ },
+ state.editor.capabilities.itemDefaults,
+ )
+}
+
// Provide completions for directives that take file paths
async function provideFileDirectiveCompletions(
state: State,
@@ -1613,39 +1703,43 @@ async function provideFileDirectiveCompletions(
return null
}
- let pattern = state.v4
- ? /@(?config|plugin|source)\s*(?'[^']*|"[^"]*)$/
- : /@(?config)\s*(?'[^']*|"[^"]*)$/
-
let text = document.getText({ start: { line: position.line, character: 0 }, end: position })
- let match = text.match(pattern)
- if (!match) {
- return null
+
+ let fd = await findFileDirective(state, text)
+ if (!fd) return null
+
+ let { partial, suggest } = fd
+
+ function isAllowedFile(name: string) {
+ if (suggest === 'script') return IS_SCRIPT_SOURCE.test(name)
+
+ if (suggest === 'source') return IS_TEMPLATE_SOURCE.test(name)
+
+ // Files are not allowed but directories are
+ if (suggest === 'directory') return false
+
+ return false
}
- let directive = match.groups.directive
- let partial = match.groups.partial.slice(1) // remove quote
+
let valueBeforeLastSlash = partial.substring(0, partial.lastIndexOf('/'))
let valueAfterLastSlash = partial.substring(partial.lastIndexOf('/') + 1)
let entries = await state.editor.readDirectory(document, valueBeforeLastSlash || '.')
- let isAllowedFile = directive === 'source' ? IS_TEMPLATE_SOURCE : IS_SCRIPT_SOURCE
-
- // Only show directories and JavaScript/TypeScript files
entries = entries.filter(([name, type]) => {
- return type.isDirectory || isAllowedFile.test(name)
+ return type.isDirectory || isAllowedFile(name)
})
+ let items: CompletionItem[] = entries.map(([name, type]) => ({
+ label: type.isDirectory ? name + '/' : name,
+ kind: type.isDirectory ? 19 : 17,
+ command: type.isDirectory ? { command: 'editor.action.triggerSuggest', title: '' } : undefined,
+ }))
+
return withDefaults(
{
isIncomplete: false,
- items: entries.map(([name, type]) => ({
- label: type.isDirectory ? name + '/' : name,
- kind: type.isDirectory ? 19 : 17,
- command: type.isDirectory
- ? { command: 'editor.action.triggerSuggest', title: '' }
- : undefined,
- })),
+ items,
},
{
data: {
@@ -1751,6 +1845,7 @@ export async function doComplete(
const result =
(await provideClassNameCompletions(state, document, position, context)) ||
+ (await provideThemeDirectiveCompletions(state, document, position)) ||
provideCssHelperCompletions(state, document, position) ||
provideCssDirectiveCompletions(state, document, position) ||
provideScreenDirectiveCompletions(state, document, position) ||
diff --git a/packages/tailwindcss-language-service/src/completions/file-paths.test.ts b/packages/tailwindcss-language-service/src/completions/file-paths.test.ts
new file mode 100644
index 00000000..c79fcbdd
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/completions/file-paths.test.ts
@@ -0,0 +1,56 @@
+import { expect, test } from 'vitest'
+import { findFileDirective } from './file-paths'
+
+test('Detecting v3 directives that point to files', async () => {
+ function find(text: string) {
+ return findFileDirective({ enabled: true, v4: false }, text)
+ }
+
+ await expect(find('@config "./')).resolves.toEqual({
+ directive: 'config',
+ partial: './',
+ suggest: 'script',
+ })
+
+ // The following are not supported in v3
+ await expect(find('@plugin "./')).resolves.toEqual(null)
+ await expect(find('@source "./')).resolves.toEqual(null)
+ await expect(find('@import "tailwindcss" source("./')).resolves.toEqual(null)
+ await expect(find('@tailwind utilities source("./')).resolves.toEqual(null)
+})
+
+test('Detecting v4 directives that point to files', async () => {
+ function find(text: string) {
+ return findFileDirective({ enabled: true, v4: true }, text)
+ }
+
+ await expect(find('@config "./')).resolves.toEqual({
+ directive: 'config',
+ partial: './',
+ suggest: 'script',
+ })
+
+ await expect(find('@plugin "./')).resolves.toEqual({
+ directive: 'plugin',
+ partial: './',
+ suggest: 'script',
+ })
+
+ await expect(find('@source "./')).resolves.toEqual({
+ directive: 'source',
+ partial: './',
+ suggest: 'source',
+ })
+
+ await expect(find('@import "tailwindcss" source("./')).resolves.toEqual({
+ directive: 'import',
+ partial: './',
+ suggest: 'directory',
+ })
+
+ await expect(find('@tailwind utilities source("./')).resolves.toEqual({
+ directive: 'tailwind',
+ partial: './',
+ suggest: 'directory',
+ })
+})
diff --git a/packages/tailwindcss-language-service/src/completions/file-paths.ts b/packages/tailwindcss-language-service/src/completions/file-paths.ts
new file mode 100644
index 00000000..f9b1898d
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/completions/file-paths.ts
@@ -0,0 +1,53 @@
+import type { State } from '../util/state'
+
+// @config, @plugin, @source
+const PATTERN_CUSTOM_V4 = /@(?config|plugin|source)\s*(?'[^']*|"[^"]*)$/
+const PATTERN_CUSTOM_V3 = /@(?config)\s*(?'[^']*|"[^"]*)$/
+
+// @import … source('…')
+// @tailwind utilities source('…')
+const PATTERN_IMPORT_SOURCE = /@(?import)\s*(?'[^']*'|"[^"]*")\s*source\((?'[^']*|"[^"]*)$/
+const PATTERN_UTIL_SOURCE = /@(?tailwind)\s+utilities\s+source\((?'[^']*|"[^"]*)?$/
+
+export type FileDirective = {
+ directive: string
+ partial: string
+ suggest: 'script' | 'source' | 'directory'
+}
+
+export async function findFileDirective(state: State, text: string): Promise {
+ if (state.v4) {
+ let match = text.match(PATTERN_CUSTOM_V4)
+ ?? text.match(PATTERN_IMPORT_SOURCE)
+ ?? text.match(PATTERN_UTIL_SOURCE)
+
+ if (!match) return null
+
+ let directive = match.groups.directive
+ let partial = match.groups.partial?.slice(1) ?? "" // remove leading quote
+
+ // Most suggestions are for JS files so we'll default to that
+ let suggest: FileDirective['suggest'] = 'script'
+
+ // If we're looking at @source then it's for a template file
+ if (directive === 'source') {
+ suggest = 'source'
+ }
+
+ // If we're looking at @import … source('…') or @tailwind … source('…') then
+ // we want to list directories instead of files
+ else if (directive === 'import' || directive === 'tailwind') {
+ suggest = 'directory'
+ }
+
+ return { directive, partial, suggest }
+ }
+
+ let match = text.match(PATTERN_CUSTOM_V3)
+ if (!match) return null
+
+ let directive = match.groups.directive
+ let partial = match.groups.partial.slice(1) // remove leading quote
+
+ return { directive, partial, suggest: 'script' }
+}
diff --git a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts
index 34c03b22..18994526 100644
--- a/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts
+++ b/packages/tailwindcss-language-service/src/diagnostics/diagnosticsProvider.ts
@@ -8,6 +8,7 @@ import { getInvalidVariantDiagnostics } from './getInvalidVariantDiagnostics'
import { getInvalidConfigPathDiagnostics } from './getInvalidConfigPathDiagnostics'
import { getInvalidTailwindDirectiveDiagnostics } from './getInvalidTailwindDirectiveDiagnostics'
import { getRecommendedVariantOrderDiagnostics } from './getRecommendedVariantOrderDiagnostics'
+import { getInvalidSourceDiagnostics } from './getInvalidSourceDiagnostics'
export async function doValidate(
state: State,
@@ -19,6 +20,7 @@ export async function doValidate(
DiagnosticKind.InvalidVariant,
DiagnosticKind.InvalidConfigPath,
DiagnosticKind.InvalidTailwindDirective,
+ DiagnosticKind.InvalidSourceDirective,
DiagnosticKind.RecommendedVariantOrder,
],
): Promise {
@@ -44,6 +46,9 @@ export async function doValidate(
...(only.includes(DiagnosticKind.InvalidTailwindDirective)
? getInvalidTailwindDirectiveDiagnostics(state, document, settings)
: []),
+ ...(only.includes(DiagnosticKind.InvalidSourceDirective)
+ ? getInvalidSourceDiagnostics(state, document, settings)
+ : []),
...(only.includes(DiagnosticKind.RecommendedVariantOrder)
? await getRecommendedVariantOrderDiagnostics(state, document, settings)
: []),
diff --git a/packages/tailwindcss-language-service/src/diagnostics/getInvalidSourceDiagnostics.ts b/packages/tailwindcss-language-service/src/diagnostics/getInvalidSourceDiagnostics.ts
new file mode 100644
index 00000000..e8b4ba56
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/diagnostics/getInvalidSourceDiagnostics.ts
@@ -0,0 +1,155 @@
+import type { State, Settings } from '../util/state'
+import { DiagnosticKind, InvalidSourceDirectiveDiagnostic } from './types'
+import { findAll, indexToPosition } from '../util/find'
+import type { TextDocument } from 'vscode-languageserver-textdocument'
+import { getCssBlocks } from '../util/language-blocks'
+import { absoluteRange } from '../util/absoluteRange'
+
+// @import … source('…')
+// @tailwind utilities source('…')
+const PATTERN_IMPORT_SOURCE =
+ /(?:\s|^)@(?import)\s*(?'[^']*'|"[^"]*")\s*source\((?'[^']*'?|"[^"]*"?|[a-z]*|\)|;)/dg
+const PATTERN_UTIL_SOURCE =
+ /(?:\s|^)@(?tailwind)\s+(?\S+)\s+source\((?'[^']*'?|"[^"]*"?|[a-z]*|\)|;)/dg
+
+// @source …
+const PATTERN_AT_SOURCE =
+ /(?:\s|^)@(?source)\s*(?'[^']*'?|"[^"]*"?|[a-z]*|\)|;)/dg
+
+const HAS_DRIVE_LETTER = /^[A-Z]:/
+
+export function getInvalidSourceDiagnostics(
+ state: State,
+ document: TextDocument,
+ settings: Settings,
+): InvalidSourceDirectiveDiagnostic[] {
+ let severity = settings.tailwindCSS.lint.invalidSourceDirective
+ if (severity === 'ignore') return []
+
+ let diagnostics: InvalidSourceDirectiveDiagnostic[] = []
+
+ function add(diag: Omit) {
+ diagnostics.push({
+ code: DiagnosticKind.InvalidSourceDirective,
+ severity:
+ severity === 'error'
+ ? 1 /* DiagnosticSeverity.Error */
+ : 2 /* DiagnosticSeverity.Warning */,
+ ...diag,
+ })
+ }
+
+ for (let block of getCssBlocks(state, document)) {
+ let text = block.text
+
+ let matches = [
+ ...findAll(PATTERN_IMPORT_SOURCE, text),
+ ...findAll(PATTERN_UTIL_SOURCE, text),
+ ...findAll(PATTERN_AT_SOURCE, text),
+ ]
+
+ for (let match of matches) {
+ let directive = match.groups.directive
+ let source = match.groups.source?.trim() ?? ''
+ let rawSource = source
+ let sourceRange = match.indices.groups.source
+ let isQuoted = false
+
+ if (source.startsWith("'")) {
+ source = source.slice(1)
+ isQuoted = true
+ } else if (source.startsWith('"')) {
+ source = source.slice(1)
+ isQuoted = true
+ }
+
+ if (source.endsWith("'")) {
+ source = source.slice(0, -1)
+ isQuoted = true
+ } else if (source.endsWith('"')) {
+ source = source.slice(0, -1)
+ isQuoted = true
+ }
+
+ source = source.trim()
+
+ // - `@import "tailwindcss" source()`
+ // - `@import "tailwindcss" source('')`
+ // - `@import "tailwindcss" source("")`
+
+ // - `@source ;`
+ // - `@source '';`
+ // - `@source "";`
+ if (source === '' || source === ')' || source === ';') {
+ let range = {
+ start: indexToPosition(text, sourceRange[0]),
+ end: indexToPosition(text, sourceRange[1]),
+ }
+
+ add({
+ message: 'The source directive requires a path to a directory.',
+ range: absoluteRange(range, block.range),
+ })
+ }
+
+ // - `@import "tailwindcss" source(no)`
+ // - `@tailwind utilities source('')`
+ else if (directive !== 'source' && source !== 'none' && !isQuoted) {
+ let range = {
+ start: indexToPosition(text, sourceRange[0]),
+ end: indexToPosition(text, sourceRange[1]),
+ }
+
+ add({
+ message: `\`source(${source})\` is invalid. Did you mean \`source(none)\`?`,
+ range: absoluteRange(range, block.range),
+ })
+ }
+
+ // Detection of Windows-style paths
+ else if (source.includes('\\') || HAS_DRIVE_LETTER.test(source)) {
+ source = source.replaceAll('\\\\', '\\')
+
+ let range = {
+ start: indexToPosition(text, sourceRange[0]),
+ end: indexToPosition(text, sourceRange[1]),
+ }
+
+ add({
+ message: `POSIX-style paths are required with \`source(…)\` but \`${source}\` is a Windows-style path.`,
+ range: absoluteRange(range, block.range),
+ })
+ }
+
+ // `@source none` is invalid
+ else if (directive === 'source' && source === 'none') {
+ let range = {
+ start: indexToPosition(text, sourceRange[0]),
+ end: indexToPosition(text, sourceRange[1]),
+ }
+
+ add({
+ message:
+ '`@source none;` is not valid. Did you mean to use `source(none)` on an `@import`?',
+ range: absoluteRange(range, block.range),
+ })
+ }
+
+ // - `@import "tailwindcss" source(no)`
+ // - `@tailwind utilities source('')`
+ else if (directive === 'source' && source !== 'none' && !isQuoted) {
+ let range = {
+ start: indexToPosition(text, sourceRange[0]),
+ end: indexToPosition(text, sourceRange[1]),
+ }
+
+ add({
+ message: `\`@source ${rawSource};\` is invalid.`,
+ range: absoluteRange(range, block.range),
+ })
+ }
+ }
+ }
+
+ return diagnostics
+}
diff --git a/packages/tailwindcss-language-service/src/diagnostics/getInvalidTailwindDirectiveDiagnostics.ts b/packages/tailwindcss-language-service/src/diagnostics/getInvalidTailwindDirectiveDiagnostics.ts
index 7325d49e..480bb8cd 100644
--- a/packages/tailwindcss-language-service/src/diagnostics/getInvalidTailwindDirectiveDiagnostics.ts
+++ b/packages/tailwindcss-language-service/src/diagnostics/getInvalidTailwindDirectiveDiagnostics.ts
@@ -37,51 +37,23 @@ export function getInvalidTailwindDirectiveDiagnostics(
regex = /(?:\s|^)@tailwind\s+(?[^;]+)/g
}
- let hasVariantsDirective = state.jit && semver.gte(state.version, '2.1.99')
-
ranges.forEach((range) => {
let text = getTextWithoutComments(document, 'css', range)
let matches = findAll(regex, text)
- let valid = [
- 'utilities',
- 'components',
- 'screens',
- semver.gte(state.version, '1.0.0-beta.1') ? 'base' : 'preflight',
- hasVariantsDirective && 'variants',
- ].filter(Boolean)
-
- let suggestable = valid
-
- if (hasVariantsDirective) {
- // Don't suggest `screens`, because it's deprecated
- suggestable = suggestable.filter((value) => value !== 'screens')
- }
-
matches.forEach((match) => {
- if (valid.includes(match.groups.value)) {
- return null
- }
+ let layerName = match.groups.value
- let message = `'${match.groups.value}' is not a valid value.`
- let suggestions: string[] = []
-
- if (match.groups.value === 'preflight') {
- suggestions.push('base')
- message += ` Did you mean 'base'?`
- } else {
- let suggestion = closest(match.groups.value, suggestable)
- if (suggestion) {
- suggestions.push(suggestion)
- message += ` Did you mean '${suggestion}'?`
- }
- }
+ let result = validateLayerName(state, layerName)
+ if (!result) return
+
+ let { message, suggestions } = result
diagnostics.push({
code: DiagnosticKind.InvalidTailwindDirective,
range: absoluteRange(
{
- start: indexToPosition(text, match.index + match[0].length - match.groups.value.length),
+ start: indexToPosition(text, match.index + match[0].length - layerName.length),
end: indexToPosition(text, match.index + match[0].length),
},
range,
@@ -98,3 +70,87 @@ export function getInvalidTailwindDirectiveDiagnostics(
return diagnostics
}
+
+function validateLayerName(
+ state: State,
+ layerName: string,
+): { message: string; suggestions: string[] } | null {
+ if (state.v4) {
+ // `@tailwind utilities` is valid
+ if (layerName === 'utilities') {
+ return null
+ }
+
+ // `@tailwind base | preflight` do not exist in v4
+ if (layerName === 'base' || layerName === 'preflight') {
+ return {
+ message: `'@tailwind ${layerName}' is no longer available in v4. Use '@import "tailwindcss/preflight"' instead.`,
+ suggestions: [],
+ }
+ }
+
+ // `@tailwind components | screens | variants` do not exist in v4
+ if (layerName === 'components' || layerName === 'screens' || layerName === 'variants') {
+ return {
+ message: `'@tailwind ${layerName}' is no longer available in v4. Use '@tailwind utilities' instead.`,
+ suggestions: ['utilities'],
+ }
+ }
+
+ let parts = layerName.split(/\s+/)
+
+ // `@tailwind utilities source(…)` is valid
+ if (parts[0] === 'utilities' && parts[1]?.startsWith('source(')) {
+ return null
+ }
+
+ return {
+ message: `'${layerName}' is not a valid value.`,
+ suggestions: [],
+ }
+ }
+
+ let valid = ['utilities', 'components', 'screens']
+
+ if (semver.gte(state.version, '1.0.0-beta.1')) {
+ valid.push('base')
+ } else {
+ valid.push('preflight')
+ }
+
+ let hasVariantsDirective = state.jit && semver.gte(state.version, '2.1.99')
+
+ if (hasVariantsDirective) {
+ valid.push('variants')
+ }
+
+ if (valid.includes(layerName)) {
+ return null
+ }
+
+ let suggestable = valid
+
+ if (hasVariantsDirective) {
+ // Don't suggest `screens`, because it's deprecated
+ suggestable = suggestable.filter((value) => value !== 'screens')
+ }
+
+ let message = `'${layerName}' is not a valid value.`
+ let suggestions: string[] = []
+
+ if (layerName === 'preflight') {
+ suggestions.push('base')
+ message += ` Did you mean 'base'?`
+ } else {
+ let suggestion = closest(layerName, suggestable)
+ if (suggestion) {
+ suggestions.push(suggestion)
+ message += ` Did you mean '${suggestion}'?`
+ }
+ }
+
+ return {
+ message,
+ suggestions,
+ }
+}
diff --git a/packages/tailwindcss-language-service/src/diagnostics/types.ts b/packages/tailwindcss-language-service/src/diagnostics/types.ts
index 115079a2..7cb68a7e 100644
--- a/packages/tailwindcss-language-service/src/diagnostics/types.ts
+++ b/packages/tailwindcss-language-service/src/diagnostics/types.ts
@@ -8,6 +8,7 @@ export enum DiagnosticKind {
InvalidVariant = 'invalidVariant',
InvalidConfigPath = 'invalidConfigPath',
InvalidTailwindDirective = 'invalidTailwindDirective',
+ InvalidSourceDirective = 'invalidSourceDirective',
RecommendedVariantOrder = 'recommendedVariantOrder',
}
@@ -78,6 +79,16 @@ export function isInvalidTailwindDirectiveDiagnostic(
return diagnostic.code === DiagnosticKind.InvalidTailwindDirective
}
+export type InvalidSourceDirectiveDiagnostic = Diagnostic & {
+ code: DiagnosticKind.InvalidSourceDirective
+}
+
+export function isInvalidSourceDirectiveDiagnostic(
+ diagnostic: AugmentedDiagnostic,
+): diagnostic is InvalidSourceDirectiveDiagnostic {
+ return diagnostic.code === DiagnosticKind.InvalidSourceDirective
+}
+
export type RecommendedVariantOrderDiagnostic = Diagnostic & {
code: DiagnosticKind.RecommendedVariantOrder
suggestions: string[]
@@ -96,4 +107,5 @@ export type AugmentedDiagnostic =
| InvalidVariantDiagnostic
| InvalidConfigPathDiagnostic
| InvalidTailwindDirectiveDiagnostic
+ | InvalidSourceDirectiveDiagnostic
| RecommendedVariantOrderDiagnostic
diff --git a/packages/tailwindcss-language-service/src/documentLinksProvider.ts b/packages/tailwindcss-language-service/src/documentLinksProvider.ts
index 3dcc15bb..b18a4711 100644
--- a/packages/tailwindcss-language-service/src/documentLinksProvider.ts
+++ b/packages/tailwindcss-language-service/src/documentLinksProvider.ts
@@ -1,26 +1,26 @@
import type { TextDocument } from 'vscode-languageserver-textdocument'
import type { State } from './util/state'
import type { DocumentLink, Range } from 'vscode-languageserver'
-import { isCssDoc } from './util/css'
-import { getLanguageBoundaries } from './util/getLanguageBoundaries'
import { findAll, indexToPosition } from './util/find'
-import { getTextWithoutComments } from './util/doc'
import { absoluteRange } from './util/absoluteRange'
import * as semver from './util/semver'
+import { getCssBlocks } from './util/language-blocks'
+
+const HAS_DRIVE_LETTER = /^[A-Z]:/
export function getDocumentLinks(
state: State,
document: TextDocument,
resolveTarget: (linkPath: string) => string,
): DocumentLink[] {
- let patterns = [
- /@config\s*(?'[^']+'|"[^"]+")/g,
- ]
+ let patterns = [/@config\s*(?'[^']+'|"[^"]+")/g]
if (state.v4) {
patterns.push(
/@plugin\s*(?'[^']+'|"[^"]+")/g,
/@source\s*(?'[^']+'|"[^"]+")/g,
+ /@import\s*('[^']*'|"[^"]*")\s*source\((?'[^']*'?|"[^"]*"?)/g,
+ /@tailwind\s*utilities\s*source\((?'[^']*'?|"[^"]*"?)/g,
)
}
@@ -38,18 +38,10 @@ function getDirectiveLinks(
}
let links: DocumentLink[] = []
- let ranges: Range[] = []
- if (isCssDoc(state, document)) {
- ranges.push(undefined)
- } else {
- let boundaries = getLanguageBoundaries(state, document)
- if (!boundaries) return []
- ranges.push(...boundaries.filter((b) => b.type === 'css').map(({ range }) => range))
- }
+ for (let block of getCssBlocks(state, document)) {
+ let text = block.text
- for (let range of ranges) {
- let text = getTextWithoutComments(document, 'css', range)
let matches: RegExpMatchArray[] = []
for (let pattern of patterns) {
@@ -57,15 +49,26 @@ function getDirectiveLinks(
}
for (let match of matches) {
+ let path = match.groups.path.slice(1, -1)
+
+ // Ignore glob-like paths
+ if (path.includes('*') || path.includes('{') || path.includes('}')) {
+ continue
+ }
+
+ // Ignore Windows-style paths
+ if (path.includes('\\') || HAS_DRIVE_LETTER.test(path)) {
+ continue
+ }
+
+ let range = {
+ start: indexToPosition(text, match.index + match[0].length - match.groups.path.length),
+ end: indexToPosition(text, match.index + match[0].length),
+ }
+
links.push({
- target: resolveTarget(match.groups.path.slice(1, -1)),
- range: absoluteRange(
- {
- start: indexToPosition(text, match.index + match[0].length - match.groups.path.length),
- end: indexToPosition(text, match.index + match[0].length),
- },
- range,
- ),
+ target: resolveTarget(path),
+ range: absoluteRange(range, block.range),
})
}
}
diff --git a/packages/tailwindcss-language-service/src/hoverProvider.ts b/packages/tailwindcss-language-service/src/hoverProvider.ts
index 1ef981a0..51ef67e3 100644
--- a/packages/tailwindcss-language-service/src/hoverProvider.ts
+++ b/packages/tailwindcss-language-service/src/hoverProvider.ts
@@ -1,9 +1,14 @@
import type { State } from './util/state'
-import type { Hover, Position } from 'vscode-languageserver'
+import type { Hover, MarkupContent, Position, Range } from 'vscode-languageserver'
import { stringifyCss, stringifyConfigValue } from './util/stringify'
import dlv from 'dlv'
import { isCssContext } from './util/css'
-import { findClassNameAtPosition, findHelperFunctionsInRange } from './util/find'
+import {
+ findAll,
+ findClassNameAtPosition,
+ findHelperFunctionsInRange,
+ indexToPosition,
+} from './util/find'
import { validateApply } from './util/validateApply'
import { getClassNameParts } from './util/getClassNameAtPosition'
import * as jit from './util/jit'
@@ -11,6 +16,9 @@ import { validateConfigPath } from './diagnostics/getInvalidConfigPathDiagnostic
import { isWithinRange } from './util/isWithinRange'
import type { TextDocument } from 'vscode-languageserver-textdocument'
import { addPixelEquivalentsToValue } from './util/pixelEquivalents'
+import { getTextWithoutComments } from './util/doc'
+import braces from 'braces'
+import { absoluteRange } from './util/absoluteRange'
export async function doHover(
state: State,
@@ -19,7 +27,8 @@ export async function doHover(
): Promise {
return (
(await provideClassNameHover(state, document, position)) ||
- (await provideCssHelperHover(state, document, position))
+ (await provideCssHelperHover(state, document, position)) ||
+ (await provideSourceGlobHover(state, document, position))
)
}
@@ -48,7 +57,7 @@ async function provideCssHelperHover(
helperFn.helper === 'theme' ? ['theme'] : [],
)
- // This property may not exist in the state object because of compatability with Tailwind Play
+ // This property may not exist in the state object because of compatibility with Tailwind Play
let value = validated.isValid ? stringifyConfigValue(validated.value) : null
if (value === null) return null
@@ -133,3 +142,64 @@ async function provideClassNameHover(
range: className.range,
}
}
+
+function markdown(lines: string[]): MarkupContent {
+ return {
+ kind: 'markdown',
+ value: lines.join('\n'),
+ }
+}
+
+async function provideSourceGlobHover(
+ state: State,
+ document: TextDocument,
+ position: Position,
+): Promise {
+ if (!isCssContext(state, document, position)) {
+ return null
+ }
+
+ let range = {
+ start: { line: position.line, character: 0 },
+ end: { line: position.line + 1, character: 0 },
+ }
+
+ let text = getTextWithoutComments(document, 'css', range)
+
+ let pattern = /@source\s*(?'[^']+'|"[^"]+")/dg
+
+ for (let match of findAll(pattern, text)) {
+ let path = match.groups.path.slice(1, -1)
+
+ // Ignore paths that don't need brace expansion
+ if (!path.includes('{') || !path.includes('}')) continue
+
+ // Ignore paths that don't contain the current position
+ let slice: Range = absoluteRange(
+ {
+ start: indexToPosition(text, match.indices.groups.path[0]),
+ end: indexToPosition(text, match.indices.groups.path[1]),
+ },
+ range,
+ )
+
+ if (!isWithinRange(position, slice)) continue
+
+ // Perform brace expansion
+ let paths = new Set(braces.expand(path))
+ if (paths.size < 2) continue
+
+ return {
+ range: slice,
+ contents: markdown([
+ //
+ '**Expansion**',
+ '```plaintext',
+ ...Array.from(paths, (path) => `- ${path}`),
+ '```',
+ ]),
+ }
+ }
+
+ return null
+}
diff --git a/packages/tailwindcss-language-service/src/metadata/extensions.ts b/packages/tailwindcss-language-service/src/metadata/extensions.ts
index dab890d5..15babe94 100644
--- a/packages/tailwindcss-language-service/src/metadata/extensions.ts
+++ b/packages/tailwindcss-language-service/src/metadata/extensions.ts
@@ -3,9 +3,9 @@ let scriptExtensions = [
'js',
'cjs',
'mjs',
- 'ts',
- 'mts',
- 'cts',
+ '(? {
+ // Scan backwards to see if we're in an `@import` statement
+ for (let i = match.index - 1; i >= 0; i--) {
+ let char = text[i]
+ if (char === '\n') break
+ if (char === ';') break
+ // Detecting theme(…) inside the media query list of `@import` is okay
+ if (char === '(') break
+ if (char === ')') break
+ if (text.startsWith('@import', i)) {
+ return false
+ }
+ }
+
+ return true
+ })
+
return matches.map((match) => {
let quotesBefore = ''
let path = match.groups.path
diff --git a/packages/tailwindcss-language-service/src/util/language-blocks.ts b/packages/tailwindcss-language-service/src/util/language-blocks.ts
new file mode 100644
index 00000000..10a3fe14
--- /dev/null
+++ b/packages/tailwindcss-language-service/src/util/language-blocks.ts
@@ -0,0 +1,45 @@
+import type { State } from '../util/state'
+import { type Range } from 'vscode-languageserver'
+import type { TextDocument } from 'vscode-languageserver-textdocument'
+import { getLanguageBoundaries } from '../util/getLanguageBoundaries'
+import { isCssDoc } from '../util/css'
+import { getTextWithoutComments } from './doc'
+
+export interface LanguageBlock {
+ document: TextDocument
+ range: Range | undefined
+ lang: string
+ readonly 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 []
+
+ for (let boundary of boundaries) {
+ if (boundary.type !== 'css') continue
+
+ yield {
+ document,
+ range: boundary.range,
+ lang: boundary.lang ?? document.languageId,
+ get text() {
+ return getTextWithoutComments(document, 'css', boundary.range)
+ },
+ }
+ }
+ }
+}
diff --git a/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts
index abe8863c..dd1966ce 100644
--- a/packages/tailwindcss-language-service/src/util/state.ts
+++ b/packages/tailwindcss-language-service/src/util/state.ts
@@ -59,6 +59,7 @@ export type TailwindCssSettings = {
invalidVariant: DiagnosticSeveritySetting
invalidConfigPath: DiagnosticSeveritySetting
invalidTailwindDirective: DiagnosticSeveritySetting
+ invalidSourceDirective: DiagnosticSeveritySetting
recommendedVariantOrder: DiagnosticSeveritySetting
}
experimental: {
diff --git a/packages/tailwindcss-language-service/src/util/v4/design-system.ts b/packages/tailwindcss-language-service/src/util/v4/design-system.ts
index ca2f9ad2..ee9396a8 100644
--- a/packages/tailwindcss-language-service/src/util/v4/design-system.ts
+++ b/packages/tailwindcss-language-service/src/util/v4/design-system.ts
@@ -3,7 +3,7 @@ import type { Rule } from './ast'
import type { NamedVariant } from './candidate'
export interface Theme {
- // Prefix didn't exist on
+ // Prefix didn't exist for earlier Tailwind versions
prefix?: string
entries(): [string, any][]
}
diff --git a/packages/vscode-tailwindcss/CHANGELOG.md b/packages/vscode-tailwindcss/CHANGELOG.md
index 665d847e..69321177 100644
--- a/packages/vscode-tailwindcss/CHANGELOG.md
+++ b/packages/vscode-tailwindcss/CHANGELOG.md
@@ -2,7 +2,21 @@
## Prerelease
-- Nothing yet!
+- Add suggestions for theme options ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083))
+- Add suggestions when using `@source "…"` and `source(…)` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083))
+- Show brace expansion when hovering `@source` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083))
+- Highlight `source(…)`, `theme(…)`, and `prefix(…)` when used with `@import "…"` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083))
+- Highlight `@tailwind utilities source(…)` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083))
+- Show document links when using `source(…)` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083))
+
+- Ensure language server starts as needed ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083))
+- Don't show syntax errors when using `source(…)`, `theme(…)`, or `prefix(…)` with `@import "…"` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083))
+- Don't show warning when using `@tailwind utilities source(…)` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083))
+- Don't suggest TypeScript declaration files for `@config`, `@plugin`, and `@source` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083))
+- Don't link Windows-style paths in `@source`, `@config`, and `@plugin` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083))
+
+- Warn on invalid uses of `source(…)`, `@source`, `@config`, and `@plugin` ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083))
+- Warn when a v4 project uses an old `@tailwind` directive ([#1083](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1083))
## 0.12.13
diff --git a/packages/vscode-tailwindcss/src/extension.ts b/packages/vscode-tailwindcss/src/extension.ts
index 3df1c216..d717d5a5 100755
--- a/packages/vscode-tailwindcss/src/extension.ts
+++ b/packages/vscode-tailwindcss/src/extension.ts
@@ -609,7 +609,7 @@ export async function activate(context: ExtensionContext) {
return
}
- if (!anyFolderNeedsLanguageServer(Workspace.workspaceFolders ?? [])) {
+ if (!await anyFolderNeedsLanguageServer(Workspace.workspaceFolders ?? [])) {
return
}
diff --git a/packages/vscode-tailwindcss/syntaxes/at-rules.tmLanguage.json b/packages/vscode-tailwindcss/syntaxes/at-rules.tmLanguage.json
index d50b1007..92d02bd3 100644
--- a/packages/vscode-tailwindcss/syntaxes/at-rules.tmLanguage.json
+++ b/packages/vscode-tailwindcss/syntaxes/at-rules.tmLanguage.json
@@ -4,6 +4,53 @@
"injectionSelector": "L:source.css -comment -source.css.scss",
"name": "TailwindCSS",
"patterns": [
+ {
+ "begin": "(?i)((@)import)(?:\\s+|$|(?=['\"]|/\\*))",
+ "beginCaptures": {
+ "1": {
+ "name": "keyword.control.at-rule.import.css"
+ },
+ "2": {
+ "name": "punctuation.definition.keyword.css"
+ }
+ },
+ "end": ";",
+ "endCaptures": {
+ "0": {
+ "name": "punctuation.terminator.rule.css"
+ }
+ },
+ "name": "meta.at-rule.import.css",
+ "patterns": [
+ {
+ "begin": "\\G\\s*(?=/\\*)",
+ "end": "(?<=\\*/)\\s*",
+ "patterns": [
+ {
+ "include": "source.css#comment-block"
+ }
+ ]
+ },
+ {
+ "include": "source.css#string"
+ },
+ {
+ "include": "source.css#url"
+ },
+ {
+ "include": "#source-fn"
+ },
+ {
+ "include": "#theme-meta-fn"
+ },
+ {
+ "include": "#prefix-meta-fn"
+ },
+ {
+ "include": "source.css#media-query-list"
+ }
+ ]
+ },
{
"begin": "(?i)((@)tailwind)(?=\\s|/\\*|$)",
"beginCaptures": {
@@ -28,6 +75,9 @@
{
"include": "source.css#escapes"
},
+ {
+ "include": "#source-fn"
+ },
{
"match": "[^\\s;]+?",
"name": "variable.parameter.tailwind.tailwind"
@@ -200,6 +250,10 @@
}
},
"patterns": [
+ {
+ "match": "none(?=;)",
+ "name": "invalid.illegal.invalid-source.css"
+ },
{
"include": "source.css#string"
}
@@ -446,6 +500,114 @@
"name": "punctuation.terminator.rule.css"
}
]
+ },
+ "source-fn": {
+ "patterns": [
+ {
+ "begin": "(?i)(?:\\s*)(?