Skip to content

Improve stylesheet "root" detection #1401

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions packages/tailwindcss-language-server/src/project-locator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,95 @@ testLocator({
],
})

testLocator({
name: 'Stylesheets that import Tailwind CSS are picked over ones that dont',
fs: {
'a/foo.css': css`
@import './bar.css';
.a {
color: red;
}
`,
'a/bar.css': css`
.b {
color: red;
}
`,
'src/app.css': css`
@import 'tailwindcss';
`,
},
expected: [
{
version: '4.1.1 (bundled)',
config: '/src/app.css',
content: [],
},
{
version: '4.1.1 (bundled)',
config: '/a/foo.css',
content: [],
},
],
})

testLocator({
name: 'Stylesheets that import Tailwind CSS indirectly are picked over ones that dont',
fs: {
'a/foo.css': css`
@import './bar.css';
.a {
color: red;
}
`,
'a/bar.css': css`
.b {
color: red;
}
`,
'src/app.css': css`
@import './tw.css';
`,
'src/tw.css': css`
@import 'tailwindcss';
`,
},
expected: [
{
version: '4.1.1 (bundled)',
config: '/src/app.css',
content: [],
},
{
version: '4.1.1 (bundled)',
config: '/a/foo.css',
content: [],
},
],
})

testLocator({
name: 'Stylesheets that only have URL imports are not considered roots',
fs: {
'a/fonts.css': css`
@import 'https://example.com/fonts/some-font.css';
.a {
color: red;
}
`,
'src/app.css': css`
@import 'tailwindcss';
`,
},
expected: [
{
version: '4.1.1 (bundled)',
config: '/src/app.css',
content: [],
},
],
})

// ---

function testLocator({
Expand Down
53 changes: 47 additions & 6 deletions packages/tailwindcss-language-server/src/project-locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,17 @@ export class ProjectLocator {
// Resolve all @source directives
await Promise.all(imports.map((file) => file.resolveSourceDirectives()))

let byRealPath: Record<string, FileEntry> = {}
for (let file of imports) byRealPath[file.realpath] = file

// TODO: Link every entry in the import graph
// This breaks things tho
// for (let file of imports) file.deps = file.deps.map((dep) => byRealPath[dep.realpath] ?? dep)

// Check if each file has a direct or indirect tailwind import
// TODO: Remove the `byRealPath` argument and use linked deps instead
await Promise.all(imports.map((file) => file.resolveImportsTailwind(byRealPath)))

// Create a graph of all the CSS files that might (indirectly) use Tailwind
let graph = new Graph<FileEntry>()

Expand Down Expand Up @@ -382,14 +393,20 @@ export class ProjectLocator {
if (indexPath && themePath) graph.connect(indexPath, themePath)
if (indexPath && utilitiesPath) graph.connect(indexPath, utilitiesPath)

// Sort the graph so potential "roots" appear first
// The entire concept of roots needs to be rethought because it's not always
// clear what the root of a project is. Even when imports are present a file
// may import a file that is the actual "root" of the project.
let roots = Array.from(graph.roots())

roots.sort((a, b) => {
return a.meta.root === b.meta.root ? 0 : a.meta.root ? -1 : 1
return (
// Sort the graph so potential "roots" appear first
// The entire concept of roots needs to be rethought because it's not always
// clear what the root of a project is. Even when imports are present a file
// may import a file that is the actual "root" of the project.
Number(b.meta.root) - Number(a.meta.root) ||
// Move stylesheets with an explicit tailwindcss import before others
Number(b.importsTailwind) - Number(a.importsTailwind) ||
// Otherwise stylesheets are kept in discovery order
0
)
})

for (let root of roots) {
Expand Down Expand Up @@ -725,7 +742,31 @@ class FileEntry {
* Determine which Tailwind versions this file might be using
*/
async resolvePossibleVersions() {
this.meta = this.content ? analyzeStylesheet(this.content) : null
this.meta ??= this.content ? analyzeStylesheet(this.content) : null
}

/**
* Determine if this entry or any of its dependencies import a Tailwind CSS
* stylesheet
*/
importsTailwind: boolean | null = null

resolveImportsTailwind(byPath: Record<string, FileEntry>) {
// Already calculated so nothing to do
if (this.importsTailwind !== null) return

// We import it directly
let self = byPath[this.realpath]

if (this.meta?.explicitImport || self?.meta?.explicitImport) {
this.importsTailwind = true
return
}

// Maybe one of our deps does
for (let dep of this.deps) dep.resolveImportsTailwind(byPath)

this.importsTailwind = this.deps.some((dep) => dep.importsTailwind)
}

/**
Expand Down
19 changes: 17 additions & 2 deletions packages/tailwindcss-language-server/src/version-guesser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export interface TailwindStylesheet {
* The likely Tailwind version used by the given file
*/
versions: TailwindVersion[]

/**
* Whether or not this stylesheet explicitly imports Tailwind CSS
*/
explicitImport: boolean
}

// It's likely this is a v4 file if it has a v4 import:
Expand Down Expand Up @@ -44,7 +49,8 @@ const HAS_TAILWIND = /@tailwind\s*[^;]+;/
const HAS_COMMON_DIRECTIVE = /@(config|apply)\s*[^;{]+[;{]/

// If it's got imports at all it could be either
const HAS_IMPORT = /@import\s*['"]/
// Note: We only care about non-url imports
const HAS_NON_URL_IMPORT = /@import\s*['"](?!([a-z]+:|\/\/))/

/**
* Determine the likely Tailwind version used by the given file
Expand All @@ -60,6 +66,7 @@ export function analyzeStylesheet(content: string): TailwindStylesheet {
return {
root: true,
versions: ['4'],
explicitImport: true,
}
}

Expand All @@ -71,13 +78,15 @@ export function analyzeStylesheet(content: string): TailwindStylesheet {
return {
root: true,
versions: ['4'],
explicitImport: false,
}
}

return {
// This file MUST be imported by another file to be a valid root
root: false,
versions: ['4'],
explicitImport: false,
}
}

Expand All @@ -87,6 +96,7 @@ export function analyzeStylesheet(content: string): TailwindStylesheet {
// This file MUST be imported by another file to be a valid root
root: false,
versions: ['4'],
explicitImport: false,
}
}

Expand All @@ -96,6 +106,7 @@ export function analyzeStylesheet(content: string): TailwindStylesheet {
// Roots are only a valid concept in v4
root: false,
versions: ['3'],
explicitImport: false,
}
}

Expand All @@ -104,6 +115,7 @@ export function analyzeStylesheet(content: string): TailwindStylesheet {
return {
root: true,
versions: ['4', '3'],
explicitImport: false,
}
}

Expand All @@ -112,20 +124,23 @@ export function analyzeStylesheet(content: string): TailwindStylesheet {
return {
root: false,
versions: ['4', '3'],
explicitImport: false,
}
}

// Files that import other files could be either and are potentially roots
if (HAS_IMPORT.test(content)) {
if (HAS_NON_URL_IMPORT.test(content)) {
return {
root: true,
versions: ['4', '3'],
explicitImport: false,
}
}

// Pretty sure it's not related to Tailwind at all
return {
root: false,
versions: [],
explicitImport: false,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ test('replacing CSS variables with their fallbacks (when they have them)', () =>

let state: State = {
enabled: true,
features: [],
designSystem: {
theme: { prefix: null } as any,
resolveThemeValue: (name) => map.get(name) ?? null,
Expand Down Expand Up @@ -102,6 +103,7 @@ test('recursive theme replacements', () => {

let state: State = {
enabled: true,
features: [],
designSystem: {
theme: { prefix: null } as any,
resolveThemeValue: (name) => map.get(name) ?? null,
Expand Down Expand Up @@ -142,6 +144,7 @@ test('recursive theme replacements (inlined)', () => {

let state: State = {
enabled: true,
features: [],
designSystem: {
theme: { prefix: null } as any,
resolveThemeValue: (name) => map.get(name) ?? null,
Expand Down Expand Up @@ -184,6 +187,7 @@ test('Inlining calc expressions using the design system', () => {

let state: State = {
enabled: true,
features: [],
designSystem: {
theme: { prefix: null } as any,
resolveThemeValue: (name) => map.get(name) ?? null,
Expand Down
1 change: 1 addition & 0 deletions packages/vscode-tailwindcss/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Bump bundled CSS language service ([#1395](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1395))
- Fix infinite loop when resolving completion details with recursive theme keys ([#1400](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1400))
- Simplify completion details for more utilities ([#1397](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1397))
- Improve project stylesheet detection ([#1401](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1401))

## 0.14.20

Expand Down