Skip to content

Perform CSS variable resolution recursively #1168

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Feb 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion packages/tailwindcss-language-server/tests/colors/colors.test.js
Original file line number Diff line number Diff line change
@@ -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 }) {
Expand Down Expand Up @@ -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: '<div class="bg-primary bg-level-1 bg-level-2 bg-level-3 bg-level-4 bg-level-5">',
})

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) },
])
},
})
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>([['--known', 'blue']])
let map = new Map<string, string>([
['--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,
}
Expand Down Expand Up @@ -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')

Expand All @@ -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', () => {
Expand All @@ -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,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()

for (let i = 0; i < str.length; ++i) {
if (!str.startsWith('var(', i)) continue

Expand All @@ -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) {
Expand All @@ -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
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RobinMalfait or @philipp-spiess can y'all think of a way to break this? It has the potential for infinite recursion (due to circular theme key lookups) but I guarded against it here.

I think this should be sufficient but if you could give it some thought that would be much appreciated.


break
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
})
}
3 changes: 3 additions & 0 deletions packages/vscode-tailwindcss/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down