Skip to content

Commit 4db823d

Browse files
Perform CSS variable resolution recursively (#1168)
Fixes #1102
1 parent 22438ce commit 4db823d

File tree

7 files changed

+197
-71
lines changed

7 files changed

+197
-71
lines changed

packages/tailwindcss-language-server/tests/colors/colors.test.js

+54-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { test, expect } from 'vitest'
2-
import { withFixture } from '../common'
2+
import { init, withFixture } from '../common'
3+
import { css, defineTest } from '../../src/testing'
4+
import { DocumentColorRequest } from 'vscode-languageserver'
5+
6+
const color = (red, green, blue, alpha) => ({ red, green, blue, alpha })
7+
const range = (startLine, startCol, endLine, endCol) => ({
8+
start: { line: startLine, character: startCol },
9+
end: { line: endLine, character: endCol },
10+
})
311

412
withFixture('basic', (c) => {
513
async function testColors(name, { text, expected }) {
@@ -300,3 +308,48 @@ withFixture('v4/basic', (c) => {
300308
],
301309
})
302310
})
311+
312+
defineTest({
313+
name: 'v4: colors are recursively resolved from the theme',
314+
fs: {
315+
'app.css': css`
316+
@import 'tailwindcss';
317+
@theme {
318+
--color-*: initial;
319+
--color-primary: #ff0000;
320+
--color-level-1: var(--color-primary);
321+
--color-level-2: var(--color-level-1);
322+
--color-level-3: var(--color-level-2);
323+
--color-level-4: var(--color-level-3);
324+
--color-level-5: var(--color-level-4);
325+
}
326+
`,
327+
},
328+
prepare: async ({ root }) => ({ c: await init(root) }),
329+
handle: async ({ c }) => {
330+
let textDocument = await c.openDocument({
331+
lang: 'html',
332+
text: '<div class="bg-primary bg-level-1 bg-level-2 bg-level-3 bg-level-4 bg-level-5">',
333+
})
334+
335+
expect(c.project).toMatchObject({
336+
tailwind: {
337+
version: '4.0.0',
338+
isDefaultVersion: true,
339+
},
340+
})
341+
342+
let colors = await c.sendRequest(DocumentColorRequest.type, {
343+
textDocument,
344+
})
345+
346+
expect(colors).toEqual([
347+
{ range: range(0, 12, 0, 22), color: color(1, 0, 0, 1) },
348+
{ range: range(0, 23, 0, 33), color: color(1, 0, 0, 1) },
349+
{ range: range(0, 34, 0, 44), color: color(1, 0, 0, 1) },
350+
{ range: range(0, 45, 0, 55), color: color(1, 0, 0, 1) },
351+
{ range: range(0, 56, 0, 66), color: color(1, 0, 0, 1) },
352+
{ range: range(0, 67, 0, 77), color: color(1, 0, 0, 1) },
353+
])
354+
},
355+
})

packages/tailwindcss-language-service/src/util/rewriting/add-theme-values.ts

+47-42
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,18 @@ export function addThemeValues(css: string, state: State, settings: TailwindCssS
1414
let replaced: Range[] = []
1515

1616
css = replaceCssCalc(css, (expr) => {
17-
let inlined = replaceCssVars(expr.value, ({ name }) => {
18-
if (!name.startsWith('--')) return null
17+
let inlined = replaceCssVars(expr.value, {
18+
replace({ name }) {
19+
if (!name.startsWith('--')) return null
1920

20-
let value = resolveVariableValue(state.designSystem, name)
21-
if (value === null) return null
21+
let value = resolveVariableValue(state.designSystem, name)
22+
if (value === null) return null
2223

23-
// Inline CSS calc expressions in theme values
24-
value = replaceCssCalc(value, (expr) => evaluateExpression(expr.value))
24+
// Inline CSS calc expressions in theme values
25+
value = replaceCssCalc(value, (expr) => evaluateExpression(expr.value))
2526

26-
return value
27+
return value
28+
},
2729
})
2830

2931
let evaluated = evaluateExpression(inlined)
@@ -62,53 +64,56 @@ export function addThemeValues(css: string, state: State, settings: TailwindCssS
6264
return null
6365
})
6466

65-
css = replaceCssVars(css, ({ name, range }) => {
66-
if (!name.startsWith('--')) return null
67+
css = replaceCssVars(css, {
68+
recursive: false,
69+
replace({ name, range }) {
70+
if (!name.startsWith('--')) return null
71+
72+
for (let r of replaced) {
73+
if (r.start <= range.start && r.end >= range.end) {
74+
return null
75+
}
76+
}
77+
78+
let value = resolveVariableValue(state.designSystem, name)
79+
if (value === null) return null
80+
81+
let px = addPixelEquivalentsToValue(value, settings.rootFontSize, false)
82+
if (px !== value) {
83+
comments.push({
84+
index: range.end + 1,
85+
value: `${value} = ${px}`,
86+
})
6787

68-
for (let r of replaced) {
69-
if (r.start <= range.start && r.end >= range.end) {
7088
return null
7189
}
72-
}
7390

74-
let value = resolveVariableValue(state.designSystem, name)
75-
if (value === null) return null
91+
let color = getEquivalentColor(value)
92+
if (color !== value) {
93+
comments.push({
94+
index: range.end + 1,
95+
value: `${value} = ${color}`,
96+
})
7697

77-
let px = addPixelEquivalentsToValue(value, settings.rootFontSize, false)
78-
if (px !== value) {
79-
comments.push({
80-
index: range.end + 1,
81-
value: `${value} = ${px}`,
82-
})
98+
return null
99+
}
83100

84-
return null
85-
}
101+
// Inline CSS calc expressions in theme values
102+
value = replaceCssCalc(value, (expr) => {
103+
let evaluated = evaluateExpression(expr.value)
104+
if (!evaluated) return null
105+
if (evaluated === expr.value) return null
106+
107+
return `calc(${expr.value}) ≈ ${evaluated}`
108+
})
86109

87-
let color = getEquivalentColor(value)
88-
if (color !== value) {
89110
comments.push({
90111
index: range.end + 1,
91-
value: `${value} = ${color}`,
112+
value,
92113
})
93114

94115
return null
95-
}
96-
97-
// Inline CSS calc expressions in theme values
98-
value = replaceCssCalc(value, (expr) => {
99-
let evaluated = evaluateExpression(expr.value)
100-
if (!evaluated) return null
101-
if (evaluated === expr.value) return null
102-
103-
return `calc(${expr.value}) ≈ ${evaluated}`
104-
})
105-
106-
comments.push({
107-
index: range.end + 1,
108-
value,
109-
})
110-
111-
return null
116+
},
112117
})
113118

114119
return applyComments(css, comments)

packages/tailwindcss-language-service/src/util/rewriting/index.test.ts

+28-1
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,23 @@ import { State, TailwindCssSettings } from '../state'
99
import { DesignSystem } from '../v4'
1010

1111
test('replacing CSS variables with their fallbacks (when they have them)', () => {
12-
let map = new Map<string, string>([['--known', 'blue']])
12+
let map = new Map<string, string>([
13+
['--known', 'blue'],
14+
['--level-1', 'var(--known)'],
15+
['--level-2', 'var(--level-1)'],
16+
['--level-3', 'var(--level-2)'],
17+
18+
['--circular-1', 'var(--circular-3)'],
19+
['--circular-2', 'var(--circular-1)'],
20+
['--circular-3', 'var(--circular-2)'],
21+
22+
['--escaped\\,name', 'green'],
23+
])
1324

1425
let state: State = {
1526
enabled: true,
1627
designSystem: {
28+
theme: { prefix: null } as any,
1729
resolveThemeValue: (name) => map.get(name) ?? null,
1830
} as DesignSystem,
1931
}
@@ -48,6 +60,9 @@ test('replacing CSS variables with their fallbacks (when they have them)', () =>
4860
// Known theme keys are replaced with their values
4961
expect(replaceCssVarsWithFallbacks(state, 'var(--known)')).toBe('blue')
5062

63+
// Escaped commas are not treated as separators
64+
expect(replaceCssVarsWithFallbacks(state, 'var(--escaped\\,name)')).toBe('green')
65+
5166
// Values from the theme take precedence over fallbacks
5267
expect(replaceCssVarsWithFallbacks(state, 'var(--known, red)')).toBe('blue')
5368

@@ -56,6 +71,17 @@ test('replacing CSS variables with their fallbacks (when they have them)', () =>
5671

5772
// Unknown theme keys without fallbacks are not replaced
5873
expect(replaceCssVarsWithFallbacks(state, 'var(--unknown)')).toBe('var(--unknown)')
74+
75+
// Fallbacks are replaced recursively
76+
expect(replaceCssVarsWithFallbacks(state, 'var(--unknown,var(--unknown-2,red))')).toBe('red')
77+
expect(replaceCssVarsWithFallbacks(state, 'var(--level-1)')).toBe('blue')
78+
expect(replaceCssVarsWithFallbacks(state, 'var(--level-2)')).toBe('blue')
79+
expect(replaceCssVarsWithFallbacks(state, 'var(--level-3)')).toBe('blue')
80+
81+
// Circular replacements don't cause infinite loops
82+
expect(replaceCssVarsWithFallbacks(state, 'var(--circular-1)')).toBe('var(--circular-3)')
83+
expect(replaceCssVarsWithFallbacks(state, 'var(--circular-2)')).toBe('var(--circular-1)')
84+
expect(replaceCssVarsWithFallbacks(state, 'var(--circular-3)')).toBe('var(--circular-2)')
5985
})
6086

6187
test('Evaluating CSS calc expressions', () => {
@@ -80,6 +106,7 @@ test('Inlining calc expressions using the design system', () => {
80106
let state: State = {
81107
enabled: true,
82108
designSystem: {
109+
theme: { prefix: null } as any,
83110
resolveThemeValue: (name) => map.get(name) ?? null,
84111
} as DesignSystem,
85112
}

packages/tailwindcss-language-service/src/util/rewriting/inline-theme-values.ts

+14-10
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,29 @@ export function inlineThemeValues(css: string, state: State) {
88
if (!state.designSystem) return css
99

1010
css = replaceCssCalc(css, (expr) => {
11-
let inlined = replaceCssVars(expr.value, ({ name, fallback }) => {
12-
if (!name.startsWith('--')) return null
11+
let inlined = replaceCssVars(expr.value, {
12+
replace({ name, fallback }) {
13+
if (!name.startsWith('--')) return null
1314

14-
let value = resolveVariableValue(state.designSystem, name)
15-
if (value === null) return fallback
15+
let value = resolveVariableValue(state.designSystem, name)
16+
if (value === null) return fallback
1617

17-
return value
18+
return value
19+
},
1820
})
1921

2022
return evaluateExpression(inlined)
2123
})
2224

23-
css = replaceCssVars(css, ({ name, fallback }) => {
24-
if (!name.startsWith('--')) return null
25+
css = replaceCssVars(css, {
26+
replace({ name, fallback }) {
27+
if (!name.startsWith('--')) return null
2528

26-
let value = resolveVariableValue(state.designSystem, name)
27-
if (value === null) return fallback
29+
let value = resolveVariableValue(state.designSystem, name)
30+
if (value === null) return fallback
2831

29-
return value
32+
return value
33+
},
3034
})
3135

3236
return css

packages/tailwindcss-language-service/src/util/rewriting/replacements.ts

+36-4
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,31 @@ export interface Range {
1616
end: number
1717
}
1818

19+
export interface ReplacerOptions {
20+
/**
21+
* Whether or not the replacement should be performed recursively
22+
*
23+
* default: true
24+
*/
25+
recursive?: boolean
26+
27+
/**
28+
* How to replace the CSS variable
29+
*/
30+
replace: CssVarReplacer
31+
}
32+
1933
export type CssVarReplacer = (node: CssVariable) => string | null
2034

2135
/**
2236
* Replace all var expressions in a string using the replacer function
2337
*/
24-
export function replaceCssVars(str: string, replace: CssVarReplacer): string {
38+
export function replaceCssVars(
39+
str: string,
40+
{ replace, recursive = true }: ReplacerOptions,
41+
): string {
42+
let seen = new Set<string>()
43+
2544
for (let i = 0; i < str.length; ++i) {
2645
if (!str.startsWith('var(', i)) continue
2746

@@ -33,6 +52,8 @@ export function replaceCssVars(str: string, replace: CssVarReplacer): string {
3352
depth++
3453
} else if (str[j] === ')' && depth > 0) {
3554
depth--
55+
} else if (str[j] === '\\') {
56+
j++
3657
} else if (str[j] === ',' && depth === 0 && fallbackStart === null) {
3758
fallbackStart = j + 1
3859
} else if (str[j] === ')' && depth === 0) {
@@ -58,9 +79,20 @@ export function replaceCssVars(str: string, replace: CssVarReplacer): string {
5879
str = str.slice(0, i) + replacement + str.slice(j + 1)
5980
}
6081

61-
// We don't want to skip past anything here because `replacement`
62-
// might contain more var(…) calls in which case `i` will already
63-
// be pointing at the right spot to start looking for them
82+
// Move the index back one so it can look at the spot again since it'll
83+
// be incremented by the outer loop. However, since we're replacing
84+
// variables recursively we might end up in a loop so we need to keep
85+
// track of which variables we've already seen and where they were
86+
// replaced to avoid infinite loops.
87+
if (recursive) {
88+
let key = `${i}:${replacement}`
89+
90+
if (!seen.has(key)) {
91+
seen.add(key)
92+
i -= 1
93+
}
94+
}
95+
6496
break
6597
}
6698
}

packages/tailwindcss-language-service/src/util/rewriting/var-fallbacks.ts

+15-13
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,22 @@ import { resolveVariableValue } from './lookup'
33
import { replaceCssVars } from './replacements'
44

55
export function replaceCssVarsWithFallbacks(state: State, str: string): string {
6-
return replaceCssVars(str, ({ name, fallback }) => {
7-
// Replace with the value from the design system first. The design system
8-
// take precedences over other sources as that emulates the behavior of a
9-
// browser where the fallback is only used if the variable is defined.
10-
if (state.designSystem && name.startsWith('--')) {
11-
let value = resolveVariableValue(state.designSystem, name)
12-
if (value !== null) return value
13-
}
6+
return replaceCssVars(str, {
7+
replace({ name, fallback }) {
8+
// Replace with the value from the design system first. The design system
9+
// take precedences over other sources as that emulates the behavior of a
10+
// browser where the fallback is only used if the variable is defined.
11+
if (state.designSystem && name.startsWith('--')) {
12+
let value = resolveVariableValue(state.designSystem, name)
13+
if (value !== null) return value
14+
}
1415

15-
if (fallback) {
16-
return fallback
17-
}
16+
if (fallback) {
17+
return fallback
18+
}
1819

19-
// Don't touch it since there's no suitable replacement
20-
return null
20+
// Don't touch it since there's no suitable replacement
21+
return null
22+
},
2123
})
2224
}

packages/vscode-tailwindcss/CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
- Make sure `@slot` isn't considered an unknown at-rule ([#1165](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1165))
1313
- Fix equivalent calculation when using prefixes in v4 ([#1166](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1166))
1414
- Fix use of `tailwindCSS.experimental.configFile` option when using the bundled version of v4 ([#1167](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1167))
15+
- Recursively resolve values from the theme ([#1168](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1168))
16+
- Handle theme keys containing escaped commas ([#1168](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1168))
17+
- Show colors for utilities when they point to CSS variables contained in the theme ([#1168](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1168))
1518

1619
## 0.14.2
1720

0 commit comments

Comments
 (0)