Skip to content

Commit 20e1448

Browse files
Improve support for source(…) feature in v4 (#1083)
This PR mades a fair number of changes to improve the developer experience in v4. Most of it is related to `@source` and `source(…)` but there are some additional tweaks: - [x] Make sure `@import "…" source(…)` does not issue syntax warnings - [x] Make sure `@import "…" theme(…)` does not issue syntax warnings - [x] Make sure `@import "…" prefix(…)` does not issue syntax warnings - [x] Make sure `@tailwind utilities source(…)` isn't diagnosed as invalid - [x] Add suggestions for `@theme` options - [x] Add suggestions for `@import "…" theme(…)` options - [x] Directory auto-completion for `source(…)` - [x] Directory auto-completion for `@source "…"` - [x] Don’t suggest TypeScript declaration files for `@config`, `@plugin`, and `@source` - [x] In a new workspace creating/editing a CSS file should check if it's the language server needs to start. - [x] Show brace expansion when hovering `@source` - [x] Highlight `@import "…" source(…)` properly - [x] Highlight `@import "…" theme(…)` properly - [x] Highlight `@import "…" prefix(…)` properly - [x] Highlight `@tailwind utilities source(…)` properly - [x] Highlight uses of `source(none)` explicitly - [x] Link paths in valid uses of `source("…")` - [x] Don't link Windows-style paths in `@source`, `@config`, and `@plugin` - [x] Warn that `@source none` is invalid - [x] Highlight `@source none` as invalid (theme-dependent) - [x] Warn when `source(…)` is not passed anything - [x] Warn when `source(none)` is mispelled - [x] Warn when a v4 project uses `@tailwind base` or `@tailwind components` - [x] Warn when non-POSIX paths are passed to `@source` and `source(…)` - [x] Warn when `@tailwind base` is used in a v4 project - [x] Warn when `@tailwind preflight` is used in a v4 project - [x] Warn when `@tailwind components` is used in a v4 project - [x] Warn when `@tailwind screens` is used in a v4 project - [x] Warn when `@tailwind variants` is used in a v4 project I had some stretch goals but I don't think I'll get to them in this PR unless we think they're important enough to hold up the PR: - [ ] Warn when braces surround a single item in `@source` globs - [ ] Warn when unsupported glob syntax is used in `@source` - [ ] Warn when a v4 project uses `@import "tailwindcss/tailwind"` - [ ] Warn when a v4 project uses `@import "tailwindcss/tailwind.css"` - [ ] Highlight glob parts in `@source "…"` strings - [ ] Auto-complete `source(none)` when typing `source(…)` --------- Co-authored-by: Robin Malfait <[email protected]>
1 parent 55050a4 commit 20e1448

28 files changed

+1276
-109
lines changed

packages/tailwindcss-language-server/src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ function getDefaultSettings(): Settings {
2727
invalidVariant: 'error',
2828
invalidConfigPath: 'error',
2929
invalidTailwindDirective: 'error',
30+
invalidSourceDirective: 'error',
3031
recommendedVariantOrder: 'warning',
3132
},
3233
showPixelEquivalents: true,

packages/tailwindcss-language-server/src/language/cssServer.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -336,11 +336,7 @@ function replace(delta = 0) {
336336
}
337337

338338
function createVirtualCssDocument(textDocument: TextDocument): TextDocument {
339-
return TextDocument.create(
340-
textDocument.uri,
341-
textDocument.languageId,
342-
textDocument.version,
343-
textDocument
339+
let content = textDocument
344340
.getText()
345341
.replace(/@screen(\s+[^{]+){/g, replace(-2))
346342
.replace(/@variants(\s+[^{]+){/g, replace())
@@ -350,7 +346,19 @@ function createVirtualCssDocument(textDocument: TextDocument): TextDocument {
350346
/@media(\s+screen\s*\([^)]+\))/g,
351347
(_match, screen) => `@media (${MEDIA_MARKER})${' '.repeat(screen.length - 4)}`,
352348
)
353-
.replace(/(?<=\b(?:theme|config)\([^)]*)[.[\]]/g, '_'),
349+
// Remove`source(…)`, `theme(…)`, and `prefix(…)` from `@import`s
350+
// otherwise we'll show syntax-error diagnostics which we don't want
351+
.replace(
352+
/@import\s*("(?:[^"]+)"|'(?:[^']+)')\s*((source|theme|prefix)\([^)]+\)\s*)+/g,
353+
(_match, url) => `@import "${url.slice(1, -1)}"`,
354+
)
355+
.replace(/(?<=\b(?:theme|config)\([^)]*)[.[\]]/g, '_')
356+
357+
return TextDocument.create(
358+
textDocument.uri,
359+
textDocument.languageId,
360+
textDocument.version,
361+
content,
354362
)
355363
}
356364

packages/tailwindcss-language-server/tests/completions/at-config.test.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,4 +296,90 @@ withFixture('v4/dependencies', (c) => {
296296
],
297297
})
298298
})
299+
300+
test.concurrent('@import "…" source(…)', async ({ expect }) => {
301+
let result = await completion({
302+
text: '@import "tailwindcss" source("',
303+
lang: 'css',
304+
position: {
305+
line: 0,
306+
character: 30,
307+
},
308+
})
309+
310+
expect(result).toEqual({
311+
isIncomplete: false,
312+
items: [
313+
{
314+
label: 'sub-dir/',
315+
kind: 19,
316+
command: { command: 'editor.action.triggerSuggest', title: '' },
317+
data: expect.anything(),
318+
textEdit: {
319+
newText: 'sub-dir/',
320+
range: { start: { line: 0, character: 30 }, end: { line: 0, character: 30 } },
321+
},
322+
},
323+
],
324+
})
325+
})
326+
327+
test.concurrent('@tailwind utilities source(…)', async ({ expect }) => {
328+
let result = await completion({
329+
text: '@tailwind utilities source("',
330+
lang: 'css',
331+
position: {
332+
line: 0,
333+
character: 28,
334+
},
335+
})
336+
337+
expect(result).toEqual({
338+
isIncomplete: false,
339+
items: [
340+
{
341+
label: 'sub-dir/',
342+
kind: 19,
343+
command: { command: 'editor.action.triggerSuggest', title: '' },
344+
data: expect.anything(),
345+
textEdit: {
346+
newText: 'sub-dir/',
347+
range: { start: { line: 0, character: 28 }, end: { line: 0, character: 28 } },
348+
},
349+
},
350+
],
351+
})
352+
})
353+
354+
test.concurrent('@import "…" source(…) directory', async ({ expect }) => {
355+
let result = await completion({
356+
text: '@import "tailwindcss" source("sub-dir/',
357+
lang: 'css',
358+
position: {
359+
line: 0,
360+
character: 38,
361+
},
362+
})
363+
364+
expect(result).toEqual({
365+
isIncomplete: false,
366+
items: [],
367+
})
368+
})
369+
370+
test.concurrent('@tailwind utilities source(…) directory', async ({ expect }) => {
371+
let result = await completion({
372+
text: '@tailwind utilities source("sub-dir/',
373+
lang: 'css',
374+
position: {
375+
line: 0,
376+
character: 36,
377+
},
378+
})
379+
380+
expect(result).toEqual({
381+
isIncomplete: false,
382+
items: [],
383+
})
384+
})
299385
})

packages/tailwindcss-language-server/tests/completions/completions.test.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,40 @@ withFixture('v4/basic', (c) => {
507507
)
508508
})
509509

510+
test.concurrent('@theme suggests options', async ({ expect }) => {
511+
let result = await completion({
512+
lang: 'css',
513+
text: '@theme ',
514+
position: { line: 0, character: 7 },
515+
})
516+
517+
expect(result.items.length).toBe(3)
518+
expect(result.items).toEqual(
519+
expect.arrayContaining([
520+
expect.objectContaining({ label: 'reference' }),
521+
expect.objectContaining({ label: 'inline' }),
522+
expect.objectContaining({ label: 'default' }),
523+
]),
524+
)
525+
})
526+
527+
test.concurrent('@import "…" theme(…) suggests options', async ({ expect }) => {
528+
let result = await completion({
529+
lang: 'css',
530+
text: '@import "tailwindcss/theme" theme()',
531+
position: { line: 0, character: 34 },
532+
})
533+
534+
expect(result.items.length).toBe(3)
535+
expect(result.items).toEqual(
536+
expect.arrayContaining([
537+
expect.objectContaining({ label: 'reference' }),
538+
expect.objectContaining({ label: 'inline' }),
539+
expect.objectContaining({ label: 'default' }),
540+
]),
541+
)
542+
})
543+
510544
test.concurrent('resolve', async ({ expect }) => {
511545
let result = await completion({
512546
text: '<div class="">',

packages/tailwindcss-language-server/tests/diagnostics/diagnostics.test.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,4 +314,72 @@ withFixture('v4/basic', (c) => {
314314
},
315315
],
316316
})
317+
318+
testMatch('Old Tailwind directives warn when used in a v4 project', {
319+
language: 'css',
320+
code: `
321+
@tailwind base;
322+
@tailwind preflight;
323+
@tailwind components;
324+
@tailwind screens;
325+
@tailwind variants;
326+
`,
327+
expected: [
328+
{
329+
code: 'invalidTailwindDirective',
330+
message:
331+
"'@tailwind base' is no longer available in v4. Use '@import \"tailwindcss/preflight\"' instead.",
332+
suggestions: [],
333+
range: {
334+
start: { line: 1, character: 16 },
335+
end: { line: 1, character: 20 },
336+
},
337+
severity: 1,
338+
},
339+
{
340+
code: 'invalidTailwindDirective',
341+
message:
342+
"'@tailwind preflight' is no longer available in v4. Use '@import \"tailwindcss/preflight\"' instead.",
343+
suggestions: [],
344+
range: {
345+
start: { line: 2, character: 16 },
346+
end: { line: 2, character: 25 },
347+
},
348+
severity: 1,
349+
},
350+
{
351+
code: 'invalidTailwindDirective',
352+
message:
353+
"'@tailwind components' is no longer available in v4. Use '@tailwind utilities' instead.",
354+
suggestions: ['utilities'],
355+
range: {
356+
start: { line: 3, character: 16 },
357+
end: { line: 3, character: 26 },
358+
},
359+
severity: 1,
360+
},
361+
{
362+
code: 'invalidTailwindDirective',
363+
message:
364+
"'@tailwind screens' is no longer available in v4. Use '@tailwind utilities' instead.",
365+
suggestions: ['utilities'],
366+
range: {
367+
start: { line: 4, character: 16 },
368+
end: { line: 4, character: 23 },
369+
},
370+
severity: 1,
371+
},
372+
{
373+
code: 'invalidTailwindDirective',
374+
message:
375+
"'@tailwind variants' is no longer available in v4. Use '@tailwind utilities' instead.",
376+
suggestions: ['utilities'],
377+
range: {
378+
start: { line: 5, character: 16 },
379+
end: { line: 5, character: 24 },
380+
},
381+
severity: 1,
382+
},
383+
],
384+
})
317385
})
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { expect, test } from 'vitest'
2+
import { withFixture } from '../common'
3+
4+
withFixture('v4/basic', (c) => {
5+
function runTest(name, { code, expected, language }) {
6+
test(name, async () => {
7+
let promise = new Promise((resolve) => {
8+
c.onNotification('textDocument/publishDiagnostics', ({ diagnostics }) => {
9+
resolve(diagnostics)
10+
})
11+
})
12+
13+
let doc = await c.openDocument({ text: code, lang: language })
14+
let diagnostics = await promise
15+
16+
expected = JSON.parse(JSON.stringify(expected).replaceAll('{{URI}}', doc.uri))
17+
18+
expect(diagnostics).toMatchObject(expected)
19+
})
20+
}
21+
22+
runTest('Source directives require paths', {
23+
language: 'css',
24+
code: `
25+
@import 'tailwindcss' source();
26+
@import 'tailwindcss' source('');
27+
@import 'tailwindcss' source("");
28+
@tailwind utilities source();
29+
@tailwind utilities source('');
30+
@tailwind utilities source("");
31+
`,
32+
expected: [
33+
{
34+
code: 'invalidSourceDirective',
35+
message: 'The source directive requires a path to a directory.',
36+
range: {
37+
start: { line: 1, character: 35 },
38+
end: { line: 1, character: 35 },
39+
},
40+
},
41+
{
42+
code: 'invalidSourceDirective',
43+
message: 'The source directive requires a path to a directory.',
44+
range: {
45+
start: { line: 2, character: 35 },
46+
end: { line: 2, character: 37 },
47+
},
48+
},
49+
{
50+
code: 'invalidSourceDirective',
51+
message: 'The source directive requires a path to a directory.',
52+
range: {
53+
start: { line: 3, character: 35 },
54+
end: { line: 3, character: 37 },
55+
},
56+
},
57+
{
58+
code: 'invalidSourceDirective',
59+
message: 'The source directive requires a path to a directory.',
60+
range: {
61+
start: { line: 4, character: 33 },
62+
end: { line: 4, character: 33 },
63+
},
64+
},
65+
{
66+
code: 'invalidSourceDirective',
67+
message: 'The source directive requires a path to a directory.',
68+
range: {
69+
start: { line: 5, character: 33 },
70+
end: { line: 5, character: 35 },
71+
},
72+
},
73+
{
74+
code: 'invalidSourceDirective',
75+
message: 'The source directive requires a path to a directory.',
76+
range: {
77+
start: { line: 6, character: 33 },
78+
end: { line: 6, character: 35 },
79+
},
80+
},
81+
],
82+
})
83+
84+
runTest('source(none) must not be misspelled', {
85+
language: 'css',
86+
code: `
87+
@import 'tailwindcss' source(no);
88+
@tailwind utilities source(no);
89+
`,
90+
expected: [
91+
{
92+
code: 'invalidSourceDirective',
93+
message: '`source(no)` is invalid. Did you mean `source(none)`?',
94+
range: {
95+
start: { line: 1, character: 35 },
96+
end: { line: 1, character: 37 },
97+
},
98+
},
99+
{
100+
code: 'invalidSourceDirective',
101+
message: '`source(no)` is invalid. Did you mean `source(none)`?',
102+
range: {
103+
start: { line: 2, character: 33 },
104+
end: { line: 2, character: 35 },
105+
},
106+
},
107+
],
108+
})
109+
110+
runTest('source("…") does not produce diagnostics', {
111+
language: 'css',
112+
code: `
113+
@import 'tailwindcss' source('../app');
114+
@tailwind utilities source('../app');
115+
@import 'tailwindcss' source("../app");
116+
@tailwind utilities source("../app");
117+
`,
118+
expected: [],
119+
})
120+
121+
runTest('paths given to source("…") must error when not POSIX', {
122+
language: 'css',
123+
code: String.raw`
124+
@import 'tailwindcss' source('C:\\absolute\\path');
125+
@import 'tailwindcss' source('C:relative.txt');
126+
`,
127+
expected: [
128+
{
129+
code: 'invalidSourceDirective',
130+
message:
131+
'POSIX-style paths are required with `source(…)` but `C:\\absolute\\path` is a Windows-style path.',
132+
range: {
133+
start: { line: 1, character: 35 },
134+
end: { line: 1, character: 55 },
135+
},
136+
},
137+
{
138+
code: 'invalidSourceDirective',
139+
message:
140+
'POSIX-style paths are required with `source(…)` but `C:relative.txt` is a Windows-style path.',
141+
range: {
142+
start: { line: 2, character: 35 },
143+
end: { line: 2, character: 51 },
144+
},
145+
},
146+
],
147+
})
148+
})

0 commit comments

Comments
 (0)