Skip to content

Commit b9b40d5

Browse files
Don't create v4 projects for CSS files that don't look like v4 configs (#1164)
Fixes #926
1 parent afea6ed commit b9b40d5

File tree

4 files changed

+199
-21
lines changed

4 files changed

+199
-21
lines changed

packages/tailwindcss-language-server/src/project-locator.ts

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { extractSourceDirectives, resolveCssImports } from './css'
1616
import { normalizeDriveLetter, normalizePath, pathToFileURL } from './utils'
1717
import postcss from 'postcss'
1818
import * as oxide from './oxide'
19+
import { guessTailwindVersion, TailwindVersion } from './version-guesser'
1920

2021
export interface ProjectConfig {
2122
/** The folder that contains the project */
@@ -130,6 +131,8 @@ export class ProjectLocator {
130131
private async createProject(config: ConfigEntry): Promise<ProjectConfig | null> {
131132
let tailwind = await this.detectTailwindVersion(config)
132133

134+
let possibleVersions = config.entries.flatMap((entry) => entry.versions)
135+
133136
console.log(
134137
JSON.stringify({
135138
tailwind,
@@ -160,6 +163,15 @@ export class ProjectLocator {
160163
return null
161164
}
162165

166+
// This config doesn't include any v4 features (even ones that were also in v3)
167+
if (!possibleVersions.includes('4')) {
168+
console.warn(
169+
`The config ${config.path} looks like it might be for a different Tailwind CSS version. Skipping.`,
170+
)
171+
172+
return null
173+
}
174+
163175
// v4 does not support .sass, .scss, .less, and .styl files as configs
164176
if (requiresPreprocessor(config.path)) {
165177
console.warn(
@@ -324,15 +336,19 @@ export class ProjectLocator {
324336
// Read the content of all the CSS files
325337
await Promise.all(css.map((entry) => entry.read()))
326338

339+
// Determine what tailwind versions each file might be using
340+
await Promise.all(css.map((entry) => entry.resolvePossibleVersions()))
341+
327342
// Keep track of files that might import or involve Tailwind in some way
328343
let imports: FileEntry[] = []
329344

330345
for (let file of css) {
331346
// If the CSS file couldn't be read for some reason, skip it
332347
if (!file.content) continue
333348

334-
// Look for `@import`, `@tailwind`, `@theme`, `@config`, etc…
335-
if (!file.isMaybeTailwindRelated()) continue
349+
// This file doesn't appear to use Tailwind CSS nor any imports
350+
// so we can skip it
351+
if (file.versions.length === 0) continue
336352

337353
// Find `@config` directives in CSS files and resolve them to the actual
338354
// config file that they point to. This is only relevant for v3 which
@@ -642,6 +658,7 @@ class FileEntry {
642658
deps: FileEntry[] = []
643659
realpath: string | null
644660
sources: string[] = []
661+
versions: TailwindVersion[] = []
645662

646663
constructor(
647664
public type: 'js' | 'css',
@@ -709,6 +726,13 @@ class FileEntry {
709726
}
710727
}
711728

729+
/**
730+
* Determine which Tailwind versions this file might be using
731+
*/
732+
async resolvePossibleVersions() {
733+
this.versions = this.content ? guessTailwindVersion(this.content) : []
734+
}
735+
712736
/**
713737
* Look for `@config` directives in a CSS file and return the path to the config
714738
* file that it points to. This path is (possibly) relative to the CSS file so
@@ -727,25 +751,6 @@ class FileEntry {
727751

728752
return normalizePath(path.resolve(path.dirname(this.path), match.groups.config.slice(1, -1)))
729753
}
730-
731-
/**
732-
* Look for tailwind-specific directives in a CSS file. This means that it
733-
* participates in the CSS "graph" for the project and we need to traverse
734-
* the graph to find all the CSS files that are considered entrypoints.
735-
*/
736-
isMaybeTailwindRelated(): boolean {
737-
if (!this.content) return false
738-
739-
let HAS_IMPORT = /@import\s*['"]/
740-
let HAS_TAILWIND = /@tailwind\s*[^;]+;/
741-
let HAS_DIRECTIVE = /@(theme|plugin|config|utility|variant|apply)\s*[^;{]+[;{]/
742-
743-
return (
744-
HAS_IMPORT.test(this.content) ||
745-
HAS_TAILWIND.test(this.content) ||
746-
HAS_DIRECTIVE.test(this.content)
747-
)
748-
}
749754
}
750755

751756
function requiresPreprocessor(filepath: string) {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
export type TailwindVersion = '3' | '4'
2+
3+
/**
4+
* Determine the likely Tailwind version used by the given file
5+
*
6+
* This returns an array of possible versions, as a file could contain
7+
* features that make determining the version ambiguous.
8+
*
9+
* The order *does* matter, as the first item is the most likely version.
10+
*/
11+
export function guessTailwindVersion(content: string): TailwindVersion[] {
12+
// It's likely this is a v4 file if it has a v4 import:
13+
// - `@import "tailwindcss"`
14+
// - `@import "tailwindcss/theme"
15+
// - etc…
16+
let HAS_V4_IMPORT = /@import\s*['"]tailwindcss(?:\/[^'"]+)?['"]/
17+
if (HAS_V4_IMPORT.test(content)) return ['4']
18+
19+
// It's likely this is a v4 file if it has a v4-specific feature:
20+
// - @theme
21+
// - @plugin
22+
// - @utility
23+
// - @variant
24+
// - @custom-variant
25+
let HAS_V4_DIRECTIVE = /@(theme|plugin|utility|custom-variant|variant|reference)\s*[^;{]+[;{]/
26+
if (HAS_V4_DIRECTIVE.test(content)) return ['4']
27+
28+
// It's likely this is a v4 file if it's using v4's custom functions:
29+
// - --alpha(…)
30+
// - --spacing(…)
31+
// - --theme(…)
32+
let HAS_V4_FN = /--(alpha|spacing|theme)\(/
33+
if (HAS_V4_FN.test(content)) return ['4']
34+
35+
// If the file contains older `@tailwind` directives, it's likely a v3 file
36+
let HAS_LEGACY_TAILWIND = /@tailwind\s*(base|preflight|components|variants|screens)+;/
37+
if (HAS_LEGACY_TAILWIND.test(content)) return ['3']
38+
39+
// If the file contains other `@tailwind` directives it might be either
40+
let HAS_TAILWIND = /@tailwind\s*[^;]+;/
41+
if (HAS_TAILWIND.test(content)) return ['4', '3']
42+
43+
// If the file contains other `@apply` or `@config` it might be either
44+
let HAS_COMMON_DIRECTIVE = /@(config|apply)\s*[^;{]+[;{]/
45+
if (HAS_COMMON_DIRECTIVE.test(content)) return ['4', '3']
46+
47+
// If it's got imports at all it could be either
48+
let HAS_IMPORT = /@import\s*['"]/
49+
if (HAS_IMPORT.test(content)) return ['4', '3']
50+
51+
// There's chance this file isn't tailwind-related
52+
return []
53+
}

packages/tailwindcss-language-server/tests/env/v4.test.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,122 @@ defineTest({
225225
expect(completion.items.length).toBe(12288)
226226
},
227227
})
228+
229+
defineTest({
230+
name: 'v4, uses npm, does not detect v3 config files as possible roots',
231+
fs: {
232+
'package.json': json`
233+
{
234+
"dependencies": {
235+
"tailwindcss": "4.0.1"
236+
}
237+
}
238+
`,
239+
// This file MUST be before the v4 CSS file when sorting alphabetically
240+
'_globals.css': css`
241+
@tailwind base;
242+
@tailwind utilities;
243+
@tailwind components;
244+
`,
245+
'app.css': css`
246+
@import 'tailwindcss';
247+
248+
@theme {
249+
--color-primary: #c0ffee;
250+
}
251+
`,
252+
},
253+
prepare: async ({ root }) => ({ c: await init(root) }),
254+
handle: async ({ c }) => {
255+
let textDocument = await c.openDocument({
256+
lang: 'html',
257+
text: '<div class="bg-primary">',
258+
})
259+
260+
expect(c.project).toMatchObject({
261+
tailwind: {
262+
version: '4.0.1',
263+
isDefaultVersion: false,
264+
},
265+
})
266+
267+
let hover = await c.sendRequest(HoverRequest.type, {
268+
textDocument,
269+
270+
// <div class="bg-primary">
271+
// ^
272+
position: { line: 0, character: 13 },
273+
})
274+
275+
expect(hover).toEqual({
276+
contents: {
277+
language: 'css',
278+
value: dedent`
279+
.bg-primary {
280+
background-color: var(--color-primary) /* #c0ffee */;
281+
}
282+
`,
283+
},
284+
range: {
285+
start: { line: 0, character: 12 },
286+
end: { line: 0, character: 22 },
287+
},
288+
})
289+
},
290+
})
291+
292+
defineTest({
293+
name: 'v4, uses fallback, does not detect v3 config files as possible roots',
294+
fs: {
295+
// This file MUST be before the v4 CSS file when sorting alphabetically
296+
'_globals.css': css`
297+
@tailwind base;
298+
@tailwind utilities;
299+
@tailwind components;
300+
`,
301+
'app.css': css`
302+
@import 'tailwindcss';
303+
304+
@theme {
305+
--color-primary: #c0ffee;
306+
}
307+
`,
308+
},
309+
prepare: async ({ root }) => ({ c: await init(root) }),
310+
handle: async ({ c }) => {
311+
let textDocument = await c.openDocument({
312+
lang: 'html',
313+
text: '<div class="bg-primary">',
314+
})
315+
316+
expect(c.project).toMatchObject({
317+
tailwind: {
318+
version: '4.0.0',
319+
isDefaultVersion: true,
320+
},
321+
})
322+
323+
let hover = await c.sendRequest(HoverRequest.type, {
324+
textDocument,
325+
326+
// <div class="bg-primary">
327+
// ^
328+
position: { line: 0, character: 13 },
329+
})
330+
331+
expect(hover).toEqual({
332+
contents: {
333+
language: 'css',
334+
value: dedent`
335+
.bg-primary {
336+
background-color: var(--color-primary) /* #c0ffee */;
337+
}
338+
`,
339+
},
340+
range: {
341+
start: { line: 0, character: 12 },
342+
end: { line: 0, character: 22 },
343+
},
344+
})
345+
},
346+
})

packages/vscode-tailwindcss/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Allow v4.0 projects not installed with npm to use IntelliSense ([#1157](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1157))
66
- Ignore preprocessor files when looking for v4 configs ([#1159](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1159))
77
- Allow language service to be used in native ESM environments ([#1122](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1122))
8+
- Don't create v4 projects for CSS files that don't look like v4 configs [#1164](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1164)
89

910
## 0.14.2
1011

0 commit comments

Comments
 (0)