Skip to content
25 changes: 9 additions & 16 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,28 @@ 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 errors: any[] = []

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

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,10 +256,10 @@ 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
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as jit from '../util/jit'
import * as postcss from 'postcss'
import type { AtRule, Node, Rule } from 'postcss'
import type { TextDocument } from 'vscode-languageserver-textdocument'
import { walk, WalkAction } from '../util/walk'

function isCustomProperty(property: string): boolean {
return property.startsWith('--')
Expand Down Expand Up @@ -238,20 +239,6 @@ interface RuleEntry {

type ClassDetails = Record<string, RuleEntry[]>

export function visit(
nodes: postcss.AnyNode[],
cb: (node: postcss.AnyNode, path: postcss.AnyNode[]) => void,
path: postcss.AnyNode[] = [],
): void {
for (let child of nodes) {
path = [...path, child]
cb(child, path)
if ('nodes' in child && child.nodes && child.nodes.length > 0) {
visit(child.nodes, cb, path)
}
}
}

function recordClassDetails(state: State, classes: DocumentClassName[]): ClassDetails {
const groups: Record<string, RuleEntry[]> = {}

Expand All @@ -261,34 +248,38 @@ function recordClassDetails(state: State, classes: DocumentClassName[]): ClassDe
for (let [idx, root] of roots.entries()) {
let { className } = classes[idx]

visit([root], (node, path) => {
if (node.type !== 'rule' && node.type !== 'atrule') return
walk(root, (node, ctx) => {
if (node.kind !== 'rule' && node.kind !== 'at-rule') return WalkAction.Continue

let properties: string[] = []

for (let child of node.nodes ?? []) {
if (child.type !== 'decl') continue
properties.push(child.prop)
if (child.kind !== 'declaration') continue
properties.push(child.property)
}

if (properties.length === 0) return
if (properties.length === 0) return WalkAction.Continue

// We have to slice off the first `context` item because it's the class name and that's always different
let path = [...ctx.path(), node].slice(1)

groups[className] ??= []
groups[className].push({
properties,
context: path
.map((node) => {
if (node.type === 'rule') {
if (node.kind === 'rule') {
return node.selector
} else if (node.type === 'atrule') {
return `@${node.name} ${node.params}`
} else if (node.kind === 'at-rule') {
return `${node.name} ${node.params}`
}

return ''
})
.filter(Boolean)
.slice(1),
.filter(Boolean),
})

return WalkAction.Continue
})
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { resolveKnownThemeKeys } from '../util/v4/theme-keys'
import dlv from 'dlv'
import type { TextDocument } from 'vscode-languageserver-textdocument'
import type { DesignSystem } from '../util/v4'
import { walk, WalkAction } from '../util/walk'

type ValidationResult =
| { isValid: true; value: any }
Expand Down Expand Up @@ -224,12 +225,17 @@ function resolveThemeValue(design: DesignSystem, path: string) {
//
// Non-CSS representable values are not a concern here because the validation
// only happens for calls in a CSS context.
let [root] = design.compile([candidate])
let root = design.compile([candidate])[0]

let value: string | null = null

root.walkDecls((decl) => {
value = decl.value
walk(root, (node) => {
if (node.kind === 'declaration') {
value = node.value
return WalkAction.Stop
}

return WalkAction.Continue
})

return value
Expand Down
8 changes: 3 additions & 5 deletions packages/tailwindcss-language-service/src/hoverProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { getTextWithoutComments } from './util/doc'
import braces from 'braces'
import { absoluteRange } from './util/absoluteRange'
import { segment } from './util/segment'
import { toPostCSSAst } from './css'

export async function doHover(
state: State,
Expand Down Expand Up @@ -101,15 +102,12 @@ async function provideClassNameHover(

if (state.v4) {
let root = state.designSystem.compile([className.className])[0]

if (root.nodes.length === 0) {
return null
}
if (root.length === 0) return null

return {
contents: {
language: 'css',
value: await jit.stringifyRoot(state, root, document.uri),
value: await jit.stringifyRoot(state, toPostCSSAst(root), document.uri),
},
range: className.range,
}
Expand Down
Loading