diff --git a/packages/tailwindcss-language-server/src/util/v4/design-system.ts b/packages/tailwindcss-language-server/src/util/v4/design-system.ts index 7731e4e4..f74d13fd 100644 --- a/packages/tailwindcss-language-server/src/util/v4/design-system.ts +++ b/packages/tailwindcss-language-server/src/util/v4/design-system.ts @@ -10,6 +10,7 @@ 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' +import { walk, WalkAction } from '@tailwindcss/language-service/src/util/walk' const HAS_V4_IMPORT = /@import\s*(?:'tailwindcss'|"tailwindcss")/ const HAS_V4_THEME = /@theme\s*\{/ @@ -240,7 +241,27 @@ export async function loadDesignSystem( let str = css[idx] if (Array.isArray(str)) { - cache[cls] = str + let ast = str.map(cloneAstNode) + + // Rewrite at-rules with zero nodes to act as if they have no body + // + // At a future time we'll only do this conditionally for earlier + // Tailwind CSS v4 versions. We have to clone the AST *first* + // because if the AST was shared with Tailwind CSS internals + // and we mutated it we could break things. + walk(ast, (node) => { + if (node.kind !== 'at-rule') return WalkAction.Continue + if (node.nodes === null) return WalkAction.Continue + if (node.nodes.length !== 0) return WalkAction.Continue + + // Treat + node.nodes = null + + return WalkAction.Continue + }) + + cache[cls] = ast + continue } diff --git a/packages/tailwindcss-language-service/src/css/ast.ts b/packages/tailwindcss-language-service/src/css/ast.ts index ac95d204..6d206c96 100644 --- a/packages/tailwindcss-language-service/src/css/ast.ts +++ b/packages/tailwindcss-language-service/src/css/ast.ts @@ -1,6 +1,5 @@ -import { parseAtRule } from './parse' import type { SourceLocation } from './source' -import type { VisitContext } from '../util/walk' +import { parseAtRule } from './parse' const AT_SIGN = 0x40 @@ -17,7 +16,7 @@ export type AtRule = { kind: 'at-rule' name: string params: string - nodes: AstNode[] + nodes: AstNode[] | null src?: SourceLocation dst?: SourceLocation @@ -70,7 +69,7 @@ export function styleRule(selector: string, nodes: AstNode[] = []): StyleRule { } } -export function atRule(name: string, params: string = '', nodes: AstNode[] = []): AtRule { +export function atRule(name: string, params: string = '', nodes: AstNode[] | null = []): AtRule { return { kind: 'at-rule', name, @@ -79,12 +78,12 @@ export function atRule(name: string, params: string = '', nodes: AstNode[] = []) } } -export function rule(selector: string, nodes: AstNode[] = []): StyleRule | AtRule { +export function rule(selector: string, nodes: AstNode[] | null = []): StyleRule | AtRule { if (selector.charCodeAt(0) === AT_SIGN) { return parseAtRule(selector, nodes) } - return styleRule(selector, nodes) + return styleRule(selector, nodes ?? []) } export function decl(property: string, value: string | undefined, important = false): Declaration { @@ -117,95 +116,3 @@ export function atRoot(nodes: AstNode[]): AtRoot { nodes, } } - -export function cloneAstNode(node: T): T { - switch (node.kind) { - case 'rule': - return { - kind: node.kind, - selector: node.selector, - nodes: node.nodes.map(cloneAstNode), - src: node.src, - dst: node.dst, - } satisfies StyleRule as T - - case 'at-rule': - return { - kind: node.kind, - name: node.name, - params: node.params, - nodes: node.nodes.map(cloneAstNode), - src: node.src, - dst: node.dst, - } satisfies AtRule as T - - case 'at-root': - return { - kind: node.kind, - nodes: node.nodes.map(cloneAstNode), - src: node.src, - dst: node.dst, - } satisfies AtRoot as T - - case 'context': - return { - kind: node.kind, - context: { ...node.context }, - nodes: node.nodes.map(cloneAstNode), - src: node.src, - dst: node.dst, - } satisfies Context as T - - case 'declaration': - return { - kind: node.kind, - property: node.property, - value: node.value, - important: node.important, - src: node.src, - dst: node.dst, - } satisfies Declaration as T - - case 'comment': - return { - kind: node.kind, - value: node.value, - src: node.src, - dst: node.dst, - } satisfies Comment as T - - default: - node satisfies never - throw new Error(`Unknown node kind: ${(node as any).kind}`) - } -} - -export function cssContext( - ctx: VisitContext, -): VisitContext & { context: Record } { - return { - depth: ctx.depth, - get context() { - let context: Record = {} - for (let child of ctx.path()) { - if (child.kind === 'context') { - Object.assign(context, child.context) - } - } - - // Once computed, we never need to compute this again - Object.defineProperty(this, 'context', { value: context }) - return context - }, - get parent() { - let parent = (this.path().pop() as Extract) ?? null - - // Once computed, we never need to compute this again - Object.defineProperty(this, 'parent', { value: parent }) - return parent - }, - path() { - return ctx.path().filter((n) => n.kind !== 'context') - }, - } -} diff --git a/packages/tailwindcss-language-service/src/css/clone-ast-node.ts b/packages/tailwindcss-language-service/src/css/clone-ast-node.ts index ec62e482..7d0c4ea8 100644 --- a/packages/tailwindcss-language-service/src/css/clone-ast-node.ts +++ b/packages/tailwindcss-language-service/src/css/clone-ast-node.ts @@ -16,7 +16,7 @@ export function cloneAstNode(node: T): T { kind: node.kind, name: node.name, params: node.params, - nodes: node.nodes.map(cloneAstNode), + nodes: node.nodes?.map(cloneAstNode) ?? null, src: node.src, dst: node.dst, } satisfies AtRule as T diff --git a/packages/tailwindcss-language-service/src/css/css-context.ts b/packages/tailwindcss-language-service/src/css/css-context.ts new file mode 100644 index 00000000..02ca1001 --- /dev/null +++ b/packages/tailwindcss-language-service/src/css/css-context.ts @@ -0,0 +1,32 @@ +import type { VisitContext } from '../util/walk' +import type { AstNode } from './ast' + +export function cssContext( + ctx: VisitContext, +): VisitContext & { context: Record } { + return { + depth: ctx.depth, + get context() { + let context: Record = {} + for (let child of ctx.path()) { + if (child.kind === 'context') { + Object.assign(context, child.context) + } + } + + // Once computed, we never need to compute this again + Object.defineProperty(this, 'context', { value: context }) + return context + }, + get parent() { + let parent = (this.path().pop() as Extract) ?? null + + // Once computed, we never need to compute this again + Object.defineProperty(this, 'parent', { value: parent }) + return parent + }, + path() { + return ctx.path().filter((n) => n.kind !== 'context') + }, + } +} diff --git a/packages/tailwindcss-language-service/src/css/from-postcss-ast.ts b/packages/tailwindcss-language-service/src/css/from-postcss-ast.ts index 9fb26ff7..04cc59ad 100644 --- a/packages/tailwindcss-language-service/src/css/from-postcss-ast.ts +++ b/packages/tailwindcss-language-service/src/css/from-postcss-ast.ts @@ -44,9 +44,9 @@ export function fromPostCSSAst(root: postcss.Root): AstNode[] { // AtRule else if (node.type === 'atrule') { - let astNode = atRule(`@${node.name}`, node.params) + let astNode = atRule(`@${node.name}`, node.params, node.nodes ? [] : null) astNode.src = toSource(node) - node.each((child) => transform(child, astNode.nodes)) + node.each((child) => transform(child, astNode.nodes!)) parent.push(astNode) } diff --git a/packages/tailwindcss-language-service/src/css/index.ts b/packages/tailwindcss-language-service/src/css/index.ts index c053e61b..3612b101 100644 --- a/packages/tailwindcss-language-service/src/css/index.ts +++ b/packages/tailwindcss-language-service/src/css/index.ts @@ -1,6 +1,7 @@ export * from './ast' export * from './source' export { parse } from './parse' +export { cloneAstNode } from './clone-ast-node' export { fromPostCSSAst } from './from-postcss-ast' export { toPostCSSAst } from './to-postcss-ast' export { toCss } from './to-css' diff --git a/packages/tailwindcss-language-service/src/css/parse.ts b/packages/tailwindcss-language-service/src/css/parse.ts index d0f8ff22..4386f6d6 100644 --- a/packages/tailwindcss-language-service/src/css/parse.ts +++ b/packages/tailwindcss-language-service/src/css/parse.ts @@ -277,6 +277,7 @@ export function parse(input: string, opts?: ParseOptions): AstNode[] { } if (parent) { + parent.nodes ??= [] parent.nodes.push(declaration) } else { ast.push(declaration) @@ -303,6 +304,7 @@ export function parse(input: string, opts?: ParseOptions): AstNode[] { // At-rule is nested inside of a rule, attach it to the parent. if (parent) { + parent.nodes ??= [] parent.nodes.push(node) } @@ -343,6 +345,7 @@ export function parse(input: string, opts?: ParseOptions): AstNode[] { } if (parent) { + parent.nodes ??= [] parent.nodes.push(declaration) } else { ast.push(declaration) @@ -369,6 +372,7 @@ export function parse(input: string, opts?: ParseOptions): AstNode[] { // Attach the rule to the parent in case it's nested. if (parent) { + parent.nodes ??= [] parent.nodes.push(node) } @@ -421,6 +425,7 @@ export function parse(input: string, opts?: ParseOptions): AstNode[] { // At-rule is nested inside of a rule, attach it to the parent. if (parent) { + parent.nodes ??= [] parent.nodes.push(node) } @@ -460,6 +465,7 @@ export function parse(input: string, opts?: ParseOptions): AstNode[] { node.dst = [source, bufferStart, i] } + parent.nodes ??= [] parent.nodes.push(node) } } @@ -548,7 +554,7 @@ export function parse(input: string, opts?: ParseOptions): AstNode[] { return ast } -export function parseAtRule(buffer: string, nodes: AstNode[] = []): AtRule { +export function parseAtRule(buffer: string, nodes: AstNode[] | null = []): AtRule { let name = buffer let params = '' diff --git a/packages/tailwindcss-language-service/src/css/to-css.ts b/packages/tailwindcss-language-service/src/css/to-css.ts index 83e94742..cc532f67 100644 --- a/packages/tailwindcss-language-service/src/css/to-css.ts +++ b/packages/tailwindcss-language-service/src/css/to-css.ts @@ -91,7 +91,7 @@ export function toCss(ast: AstNode[], track?: boolean): string { // ```css // @layer base, components, utilities; // ``` - if (node.nodes.length === 0) { + if (!node.nodes) { let css = `${indent}${node.name} ${node.params};\n` if (track) { diff --git a/packages/tailwindcss-language-service/src/css/to-postcss-ast.ts b/packages/tailwindcss-language-service/src/css/to-postcss-ast.ts index 3495f323..7fd6f979 100644 --- a/packages/tailwindcss-language-service/src/css/to-postcss-ast.ts +++ b/packages/tailwindcss-language-service/src/css/to-postcss-ast.ts @@ -1,5 +1,5 @@ import * as postcss from 'postcss' -import { atRule, comment, decl, styleRule, type AstNode } from './ast' +import type { AstNode } from './ast' import type { Source, SourceLocation } from './source' import { DefaultMap } from '../util/default-map' import { createLineTable, LineTable } from '../util/line-table' @@ -85,11 +85,15 @@ export function toPostCSSAst(ast: AstNode[], source?: postcss.Source): postcss.R // AtRule else if (node.kind === 'at-rule') { - let astNode = postcss.atRule({ name: node.name.slice(1), params: node.params }) + let astNode = postcss.atRule({ + name: node.name.slice(1), + params: node.params, + ...(node.nodes ? { nodes: [] } : {}), + }) updateSource(astNode, node.src) astNode.raws.semicolon = true parent.append(astNode) - for (let child of node.nodes) { + for (let child of node.nodes ?? []) { transform(child, astNode) } }