Skip to content
Draft
40 changes: 17 additions & 23 deletions packages/tailwindcss-language-server/src/util/v4/design-system.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { DesignSystem } from '@tailwindcss/language-service/src/util/v4'

import postcss from 'postcss'
import { createJiti } from 'jiti'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
Expand All @@ -10,6 +9,7 @@ import { pathToFileURL } from '../../utils'
import type { Jiti } from 'jiti/lib/types'
import { assets } from './assets'
import { plugins } from './plugins'
import { AstNode, cloneAstNode, parse } from '@tailwindcss/language-service/src/css'

const HAS_V4_IMPORT = /@import\s*(?:'tailwindcss'|"tailwindcss")/
const HAS_V4_THEME = /@theme\s*\{/
Expand Down Expand Up @@ -225,35 +225,35 @@ export async function loadDesignSystem(
Object.assign(design, {
dependencies: () => dependencies,

// TODOs:
//
// 1. Remove PostCSS parsing — its roughly 60% of the processing time
// ex: compiling 19k classes take 650ms and 400ms of that is PostCSS
//
// - Replace `candidatesToCss` with a `candidatesToAst` API
// First step would be to convert to a PostCSS AST by transforming the nodes directly
// Then it would be to drop the PostCSS AST representation entirely in all v4 code paths
compile(classes: string[]): postcss.Root[] {
compile(classes: string[]): AstNode[][] {
// 1. Compile any uncached classes
let cache = design.storage[COMPILE_CACHE] as Record<string, postcss.Root>
let cache = design.storage[COMPILE_CACHE] as Record<string, AstNode[]>
let uncached = classes.filter((name) => cache[name] === undefined)

let css = design.candidatesToCss(uncached)
let css = design.candidatesToAst
? design.candidatesToAst(uncached)
: design.candidatesToCss(uncached)

let errors: any[] = []

for (let [idx, cls] of uncached.entries()) {
let str = css[idx]

if (Array.isArray(str)) {
cache[cls] = str
continue
}

if (str === null) {
cache[cls] = postcss.root()
cache[cls] = []
continue
}

try {
cache[cls] = postcss.parse(str.trimEnd())
cache[cls] = parse(str.trimEnd())
} catch (err) {
errors.push(err)
cache[cls] = postcss.root()
cache[cls] = []
continue
}
}
Expand All @@ -263,20 +263,14 @@ export async function loadDesignSystem(
}

// 2. Pull all the classes from the cache
let roots: postcss.Root[] = []
let roots: AstNode[][] = []

for (let cls of classes) {
roots.push(cache[cls].clone())
roots.push(cache[cls].map(cloneAstNode))
}

return roots
},

toCss(nodes: postcss.Root | postcss.Node[]): string {
return Array.isArray(nodes)
? postcss.root({ nodes }).toString().trim()
: nodes.toString().trim()
},
})

return design
Expand Down
11 changes: 9 additions & 2 deletions packages/tailwindcss-language-server/vitest.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,16 @@ export default defineConfig({
name: 'force-inline-css',
enforce: 'pre',
resolveId(id) {
if (!id.includes('index.css')) return
if (id.includes('?raw')) return
return this.resolve(`${id}?raw`)

if (
id.includes('index.css') ||
id.includes('theme.css') ||
id.includes('utilities.css') ||
id.includes('preflight.css')
) {
return this.resolve(`${id}?raw`)
}
},
},
],
Expand Down
91 changes: 49 additions & 42 deletions packages/tailwindcss-language-service/src/completionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import { resolveKnownThemeKeys, resolveKnownThemeNamespaces } from './util/v4/th
import { SEARCH_RANGE } from './util/constants'
import { getLanguageBoundaries } from './util/getLanguageBoundaries'
import { isWithinRange } from './util/isWithinRange'
import { walk, WalkAction } from './util/walk'
import { Declaration, toPostCSSAst } from './css'

let isUtil = (className) =>
Array.isArray(className.__info)
Expand Down Expand Up @@ -2296,35 +2298,11 @@ export async function resolveCompletionItem(
let base = state.designSystem.compile([className])[0]
let root = state.designSystem.compile([[...variants, className].join(state.separator)])[0]

let rules = root.nodes.filter((node) => node.type === 'rule')
let rules = root.filter((node) => node.kind === 'rule')
if (rules.length === 0) return item

if (!item.detail) {
if (rules.length === 1) {
let decls: postcss.Declaration[] = []

// Remove any `@property` rules
base = base.clone()
base.walkAtRules((rule) => {
// Ignore declarations inside `@property` rules
if (rule.name === 'property') {
rule.remove()
}

// Ignore declarations @supports (-moz-orient: inline)
// this is a hack used for `@property` fallbacks in Firefox
if (rule.name === 'supports' && rule.params === '(-moz-orient: inline)') {
rule.remove()
}

if (
rule.name === 'supports' &&
rule.params === '(background-image: linear-gradient(in lab, red, red))'
) {
rule.remove()
}
})

let ignoredValues = new Set([
'var(--tw-border-style)',
'var(--tw-outline-style)',
Expand All @@ -2334,26 +2312,51 @@ export async function resolveCompletionItem(
'var(--tw-scale-x) var(--tw-scale-y) var(--tw-scale-z)',
])

base.walkDecls((node) => {
if (ignoredValues.has(node.value)) return
let decls: Declaration[] = []

walk(base, (node) => {
if (node.kind === 'at-rule') {
// Ignore declarations inside `@property` rules
if (node.name === '@property') {
return WalkAction.Skip
}

decls.push(node)
// Ignore declarations @supports (-moz-orient: inline)
// this is a hack used for `@property` fallbacks in Firefox
if (node.name === '@supports' && node.params === '(-moz-orient: inline)') {
return WalkAction.Skip
}

if (
node.name === '@supports' &&
node.params === '(background-image: linear-gradient(in lab, red, red))'
) {
return WalkAction.Skip
}
}

if (node.kind === 'declaration') {
if (ignoredValues.has(node.value)) return WalkAction.Continue
decls.push(node)
}

return WalkAction.Continue
})

// TODO: Hardcoding this list is really unfortunate. We should be able
// to handle this in Tailwind CSS itself.
function isOtherDecl(node: postcss.Declaration) {
if (node.prop === '--tw-leading') return false
if (node.prop === '--tw-duration') return false
if (node.prop === '--tw-ease') return false
if (node.prop === '--tw-font-weight') return false
if (node.prop === '--tw-gradient-via-stops') return false
if (node.prop === '--tw-gradient-stops') return false
if (node.prop === '--tw-tracking') return false
if (node.prop === '--tw-space-x-reverse' && node.value === '0') return false
if (node.prop === '--tw-space-y-reverse' && node.value === '0') return false
if (node.prop === '--tw-divide-x-reverse' && node.value === '0') return false
if (node.prop === '--tw-divide-y-reverse' && node.value === '0') return false
function isOtherDecl(node: Declaration) {
if (node.property === '--tw-leading') return false
if (node.property === '--tw-duration') return false
if (node.property === '--tw-ease') return false
if (node.property === '--tw-font-weight') return false
if (node.property === '--tw-gradient-via-stops') return false
if (node.property === '--tw-gradient-stops') return false
if (node.property === '--tw-tracking') return false
if (node.property === '--tw-space-x-reverse' && node.value === '0') return false
if (node.property === '--tw-space-y-reverse' && node.value === '0') return false
if (node.property === '--tw-divide-x-reverse' && node.value === '0') return false
if (node.property === '--tw-divide-y-reverse' && node.value === '0') return false

return true
}
Expand All @@ -2363,7 +2366,10 @@ export async function resolveCompletionItem(
decls = decls.filter(isOtherDecl)
}

item.detail = await jit.stringifyDecls(state, postcss.rule({ selectors: [], nodes: decls }))
let root = toPostCSSAst([{ kind: 'rule', selector: '', nodes: decls }])
let rule = root.nodes[0] as postcss.Rule

item.detail = await jit.stringifyDecls(state, rule)
} else {
item.detail = `${rules.length} rules`
}
Expand All @@ -2373,8 +2379,9 @@ export async function resolveCompletionItem(
item.documentation = {
kind: 'markdown' as typeof MarkupKind.Markdown,
value: [
//
'```css',
await jit.stringifyRoot(state, postcss.root({ nodes: rules })),
await jit.stringifyRoot(state, toPostCSSAst(rules)),
'```',
].join('\n'),
}
Expand Down
Loading