From 7012df6b5911f23fd023e283dc5cc4ccf7448e4c Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Thu, 4 Dec 2025 12:12:16 -0500 Subject: [PATCH 01/12] Make debug port configurable --- packages/vscode-tailwindcss/src/extension.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vscode-tailwindcss/src/extension.ts b/packages/vscode-tailwindcss/src/extension.ts index 467d4e08..aac64939 100755 --- a/packages/vscode-tailwindcss/src/extension.ts +++ b/packages/vscode-tailwindcss/src/extension.ts @@ -354,7 +354,7 @@ export async function activate(context: ExtensionContext) { module, transport: TransportKind.ipc, options: { - execArgv: ['--nolazy', `--inspect=6011`], + execArgv: ['--nolazy', `--inspect=${inspectPort ?? 6011}`], }, }, } From 7b22853ad512222a3ce9cf39de517a6d4533e313 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 3 Dec 2025 20:29:37 -0500 Subject: [PATCH 02/12] Fix CSS imports during tests --- .../tailwindcss-language-server/vitest.config.mts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/tailwindcss-language-server/vitest.config.mts b/packages/tailwindcss-language-server/vitest.config.mts index 223ec990..7dd538e8 100644 --- a/packages/tailwindcss-language-server/vitest.config.mts +++ b/packages/tailwindcss-language-server/vitest.config.mts @@ -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`) + } }, }, ], From 1fffe50aa73727c2513d85580d9f6a0dae4ec07a Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 3 Dec 2025 20:36:26 -0500 Subject: [PATCH 03/12] Remove `toCss` helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It’s not used --- .../src/util/v4/design-system.ts | 6 ------ .../src/util/v4/design-system.ts | 1 - 2 files changed, 7 deletions(-) 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 b7bf8daf..460110a2 100644 --- a/packages/tailwindcss-language-server/src/util/v4/design-system.ts +++ b/packages/tailwindcss-language-server/src/util/v4/design-system.ts @@ -271,12 +271,6 @@ export async function loadDesignSystem( return roots }, - - toCss(nodes: postcss.Root | postcss.Node[]): string { - return Array.isArray(nodes) - ? postcss.root({ nodes }).toString().trim() - : nodes.toString().trim() - }, }) return design diff --git a/packages/tailwindcss-language-service/src/util/v4/design-system.ts b/packages/tailwindcss-language-service/src/util/v4/design-system.ts index 2e00d8ce..d4006046 100644 --- a/packages/tailwindcss-language-service/src/util/v4/design-system.ts +++ b/packages/tailwindcss-language-service/src/util/v4/design-system.ts @@ -59,5 +59,4 @@ export interface DesignSystem { export interface DesignSystem { dependencies(): Set compile(classes: string[]): (postcss.Root | null)[] - toCss(nodes: postcss.Root | postcss.Node[]): string } From 1d7fd6e4815e5b34b575080ac0bf5df24fdb729a Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 3 Dec 2025 20:35:16 -0500 Subject: [PATCH 04/12] Add CSS AST implementation This is intended to mirror the AST implementation of Tailwind CSS v4.1.18+ --- .../src/css/ast.ts | 211 ++++++ .../src/css/clone-ast-node.ts | 63 ++ .../src/css/from-postcss-ast.ts | 71 ++ .../src/css/index.ts | 6 + .../src/css/parse.ts | 665 ++++++++++++++++++ .../src/css/source.ts | 27 + .../src/css/to-css.ts | 209 ++++++ .../src/css/to-postcss-ast.ts | 123 ++++ .../src/util/default-map.ts | 20 + .../src/util/line-table.ts | 100 +++ .../src/util/v4/ast.ts | 33 - .../src/util/v4/design-system.ts | 2 +- .../src/util/v4/index.ts | 1 - .../src/util/walk.ts | 190 +++++ 14 files changed, 1686 insertions(+), 35 deletions(-) create mode 100644 packages/tailwindcss-language-service/src/css/ast.ts create mode 100644 packages/tailwindcss-language-service/src/css/clone-ast-node.ts create mode 100644 packages/tailwindcss-language-service/src/css/from-postcss-ast.ts create mode 100644 packages/tailwindcss-language-service/src/css/index.ts create mode 100644 packages/tailwindcss-language-service/src/css/parse.ts create mode 100644 packages/tailwindcss-language-service/src/css/source.ts create mode 100644 packages/tailwindcss-language-service/src/css/to-css.ts create mode 100644 packages/tailwindcss-language-service/src/css/to-postcss-ast.ts create mode 100644 packages/tailwindcss-language-service/src/util/default-map.ts create mode 100644 packages/tailwindcss-language-service/src/util/line-table.ts delete mode 100644 packages/tailwindcss-language-service/src/util/v4/ast.ts create mode 100644 packages/tailwindcss-language-service/src/util/walk.ts diff --git a/packages/tailwindcss-language-service/src/css/ast.ts b/packages/tailwindcss-language-service/src/css/ast.ts new file mode 100644 index 00000000..ac95d204 --- /dev/null +++ b/packages/tailwindcss-language-service/src/css/ast.ts @@ -0,0 +1,211 @@ +import { parseAtRule } from './parse' +import type { SourceLocation } from './source' +import type { VisitContext } from '../util/walk' + +const AT_SIGN = 0x40 + +export type StyleRule = { + kind: 'rule' + selector: string + nodes: AstNode[] + + src?: SourceLocation + dst?: SourceLocation +} + +export type AtRule = { + kind: 'at-rule' + name: string + params: string + nodes: AstNode[] + + src?: SourceLocation + dst?: SourceLocation +} + +export type Declaration = { + kind: 'declaration' + property: string + value: string | undefined + important: boolean + + src?: SourceLocation + dst?: SourceLocation +} + +export type Comment = { + kind: 'comment' + value: string + + src?: SourceLocation + dst?: SourceLocation +} + +export type Context = { + kind: 'context' + context: Record + nodes: AstNode[] + + src?: undefined + dst?: undefined +} + +export type AtRoot = { + kind: 'at-root' + nodes: AstNode[] + + src?: undefined + dst?: undefined +} + +export type Rule = StyleRule | AtRule +export type AstNode = StyleRule | AtRule | Declaration | Comment | Context | AtRoot +export type Stylesheet = AstNode[] + +export function styleRule(selector: string, nodes: AstNode[] = []): StyleRule { + return { + kind: 'rule', + selector, + nodes, + } +} + +export function atRule(name: string, params: string = '', nodes: AstNode[] = []): AtRule { + return { + kind: 'at-rule', + name, + params, + nodes, + } +} + +export function rule(selector: string, nodes: AstNode[] = []): StyleRule | AtRule { + if (selector.charCodeAt(0) === AT_SIGN) { + return parseAtRule(selector, nodes) + } + + return styleRule(selector, nodes) +} + +export function decl(property: string, value: string | undefined, important = false): Declaration { + return { + kind: 'declaration', + property, + value, + important, + } +} + +export function comment(value: string): Comment { + return { + kind: 'comment', + value: value, + } +} + +export function context(context: Record, nodes: AstNode[]): Context { + return { + kind: 'context', + context, + nodes, + } +} + +export function atRoot(nodes: AstNode[]): AtRoot { + return { + kind: 'at-root', + 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 new file mode 100644 index 00000000..ec62e482 --- /dev/null +++ b/packages/tailwindcss-language-service/src/css/clone-ast-node.ts @@ -0,0 +1,63 @@ +import type { AstNode, AtRoot, AtRule, Comment, Context, Declaration, StyleRule } from './ast' + +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}`) + } +} diff --git a/packages/tailwindcss-language-service/src/css/from-postcss-ast.ts b/packages/tailwindcss-language-service/src/css/from-postcss-ast.ts new file mode 100644 index 00000000..9fb26ff7 --- /dev/null +++ b/packages/tailwindcss-language-service/src/css/from-postcss-ast.ts @@ -0,0 +1,71 @@ +import type * as postcss from 'postcss' +import { atRule, comment, decl, styleRule, type AstNode } from './ast' +import type { Source, SourceLocation } from './source' +import { DefaultMap } from '../util/default-map' + +const EXCLAMATION_MARK = 0x21 + +export function fromPostCSSAst(root: postcss.Root): AstNode[] { + let inputMap = new DefaultMap((input) => ({ + file: input.file ?? input.id ?? null, + code: input.css, + })) + + function toSource(node: postcss.ChildNode): SourceLocation | undefined { + let source = node.source + if (!source) return undefined + + let input = source.input + if (!input) return undefined + if (source.start === undefined) return undefined + if (source.end === undefined) return undefined + + return [inputMap.get(input), source.start.offset, source.end.offset] + } + + function transform( + node: postcss.ChildNode, + parent: Extract['nodes'], + ) { + // Declaration + if (node.type === 'decl') { + let astNode = decl(node.prop, node.value, node.important) + astNode.src = toSource(node) + parent.push(astNode) + } + + // Rule + else if (node.type === 'rule') { + let astNode = styleRule(node.selector) + astNode.src = toSource(node) + node.each((child) => transform(child, astNode.nodes)) + parent.push(astNode) + } + + // AtRule + else if (node.type === 'atrule') { + let astNode = atRule(`@${node.name}`, node.params) + astNode.src = toSource(node) + node.each((child) => transform(child, astNode.nodes)) + parent.push(astNode) + } + + // Comment + else if (node.type === 'comment') { + if (node.text.charCodeAt(0) !== EXCLAMATION_MARK) return + let astNode = comment(node.text) + astNode.src = toSource(node) + parent.push(astNode) + } + + // Unknown + else { + node satisfies never + } + } + + let ast: AstNode[] = [] + root.each((node) => transform(node, ast)) + + return ast +} diff --git a/packages/tailwindcss-language-service/src/css/index.ts b/packages/tailwindcss-language-service/src/css/index.ts new file mode 100644 index 00000000..c053e61b --- /dev/null +++ b/packages/tailwindcss-language-service/src/css/index.ts @@ -0,0 +1,6 @@ +export * from './ast' +export * from './source' +export { parse } from './parse' +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 new file mode 100644 index 00000000..d0f8ff22 --- /dev/null +++ b/packages/tailwindcss-language-service/src/css/parse.ts @@ -0,0 +1,665 @@ +import { + atRule, + comment, + decl, + rule, + type AstNode, + type AtRule, + type Comment, + type Declaration, + type Rule, +} from './ast' +import type { Source } from './source' + +const BACKSLASH = 0x5c +const SLASH = 0x2f +const ASTERISK = 0x2a +const DOUBLE_QUOTE = 0x22 +const SINGLE_QUOTE = 0x27 +const COLON = 0x3a +const SEMICOLON = 0x3b +const LINE_BREAK = 0x0a +const CARRIAGE_RETURN = 0xd +const SPACE = 0x20 +const TAB = 0x09 +const OPEN_CURLY = 0x7b +const CLOSE_CURLY = 0x7d +const OPEN_PAREN = 0x28 +const CLOSE_PAREN = 0x29 +const OPEN_BRACKET = 0x5b +const CLOSE_BRACKET = 0x5d +const DASH = 0x2d +const AT_SIGN = 0x40 +const EXCLAMATION_MARK = 0x21 + +export interface ParseOptions { + from?: string +} + +export function parse(input: string, opts?: ParseOptions): AstNode[] { + let source: Source | null = opts?.from ? { file: opts.from, code: input } : null + + // Note: it is important that any transformations of the input string + // *before* processing do NOT change the length of the string. This + // would invalidate the mechanism used to track source locations. + if (input[0] === '\uFEFF') input = ' ' + input.slice(1) + + let ast: AstNode[] = [] + let licenseComments: Comment[] = [] + + let stack: (Rule | null)[] = [] + + let parent = null as Rule | null + let node = null as AstNode | null + + let buffer = '' + let closingBracketStack = '' + + // The start of the first non-whitespace character in the buffer + let bufferStart = 0 + + let peekChar + + for (let i = 0; i < input.length; i++) { + let currentChar = input.charCodeAt(i) + + // Skip over the CR in CRLF. This allows code below to only check for a line + // break even if we're looking at a Windows newline. Peeking the input still + // has to check for CRLF but that happens less often. + if (currentChar === CARRIAGE_RETURN) { + peekChar = input.charCodeAt(i + 1) + if (peekChar === LINE_BREAK) continue + } + + // Current character is a `\` therefore the next character is escaped, + // consume it together with the next character and continue. + // + // E.g.: + // + // ```css + // .hover\:foo:hover {} + // ^ + // ``` + // + if (currentChar === BACKSLASH) { + if (buffer === '') bufferStart = i + buffer += input.slice(i, i + 2) + i += 1 + } + + // Start of a comment. + // + // E.g.: + // + // ```css + // /* Example */ + // ^^^^^^^^^^^^^ + // .foo { + // color: red; /* Example */ + // ^^^^^^^^^^^^^ + // } + // .bar { + // color: /* Example */ red; + // ^^^^^^^^^^^^^ + // } + // ``` + else if (currentChar === SLASH && input.charCodeAt(i + 1) === ASTERISK) { + let start = i + + for (let j = i + 2; j < input.length; j++) { + peekChar = input.charCodeAt(j) + + // Current character is a `\` therefore the next character is escaped. + if (peekChar === BACKSLASH) { + j += 1 + } + + // End of the comment + else if (peekChar === ASTERISK && input.charCodeAt(j + 1) === SLASH) { + i = j + 1 + break + } + } + + let commentString = input.slice(start, i + 1) + + // Collect all license comments so that we can hoist them to the top of + // the AST. + if (commentString.charCodeAt(2) === EXCLAMATION_MARK) { + let node = comment(commentString.slice(2, -2)) + licenseComments.push(node) + + if (source) { + node.src = [source, start, i + 1] + node.dst = [source, start, i + 1] + } + } + } + + // Start of a string. + else if (currentChar === SINGLE_QUOTE || currentChar === DOUBLE_QUOTE) { + let end = parseString(input, i, currentChar) + + // Adjust `buffer` to include the string. + buffer += input.slice(i, end + 1) + i = end + } + + // Skip whitespace if the next character is also whitespace. This allows us + // to reduce the amount of whitespace in the AST. + else if ( + (currentChar === SPACE || currentChar === LINE_BREAK || currentChar === TAB) && + (peekChar = input.charCodeAt(i + 1)) && + (peekChar === SPACE || + peekChar === LINE_BREAK || + peekChar === TAB || + (peekChar === CARRIAGE_RETURN && + (peekChar = input.charCodeAt(i + 2)) && + peekChar == LINE_BREAK)) + ) { + continue + } + + // Replace new lines with spaces. + else if (currentChar === LINE_BREAK) { + if (buffer.length === 0) continue + + peekChar = buffer.charCodeAt(buffer.length - 1) + if (peekChar !== SPACE && peekChar !== LINE_BREAK && peekChar !== TAB) { + buffer += ' ' + } + } + + // Start of a custom property. + // + // Custom properties are very permissive and can contain almost any + // character, even `;` and `}`. Therefore we have to make sure that we are + // at the correct "end" of the custom property by making sure everything is + // balanced. + else if (currentChar === DASH && input.charCodeAt(i + 1) === DASH && buffer.length === 0) { + let closingBracketStack = '' + + let start = i + let colonIdx = -1 + + for (let j = i + 2; j < input.length; j++) { + peekChar = input.charCodeAt(j) + + // Current character is a `\` therefore the next character is escaped. + if (peekChar === BACKSLASH) { + j += 1 + } + + // Start of a string. + else if (peekChar === SINGLE_QUOTE || peekChar === DOUBLE_QUOTE) { + j = parseString(input, j, peekChar) + } + + // Start of a comment. + else if (peekChar === SLASH && input.charCodeAt(j + 1) === ASTERISK) { + for (let k = j + 2; k < input.length; k++) { + peekChar = input.charCodeAt(k) + // Current character is a `\` therefore the next character is escaped. + if (peekChar === BACKSLASH) { + k += 1 + } + + // End of the comment + else if (peekChar === ASTERISK && input.charCodeAt(k + 1) === SLASH) { + j = k + 1 + break + } + } + } + + // End of the "property" of the property-value pair. + else if (colonIdx === -1 && peekChar === COLON) { + colonIdx = buffer.length + j - start + } + + // End of the custom property. + else if (peekChar === SEMICOLON && closingBracketStack.length === 0) { + buffer += input.slice(start, j) + i = j + break + } + + // Start of a block. + else if (peekChar === OPEN_PAREN) { + closingBracketStack += ')' + } else if (peekChar === OPEN_BRACKET) { + closingBracketStack += ']' + } else if (peekChar === OPEN_CURLY) { + closingBracketStack += '}' + } + + // End of the custom property if didn't use a `;` to end the custom + // property. + // + // E.g.: + // + // ```css + // .foo { + // --custom: value + // ^ + // } + // ``` + else if ( + (peekChar === CLOSE_CURLY || input.length - 1 === j) && + closingBracketStack.length === 0 + ) { + i = j - 1 + buffer += input.slice(start, j) + break + } + + // End of a block. + else if ( + peekChar === CLOSE_PAREN || + peekChar === CLOSE_BRACKET || + peekChar === CLOSE_CURLY + ) { + if ( + closingBracketStack.length > 0 && + input[j] === closingBracketStack[closingBracketStack.length - 1] + ) { + closingBracketStack = closingBracketStack.slice(0, -1) + } + } + } + + let declaration = parseDeclaration(buffer, colonIdx) + if (!declaration) throw new Error(`Invalid custom property, expected a value`) + + if (source) { + declaration.src = [source, start, i] + declaration.dst = [source, start, i] + } + + if (parent) { + parent.nodes.push(declaration) + } else { + ast.push(declaration) + } + + buffer = '' + } + + // End of a body-less at-rule. + // + // E.g.: + // + // ```css + // @charset "UTF-8"; + // ^ + // ``` + else if (currentChar === SEMICOLON && buffer.charCodeAt(0) === AT_SIGN) { + node = parseAtRule(buffer) + + if (source) { + node.src = [source, bufferStart, i] + node.dst = [source, bufferStart, i] + } + + // At-rule is nested inside of a rule, attach it to the parent. + if (parent) { + parent.nodes.push(node) + } + + // We are the root node which means we are done with the current node. + else { + ast.push(node) + } + + // Reset the state for the next node. + buffer = '' + node = null + } + + // End of a declaration. + // + // E.g.: + // + // ```css + // .foo { + // color: red; + // ^ + // } + // ``` + // + else if ( + currentChar === SEMICOLON && + closingBracketStack[closingBracketStack.length - 1] !== ')' + ) { + let declaration = parseDeclaration(buffer) + if (!declaration) { + if (buffer.length === 0) continue + throw new Error(`Invalid declaration: \`${buffer.trim()}\``) + } + + if (source) { + declaration.src = [source, bufferStart, i] + declaration.dst = [source, bufferStart, i] + } + + if (parent) { + parent.nodes.push(declaration) + } else { + ast.push(declaration) + } + + buffer = '' + } + + // Start of a block. + else if ( + currentChar === OPEN_CURLY && + closingBracketStack[closingBracketStack.length - 1] !== ')' + ) { + closingBracketStack += '}' + + // At this point `buffer` should resemble a selector or an at-rule. + node = rule(buffer.trim()) + + // Track the source location for source maps + if (source) { + node.src = [source, bufferStart, i] + node.dst = [source, bufferStart, i] + } + + // Attach the rule to the parent in case it's nested. + if (parent) { + parent.nodes.push(node) + } + + // Push the parent node to the stack, so that we can go back once the + // nested nodes are done. + stack.push(parent) + + // Make the current node the new parent, so that nested nodes can be + // attached to it. + parent = node + + // Reset the state for the next node. + buffer = '' + node = null + } + + // End of a block. + else if ( + currentChar === CLOSE_CURLY && + closingBracketStack[closingBracketStack.length - 1] !== ')' + ) { + if (closingBracketStack === '') { + throw new Error('Missing opening {') + } + + closingBracketStack = closingBracketStack.slice(0, -1) + + // When we hit a `}` and `buffer` is filled in, then it means that we did + // not complete the previous node yet. This means that we hit a + // declaration without a `;` at the end. + if (buffer.length > 0) { + // This can happen for nested at-rules. + // + // E.g.: + // + // ```css + // @layer foo { + // @tailwind utilities + // ^ + // } + // ``` + if (buffer.charCodeAt(0) === AT_SIGN) { + node = parseAtRule(buffer) + + // Track the source location for source maps + if (source) { + node.src = [source, bufferStart, i] + node.dst = [source, bufferStart, i] + } + + // At-rule is nested inside of a rule, attach it to the parent. + if (parent) { + parent.nodes.push(node) + } + + // We are the root node which means we are done with the current node. + else { + ast.push(node) + } + + // Reset the state for the next node. + buffer = '' + node = null + } + + // But it can also happen for declarations. + // + // E.g.: + // + // ```css + // .foo { + // color: red + // ^ + // } + // ``` + else { + // Split `buffer` into a `property` and a `value`. At this point the + // comments are already removed which means that we don't have to worry + // about `:` inside of comments. + let colonIdx = buffer.indexOf(':') + + // Attach the declaration to the parent. + if (parent) { + let node = parseDeclaration(buffer, colonIdx) + if (!node) throw new Error(`Invalid declaration: \`${buffer.trim()}\``) + + if (source) { + node.src = [source, bufferStart, i] + node.dst = [source, bufferStart, i] + } + + parent.nodes.push(node) + } + } + } + + // We are done with the current node, which means we can go up one level + // in the stack. + let grandParent = stack.pop() ?? null + + // We are the root node which means we are done and continue with the next + // node. + if (grandParent === null && parent) { + ast.push(parent) + } + + // Go up one level in the stack. + parent = grandParent + + // Reset the state for the next node. + buffer = '' + node = null + } + + // `(` + else if (currentChar === OPEN_PAREN) { + closingBracketStack += ')' + buffer += '(' + } + + // `)` + else if (currentChar === CLOSE_PAREN) { + if (closingBracketStack[closingBracketStack.length - 1] !== ')') { + throw new Error('Missing opening (') + } + + closingBracketStack = closingBracketStack.slice(0, -1) + buffer += ')' + } + + // Any other character is part of the current node. + else { + // Skip whitespace at the start of a new node. + if ( + buffer.length === 0 && + (currentChar === SPACE || currentChar === LINE_BREAK || currentChar === TAB) + ) { + continue + } + + if (buffer === '') bufferStart = i + + buffer += String.fromCharCode(currentChar) + } + } + + // If we have a leftover `buffer` that happens to start with an `@` then it + // means that we have an at-rule that is not terminated with a semicolon at + // the end of the input. + if (buffer.charCodeAt(0) === AT_SIGN) { + let node = parseAtRule(buffer) + + // Track the source location for source maps + if (source) { + node.src = [source, bufferStart, input.length] + node.dst = [source, bufferStart, input.length] + } + + ast.push(node) + } + + // When we are done parsing then everything should be balanced. If we still + // have a leftover `parent`, then it means that we have an unterminated block. + if (closingBracketStack.length > 0 && parent) { + if (parent.kind === 'rule') { + throw new Error(`Missing closing } at ${parent.selector}`) + } + if (parent.kind === 'at-rule') { + throw new Error(`Missing closing } at ${parent.name} ${parent.params}`) + } + } + + if (licenseComments.length > 0) { + return (licenseComments as AstNode[]).concat(ast) + } + + return ast +} + +export function parseAtRule(buffer: string, nodes: AstNode[] = []): AtRule { + let name = buffer + let params = '' + + // Assumption: The smallest at-rule in CSS right now is `@page`, this means + // that we can always skip the first 5 characters and start at the + // sixth (at index 5). + // + // There is a chance someone is using a shorter at-rule, in that case we have + // to adjust this number back to 2, e.g.: `@x`. + // + // This issue can only occur if somebody does the following things: + // + // 1. Uses a shorter at-rule than `@page` + // 2. Disables Lightning CSS from `@tailwindcss/postcss` (because Lightning + // CSS doesn't handle custom at-rules properly right now) + // 3. Sandwiches the `@tailwindcss/postcss` plugin between two other plugins + // that can handle the shorter at-rule + // + // Let's use the more common case as the default and we can adjust this + // behavior if necessary. + for (let i = 5 /* '@page'.length */; i < buffer.length; i++) { + let currentChar = buffer.charCodeAt(i) + if (currentChar === SPACE || currentChar === TAB || currentChar === OPEN_PAREN) { + name = buffer.slice(0, i) + params = buffer.slice(i) + break + } + } + + return atRule(name.trim(), params.trim(), nodes) +} + +function parseDeclaration( + buffer: string, + colonIdx: number = buffer.indexOf(':'), +): Declaration | null { + if (colonIdx === -1) return null + let importantIdx = buffer.indexOf('!important', colonIdx + 1) + return decl( + buffer.slice(0, colonIdx).trim(), + buffer.slice(colonIdx + 1, importantIdx === -1 ? buffer.length : importantIdx).trim(), + importantIdx !== -1, + ) +} + +function parseString(input: string, startIdx: number, quoteChar: number): number { + let peekChar: number + + // We need to ensure that the closing quote is the same as the opening + // quote. + // + // E.g.: + // + // ```css + // .foo { + // content: "This is a string with a 'quote' in it"; + // ^ ^ -> These are not the end of the string. + // } + // ``` + for (let i = startIdx + 1; i < input.length; i++) { + peekChar = input.charCodeAt(i) + + // Current character is a `\` therefore the next character is escaped. + if (peekChar === BACKSLASH) { + i += 1 + } + + // End of the string. + else if (peekChar === quoteChar) { + return i + } + + // End of the line without ending the string but with a `;` at the end. + // + // E.g.: + // + // ```css + // .foo { + // content: "This is a string with a; + // ^ Missing " + // } + // ``` + else if ( + peekChar === SEMICOLON && + (input.charCodeAt(i + 1) === LINE_BREAK || + (input.charCodeAt(i + 1) === CARRIAGE_RETURN && input.charCodeAt(i + 2) === LINE_BREAK)) + ) { + throw new Error( + `Unterminated string: ${input.slice(startIdx, i + 1) + String.fromCharCode(quoteChar)}`, + ) + } + + // End of the line without ending the string. + // + // E.g.: + // + // ```css + // .foo { + // content: "This is a string with a + // ^ Missing " + // } + // ``` + else if ( + peekChar === LINE_BREAK || + (peekChar === CARRIAGE_RETURN && input.charCodeAt(i + 1) === LINE_BREAK) + ) { + throw new Error( + `Unterminated string: ${input.slice(startIdx, i) + String.fromCharCode(quoteChar)}`, + ) + } + } + + return startIdx +} diff --git a/packages/tailwindcss-language-service/src/css/source.ts b/packages/tailwindcss-language-service/src/css/source.ts new file mode 100644 index 00000000..c3a4f898 --- /dev/null +++ b/packages/tailwindcss-language-service/src/css/source.ts @@ -0,0 +1,27 @@ +/** + * The source code for one or more nodes in the AST + * + * This generally corresponds to a stylesheet + */ +export interface Source { + /** + * The path to the file that contains the referenced source code + * + * If this references the *output* source code, this is `null`. + */ + file: string | null + + /** + * The referenced source code + */ + code: string +} + +/** + * The file and offsets within it that this node covers + * + * This can represent either: + * - A location in the original CSS which caused this node to be created + * - A location in the output CSS where this node resides + */ +export type SourceLocation = [source: Source, start: number, end: number] diff --git a/packages/tailwindcss-language-service/src/css/to-css.ts b/packages/tailwindcss-language-service/src/css/to-css.ts new file mode 100644 index 00000000..83e94742 --- /dev/null +++ b/packages/tailwindcss-language-service/src/css/to-css.ts @@ -0,0 +1,209 @@ +import { AstNode } from './ast' +import { Source } from './source' + +export function toCss(ast: AstNode[], track?: boolean): string { + let pos = 0 + + let source: Source = { + file: null, + code: '', + } + + function stringify(node: AstNode, depth = 0): string { + let css = '' + let indent = ' '.repeat(depth) + + // Declaration + if (node.kind === 'declaration') { + css += `${indent}${node.property}: ${node.value}${node.important ? ' !important' : ''};\n` + + if (track) { + // indent + pos += indent.length + + // node.property + let start = pos + pos += node.property.length + + // `: ` + pos += 2 + + // node.value + pos += node.value?.length ?? 0 + + // !important + if (node.important) { + pos += 11 + } + + let end = pos + + // `;\n` + pos += 2 + + node.dst = [source, start, end] + } + } + + // Rule + else if (node.kind === 'rule') { + css += `${indent}${node.selector} {\n` + + if (track) { + // indent + pos += indent.length + + // node.selector + let start = pos + pos += node.selector.length + + // ` ` + pos += 1 + + let end = pos + node.dst = [source, start, end] + + // `{\n` + pos += 2 + } + + for (let child of node.nodes) { + css += stringify(child, depth + 1) + } + + css += `${indent}}\n` + + if (track) { + // indent + pos += indent.length + + // `}\n` + pos += 2 + } + } + + // AtRule + else if (node.kind === 'at-rule') { + // Print at-rules without nodes with a `;` instead of an empty block. + // + // E.g.: + // + // ```css + // @layer base, components, utilities; + // ``` + if (node.nodes.length === 0) { + let css = `${indent}${node.name} ${node.params};\n` + + if (track) { + // indent + pos += indent.length + + // node.name + let start = pos + pos += node.name.length + + // ` ` + pos += 1 + + // node.params + pos += node.params.length + let end = pos + + // `;\n` + pos += 2 + + node.dst = [source, start, end] + } + + return css + } + + css += `${indent}${node.name}${node.params ? ` ${node.params} ` : ' '}{\n` + + if (track) { + // indent + pos += indent.length + + // node.name + let start = pos + pos += node.name.length + + if (node.params) { + // ` ` + pos += 1 + + // node.params + pos += node.params.length + } + + // ` ` + pos += 1 + + let end = pos + node.dst = [source, start, end] + + // `{\n` + pos += 2 + } + + for (let child of node.nodes) { + css += stringify(child, depth + 1) + } + + css += `${indent}}\n` + + if (track) { + // indent + pos += indent.length + + // `}\n` + pos += 2 + } + } + + // Comment + else if (node.kind === 'comment') { + css += `${indent}/*${node.value}*/\n` + + if (track) { + // indent + pos += indent.length + + // The comment itself. We do this instead of just the inside because + // it seems more useful to have the entire comment span tracked. + let start = pos + pos += 2 + node.value.length + 2 + let end = pos + + node.dst = [source, start, end] + + // `\n` + pos += 1 + } + } + + // These should've been handled already by `optimizeAst` which + // means we can safely ignore them here. We return an empty string + // immediately to signal that something went wrong. + else if (node.kind === 'context' || node.kind === 'at-root') { + return '' + } + + // Unknown + else { + node satisfies never + } + + return css + } + + let css = '' + + for (let node of ast) { + css += stringify(node, 0) + } + + source.code = css + + return css +} diff --git a/packages/tailwindcss-language-service/src/css/to-postcss-ast.ts b/packages/tailwindcss-language-service/src/css/to-postcss-ast.ts new file mode 100644 index 00000000..3495f323 --- /dev/null +++ b/packages/tailwindcss-language-service/src/css/to-postcss-ast.ts @@ -0,0 +1,123 @@ +import * as postcss from 'postcss' +import { atRule, comment, decl, styleRule, type AstNode } from './ast' +import type { Source, SourceLocation } from './source' +import { DefaultMap } from '../util/default-map' +import { createLineTable, LineTable } from '../util/line-table' + +export function toPostCSSAst(ast: AstNode[], source?: postcss.Source): postcss.Root { + let inputMap = new DefaultMap((src) => { + return new postcss.Input(src.code, { + map: source?.input.map, + from: src.file ?? undefined, + }) + }) + + let lineTables = new DefaultMap((src) => createLineTable(src.code)) + + let root = postcss.root() + + // Trick PostCSS into thinking the indent is 2 spaces, so it uses that + // as the default instead of 4. + root.raws.indent = ' ' + + root.source = source + + function toSource(loc: SourceLocation | undefined): postcss.Source | undefined { + // Use the fallback if this node has no location info in the AST + if (!loc) return undefined + if (!loc[0]) return undefined + + let table = lineTables.get(loc[0]) + let start = table.find(loc[1]) + let end = table.find(loc[2]) + + return { + input: inputMap.get(loc[0]), + start: { + line: start.line, + column: start.column + 1, + offset: loc[1], + }, + end: { + line: end.line, + column: end.column + 1, + offset: loc[2], + }, + } + } + + function updateSource(astNode: postcss.ChildNode, loc: SourceLocation | undefined) { + let source = toSource(loc) + + // The `source` property on PostCSS nodes must be defined if present because + // `toJSON()` reads each property and tries to read from source.input if it + // sees a `source` property. This means for a missing or otherwise absent + // source it must be *missing* from the object rather than just `undefined` + if (source) { + astNode.source = source + } else { + delete astNode.source + } + } + + function transform(node: AstNode, parent: postcss.Container) { + // Declaration + if (node.kind === 'declaration') { + let astNode = postcss.decl({ + prop: node.property, + value: node.value ?? '', + important: node.important, + }) + updateSource(astNode, node.src) + parent.append(astNode) + } + + // Rule + else if (node.kind === 'rule') { + let astNode = postcss.rule({ selector: node.selector }) + updateSource(astNode, node.src) + astNode.raws.semicolon = true + parent.append(astNode) + for (let child of node.nodes) { + transform(child, astNode) + } + } + + // AtRule + else if (node.kind === 'at-rule') { + let astNode = postcss.atRule({ name: node.name.slice(1), params: node.params }) + updateSource(astNode, node.src) + astNode.raws.semicolon = true + parent.append(astNode) + for (let child of node.nodes) { + transform(child, astNode) + } + } + + // Comment + else if (node.kind === 'comment') { + let astNode = postcss.comment({ text: node.value }) + // Spaces are encoded in our node.value already, no need to add additional + // spaces. + astNode.raws.left = '' + astNode.raws.right = '' + updateSource(astNode, node.src) + parent.append(astNode) + } + + // AtRoot & Context should not happen + else if (node.kind === 'at-root' || node.kind === 'context') { + } + + // Unknown + else { + node satisfies never + } + } + + for (let node of ast) { + transform(node, root) + } + + return root +} diff --git a/packages/tailwindcss-language-service/src/util/default-map.ts b/packages/tailwindcss-language-service/src/util/default-map.ts new file mode 100644 index 00000000..a045b828 --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/default-map.ts @@ -0,0 +1,20 @@ +/** + * A Map that can generate default values for keys that don't exist. + * Generated default values are added to the map to avoid recomputation. + */ +export class DefaultMap extends Map { + constructor(private factory: (key: T, self: DefaultMap) => V) { + super() + } + + get(key: T): V { + let value = super.get(key) + + if (value === undefined) { + value = this.factory(key, this) + this.set(key, value) + } + + return value + } +} diff --git a/packages/tailwindcss-language-service/src/util/line-table.ts b/packages/tailwindcss-language-service/src/util/line-table.ts new file mode 100644 index 00000000..e1605176 --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/line-table.ts @@ -0,0 +1,100 @@ +/** + * Line offset tables are the key to generating our source maps. They allow us + * to store indexes with our AST nodes and later convert them into positions as + * when given the source that the indexes refer to. + */ + +const LINE_BREAK = 0x0a + +/** + * A position in source code + * + * https://tc39.es/ecma426/#sec-position-record-type + */ +export interface Position { + /** The line number, one-based */ + line: number + + /** The column/character number, one-based */ + column: number +} + +/** + * A table that lets you turn an offset into a line number and column + */ +export interface LineTable { + /** + * Find the line/column position in the source code for a given offset + * + * Searching for a given offset takes O(log N) time where N is the number of + * lines of code. + * + * @param offset The index for which to find the position + */ + find(offset: number): Position + + /** + * Find the most likely byte offset for given a position + * + * @param offset The position for which to find the byte offset + */ + findOffset(pos: Position): number +} + +/** + * Compute a lookup table to allow for efficient line/column lookups based on + * offsets in the source code. + * + * Creating this table is an O(N) operation where N is the length of the source + */ +export function createLineTable(source: string): LineTable { + let table: number[] = [0] + + // Compute the offsets for the start of each line + for (let i = 0; i < source.length; i++) { + if (source.charCodeAt(i) === LINE_BREAK) { + table.push(i + 1) + } + } + + function find(offset: number) { + // Based on esbuild's binary search for line numbers + let line = 0 + let count = table.length + while (count > 0) { + // `| 0` improves performance (in V8 at least) + let mid = (count | 0) >> 1 + let i = line + mid + if (table[i] <= offset) { + line = i + 1 + count = count - mid - 1 + } else { + count = mid + } + } + + line -= 1 + + let column = offset - table[line] + + return { + line: line + 1, + column: column, + } + } + + function findOffset({ line, column }: Position) { + line -= 1 + line = Math.min(Math.max(line, 0), table.length - 1) + + let offsetA = table[line] + let offsetB = table[line + 1] ?? offsetA + + return Math.min(Math.max(offsetA + column, 0), offsetB) + } + + return { + find, + findOffset, + } +} diff --git a/packages/tailwindcss-language-service/src/util/v4/ast.ts b/packages/tailwindcss-language-service/src/util/v4/ast.ts deleted file mode 100644 index a60d7934..00000000 --- a/packages/tailwindcss-language-service/src/util/v4/ast.ts +++ /dev/null @@ -1,33 +0,0 @@ -export type Rule = { - kind: 'rule' - selector: string - nodes: AstNode[] -} - -export type Declaration = { - kind: 'declaration' - property: string - value: string - important: boolean -} - -export type Comment = { - kind: 'comment' - value: string -} - -export type AstNode = Rule | Declaration | Comment - -export function visit( - nodes: AstNode[], - cb: (node: AstNode, path: AstNode[]) => void, - path: AstNode[] = [], -): void { - for (let child of nodes) { - path = [...path, child] - cb(child, path) - if (child.kind === 'rule') { - visit(child.nodes, cb, path) - } - } -} diff --git a/packages/tailwindcss-language-service/src/util/v4/design-system.ts b/packages/tailwindcss-language-service/src/util/v4/design-system.ts index d4006046..75210ccf 100644 --- a/packages/tailwindcss-language-service/src/util/v4/design-system.ts +++ b/packages/tailwindcss-language-service/src/util/v4/design-system.ts @@ -1,6 +1,6 @@ import postcss from 'postcss' -import type { Rule } from './ast' import type { NamedVariant } from './candidate' +import type { Rule } from '../../css' export interface Theme { // Prefix didn't exist for earlier Tailwind versions diff --git a/packages/tailwindcss-language-service/src/util/v4/index.ts b/packages/tailwindcss-language-service/src/util/v4/index.ts index 18906ab6..4787a0c1 100644 --- a/packages/tailwindcss-language-service/src/util/v4/index.ts +++ b/packages/tailwindcss-language-service/src/util/v4/index.ts @@ -1,3 +1,2 @@ -export * from './ast' export * from './candidate' export * from './design-system' diff --git a/packages/tailwindcss-language-service/src/util/walk.ts b/packages/tailwindcss-language-service/src/util/walk.ts new file mode 100644 index 00000000..f09f1705 --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/walk.ts @@ -0,0 +1,190 @@ +const enum WalkKind { + Continue, + Skip, + Stop, + Replace, + ReplaceSkip, + ReplaceStop, +} + +type WalkActionResult = { + Continue: { kind: WalkKind.Continue } + Skip: { kind: WalkKind.Skip } + Stop: { kind: WalkKind.Stop } + Replace: (nodes: T | T[]) => { kind: WalkKind.Replace; nodes: T[] } + ReplaceSkip: (nodes: T | T[]) => { kind: WalkKind.ReplaceSkip; nodes: T[] } + ReplaceStop: (nodes: T | T[]) => { kind: WalkKind.ReplaceStop; nodes: T[] } +} + +export const WalkAction: WalkActionResult = { + Continue: { kind: WalkKind.Continue } as const, + Skip: { kind: WalkKind.Skip } as const, + Stop: { kind: WalkKind.Stop } as const, + Replace: (nodes: T | T[]) => + ({ kind: WalkKind.Replace, nodes: Array.isArray(nodes) ? nodes : [nodes] }) as const, + ReplaceSkip: (nodes: T | T[]) => + ({ kind: WalkKind.ReplaceSkip, nodes: Array.isArray(nodes) ? nodes : [nodes] }) as const, + ReplaceStop: (nodes: T | T[]) => + ({ kind: WalkKind.ReplaceStop, nodes: Array.isArray(nodes) ? nodes : [nodes] }) as const, +} as const + +type WalkAction = typeof WalkAction +type WalkResult = + | WalkAction['Continue'] + | WalkAction['Skip'] + | WalkAction['Stop'] + | ReturnType> + | ReturnType> + | ReturnType> + +type EnterResult = WalkResult +type ExitResult = Exclude, { kind: WalkKind.Skip }> + +type Parent = T & { nodes: T[] } + +export interface VisitContext { + parent: Parent | null + depth: number + path: () => T[] +} + +export function walk( + ast: T[], + hooks: + | ((node: T, ctx: VisitContext) => EnterResult) // Old API, enter only + | { + enter?: (node: T, ctx: VisitContext) => EnterResult + exit?: (node: T, ctx: VisitContext) => ExitResult + }, +): void { + if (typeof hooks === 'function') walkImplementation(ast, hooks) + else walkImplementation(ast, hooks.enter, hooks.exit) +} + +function walkImplementation( + ast: T[], + enter: (node: T, ctx: VisitContext) => EnterResult = () => WalkAction.Continue, + exit: (node: T, ctx: VisitContext) => ExitResult = () => WalkAction.Continue, +) { + let stack: [nodes: T[], offset: number, parent: Parent | null][] = [[ast, 0, null]] + let ctx: VisitContext = { + parent: null, + depth: 0, + path() { + let path: T[] = [] + + for (let i = 1; i < stack.length; i++) { + let parent = stack[i][2] + if (parent) path.push(parent) + } + + return path + }, + } + + while (stack.length > 0) { + let depth = stack.length - 1 + let frame = stack[depth] + let nodes = frame[0] + let offset = frame[1] + let parent = frame[2] + + // Done with this level + if (offset >= nodes.length) { + stack.pop() + continue + } + + ctx.parent = parent + ctx.depth = depth + + // Enter phase (offsets are positive) + if (offset >= 0) { + let node = nodes[offset] + let result = enter(node, ctx) + + switch (result.kind) { + case WalkKind.Continue: { + if (node.nodes && node.nodes.length > 0) { + stack.push([node.nodes, 0, node as Parent]) + } + + frame[1] = ~offset // Prepare for exit phase, same offset + continue + } + + case WalkKind.Stop: + return // Stop immediately + + case WalkKind.Skip: { + frame[1] = ~offset // Prepare for exit phase, same offset + continue + } + + case WalkKind.Replace: { + nodes.splice(offset, 1, ...result.nodes) + continue // Re-process at same offset + } + + case WalkKind.ReplaceStop: { + nodes.splice(offset, 1, ...result.nodes) + return // Stop immediately + } + + case WalkKind.ReplaceSkip: { + nodes.splice(offset, 1, ...result.nodes) + frame[1] += result.nodes.length // Advance to next sibling past replacements + continue + } + + default: { + result satisfies never + throw new Error( + // @ts-expect-error enterResult.kind may be invalid + `Invalid \`WalkAction.${WalkKind[result.kind] ?? `Unknown(${result.kind})`}\` in enter.`, + ) + } + } + } + + // Exit phase for nodes[~offset] + let index = ~offset // Two's complement to get original offset + let node = nodes[index] + + let result = exit(node, ctx) + + switch (result.kind) { + case WalkKind.Continue: + frame[1] = index + 1 // Advance to next sibling + continue + + case WalkKind.Stop: + return // Stop immediately + + case WalkKind.Replace: { + nodes.splice(index, 1, ...result.nodes) + frame[1] = index + result.nodes.length // Advance to next sibling past replacements + continue + } + + case WalkKind.ReplaceStop: { + nodes.splice(index, 1, ...result.nodes) + return // Stop immediately + } + + case WalkKind.ReplaceSkip: { + nodes.splice(index, 1, ...result.nodes) + frame[1] = index + result.nodes.length // Advance to next sibling past replacements + continue + } + + default: { + result satisfies never + throw new Error( + // @ts-expect-error `result.kind` could still be filled with an invalid value + `Invalid \`WalkAction.${WalkKind[result.kind] ?? `Unknown(${result.kind})`}\` in exit.`, + ) + } + } + } +} From a158e96e1fbb927f0540acc64f8a43182182cdc0 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 3 Dec 2025 20:35:55 -0500 Subject: [PATCH 05/12] Use internal CSS representation in various places MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A few APIs are shared across versions and rely on PostCSS nodes. We’ll pass in PostCSS ASTs to those APIs by translating our AST to PostCSS’s AST. This new setup is also intended to remove AST manipulation wherever possible. We build data structures and can skip over ignored nodes when walking. The eventual goal is to reduce memory usage by using nodes returned by Tailwind CSS’s internal cache. --- .../src/util/v4/design-system.ts | 25 ++--- .../src/completionProvider.ts | 91 ++++++++++--------- .../diagnostics/getCssConflictDiagnostics.ts | 39 +++----- .../getInvalidConfigPathDiagnostics.ts | 12 ++- .../src/hoverProvider.ts | 8 +- .../src/util/color.ts | 55 +++++------ .../src/util/v4/design-system.ts | 2 +- 7 files changed, 115 insertions(+), 117 deletions(-) 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 460110a2..e06de450 100644 --- a/packages/tailwindcss-language-server/src/util/v4/design-system.ts +++ b/packages/tailwindcss-language-server/src/util/v4/design-system.ts @@ -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' @@ -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*\{/ @@ -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 + let cache = design.storage[COMPILE_CACHE] as Record 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 } } @@ -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 diff --git a/packages/tailwindcss-language-service/src/completionProvider.ts b/packages/tailwindcss-language-service/src/completionProvider.ts index f64eb2fb..d2860a27 100644 --- a/packages/tailwindcss-language-service/src/completionProvider.ts +++ b/packages/tailwindcss-language-service/src/completionProvider.ts @@ -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) @@ -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)', @@ -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 } @@ -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` } @@ -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'), } diff --git a/packages/tailwindcss-language-service/src/diagnostics/getCssConflictDiagnostics.ts b/packages/tailwindcss-language-service/src/diagnostics/getCssConflictDiagnostics.ts index 09317bf6..065de72c 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/getCssConflictDiagnostics.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/getCssConflictDiagnostics.ts @@ -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('--') @@ -238,20 +239,6 @@ interface RuleEntry { type ClassDetails = Record -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 = {} @@ -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 }) } diff --git a/packages/tailwindcss-language-service/src/diagnostics/getInvalidConfigPathDiagnostics.ts b/packages/tailwindcss-language-service/src/diagnostics/getInvalidConfigPathDiagnostics.ts index 1d36d4b6..c3b87f10 100644 --- a/packages/tailwindcss-language-service/src/diagnostics/getInvalidConfigPathDiagnostics.ts +++ b/packages/tailwindcss-language-service/src/diagnostics/getInvalidConfigPathDiagnostics.ts @@ -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 } @@ -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 diff --git a/packages/tailwindcss-language-service/src/hoverProvider.ts b/packages/tailwindcss-language-service/src/hoverProvider.ts index 583cc80f..1b42e0e0 100644 --- a/packages/tailwindcss-language-service/src/hoverProvider.ts +++ b/packages/tailwindcss-language-service/src/hoverProvider.ts @@ -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, @@ -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, } diff --git a/packages/tailwindcss-language-service/src/util/color.ts b/packages/tailwindcss-language-service/src/util/color.ts index 19fb9a42..5dcced66 100644 --- a/packages/tailwindcss-language-service/src/util/color.ts +++ b/packages/tailwindcss-language-service/src/util/color.ts @@ -7,8 +7,9 @@ import { getClassNameParts } from './getClassNameAtPosition' import * as jit from './jit' import * as culori from 'culori' import namedColors from 'color-name' -import postcss from 'postcss' import { replaceCssVarsWithFallbacks } from './rewriting' +import { AstNode } from '../css' +import { walk, WalkAction } from './walk' const COLOR_PROPS = [ 'accent-color', @@ -158,36 +159,38 @@ function getColorFromDecls( return null } -function getColorFromRoot(state: State, css: postcss.Root): culori.Color | KeywordColor | null { - // Remove any `@property` rules - css = css.clone() - css.walkAtRules((rule) => { - // Ignore declarations inside `@property` rules - if (rule.name === 'property') { - rule.remove() - } +function getColorFromRoot(state: State, css: AstNode[]): culori.Color | KeywordColor | null { + let decls: Record = {} - // 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() - } - }) + walk(css, (node) => { + if (node.kind === 'at-rule') { + // Skip over any `@property` rules + if (node.name === '@property') { + return WalkAction.Skip + } - let decls: Record = {} + // Ignore @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 + } - let rule = postcss.rule({ - selector: '.x', - nodes: [], - }) + if ( + node.name === '@supports' && + node.params === '(background-image: linear-gradient(in lab, red, red))' + ) { + return WalkAction.Skip + } - css.walkDecls((decl) => { - rule.append(decl.clone()) - }) + return WalkAction.Continue + } + + if (node.kind === 'declaration' && node.value !== undefined) { + decls[node.property] ??= [] + decls[node.property].push(node.value) + } - css.walkDecls((decl) => { - decls[decl.prop] ??= [] - decls[decl.prop].push(decl.value) + return WalkAction.Continue }) return getColorFromDecls(state, decls) diff --git a/packages/tailwindcss-language-service/src/util/v4/design-system.ts b/packages/tailwindcss-language-service/src/util/v4/design-system.ts index 75210ccf..bc3cc544 100644 --- a/packages/tailwindcss-language-service/src/util/v4/design-system.ts +++ b/packages/tailwindcss-language-service/src/util/v4/design-system.ts @@ -58,5 +58,5 @@ export interface DesignSystem { export interface DesignSystem { dependencies(): Set - compile(classes: string[]): (postcss.Root | null)[] + compile(classes: string[]): AstNode[][] } From 866512c443d8558c09daaf265c9723860abfc177 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 3 Dec 2025 20:35:52 -0500 Subject: [PATCH 06/12] Use `candidatesToAst` when supported --- .../src/util/v4/design-system.ts | 9 ++++++++- .../src/util/v4/design-system.ts | 5 ++++- 2 files changed, 12 insertions(+), 2 deletions(-) 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 e06de450..7731e4e4 100644 --- a/packages/tailwindcss-language-server/src/util/v4/design-system.ts +++ b/packages/tailwindcss-language-server/src/util/v4/design-system.ts @@ -230,13 +230,20 @@ export async function loadDesignSystem( let cache = design.storage[COMPILE_CACHE] as Record 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] = [] continue diff --git a/packages/tailwindcss-language-service/src/util/v4/design-system.ts b/packages/tailwindcss-language-service/src/util/v4/design-system.ts index bc3cc544..6fb62b43 100644 --- a/packages/tailwindcss-language-service/src/util/v4/design-system.ts +++ b/packages/tailwindcss-language-service/src/util/v4/design-system.ts @@ -1,6 +1,6 @@ import postcss from 'postcss' import type { NamedVariant } from './candidate' -import type { Rule } from '../../css' +import type { AstNode, Rule } from '../../css' export interface Theme { // Prefix didn't exist for earlier Tailwind versions @@ -54,6 +54,9 @@ export interface DesignSystem { // Added in v4.1.16 // We can patch it into any design system if it doesn't exist though storage?: Record + + // Added in v4.1.18 + candidatesToAst?(classes: string[]): AstNode[][] } export interface DesignSystem { From ed39ccf6016382317813cd6a8a62cc745fb8c4c6 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 5 Dec 2025 12:27:17 -0500 Subject: [PATCH 07/12] Cleanup code a bit --- .../src/util/color.ts | 35 +++++++------------ 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/packages/tailwindcss-language-service/src/util/color.ts b/packages/tailwindcss-language-service/src/util/color.ts index 5dcced66..75942f03 100644 --- a/packages/tailwindcss-language-service/src/util/color.ts +++ b/packages/tailwindcss-language-service/src/util/color.ts @@ -39,13 +39,12 @@ export type KeywordColor = 'transparent' | 'currentColor' function getKeywordColor(value: unknown): KeywordColor | null { if (typeof value !== 'string') return null - let lowercased = value.toLowerCase() - if (lowercased === 'transparent') { - return 'transparent' - } - if (lowercased === 'currentcolor') { - return 'currentColor' - } + + value = value.toLowerCase() + + if (value === 'transparent') return 'transparent' + if (value === 'currentcolor') return 'currentColor' + return null } @@ -122,11 +121,6 @@ function getColorFromDecls( ensureArray(decls[prop]).flatMap((str) => getColorsInString(state, str)), ) - // check that all of the values are valid colors - // if (colors.some((color) => color instanceof TinyColor && !color.isValid)) { - // return null - // } - // check that all of the values are the same color, ignoring alpha const colorStrings = dedupe( colors.map((color) => @@ -138,9 +132,7 @@ function getColorFromDecls( } let keyword = getKeywordColor(colorStrings[0]) - if (keyword) { - return keyword - } + if (keyword) return keyword const nonKeywordColors = colors.filter( (color): color is culori.Color => typeof color !== 'string', @@ -216,7 +208,6 @@ export function getColor(state: State, className: string): culori.Color | Keywor if (isLikelyColorless(className)) return null let css = state.designSystem.compile([className])[0] - let color = getColorFromRoot(state, css) let prefix = state.designSystem.theme.prefix ?? '' @@ -279,13 +270,11 @@ export function getColor(state: State, className: string): culori.Color | Keywor export function getColorFromValue(value: unknown): culori.Color | KeywordColor | null { if (typeof value !== 'string') return null - const trimmedValue = value.trim() - if (trimmedValue.toLowerCase() === 'transparent') { - return 'transparent' - } - if (trimmedValue.toLowerCase() === 'currentcolor') { - return 'currentColor' - } + + let trimmedValue = value.trim() + let keyword = getKeywordColor(trimmedValue) + if (keyword) return keyword + if ( !/^\s*(?:rgba?|hsla?|(?:ok)?(?:lab|lch))\s*\([^)]+\)\s*$/.test(trimmedValue) && !/^\s*#[0-9a-f]+\s*$/i.test(trimmedValue) && From d14f5b774c99d78d5ed751e91bcda50d7643a5f3 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 5 Dec 2025 12:26:40 -0500 Subject: [PATCH 08/12] Tweak types --- .../tailwindcss-language-service/src/util/color.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/tailwindcss-language-service/src/util/color.ts b/packages/tailwindcss-language-service/src/util/color.ts index 75942f03..d0a973fb 100644 --- a/packages/tailwindcss-language-service/src/util/color.ts +++ b/packages/tailwindcss-language-service/src/util/color.ts @@ -36,6 +36,7 @@ const COLOR_PROPS = [ ] export type KeywordColor = 'transparent' | 'currentColor' +export type ParsedColor = KeywordColor | culori.Color function getKeywordColor(value: unknown): KeywordColor | null { if (typeof value !== 'string') return null @@ -56,7 +57,7 @@ const colorRegex = new RegExp( 'gi', ) -function getColorsInString(state: State, str: string): (culori.Color | KeywordColor)[] { +function getColorsInString(state: State, str: string): ParsedColor[] { if (/(?:box|drop)-shadow/.test(str) && !/--tw-drop-shadow/.test(str)) return [] function toColor(match: RegExpMatchArray) { @@ -76,7 +77,7 @@ function getColorsInString(state: State, str: string): (culori.Color | KeywordCo function getColorFromDecls( state: State, decls: Record, -): culori.Color | KeywordColor | null { +): ParsedColor | null { let props = Object.keys(decls).filter((prop) => { // ignore content: ""; if (prop === 'content') { @@ -151,7 +152,7 @@ function getColorFromDecls( return null } -function getColorFromRoot(state: State, css: AstNode[]): culori.Color | KeywordColor | null { +function getColorFromRoot(state: State, css: AstNode[]): ParsedColor | null { let decls: Record = {} walk(css, (node) => { @@ -202,7 +203,7 @@ function isLikelyColorless(className: string) { return false } -export function getColor(state: State, className: string): culori.Color | KeywordColor | null { +export function getColor(state: State, className: string): ParsedColor | null { if (state.v4) { // FIXME: This is a performance optimization and not strictly correct if (isLikelyColorless(className)) return null @@ -268,7 +269,7 @@ export function getColor(state: State, className: string): culori.Color | Keywor return getColorFromDecls(state, removeMeta(item)) } -export function getColorFromValue(value: unknown): culori.Color | KeywordColor | null { +export function getColorFromValue(value: unknown): ParsedColor | null { if (typeof value !== 'string') return null let trimmedValue = value.trim() From 0296221ba49d3a4400c302dc6e2adfc21e11558a Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 5 Dec 2025 12:27:41 -0500 Subject: [PATCH 09/12] Tweak numeric utility regex --- packages/tailwindcss-language-service/src/util/color.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwindcss-language-service/src/util/color.ts b/packages/tailwindcss-language-service/src/util/color.ts index d0a973fb..03af0bc4 100644 --- a/packages/tailwindcss-language-service/src/util/color.ts +++ b/packages/tailwindcss-language-service/src/util/color.ts @@ -191,7 +191,7 @@ function getColorFromRoot(state: State, css: AstNode[]): ParsedColor | null { let isNegative = /^-/ let isNumericUtility = - /^-?((min-|max-)?[wh]|z|start|order|opacity|rounded|row|col|size|basis|end|duration|ease|font|top|left|bottom|right|inset|leading|cursor|(space|scale|skew|rotate)-[xyz]|gap(-[xy])?|(scroll-)?[pm][trblxyse]?)-/ + /^-?((min-|max-)?[wh]|z|start|indent|flex|columns|order|rounded|row|col|size|basis|end|delay|duration|ease|font|top|left|bottom|right|leading|cursor|(backdrop-)?(opacity|brightness|sepia|saturate|hue-rotate|grayscale|contrast|blur)|(space|scale|skew|rotate|translate|border-spacing|gap)(-[xyz])?|(scroll-)?[pm][trblxyse]?)-/ let isMaskUtility = /^-?mask-/ function isLikelyColorless(className: string) { From c9086eb221ab70640fbdc59eeb40cf454e5125bb Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 8 Dec 2025 10:49:13 -0500 Subject: [PATCH 10/12] Cleanup --- packages/tailwindcss-language-service/src/util/color.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss-language-service/src/util/color.ts b/packages/tailwindcss-language-service/src/util/color.ts index 03af0bc4..db1f68f8 100644 --- a/packages/tailwindcss-language-service/src/util/color.ts +++ b/packages/tailwindcss-language-service/src/util/color.ts @@ -196,10 +196,12 @@ let isMaskUtility = /^-?mask-/ function isLikelyColorless(className: string) { if (isNegative.test(className)) return true + if (isNumericUtility.test(className)) return true + // TODO: This is **not** correct but is intentional because there are 5k mask utilities and a LOT of them are colors // This causes a massive slowdown when building the design system if (isMaskUtility.test(className)) return true - if (isNumericUtility.test(className)) return true + return false } From cb3a7b99a11db5f319dca8f5308b67c3c12891ba Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Fri, 5 Dec 2025 12:28:02 -0500 Subject: [PATCH 11/12] Improve color detection performance --- .../src/util/color.test.ts | 82 ++++++ .../src/util/color.ts | 241 ++++++++++++++++-- 2 files changed, 307 insertions(+), 16 deletions(-) create mode 100644 packages/tailwindcss-language-service/src/util/color.test.ts diff --git a/packages/tailwindcss-language-service/src/util/color.test.ts b/packages/tailwindcss-language-service/src/util/color.test.ts new file mode 100644 index 00000000..b6cb3814 --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/color.test.ts @@ -0,0 +1,82 @@ +import { test, expect } from 'vitest' +import namedColors from 'color-name' +import { findColors } from './color' + +let table: string[] = [] + +// 1. Named colors +table.push(...Object.keys(namedColors)) + +// We don't show swatches for transparent colors so we don't need to detect it +// table.push('transparent') + +// 2. Hex +table.push('#639') +table.push('#0000') +table.push('#7f7f7f') +table.push('#7f7f7f7f') + +// 3. Legacy color syntax +for (let fn of ['rgb', 'hsl']) { + table.push(`${fn}(0, 0, 0)`) + table.push(`${fn}(127, 127, 127)`) + + table.push(`${fn}a(0, 0, 0, 0)`) + table.push(`${fn}a(127, 127, 127, .5)`) + table.push(`${fn}a(127, 127, 127, 0.5)`) +} + +// 4. Modern color syntax +let numeric = ['0', '0.0', '0.3', '1.0', '50%', '1deg', '1grad', '1turn'] +let alphas = ['0', '0.0', '0.3', '1.0'] + +let fields = [...numeric.flatMap((field) => [field, `-${field}`]), 'var(--foo)'] + +for (let fn of ['rgb', 'hsl', 'lab', 'lch', 'oklab', 'oklch']) { + for (let field of fields) { + table.push(`${fn}(${field} ${field} ${field})`) + + for (let alpha of alphas) { + table.push(`${fn}(${field} ${field} ${field} / ${alpha})`) + } + } +} + +// https://github.com/khalilgharbaoui/coloregex +const COLOR_REGEX = new RegExp( + `(?<=^|[\\s(,])(#(?:[0-9a-f]{3,4}|[0-9a-f]{6,8})|(?:rgba?|hsla?|(?:ok)?(?:lab|lch))\\(\\s*(?:(?:-?[\\d.]+(?:%|deg|g?rad|turn)?|var\\([^)]+\\))(\\s*[,/]\\s*|\\s+)+){2,3}\\s*(?:-?[\\d.]+(?:%|deg|g?rad|turn)?|var\\([^)]+\\))?\\)|transparent|${Object.keys( + namedColors, + ).join('|')})(?=$|[\\s),])`, + 'gi', +) + +function findColorsRegex(str: string): string[] { + let matches = str.matchAll(COLOR_REGEX) + return Array.from(matches, (match) => match[1]) +} + +let boundaries = ['', ' ', '(', ','] + +test.for(table)('finds color: $0', (color) => { + for (let start of boundaries) { + for (let end of boundaries) { + if (end === '(') end = ')' + + expect(findColors(`${start}${color}${end}`)).toEqual([color]) + expect(findColorsRegex(`${start}${color}${end}`)).toEqual([color]) + } + } + + expect(findColors(`var(--foo, ${color})`)).toEqual([color]) + expect(findColorsRegex(`var(--foo, ${color})`)).toEqual([color]) +}) + +test('invalid named', () => { + expect(findColors(`blackz`)).toEqual([]) + expect(findColorsRegex(`blackz`)).toEqual([]) +}) + +test('invalid hex', () => { + expect(findColors(`#7f7f7fz`)).toEqual([]) + expect(findColorsRegex(`#7f7f7fz`)).toEqual([]) +}) diff --git a/packages/tailwindcss-language-service/src/util/color.ts b/packages/tailwindcss-language-service/src/util/color.ts index db1f68f8..93a26e12 100644 --- a/packages/tailwindcss-language-service/src/util/color.ts +++ b/packages/tailwindcss-language-service/src/util/color.ts @@ -49,29 +49,14 @@ function getKeywordColor(value: unknown): KeywordColor | null { return null } -// https://github.com/khalilgharbaoui/coloregex -const colorRegex = new RegExp( - `(?:^|\\s|\\(|,)(#(?:[0-9a-f]{2}){2,4}|(#[0-9a-f]{3})|(rgba?|hsla?|(?:ok)?(?:lab|lch))\\(\\s*(-?[\\d.]+(%|deg|rad|grad|turn)?(\\s*[,/]\\s*|\\s+)+){2,3}\\s*([\\d.]+(%|deg|rad|grad|turn)?|var\\([^)]+\\))?\\)|transparent|currentColor|${Object.keys( - namedColors, - ).join('|')})(?:$|\\s|\\)|,)`, - 'gi', -) - function getColorsInString(state: State, str: string): ParsedColor[] { if (/(?:box|drop)-shadow/.test(str) && !/--tw-drop-shadow/.test(str)) return [] - function toColor(match: RegExpMatchArray) { - let color = match[1].replace(/var\([^)]+\)/, '1') - return getKeywordColor(color) ?? tryParseColor(color) - } - str = replaceCssVarsWithFallbacks(state, str) str = removeColorMixWherePossible(str) str = resolveLightDark(str) - let possibleColors = str.matchAll(colorRegex) - - return Array.from(possibleColors, toColor).filter(Boolean) + return parseColors(str) } function getColorFromDecls( @@ -335,3 +320,227 @@ const LIGHT_DARK_REGEX = /light-dark\(\s*(.*?)\s*,\s*.*?\s*\)/g function resolveLightDark(str: string) { return str.replace(LIGHT_DARK_REGEX, (_, lightColor) => lightColor) } + +const COLOR_FNS = new Set([ + // + 'rgb', + 'rgba', + 'hwb', + 'hsl', + 'hsla', + 'lab', + 'lch', + 'oklab', + 'oklch', + 'color', +]) + +const COLOR_NAMES = new Set([ + ...Object.keys(namedColors).map((c) => c.toLowerCase()), + 'transparent', + 'currentcolor', +]) + +const CSS_VARS = /var\([^)]+\)/ +const COLOR_FN_ARGS = + /^\s*(?:(?:-?[\d.]+(?:%|deg|g?rad|turn)?|var\([^)]+\))(?:\s*[,/]\s*|\s+)){2,3}(?:-?[\d.]+(?:%|deg|g?rad|turn)?|var\([^)]+\))\s*$/i + +const POUND = 0x23 +const ZERO = 0x30 +const NINE = 0x39 +const DOUBLE_QUOTE = 0x22 +const SINGLE_QUOTE = 0x27 +const BACKSLASH = 0x5c +const LOWER_A = 0x61 +const LOWER_F = 0x66 +const LOWER_Z = 0x7a +const L_PAREN = 0x28 +const R_PAREN = 0x29 +const SPACE = 0x20 +const COMMA = 0x2c +const DASH = 0x2d +const LINE_BREAK = 0x0a +const CARRIAGE_RETURN = 0xd +const TAB = 0x09 + +type Span = [start: number, end: number] + +function maybeFindColors(input: string): Span[] { + let colors: Span[] = [] + let len = input.length + + for (let i = 0; i < len; ++i) { + let char = input.charCodeAt(i) + let inner = char + + if (char >= LOWER_A && char <= LOWER_Z) { + // Read until we don't have a named color character + let start = i + let end = i + + for (let j = start + 1; j < len; j++) { + inner = input.charCodeAt(j) + + if (inner >= ZERO && inner <= NINE) { + end = j // 0-9 + } else if (inner >= LOWER_A && inner <= LOWER_Z) { + end = j // a-z + } else if (inner === DASH) { + end = j // - + } else if (inner === L_PAREN) { + // Start of a function + break + } else if ( + inner === COMMA || + inner === SPACE || + inner === LINE_BREAK || + inner === TAB || + inner === CARRIAGE_RETURN || + inner === R_PAREN + ) { + // (?=$|[\\s),]) + break + } else { + end = i + break + } + } + + let name = input.slice(start, end + 1) + + if (COLOR_NAMES.has(name)) { + i = end + colors.push([start, end + 1]) + continue + } + + if (inner === L_PAREN && COLOR_FNS.has(name)) { + // Scan until the next balanced R_PAREN + let depth = 1 + let argStart = end + 2 + + for (let j = argStart; j < len; ++j) { + inner = input.charCodeAt(j) + + // The next character is escaped, so we skip it. + if (inner === BACKSLASH) { + j += 1 + } + + // Strings should be handled as-is until the end of the string. No need to + // worry about balancing parens, brackets, or curlies inside a string. + else if (inner === SINGLE_QUOTE || inner === DOUBLE_QUOTE) { + // Ensure we don't go out of bounds. + while (++j < len) { + let nextChar = input.charCodeAt(j) + + // The next character is escaped, so we skip it. + if (nextChar === BACKSLASH) { + j += 1 + continue + } + + if (nextChar === char) { + break + } + } + } + + // Track opening parens + else if (inner === L_PAREN) { + depth++ + } + + // Track closing parens + else if (inner === R_PAREN) { + depth-- + } + + if (depth > 0) continue + + let args = input.slice(argStart, j) + + if (!COLOR_FN_ARGS.test(args)) continue + colors.push([start, j + 1]) + i = j + 1 + + break + } + + continue + } + + i = end + } + + // + else if (char === POUND) { + // Read until we don't have a named color character + let start = i + let end = i + + // i + 1 = first hex digit + // i + 1 + 8 = one past the last hex digit + let last = Math.min(start + 1 + 8, len) + + for (let j = start + 1; j < last; j++) { + let inner = input.charCodeAt(j) + + if (inner >= ZERO && inner <= NINE) { + end = j // 0-9 + } else if (inner >= LOWER_A && inner <= LOWER_F) { + end = j // a-f + } else if ( + inner === COMMA || + inner === SPACE || + inner === TAB || + inner === LINE_BREAK || + inner === CARRIAGE_RETURN || + inner === R_PAREN + ) { + // (?=$|[\\s),]) + break + } else { + end = start + break + } + } + + let hexLen = end - start + i = end + + if (hexLen === 3 || hexLen === 4 || hexLen === 6 || hexLen === 8) { + colors.push([start, end + 1]) + continue + } + } + } + + return colors +} + +export function findColors(input: string): string[] { + return maybeFindColors(input.toLowerCase()).map(([start, end]) => input.slice(start, end)) +} + +export function parseColors(input: string): ParsedColor[] { + let colors: ParsedColor[] = [] + + for (let str of findColors(input)) { + str = str.replace(CSS_VARS, '1') + + let keyword = getKeywordColor(str) + if (keyword) { + colors.push(keyword) + continue + } + + let color = tryParseColor(str) + if (color) { + colors.push(color) + continue + } + } + + return colors +} From d177b786d46b8fe46f2892a78ec95a1ac884ff07 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 8 Dec 2025 11:53:39 -0500 Subject: [PATCH 12/12] Update changelog --- packages/vscode-tailwindcss/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/vscode-tailwindcss/CHANGELOG.md b/packages/vscode-tailwindcss/CHANGELOG.md index d817fbb7..69d2e6ff 100644 --- a/packages/vscode-tailwindcss/CHANGELOG.md +++ b/packages/vscode-tailwindcss/CHANGELOG.md @@ -4,6 +4,7 @@ - Add a source to all emitted diagnostics ([#1491](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1491)) - Improve performance in large files ([#1507](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1507)) +- Improve utility lookup performance when using v4 ([#1509](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1509)) ## 0.14.29