diff --git a/packages/tailwindcss-language-server/tests/colors/colors.test.js b/packages/tailwindcss-language-server/tests/colors/colors.test.js index 317d167a..2a584591 100644 --- a/packages/tailwindcss-language-server/tests/colors/colors.test.js +++ b/packages/tailwindcss-language-server/tests/colors/colors.test.js @@ -1,5 +1,13 @@ import { test, expect } from 'vitest' -import { withFixture } from '../common' +import { init, withFixture } from '../common' +import { css, defineTest } from '../../src/testing' +import { DocumentColorRequest } from 'vscode-languageserver' + +const color = (red, green, blue, alpha) => ({ red, green, blue, alpha }) +const range = (startLine, startCol, endLine, endCol) => ({ + start: { line: startLine, character: startCol }, + end: { line: endLine, character: endCol }, +}) withFixture('basic', (c) => { async function testColors(name, { text, expected }) { @@ -300,3 +308,48 @@ withFixture('v4/basic', (c) => { ], }) }) + +defineTest({ + name: 'v4: colors are recursively resolved from the theme', + fs: { + 'app.css': css` + @import 'tailwindcss'; + @theme { + --color-*: initial; + --color-primary: #ff0000; + --color-level-1: var(--color-primary); + --color-level-2: var(--color-level-1); + --color-level-3: var(--color-level-2); + --color-level-4: var(--color-level-3); + --color-level-5: var(--color-level-4); + } + `, + }, + prepare: async ({ root }) => ({ c: await init(root) }), + handle: async ({ c }) => { + let textDocument = await c.openDocument({ + lang: 'html', + text: '
', + }) + + expect(c.project).toMatchObject({ + tailwind: { + version: '4.0.0', + isDefaultVersion: true, + }, + }) + + let colors = await c.sendRequest(DocumentColorRequest.type, { + textDocument, + }) + + expect(colors).toEqual([ + { range: range(0, 12, 0, 22), color: color(1, 0, 0, 1) }, + { range: range(0, 23, 0, 33), color: color(1, 0, 0, 1) }, + { range: range(0, 34, 0, 44), color: color(1, 0, 0, 1) }, + { range: range(0, 45, 0, 55), color: color(1, 0, 0, 1) }, + { range: range(0, 56, 0, 66), color: color(1, 0, 0, 1) }, + { range: range(0, 67, 0, 77), color: color(1, 0, 0, 1) }, + ]) + }, +}) diff --git a/packages/tailwindcss-language-service/src/util/rewriting/add-theme-values.ts b/packages/tailwindcss-language-service/src/util/rewriting/add-theme-values.ts index ad167eba..b0f30891 100644 --- a/packages/tailwindcss-language-service/src/util/rewriting/add-theme-values.ts +++ b/packages/tailwindcss-language-service/src/util/rewriting/add-theme-values.ts @@ -14,16 +14,18 @@ export function addThemeValues(css: string, state: State, settings: TailwindCssS let replaced: Range[] = [] css = replaceCssCalc(css, (expr) => { - let inlined = replaceCssVars(expr.value, ({ name }) => { - if (!name.startsWith('--')) return null + let inlined = replaceCssVars(expr.value, { + replace({ name }) { + if (!name.startsWith('--')) return null - let value = resolveVariableValue(state.designSystem, name) - if (value === null) return null + let value = resolveVariableValue(state.designSystem, name) + if (value === null) return null - // Inline CSS calc expressions in theme values - value = replaceCssCalc(value, (expr) => evaluateExpression(expr.value)) + // Inline CSS calc expressions in theme values + value = replaceCssCalc(value, (expr) => evaluateExpression(expr.value)) - return value + return value + }, }) let evaluated = evaluateExpression(inlined) @@ -62,53 +64,56 @@ export function addThemeValues(css: string, state: State, settings: TailwindCssS return null }) - css = replaceCssVars(css, ({ name, range }) => { - if (!name.startsWith('--')) return null + css = replaceCssVars(css, { + recursive: false, + replace({ name, range }) { + if (!name.startsWith('--')) return null + + for (let r of replaced) { + if (r.start <= range.start && r.end >= range.end) { + return null + } + } + + let value = resolveVariableValue(state.designSystem, name) + if (value === null) return null + + let px = addPixelEquivalentsToValue(value, settings.rootFontSize, false) + if (px !== value) { + comments.push({ + index: range.end + 1, + value: `${value} = ${px}`, + }) - for (let r of replaced) { - if (r.start <= range.start && r.end >= range.end) { return null } - } - let value = resolveVariableValue(state.designSystem, name) - if (value === null) return null + let color = getEquivalentColor(value) + if (color !== value) { + comments.push({ + index: range.end + 1, + value: `${value} = ${color}`, + }) - let px = addPixelEquivalentsToValue(value, settings.rootFontSize, false) - if (px !== value) { - comments.push({ - index: range.end + 1, - value: `${value} = ${px}`, - }) + return null + } - return null - } + // Inline CSS calc expressions in theme values + value = replaceCssCalc(value, (expr) => { + let evaluated = evaluateExpression(expr.value) + if (!evaluated) return null + if (evaluated === expr.value) return null + + return `calc(${expr.value}) ≈ ${evaluated}` + }) - let color = getEquivalentColor(value) - if (color !== value) { comments.push({ index: range.end + 1, - value: `${value} = ${color}`, + value, }) return null - } - - // Inline CSS calc expressions in theme values - value = replaceCssCalc(value, (expr) => { - let evaluated = evaluateExpression(expr.value) - if (!evaluated) return null - if (evaluated === expr.value) return null - - return `calc(${expr.value}) ≈ ${evaluated}` - }) - - comments.push({ - index: range.end + 1, - value, - }) - - return null + }, }) return applyComments(css, comments) diff --git a/packages/tailwindcss-language-service/src/util/rewriting/index.test.ts b/packages/tailwindcss-language-service/src/util/rewriting/index.test.ts index 67fee890..6eb840ef 100644 --- a/packages/tailwindcss-language-service/src/util/rewriting/index.test.ts +++ b/packages/tailwindcss-language-service/src/util/rewriting/index.test.ts @@ -9,11 +9,23 @@ import { State, TailwindCssSettings } from '../state' import { DesignSystem } from '../v4' test('replacing CSS variables with their fallbacks (when they have them)', () => { - let map = new Map([['--known', 'blue']]) + let map = new Map([ + ['--known', 'blue'], + ['--level-1', 'var(--known)'], + ['--level-2', 'var(--level-1)'], + ['--level-3', 'var(--level-2)'], + + ['--circular-1', 'var(--circular-3)'], + ['--circular-2', 'var(--circular-1)'], + ['--circular-3', 'var(--circular-2)'], + + ['--escaped\\,name', 'green'], + ]) let state: State = { enabled: true, designSystem: { + theme: { prefix: null } as any, resolveThemeValue: (name) => map.get(name) ?? null, } as DesignSystem, } @@ -48,6 +60,9 @@ test('replacing CSS variables with their fallbacks (when they have them)', () => // Known theme keys are replaced with their values expect(replaceCssVarsWithFallbacks(state, 'var(--known)')).toBe('blue') + // Escaped commas are not treated as separators + expect(replaceCssVarsWithFallbacks(state, 'var(--escaped\\,name)')).toBe('green') + // Values from the theme take precedence over fallbacks expect(replaceCssVarsWithFallbacks(state, 'var(--known, red)')).toBe('blue') @@ -56,6 +71,17 @@ test('replacing CSS variables with their fallbacks (when they have them)', () => // Unknown theme keys without fallbacks are not replaced expect(replaceCssVarsWithFallbacks(state, 'var(--unknown)')).toBe('var(--unknown)') + + // Fallbacks are replaced recursively + expect(replaceCssVarsWithFallbacks(state, 'var(--unknown,var(--unknown-2,red))')).toBe('red') + expect(replaceCssVarsWithFallbacks(state, 'var(--level-1)')).toBe('blue') + expect(replaceCssVarsWithFallbacks(state, 'var(--level-2)')).toBe('blue') + expect(replaceCssVarsWithFallbacks(state, 'var(--level-3)')).toBe('blue') + + // Circular replacements don't cause infinite loops + expect(replaceCssVarsWithFallbacks(state, 'var(--circular-1)')).toBe('var(--circular-3)') + expect(replaceCssVarsWithFallbacks(state, 'var(--circular-2)')).toBe('var(--circular-1)') + expect(replaceCssVarsWithFallbacks(state, 'var(--circular-3)')).toBe('var(--circular-2)') }) test('Evaluating CSS calc expressions', () => { @@ -80,6 +106,7 @@ test('Inlining calc expressions using the design system', () => { let state: State = { enabled: true, designSystem: { + theme: { prefix: null } as any, resolveThemeValue: (name) => map.get(name) ?? null, } as DesignSystem, } diff --git a/packages/tailwindcss-language-service/src/util/rewriting/inline-theme-values.ts b/packages/tailwindcss-language-service/src/util/rewriting/inline-theme-values.ts index 69010c11..c002ab9c 100644 --- a/packages/tailwindcss-language-service/src/util/rewriting/inline-theme-values.ts +++ b/packages/tailwindcss-language-service/src/util/rewriting/inline-theme-values.ts @@ -8,25 +8,29 @@ export function inlineThemeValues(css: string, state: State) { if (!state.designSystem) return css css = replaceCssCalc(css, (expr) => { - let inlined = replaceCssVars(expr.value, ({ name, fallback }) => { - if (!name.startsWith('--')) return null + let inlined = replaceCssVars(expr.value, { + replace({ name, fallback }) { + if (!name.startsWith('--')) return null - let value = resolveVariableValue(state.designSystem, name) - if (value === null) return fallback + let value = resolveVariableValue(state.designSystem, name) + if (value === null) return fallback - return value + return value + }, }) return evaluateExpression(inlined) }) - css = replaceCssVars(css, ({ name, fallback }) => { - if (!name.startsWith('--')) return null + css = replaceCssVars(css, { + replace({ name, fallback }) { + if (!name.startsWith('--')) return null - let value = resolveVariableValue(state.designSystem, name) - if (value === null) return fallback + let value = resolveVariableValue(state.designSystem, name) + if (value === null) return fallback - return value + return value + }, }) return css diff --git a/packages/tailwindcss-language-service/src/util/rewriting/replacements.ts b/packages/tailwindcss-language-service/src/util/rewriting/replacements.ts index dd3d0bb4..df63e1ae 100644 --- a/packages/tailwindcss-language-service/src/util/rewriting/replacements.ts +++ b/packages/tailwindcss-language-service/src/util/rewriting/replacements.ts @@ -16,12 +16,31 @@ export interface Range { end: number } +export interface ReplacerOptions { + /** + * Whether or not the replacement should be performed recursively + * + * default: true + */ + recursive?: boolean + + /** + * How to replace the CSS variable + */ + replace: CssVarReplacer +} + export type CssVarReplacer = (node: CssVariable) => string | null /** * Replace all var expressions in a string using the replacer function */ -export function replaceCssVars(str: string, replace: CssVarReplacer): string { +export function replaceCssVars( + str: string, + { replace, recursive = true }: ReplacerOptions, +): string { + let seen = new Set() + for (let i = 0; i < str.length; ++i) { if (!str.startsWith('var(', i)) continue @@ -33,6 +52,8 @@ export function replaceCssVars(str: string, replace: CssVarReplacer): string { depth++ } else if (str[j] === ')' && depth > 0) { depth-- + } else if (str[j] === '\\') { + j++ } else if (str[j] === ',' && depth === 0 && fallbackStart === null) { fallbackStart = j + 1 } else if (str[j] === ')' && depth === 0) { @@ -58,9 +79,20 @@ export function replaceCssVars(str: string, replace: CssVarReplacer): string { str = str.slice(0, i) + replacement + str.slice(j + 1) } - // We don't want to skip past anything here because `replacement` - // might contain more var(…) calls in which case `i` will already - // be pointing at the right spot to start looking for them + // Move the index back one so it can look at the spot again since it'll + // be incremented by the outer loop. However, since we're replacing + // variables recursively we might end up in a loop so we need to keep + // track of which variables we've already seen and where they were + // replaced to avoid infinite loops. + if (recursive) { + let key = `${i}:${replacement}` + + if (!seen.has(key)) { + seen.add(key) + i -= 1 + } + } + break } } diff --git a/packages/tailwindcss-language-service/src/util/rewriting/var-fallbacks.ts b/packages/tailwindcss-language-service/src/util/rewriting/var-fallbacks.ts index b2d5d7ce..728b53bf 100644 --- a/packages/tailwindcss-language-service/src/util/rewriting/var-fallbacks.ts +++ b/packages/tailwindcss-language-service/src/util/rewriting/var-fallbacks.ts @@ -3,20 +3,22 @@ import { resolveVariableValue } from './lookup' import { replaceCssVars } from './replacements' export function replaceCssVarsWithFallbacks(state: State, str: string): string { - return replaceCssVars(str, ({ name, fallback }) => { - // Replace with the value from the design system first. The design system - // take precedences over other sources as that emulates the behavior of a - // browser where the fallback is only used if the variable is defined. - if (state.designSystem && name.startsWith('--')) { - let value = resolveVariableValue(state.designSystem, name) - if (value !== null) return value - } + return replaceCssVars(str, { + replace({ name, fallback }) { + // Replace with the value from the design system first. The design system + // take precedences over other sources as that emulates the behavior of a + // browser where the fallback is only used if the variable is defined. + if (state.designSystem && name.startsWith('--')) { + let value = resolveVariableValue(state.designSystem, name) + if (value !== null) return value + } - if (fallback) { - return fallback - } + if (fallback) { + return fallback + } - // Don't touch it since there's no suitable replacement - return null + // Don't touch it since there's no suitable replacement + return null + }, }) } diff --git a/packages/vscode-tailwindcss/CHANGELOG.md b/packages/vscode-tailwindcss/CHANGELOG.md index ca62ab97..fc7e2ab7 100644 --- a/packages/vscode-tailwindcss/CHANGELOG.md +++ b/packages/vscode-tailwindcss/CHANGELOG.md @@ -12,6 +12,9 @@ - Make sure `@slot` isn't considered an unknown at-rule ([#1165](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1165)) - Fix equivalent calculation when using prefixes in v4 ([#1166](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1166)) - Fix use of `tailwindCSS.experimental.configFile` option when using the bundled version of v4 ([#1167](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1167)) +- Recursively resolve values from the theme ([#1168](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1168)) +- Handle theme keys containing escaped commas ([#1168](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1168)) +- Show colors for utilities when they point to CSS variables contained in the theme ([#1168](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1168)) ## 0.14.2