diff --git a/packages/tailwindcss-language-service/package.json b/packages/tailwindcss-language-service/package.json index d34e87411..20f555a5e 100644 --- a/packages/tailwindcss-language-service/package.json +++ b/packages/tailwindcss-language-service/package.json @@ -13,10 +13,12 @@ "test": "vitest" }, "dependencies": { - "@csstools/css-calc": "2.1.2", - "@csstools/css-parser-algorithms": "3.0.4", - "@csstools/css-tokenizer": "3.0.3", - "@csstools/media-query-list-parser": "2.0.4", + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-color-parser": "3.1.0", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3", "@types/culori": "^2.1.0", "@types/moo": "0.5.3", "@types/semver": "7.3.10", diff --git a/packages/tailwindcss-language-service/src/util/color.ts b/packages/tailwindcss-language-service/src/util/color.ts index 19fb9a42c..07aab357e 100644 --- a/packages/tailwindcss-language-service/src/util/color.ts +++ b/packages/tailwindcss-language-service/src/util/color.ts @@ -8,7 +8,7 @@ import * as jit from './jit' import * as culori from 'culori' import namedColors from 'color-name' import postcss from 'postcss' -import { replaceCssVarsWithFallbacks } from './rewriting' +import { createProcessor } from './rewriting' const COLOR_PROPS = [ 'accent-color', @@ -64,9 +64,16 @@ function getColorsInString(state: State, str: string): (culori.Color | KeywordCo return getKeywordColor(color) ?? tryParseColor(color) } - str = replaceCssVarsWithFallbacks(state, str) - str = removeColorMixWherePossible(str) - str = resolveLightDark(str) + // @ts-ignore + state.processColors ??= createProcessor({ + style: 'full-evaluation', + fontSize: null, + variables: new Map(), + state, + }) + + // @ts-ignore + str = state.processColors(str) let possibleColors = str.matchAll(colorRegex) 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 000000000..a045b828d --- /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/jit.ts b/packages/tailwindcss-language-service/src/util/jit.ts index b2a1b218e..1fad707e5 100644 --- a/packages/tailwindcss-language-service/src/util/jit.ts +++ b/packages/tailwindcss-language-service/src/util/jit.ts @@ -1,8 +1,6 @@ import type { State } from './state' import type { Container, Document, Root, Rule, Node, AtRule } from 'postcss' -import { addPixelEquivalentsToValue } from './pixelEquivalents' -import { addEquivalents } from './equivalents' -import { addThemeValues, inlineThemeValues } from './rewriting' +import { createProcessor } from './rewriting' export function bigSign(bigIntValue: number | bigint): number { // @ts-ignore @@ -45,8 +43,14 @@ export async function stringifyRoot(state: State, root: Root, uri?: string): Pro let css = clone.toString() - css = addThemeValues(css, state, settings.tailwindCSS) - css = addEquivalents(css, settings.tailwindCSS) + let process = createProcessor({ + style: 'user-presentable', + fontSize: settings.tailwindCSS.showPixelEquivalents ? settings.tailwindCSS.rootFontSize : null, + variables: new Map(), + state, + }) + + css = process(css) let identSize = state.v4 ? 2 : 4 let identPattern = state.v4 ? /^(?: )+/gm : /^(?: )+/gm @@ -68,15 +72,17 @@ export function stringifyRules(state: State, rules: Rule[], tabSize: number = 2) export async function stringifyDecls(state: State, rule: Rule, uri?: string): Promise { let settings = await state.editor.getConfiguration(uri) + let process = createProcessor({ + style: 'full-evaluation', + fontSize: settings.tailwindCSS.showPixelEquivalents ? settings.tailwindCSS.rootFontSize : null, + variables: new Map(), + state, + }) + let result = [] rule.walkDecls(({ prop, value }) => { - // In v4 we inline theme values into declarations (this is a no-op in v3) - value = inlineThemeValues(value, state).trim() - - if (settings.tailwindCSS.showPixelEquivalents) { - value = addPixelEquivalentsToValue(value, settings.tailwindCSS.rootFontSize) - } + value = process(value).trim() result.push(`${prop}: ${value};`) }) 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 deleted file mode 100644 index c84d67510..000000000 --- a/packages/tailwindcss-language-service/src/util/rewriting/add-theme-values.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { State, TailwindCssSettings } from '../state' - -import { evaluateExpression } from './calc' -import { replaceCssVars, replaceCssCalc, Range } from './replacements' -import { addPixelEquivalentsToValue } from '../pixelEquivalents' -import { applyComments, Comment } from '../comments' -import { getEquivalentColor } from '../colorEquivalents' -import { resolveVariableValue } from './lookup' - -export function addThemeValues(css: string, state: State, settings: TailwindCssSettings): string { - if (!state.designSystem) return css - - let comments: Comment[] = [] - let replaced: Range[] = [] - - css = replaceCssCalc(css, (expr) => { - let inlined = replaceCssVars(expr.value, { - replace({ name }) { - if (!name.startsWith('--')) 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)) - - return value - }, - }) - - let evaluated = evaluateExpression(inlined) - - // No changes were made so we can just return the original expression - if (expr.value === evaluated) return null - if (!evaluated) return null - - replaced.push(expr.range) - - let px = addPixelEquivalentsToValue(evaluated, settings.rootFontSize, false) - if (px !== evaluated) { - comments.push({ - index: expr.range.end + 1, - value: `${evaluated} = ${px}`, - }) - - return null - } - - let color = getEquivalentColor(evaluated) - if (color !== evaluated) { - comments.push({ - index: expr.range.end + 1, - value: `${evaluated} = ${color}`, - }) - - return null - } - - comments.push({ - index: expr.range.end + 1, - value: evaluated, - }) - - 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}`, - }) - - return null - } - - let color = getEquivalentColor(value) - if (color !== value) { - comments.push({ - index: range.end + 1, - value: `${value} = ${color}`, - }) - - 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/calc.ts b/packages/tailwindcss-language-service/src/util/rewriting/calc.ts deleted file mode 100644 index 711b904e3..000000000 --- a/packages/tailwindcss-language-service/src/util/rewriting/calc.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { stringify, tokenize } from '@csstools/css-tokenizer' -import { isFunctionNode, parseComponentValue } from '@csstools/css-parser-algorithms' -import { calcFromComponentValues } from '@csstools/css-calc' - -export function evaluateExpression(str: string): string | null { - let tokens = tokenize({ css: `calc(${str})` }) - - let components = parseComponentValue(tokens, {}) - if (!components) return null - - let result = calcFromComponentValues([[components]], { - // Ensure evaluation of random() is deterministic - randomSeed: 1, - - // Limit precision to keep values environment independent - // - // This is mostly to limit displayed numbers to a reasonable length. Ideally - // we could perform any calcs with "infinite" precision and only then round - // numbers back for display purposes. - // - // This is short of the 7 digits that 32-bit floats provide. JS does store - // numbers as doubles so maybe this can be bumped to like 12–15? - precision: 6, - }) - - // The result array is the same shape as the original so we're guaranteed to - // have an element here - let node = result[0][0] - - // If we have a top-level `calc(…)` node then the evaluation did not resolve - // to a single value and we consider it to be incomplete - if (isFunctionNode(node)) { - if (node.name[1] === 'calc(') return null - } - - return stringify(...node.tokens()) -} diff --git a/packages/tailwindcss-language-service/src/util/rewriting/color.test.ts b/packages/tailwindcss-language-service/src/util/rewriting/color.test.ts new file mode 100644 index 000000000..b3529f1d0 --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/rewriting/color.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from 'vitest' +import { colorFromString, colorMixFromString, equivalentColorFromString } from './color' + +test('colorFromString', () => { + expect(colorFromString('red')).toEqual({ mode: 'rgb', r: 1, g: 0, b: 0 }) + expect(colorFromString('rgb(255 0 0)')).toEqual({ mode: 'rgb', r: 1, g: 0, b: 0 }) + expect(colorFromString('hsl(0 100% 50%)')).toEqual({ mode: 'hsl', h: 0, s: 1, l: 0.5 }) + expect(colorFromString('#f00')).toEqual({ mode: 'rgb', r: 1, g: 0, b: 0 }) + expect(colorFromString('#f003')).toEqual({ mode: 'rgb', r: 1, g: 0, b: 0, alpha: 0.2 }) + expect(colorFromString('#ff0000')).toEqual({ mode: 'rgb', r: 1, g: 0, b: 0 }) + expect(colorFromString('#ff000033')).toEqual({ mode: 'rgb', r: 1, g: 0, b: 0, alpha: 0.2 }) + + expect(colorFromString('color(srgb 1 0 0 )')).toEqual({ mode: 'rgb', r: 1, g: 0, b: 0 }) + expect(colorFromString('color(srgb-linear 1 0 0 )')).toEqual({ mode: 'lrgb', r: 1, g: 0, b: 0 }) + expect(colorFromString('color(display-p3 1 0 0 )')).toEqual({ mode: 'p3', r: 1, g: 0, b: 0 }) + expect(colorFromString('color(a98-rgb 1 0 0 )')).toEqual({ mode: 'a98', r: 1, g: 0, b: 0 }) + expect(colorFromString('color(prophoto-rgb 1 0 0 )')).toEqual({ + mode: 'prophoto', + r: 1, + g: 0, + b: 0, + }) + expect(colorFromString('color(rec2020 1 0 0 )')).toEqual({ mode: 'rec2020', r: 1, g: 0, b: 0 }) + + expect(colorFromString('color(xyz 1 0 0 )')).toEqual({ mode: 'xyz65', x: 1, y: 0, z: 0 }) + expect(colorFromString('color(xyz-d65 1 0 0 )')).toEqual({ mode: 'xyz65', x: 1, y: 0, z: 0 }) + expect(colorFromString('color(xyz-d50 1 0 0 )')).toEqual({ mode: 'xyz50', x: 1, y: 0, z: 0 }) + + expect(colorFromString('#ff000033cccc')).toEqual(null) + + // none keywords work too + expect(colorFromString('rgb(255 none 0)')).toEqual({ mode: 'rgb', r: 1, b: 0 }) +}) + +test('can compute color mix', () => { + expect(colorMixFromString('color-mix(in srgb, #f00 50%, transparent)')).toEqual({ + mode: 'rgb', + r: 1, + g: 0, + b: 0, + alpha: 0.5, + }) + + expect(colorMixFromString('color-mix(in srgb, #ff33ff, #33ff3380)')).toEqual({ + mode: 'rgb', + r: expect.closeTo(0.733), + g: expect.closeTo(0.467), + b: expect.closeTo(0.733), + alpha: 0.751, + }) +}) + +test('equivalentColorFromString', () => { + expect(equivalentColorFromString('oklch(0.9876 0.0249 101.95)')).toEqual('#fefce9') +}) diff --git a/packages/tailwindcss-language-service/src/util/rewriting/color.ts b/packages/tailwindcss-language-service/src/util/rewriting/color.ts new file mode 100644 index 000000000..91b81cc4f --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/rewriting/color.ts @@ -0,0 +1,129 @@ +import * as culori from 'culori' +import { + ComponentValue, + isFunctionNode, + isTokenNode, + parseComponentValue, + TokenNode, +} from '@csstools/css-parser-algorithms' +import { + HashType, + isTokenHash, + isTokenIdent, + stringify, + tokenize, + TokenType, +} from '@csstools/css-tokenizer' +import { + color, + colorDataFitsDisplayP3_Gamut, + colorDataFitsRGB_Gamut, + serializeOKLCH, + serializeP3, + serializeRGB, +} from '@csstools/css-color-parser' + +const COLOR_FN = /^(rgba?|hsla?|hwb|(ok)?(lab|lch)|color)$/i + +export type ParsedColor = culori.Color | null + +export function colorFromString(value: string): ParsedColor { + let tokens = tokenize({ css: value }) + let cv = parseComponentValue(tokens) + if (!cv) return null + + return colorFromComponentValue(cv) +} + +export function colorFromComponentValue(cv: ComponentValue): ParsedColor { + if (isTokenNode(cv)) { + if (isTokenIdent(cv.value)) { + let str = cv.value[4].value.toLowerCase() + + // We can't do anything useful with this so treat it as unparsable + if (str === 'currentcolor') return null + + if (str === 'transparent') return culori.parse('transparent') ?? null + + if (str in culori.colorsNamed) { + return culori.parseNamed(str as keyof typeof culori.colorsNamed) ?? null + } + } + + // + else if (isTokenHash(cv.value)) { + let hex = cv.value[4].value.toLowerCase() + + return culori.parseHex(hex) ?? null + } + + return null + } + + // + else if (isFunctionNode(cv)) { + let fn = cv.getName() + + if (COLOR_FN.test(fn)) { + return culori.parse(stringify(...cv.tokens())) ?? null + } + } + + return null +} + +export function equivalentColorFromString(value: string): string | null { + let color = colorFromString(value) + let equivalent = computeEquivalentColor(color) + if (!equivalent) return null + + return equivalent.toString() +} + +export function equivalentColorFromComponentValue(cv: ComponentValue): ComponentValue { + let color = colorFromComponentValue(cv) + let equivalent = computeEquivalentColor(color) + + return equivalent ?? cv +} + +function computeEquivalentColor(color: ParsedColor): TokenNode | null { + if (!color) return null + if (typeof color === 'string') return null + if (!culori.inGamut('rgb')(color)) return null + + let hex: string + + if (color.alpha === undefined || color.alpha === 1) { + hex = culori.formatHex(color) + } else { + hex = culori.formatHex8(color) + } + + return new TokenNode([TokenType.Hash, hex, 0, 0, { value: hex.slice(1), type: HashType.ID }]) +} + +export function colorMixFromString(value: string): ParsedColor { + let tokens = tokenize({ css: value }) + let cv = parseComponentValue(tokens) + if (!cv) return null + + return colorMixFromComponentValue(cv) +} + +export function colorMixFromComponentValue(cv: ComponentValue): ParsedColor { + let data = color(cv) + if (!data) return null + + let str: string + + if (colorDataFitsRGB_Gamut(data)) { + str = serializeRGB(data).toString() + } else if (colorDataFitsDisplayP3_Gamut(data)) { + str = serializeP3(data).toString() + } else { + str = serializeOKLCH(data).toString() + } + + return culori.parse(str) ?? null +} 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 0db2d8078..8438029ab 100644 --- a/packages/tailwindcss-language-service/src/util/rewriting/index.test.ts +++ b/packages/tailwindcss-language-service/src/util/rewriting/index.test.ts @@ -1,243 +1,225 @@ import { expect, test } from 'vitest' -import { - addThemeValues, - evaluateExpression, - inlineThemeValues, - replaceCssCalc, - replaceCssVarsWithFallbacks, -} from './index' -import { State, TailwindCssSettings } from '../state' -import { DesignSystem } from '../v4' +import { createProcessor } from './process' + +// Cyclic variables +// None of these should ever get replaced +// ['--color-a', 'var(--color-a)'], +// ['--color-b', 'rgb(var(--color-b))'], +// ['--color-c', 'rgb(var(--color-c) var(--color-c) var(--color-c))'], + +// ['--mutual-a', 'calc(var(--mutual-b) * 1)'], +// ['--mutual-b', 'calc(var(--mutual-a) + 1)'], + +// ['--circle-cw-1', 'var(--circle-cw-2)'], +// ['--circle-cw-2', 'var(--circle-cw-3)'], +// ['--circle-cw-3', 'var(--circle-cw-1)'], + +// ['--circle-ccw-1', 'var(--circle-ccw-3)'], +// ['--circle-ccw-2', 'var(--circle-ccw-1)'], +// ['--circle-ccw-3', 'var(--circle-ccw-2)'], + +// // None of these are cyclic and should all have replacements +// ['--color-d', 'rgb(var(--channel) var(--channel) var(--channel))'], +// ['--color-e', 'rgb(var(--indirect) var(--indirect) var(--indirect))'], +// ['--indirect', 'var(--channel)'], +// ['--channel', '255'], test('replacing CSS variables with their fallbacks (when they have them)', () => { - 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, - features: [], - designSystem: { - theme: { prefix: null } as any, - resolveThemeValue: (name) => map.get(name) ?? null, - } as DesignSystem, - } - - expect(replaceCssVarsWithFallbacks(state, 'var(--foo, red)')).toBe(' red') - expect(replaceCssVarsWithFallbacks(state, 'var(--foo, )')).toBe(' ') - - expect(replaceCssVarsWithFallbacks(state, 'rgb(var(--foo, 255 0 0))')).toBe('rgb( 255 0 0)') - expect(replaceCssVarsWithFallbacks(state, 'rgb(var(--foo, var(--bar)))')).toBe('rgb( var(--bar))') + let process = createProcessor({ + style: 'full-evaluation', + fontSize: 16, + variables: 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'], + ]), + }) + + expect(process('var(--foo, red)')).toBe(' red') + expect(process('var(--foo, )')).toBe(' ') + + expect(process('rgb(var(--foo, 255 0 0))')).toBe('rgb( 255 0 0)') + expect(process('rgb(var(--foo, var(--bar)))')).toBe('rgb( var(--bar))') + + expect(process('rgb(var(var(--bar, var(--baz), var(--qux), var(--thing))))')).toBe( + 'rgb( var(--qux), var(--thing))', + ) - expect( - replaceCssVarsWithFallbacks( - state, - 'rgb(var(var(--bar, var(--baz), var(--qux), var(--thing))))', - ), - ).toBe('rgb(var( var(--baz), var(--qux), var(--thing)))') + expect(process('rgb(var(--one, var(--bar, var(--baz), var(--qux), var(--thing))))')).toBe( + 'rgb( var(--baz), var(--qux), var(--thing))', + ) expect( - replaceCssVarsWithFallbacks( - state, - 'rgb(var(--one, var(--bar, var(--baz), var(--qux), var(--thing))))', + process( + 'color-mix(in srgb, var(--color-primary, oklch(0 0 0 / 2.5)), var(--color-secondary, oklch(0 0 0 / 2.5)) 50%)', ), - ).toBe('rgb( var(--baz), var(--qux), var(--thing))') + ).toBe('rgb(0, 0, 0)') expect( - replaceCssVarsWithFallbacks( - state, - 'color-mix(in srgb, var(--color-primary, oklch(0 0 0 / 2.5)), var(--color-secondary, oklch(0 0 0 / 2.5)), 50%)', + process( + 'color-mix(in oklch, var(--color-primary, oklch(0.64 0.2935 27 / 0.5)), var(--color-secondary, oklch(0.64 0.2195 247.76)) 50%)', ), - ).toBe('color-mix(in srgb, oklch(0 0 0 / 2.5), oklch(0 0 0 / 2.5), 50%)') + ).toBe('rgba(197, 73, 234, 0.75)') // Known theme keys are replaced with their values - expect(replaceCssVarsWithFallbacks(state, 'var(--known)')).toBe('blue') + expect(process('var(--known)')).toBe('blue') // Escaped commas are not treated as separators - expect(replaceCssVarsWithFallbacks(state, 'var(--escaped\\,name)')).toBe('green') + expect(process('var(--escaped\\,name)')).toBe('green') // Values from the theme take precedence over fallbacks - expect(replaceCssVarsWithFallbacks(state, 'var(--known, red)')).toBe('blue') + expect(process('var(--known, red)')).toBe('blue') // Unknown theme keys use a fallback if provided - expect(replaceCssVarsWithFallbacks(state, 'var(--unknown, red)')).toBe(' red') + expect(process('var(--unknown, red)')).toBe(' red') // Unknown theme keys without fallbacks are not replaced - expect(replaceCssVarsWithFallbacks(state, 'var(--unknown)')).toBe('var(--unknown)') + expect(process('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') + expect(process('var(--unknown,var(--unknown-2,red))')).toBe('red') + expect(process('var(--level-1)')).toBe('blue') + expect(process('var(--level-2)')).toBe('blue') + expect(process('var(--level-3)')).toBe('blue') // Circular replacements don't cause infinite loops - expect(replaceCssVarsWithFallbacks(state, 'var(--circular-1)')).toBe('var(--circular-1)') - expect(replaceCssVarsWithFallbacks(state, 'var(--circular-2)')).toBe('var(--circular-2)') - expect(replaceCssVarsWithFallbacks(state, 'var(--circular-3)')).toBe('var(--circular-3)') + expect(process('var(--circular-1)')).toBe('var(--circular-3)') + expect(process('var(--circular-2)')).toBe('var(--circular-1)') + expect(process('var(--circular-3)')).toBe('var(--circular-2)') }) test('recursive theme replacements', () => { - let map = new Map([ - ['--color-a', 'var(--color-a)'], - ['--color-b', 'rgb(var(--color-b))'], - ['--color-c', 'rgb(var(--channel) var(--channel) var(--channel))'], - ['--channel', '255'], - - ['--color-d', 'rgb(var(--indirect) var(--indirect) var(--indirect))'], - ['--indirect', 'var(--channel)'], - ['--channel', '255'], - - ['--mutual-a', 'calc(var(--mutual-b) * 1)'], - ['--mutual-b', 'calc(var(--mutual-a) + 1)'], - ]) - - let state: State = { - enabled: true, - features: [], - designSystem: { - theme: { prefix: null } as any, - resolveThemeValue: (name) => map.get(name) ?? null, - } as DesignSystem, - } - - expect(replaceCssVarsWithFallbacks(state, 'var(--color-a)')).toBe('var(--color-a)') - expect(replaceCssVarsWithFallbacks(state, 'var(--color-b)')).toBe('rgb(var(--color-b))') - expect(replaceCssVarsWithFallbacks(state, 'var(--color-c)')).toBe('rgb(255 255 255)') - - // This one is wrong but fixing it without breaking the infinite recursion guard is complex - expect(replaceCssVarsWithFallbacks(state, 'var(--color-d)')).toBe( - 'rgb(255 var(--indirect) var(--indirect))', - ) - - expect(replaceCssVarsWithFallbacks(state, 'var(--mutual-a)')).toBe( - 'calc(calc(var(--mutual-a) + 1) * 1)', - ) - expect(replaceCssVarsWithFallbacks(state, 'var(--mutual-b)')).toBe( - 'calc(calc(var(--mutual-b) * 1) + 1)', - ) -}) - -test('recursive theme replacements (inlined)', () => { - let map = new Map([ - ['--color-a', 'var(--color-a)'], - ['--color-b', 'rgb(var(--color-b))'], - ['--color-c', 'rgb(var(--channel) var(--channel) var(--channel))'], - ['--channel', '255'], - - ['--color-d', 'rgb(var(--indirect) var(--indirect) var(--indirect))'], - ['--indirect', 'var(--channel)'], - ['--channel', '255'], - - ['--mutual-a', 'calc(var(--mutual-b) * 1)'], - ['--mutual-b', 'calc(var(--mutual-a) + 1)'], - ]) - - let state: State = { - enabled: true, - features: [], - designSystem: { - theme: { prefix: null } as any, - resolveThemeValue: (name) => map.get(name) ?? null, - } as DesignSystem, - } - - expect(inlineThemeValues('var(--color-a)', state)).toBe('var(--color-a)') - expect(inlineThemeValues('var(--color-b)', state)).toBe('rgb(var(--color-b))') - expect(inlineThemeValues('var(--color-c)', state)).toBe('rgb(255 255 255)') - - // This one is wrong but fixing it without breaking the infinite recursion guard is complex - expect(inlineThemeValues('var(--color-d)', state)).toBe( - 'rgb(255 var(--indirect) var(--indirect))', - ) - - expect(inlineThemeValues('var(--mutual-a)', state)).toBe('calc(calc(var(--mutual-a) + 1) * 1)') - expect(inlineThemeValues('var(--mutual-b)', state)).toBe('calc(calc(var(--mutual-b) * 1) + 1)') + let process = createProcessor({ + style: 'full-evaluation', + fontSize: 16, + variables: new Map([ + // Cyclic variables + ['--color-a', 'var(--color-a)'], + ['--color-b', 'rgb(var(--color-b))'], + ['--color-c', 'rgb(var(--color-c) var(--color-c) var(--color-c))'], + + ['--mutual-a', 'calc(var(--mutual-b) * 1)'], + ['--mutual-b', 'calc(var(--mutual-a) + 1)'], + + ['--circle-cw-1', 'var(--circle-cw-2)'], + ['--circle-cw-2', 'var(--circle-cw-3)'], + ['--circle-cw-3', 'var(--circle-cw-1)'], + + ['--circle-ccw-1', 'var(--circle-ccw-3)'], + ['--circle-ccw-2', 'var(--circle-ccw-1)'], + ['--circle-ccw-3', 'var(--circle-ccw-2)'], + ]), + }) + + expect(process('var(--color-a)')).toBe('var(--color-a)') + expect(process('var(--color-b)')).toBe('rgb(var(--color-b))') + expect(process('var(--color-c)')).toBe('rgb(var(--color-c) var(--color-c) var(--color-c))') + + expect(process('var(--mutual-a)')).toBe('calc(var(--mutual-b) * 1)') + expect(process('var(--mutual-b)')).toBe('calc(var(--mutual-a) + 1)') + + expect(process('var(--circle-cw-1)')).toBe('var(--circle-cw-2)') + expect(process('var(--circle-cw-2)')).toBe('var(--circle-cw-3)') + expect(process('var(--circle-cw-3)')).toBe('var(--circle-cw-1)') + + expect(process('var(--circle-ccw-1)')).toBe('var(--circle-ccw-3)') + expect(process('var(--circle-ccw-2)')).toBe('var(--circle-ccw-1)') + expect(process('var(--circle-ccw-3)')).toBe('var(--circle-ccw-2)') }) test('Evaluating CSS calc expressions', () => { - expect(replaceCssCalc('calc(1px + 1px)', (node) => evaluateExpression(node.value))).toBe('2px') - expect(replaceCssCalc('calc(1px * 4)', (node) => evaluateExpression(node.value))).toBe('4px') - expect(replaceCssCalc('calc(1px / 4)', (node) => evaluateExpression(node.value))).toBe('0.25px') - expect(replaceCssCalc('calc(1rem + 1px)', (node) => evaluateExpression(node.value))).toBe( - 'calc(1rem + 1px)', - ) - - expect(replaceCssCalc('calc(1.25 / 0.875)', (node) => evaluateExpression(node.value))).toBe( - '1.428571', - ) - - expect(replaceCssCalc('calc(1/4 * 100%)', (node) => evaluateExpression(node.value))).toBe('25%') - - expect(replaceCssCalc('calc(0.12345rem * 0.5)', (node) => evaluateExpression(node.value))).toBe( - '0.061725rem', - ) - - expect( - replaceCssCalc('calc(0.12345789rem * 0.5)', (node) => evaluateExpression(node.value)), - ).toBe('0.061729rem') + let process = createProcessor({ + style: 'full-evaluation', + fontSize: 16, + variables: new Map(), + }) + + expect(process('calc(1/4 * 100%)')).toBe('25%') + expect(process('calc(1px + 1px)')).toBe('2px') + expect(process('calc(1px * 4)')).toBe('4px') + expect(process('calc(1px / 4)')).toBe('0.25px') + expect(process('calc(1rem + 1px)')).toBe('calc(1rem + 1px)') + expect(process('calc(1.25 / 0.875)')).toBe('1.428571') + expect(process('calc(1/4 * 100%)')).toBe('25%') + expect(process('calc(0.12345rem * 0.5)')).toBe('0.061725rem') + expect(process('calc(0.12345789rem * 0.5)')).toBe('0.061729rem') }) test('Inlining calc expressions using the design system', () => { - let map = new Map([ - ['--spacing', '0.25rem'], - ['--color-red-500', 'oklch(0.637 0.237 25.331)'], - ]) - - let state: State = { - enabled: true, - features: [], - designSystem: { - theme: { prefix: null } as any, - resolveThemeValue: (name) => map.get(name) ?? null, - } as DesignSystem, - } - - let settings: TailwindCssSettings = { - rootFontSize: 10, - } as any + let process = createProcessor({ + style: 'user-presentable', + + fontSize: 10, + variables: new Map([ + ['--spacing', '0.25rem'], + ['--color-red-500', 'oklch(0.637 0.237 25.331)'], + ]), + }) // Inlining calc expressions // + pixel equivalents - expect(addThemeValues('calc(var(--spacing) * 4)', state, settings)).toBe( - 'calc(var(--spacing) * 4) /* 1rem = 10px */', - ) + expect(process('calc(var(--spacing) * 4)')).toBe('calc(var(--spacing) * 4) /* 1rem = 10px */') - expect(addThemeValues('calc(var(--spacing) / 4)', state, settings)).toBe( + expect(process('calc(var(--spacing) / 4)')).toBe( 'calc(var(--spacing) / 4) /* 0.0625rem = 0.625px */', ) - expect(addThemeValues('calc(var(--spacing) * 1)', state, settings)).toBe( - 'calc(var(--spacing) * 1) /* 0.25rem = 2.5px */', - ) + expect(process('calc(var(--spacing) * 1)')).toBe('calc(var(--spacing) * 1) /* 0.25rem = 2.5px */') - expect(addThemeValues('calc(var(--spacing) * -1)', state, settings)).toBe( + expect(process('calc(var(--spacing) * -1)')).toBe( 'calc(var(--spacing) * -1) /* -0.25rem = -2.5px */', ) - expect(addThemeValues('calc(var(--spacing) + 1rem)', state, settings)).toBe( + expect(process('calc(var(--spacing) + 1rem)')).toBe( 'calc(var(--spacing) + 1rem) /* 1.25rem = 12.5px */', ) - expect(addThemeValues('calc(var(--spacing) - 1rem)', state, settings)).toBe( + expect(process('calc(var(--spacing) - 1rem)')).toBe( 'calc(var(--spacing) - 1rem) /* -0.75rem = -7.5px */', ) - expect(addThemeValues('calc(var(--spacing) + 1px)', state, settings)).toBe( + expect(process('calc(var(--spacing) + 1px)')).toBe( 'calc(var(--spacing) /* 0.25rem = 2.5px */ + 1px)', ) // Color equivalents - expect(addThemeValues('var(--color-red-500)', state, settings)).toBe( + expect(process('var(--color-red-500)')).toBe( 'var(--color-red-500) /* oklch(0.637 0.237 25.331) = #fb2c36 */', ) }) + +test('wip', () => { + let process = createProcessor({ + style: 'full-evaluation', + fontSize: 16, + variables: new Map([ + ['--known', '1px solid var(--level-1)'], + ['--level-1', 'a theme(--level-2) a'], + ['--level-2', 'b var(--level-3) b'], + ['--level-3', 'c theme(--level-4) c'], + ['--level-4', 'd var(--level-5) d'], + ['--level-5', 'e light-dark(var(--level-6), blue) e'], + ['--level-6', 'f calc(3 * var(--idk, 7px)) f'], + + ['--a', '0.5'], + ['--b', '255'], + ['--c', '50%'], + ['--known-2', 'color-mix(in srgb, rgb(0 var(--b) 0 / var(--a)) var(--c), transparent)'], + ]), + }) + + expect(process('var(--known)')).toBe('1px solid a b c d e f 21px f e d c b a') + expect(process('var(--known-2)')).toBe('rgba(0, 255, 0, 0.25)') + + expect(process('var(--tw-text-shadow-alpha)')).toBe('100%') + expect(process('var(--tw-drop-shadow-alpha)')).toBe('100%') + expect(process('var(--tw-shadow-alpha)')).toBe('100%') + expect(process('1rem')).toBe('1rem /* 1rem = 16px */') +}) diff --git a/packages/tailwindcss-language-service/src/util/rewriting/index.ts b/packages/tailwindcss-language-service/src/util/rewriting/index.ts index 391449afc..5466739c1 100644 --- a/packages/tailwindcss-language-service/src/util/rewriting/index.ts +++ b/packages/tailwindcss-language-service/src/util/rewriting/index.ts @@ -1,5 +1 @@ -export * from './replacements' -export * from './var-fallbacks' -export * from './calc' -export * from './add-theme-values' -export * from './inline-theme-values' +export * from './process' 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 deleted file mode 100644 index 26349f685..000000000 --- a/packages/tailwindcss-language-service/src/util/rewriting/inline-theme-values.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { State, TailwindCssSettings } from '../state' - -import { evaluateExpression } from './calc' -import { resolveVariableValue } from './lookup' -import { replaceCssVars, replaceCssCalc } from './replacements' - -export function inlineThemeValues(css: string, state: State): string { - if (!state.designSystem) return css - - let seen = new Set() - - css = replaceCssCalc(css, (expr) => { - let inlined = replaceCssVars(expr.value, { - replace({ name, fallback }) { - if (!name.startsWith('--')) return null - - // TODO: This isn't quite right as we might skip expanding a variable - // that should be expanded - if (seen.has(name)) return null - - let value = resolveVariableValue(state.designSystem, name) - if (value === null) return fallback - if (value.includes('var(')) seen.add(name) - - return value - }, - }) - - return evaluateExpression(inlined) - }) - - css = replaceCssVars(css, { - replace({ name, fallback }) { - if (!name.startsWith('--')) return null - - // TODO: This isn't quite right as we might skip expanding a variable - // that should be expanded - if (seen.has(name)) return null - - let value = resolveVariableValue(state.designSystem, name) - if (value === null) return fallback - if (value.includes('var(')) seen.add(name) - - return value - }, - }) - - return css -} diff --git a/packages/tailwindcss-language-service/src/util/rewriting/process.ts b/packages/tailwindcss-language-service/src/util/rewriting/process.ts new file mode 100644 index 000000000..7c1ed18ac --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/rewriting/process.ts @@ -0,0 +1,310 @@ +import { + isTokenComma, + isTokenDimension, + isTokenIdent, + isTokenPercentage, + TokenType, +} from '@csstools/css-tokenizer' +import { + isFunctionNode, + ComponentValue, + isTokenNode, + isWhitespaceNode, + FunctionNode, + CommentNode, + WhitespaceNode, + TokenNode, +} from '@csstools/css-parser-algorithms' +import { State } from '../state' +import { walk, VisitFn } from './walk' +import { calcFromComponentValues } from '@csstools/css-calc' +import * as culori from 'culori' +import { createCssSyntax, CssSyntax } from './syntax' +import { colorMixFromComponentValue } from './color' +import { computeSubstitutions } from './vars' + +export interface ProcessOptions { + state: State + + /** + * How to evaluate the CSS value + * + * - **`full-evaluation`**: substitute variables, evaluate calc and color-mix, + * handle relative color syntax, etc… such that the value is in its simplest + * possible form. + * + * - **`theme-evaluation`**: … + * + * - **`user-presentable`**: … + * + */ + style: 'user-presentable' | 'theme-evaluation' | 'full-evaluation' +} + +interface Context { + css: CssSyntax + + // WIP + cyclic: Set + + /** + * The font size to use for `rem` and `em` values + */ + fontSize: number | null + + /** + * A list of CSS variables that can be substituted when referenced + * + * These may be referenced via `var(…)`, `theme(…)`, etc… + */ + variables: Map + + /** + * A list of seen nodes + */ + seen: Set +} + +export interface ProcessorOptions { + /** + * + */ + style: 'full-evaluation' | 'user-presentable' + + /** + * The font size to use for `rem` and `em` values + */ + fontSize: number | null + + // WIP + state?: State + + /** + * A list of CSS variables that can be substituted when referenced + * + * These may be referenced via `var(…)`, `theme(…)`, etc… + */ + variables: Map +} + +export function createProcessor(opts: ProcessorOptions): (value: string) => string { + let css = createCssSyntax() + + let design = opts.state?.designSystem + + if (design) { + let prefix = design.theme.prefix + for (let [name] of design.theme.entries()) { + if (name.startsWith(`--${prefix}-`)) { + name = '--' + name.slice(3 + prefix.length) + } + + let value = design.resolveThemeValue?.(name, true) + + opts.variables.set(name, value) + + if (prefix !== '') { + opts.variables.set(`--${prefix}-${name.slice(2)}`, value) + } + } + } + + let { variables, cyclic } = computeSubstitutions(css, opts.variables) + + let ctx: Context = { + css, + cyclic, + variables, + fontSize: opts.fontSize, + seen: new Set(), + } + + // 1. Replace CSS vars with fallbacks √ + // 2. Down-level color mix √ + // 3. resolving light dark √ + // 4. Evaluate calc √ + // 5. Add equivalents after: + // - rem √ + // - em √ + // - colors + // - var(…) + // - theme(…) + + const FNS: Record ComponentValue[] | undefined> = { + // Replace light-dark(x, y) with the light color + 'light-dark': evaluateLightDark, + calc: evaluateCalc, + 'color-mix': evaluateColorMix, + } + + return (value: string): string => { + let css = createCssSyntax() + let list = css.components(value) + + // + // Step 1: Variable substitution + // + // We perform this *once* to ensure there are no infinite loops. This works because recursive + // replacements are unnecessary since that was handled ahead of time in computeSubstitutions. + // + let toReplace: [ComponentValue[], ComponentValue[], FunctionNode][] = [] + + walk(list, { + exit(node, list) { + if (!isFunctionNode(node)) return null + if (node.value.length === 0) return null + + let name = node.getName() + if (name !== 'var' && name !== 'theme' && name !== '--theme') return null + + let result = substituteVariables(node, ctx) + if (!result) return null + + toReplace.push([list, result, node]) + + return null + }, + }) + + for (let [list, result, fn] of toReplace) { + let index = list.indexOf(fn) + if (index === -1) continue + list.splice(index, 1, ...result) + } + + // + // Step 2: Function evaluation + // + // This must be done inside-out (on `exit`) because an inner function may evaluate to something + // an outer function needs for computation. + // + walk(list, { + exit: (node) => { + if (!isFunctionNode(node)) return null + if (node.value.length === 0) return null + + let name = node.getName() + if (name === 'calc') return evaluateCalc(node) + if (name === 'color-mix') return evaluateColorMix(node, ctx) + if (name === 'light-dark') return evaluateLightDark(node) + + // We intentionally don't replace theme(…) and --theme(…) here + // That should be done *once* in the substitution phase + // This is here to handle nested unknown variables + if (name === 'var') return substituteVariables(node, ctx, true) + + return null + }, + }) + + // + // Step 3: Pixel equivalents + // + if (opts.style === 'user-presentable') { + let added = false + walk(list, { + exit: (node) => { + let r = addPixelEquivalents(node, ctx) + if (!r) return null + added = true + return r + }, + }) + + if (!added) return value + + return `${value} ${css.componentsToString(list)}` + } + + return css.componentsToString(list) + } +} + +function addPixelEquivalents(node: ComponentValue, ctx: Context): ComponentValue[] | null { + if (!ctx.fontSize) return null + if (!isTokenNode(node)) return null + if (!isTokenDimension(node.value)) return null + + let extra = node.value[4] + if (extra.unit !== 'em' && extra.unit !== 'rem') return null + + let valueInPx = extra.value * ctx.fontSize + + return [ + node, + new WhitespaceNode([[TokenType.Whitespace, ` `, 0, 0, undefined]]), + new CommentNode([ + TokenType.Comment, + `/* ${node.value[1]} = ${valueInPx}px */`, + 0, + 0, + undefined, + ]), + ] +} + +function evaluateLightDark(fn: FunctionNode): ComponentValue[] | undefined { + let values: ComponentValue[] = [] + + for (let value of fn.value) { + if (isTokenNode(value) && isTokenComma(value.value)) break + + values.push(value) + } + + return values +} + +function evaluateCalc(fn: FunctionNode): ComponentValue[] | null { + let solved = calcFromComponentValues([[fn]], { + // Ensure evaluation of random() is deterministic + randomCaching: { + propertyName: 'width', + propertyN: 1, + elementID: '1', + documentID: '1', + }, + + // Limit precision to keep values environment independent + precision: 6, + }) + + return solved[0] ?? null +} + +function evaluateColorMix(fn: FunctionNode, ctx: Context): ComponentValue[] | null { + let color = colorMixFromComponentValue(fn) + if (!color) return null + + return ctx.css.components(culori.formatRgb(color)) +} + +function substituteVariables( + fn: FunctionNode, + ctx: Context, + unknownOnly: boolean = false, +): ComponentValue[] | null { + for (let i = 0; i < fn.value.length; ++i) { + let value = fn.value[i] + + if (!isTokenNode(value)) continue + + if (isTokenIdent(value.value)) { + let name = value.value[1] + + // Lookup in the theme + let resolvedValue = ctx.variables.get(name) + if (unknownOnly && resolvedValue) continue + if (resolvedValue) return resolvedValue + } + + // The var(…) or theme(…) fn couldn't be resolved to a value + // so we replace it with the fallback value which is everything + // after the first comma + else if (isTokenComma(value.value)) { + return fn.value.slice(i + 1) + } + } + + return null +} diff --git a/packages/tailwindcss-language-service/src/util/rewriting/replacements.ts b/packages/tailwindcss-language-service/src/util/rewriting/replacements.ts deleted file mode 100644 index df63e1aef..000000000 --- a/packages/tailwindcss-language-service/src/util/rewriting/replacements.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * A var(…) expression which may have an optional fallback value - */ -export interface CssVariable { - kind: 'css-variable' - range: Range - name: string - fallback: string | null -} - -export interface Range { - /** The zero-based offset where this node starts */ - start: number - - /** The zero-based offset where this node ends */ - 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, recursive = true }: ReplacerOptions, -): string { - let seen = new Set() - - for (let i = 0; i < str.length; ++i) { - if (!str.startsWith('var(', i)) continue - - let depth = 0 - let fallbackStart = null - - for (let j = i + 4; i < str.length; ++j) { - if (str[j] === '(') { - 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) { - let varName: string - let fallback: string | null - - if (fallbackStart === null) { - varName = str.slice(i + 4, j) - fallback = null - } else { - varName = str.slice(i + 4, fallbackStart - 1) - fallback = str.slice(fallbackStart, j) - } - - let replacement = replace({ - kind: 'css-variable', - name: varName, - fallback, - range: { start: i, end: j }, - }) - - if (replacement !== null) { - str = str.slice(0, i) + replacement + str.slice(j + 1) - } - - // 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 - } - } - } - - return str -} - -/** - * A calc(…) expression in a CSS value - */ -export interface CalcExpression { - kind: 'calc-expression' - range: Range - value: string -} - -export type CssCalcReplacer = (node: CalcExpression) => string | null - -/** - * Replace all calc expression in a string using the replacer function - */ -export function replaceCssCalc(str: string, replace: CssCalcReplacer): string { - for (let i = 0; i < str.length; ++i) { - if (!str.startsWith('calc(', i)) continue - - let depth = 0 - - for (let j = i + 5; i < str.length; ++j) { - if (str[j] === '(') { - depth++ - } else if (str[j] === ')' && depth > 0) { - depth-- - } else if (str[j] === ')' && depth === 0) { - let expr = str.slice(i + 5, j) - - let replacement = replace({ - kind: 'calc-expression', - value: expr, - range: { - start: i, - end: j, - }, - }) - - if (replacement !== null) { - 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 - break - } - } - } - - return str -} diff --git a/packages/tailwindcss-language-service/src/util/rewriting/syntax.ts b/packages/tailwindcss-language-service/src/util/rewriting/syntax.ts new file mode 100644 index 000000000..e3fdf3be6 --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/rewriting/syntax.ts @@ -0,0 +1,43 @@ +import * as parser from '@csstools/css-parser-algorithms' +import * as tokenizer from '@csstools/css-tokenizer' +import { DefaultMap } from '../default-map' +import { CSSToken } from '@csstools/css-tokenizer' + +export interface CssSyntax { + tokens(css: string): tokenizer.CSSToken[] + component(css: string): parser.ComponentValue | undefined + components(css: string): parser.ComponentValue[] + componentsFromTokens(css: CSSToken[]): parser.ComponentValue[] + componentLists(css: string): parser.ComponentValue[][] + componentListsFromTokens(css: CSSToken[]): parser.ComponentValue[][] + + tokensToString(tokens: tokenizer.CSSToken[]): string + componentsToString(values: parser.ComponentValue[]): string + componentListsToString(values: parser.ComponentValue[][]): string +} + +export function createCssSyntax(): CssSyntax { + let tokens = new DefaultMap((css) => tokenizer.tokenize({ css })) + let component = new DefaultMap((css) => parser.parseComponentValue(tokens.get(css))) + let components = new DefaultMap((css) => parser.parseListOfComponentValues(tokens.get(css))) + let componentLists = new DefaultMap((css) => + parser.parseCommaSeparatedListOfComponentValues(tokens.get(css)), + ) + + return { + // Tokenization + tokens: (css) => tokens.get(css), + tokensToString: (tokens) => tokenizer.stringify(...tokens), + + // Parsing CSS syntax + component: (css) => component.get(css), + + components: (css) => components.get(css), + componentsFromTokens: (tokens) => parser.parseListOfComponentValues(tokens), + componentsToString: (values) => parser.stringify([values]), + + componentLists: (css) => componentLists.get(css), + componentListsFromTokens: (tokens) => parser.parseCommaSeparatedListOfComponentValues(tokens), + componentListsToString: (lists) => parser.stringify(lists), + } +} diff --git a/packages/tailwindcss-language-service/src/util/rewriting/topological-sort.ts b/packages/tailwindcss-language-service/src/util/rewriting/topological-sort.ts new file mode 100644 index 000000000..831e7a311 --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/rewriting/topological-sort.ts @@ -0,0 +1,36 @@ +export function topologicalSort( + graph: Map>, + options: { onCircularDependency: (path: Key[], start: Key) => void }, +): Key[] { + let seen = new Set() + let wip = new Set() + + let sorted: Key[] = [] + + function visit(node: Key, path: Key[] = []) { + if (!graph.has(node)) return + if (seen.has(node)) return + + // Circular dependency detected + if (wip.has(node)) return options.onCircularDependency?.(path, node) + + wip.add(node) + + for (let dependency of graph.get(node) ?? []) { + path.push(node) + visit(dependency, path) + path.pop() + } + + seen.add(node) + wip.delete(node) + + sorted.push(node) + } + + for (let node of graph.keys()) { + visit(node) + } + + return sorted +} diff --git a/packages/tailwindcss-language-service/src/util/rewriting/var-fallbacks.ts b/packages/tailwindcss-language-service/src/util/rewriting/var-fallbacks.ts deleted file mode 100644 index 2eb8c918c..000000000 --- a/packages/tailwindcss-language-service/src/util/rewriting/var-fallbacks.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { State } from '../state' -import { resolveVariableValue } from './lookup' -import { replaceCssVars } from './replacements' - -export function replaceCssVarsWithFallbacks(state: State, str: string): string { - let seen = new Set() - - 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('--')) { - // TODO: This isn't quite right as we might skip expanding a variable - // that should be expanded - if (seen.has(name)) return null - let value = resolveVariableValue(state.designSystem, name) - if (value !== null) { - if (value.includes('var(')) { - seen.add(name) - } - - return value - } - } - - if (fallback) { - return fallback - } - - if ( - name === '--tw-text-shadow-alpha' || - name === '--tw-drop-shadow-alpha' || - name === '--tw-shadow-alpha' - ) { - return '100%' - } - - // Don't touch it since there's no suitable replacement - return null - }, - }) -} diff --git a/packages/tailwindcss-language-service/src/util/rewriting/vars.ts b/packages/tailwindcss-language-service/src/util/rewriting/vars.ts new file mode 100644 index 000000000..86db49670 --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/rewriting/vars.ts @@ -0,0 +1,261 @@ +import { isTokenFunction } from '@csstools/css-tokenizer' +import { CSSToken } from '@csstools/css-tokenizer' +import { isTokenCloseParen } from '@csstools/css-tokenizer' +import { isTokenEOF } from '@csstools/css-tokenizer' +import { TokenFunction } from '@csstools/css-tokenizer' +import { TokenIdent } from '@csstools/css-tokenizer' +import { isTokenIdent } from '@csstools/css-tokenizer' +import { isTokenComma } from '@csstools/css-tokenizer' +import { DefaultMap } from '../default-map' +import { createCssSyntax, CssSyntax } from './syntax' +import { topologicalSort } from './topological-sort' +import { ComponentValue } from '@csstools/css-parser-algorithms' + +export type AnyNode = OtherNode | FunctionNode + +export interface OtherNode { + kind: 'other' + tokens: CSSToken[] +} + +export interface FunctionNode { + kind: 'fn' + start: TokenFunction + end: CSSToken | undefined + name: TokenIdent | undefined + fallback: AnyNode[] +} + +/** + * Parse a sequence of tokens into nodes that represent: + * - CSS variables references (var(…), theme(…), etc…) + * - Everything else + */ +export function parse(tokens: CSSToken[]): { + nodes: AnyNode[] + refs: FunctionNode[] +} { + interface Frame { + start?: TokenFunction + value: AnyNode[] + name?: TokenIdent + comma: boolean + } + + let refs: FunctionNode[] = [] + + let root: Frame = { value: [], comma: false } + let stack: Frame[] = [root] + + let i = 0 + + while (true) { + let t = tokens[i] + + // End of input -> close all open functions + if (!t || isTokenEOF(t)) { + if (stack.length === 1) { + return { + nodes: root.value, + refs, + } + } + + let frame = stack.pop()! + + let node: FunctionNode = { + kind: 'fn', + start: frame.start!, + end: t, + name: frame.name, + fallback: frame.value, + } + + refs.push(node) + stack[stack.length - 1].value.push(node) + + continue + } + + // Start of a function -> push new frame + if (isVariableFunction(t)) { + stack.push({ start: t, value: [], comma: false }) + } + + // Close of a function -> pop and append to previous frame + else if (isTokenCloseParen(t) && stack.length > 1) { + let frame = stack.pop()! + let node: FunctionNode = { + kind: 'fn', + start: frame.start!, + end: t, + name: frame.name, + fallback: frame.value, + } + + refs.push(node) + stack[stack.length - 1].value.push(node) + } + + // Plain tokens + else { + let frame = stack[stack.length - 1] + + // Make sure there's an ident before a comma inside a var(…) function + // otherwise it's invalid + if (frame.start) { + if (isTokenIdent(t) && !frame.name && !frame.comma) { + frame.name = t as TokenIdent + i += 1 + continue + } else if (isTokenComma(t)) { + frame.comma = true + } + } + + let arr = frame.value + let last = arr[arr.length - 1] + if (last && last.kind === 'other') { + last.tokens.push(t) + } else { + arr.push({ kind: 'other', tokens: [t] }) + } + } + + i += 1 + } +} + +export function computeSubstitutions( + css: CssSyntax, + vars: Map, +): { + variables: Map + cyclic: Set +} { + // + // Step 1: Tokenize all known variables + // + vars.set('--tw-text-shadow-alpha', '100%') + vars.set('--tw-drop-shadow-alpha', '100%') + vars.set('--tw-shadow-alpha', '100%') + + let list = new Map() + for (let [name, value] of vars) list.set(name, css.tokens(value)) + + // + // Step 2: Build dependency graph of variable references + // + let dependencies = new Map>() + let locations = new DefaultMap(() => []) + + for (let [root, tokens] of list) { + let deps = new Set() + + let { refs } = parse(tokens) + + for (let node of refs) { + let name = node.name?.[1] + if (!name) continue + + deps.add(name) + locations.get(name).push({ tokens, node }) + } + + dependencies.set(root, deps) + } + + // Make sure every seen variable has a recorded dependency set + for (let deps of dependencies.values()) { + for (let dep of deps) { + if (dependencies.get(dep)) continue + dependencies.set(dep, new Set()) + } + } + + // + // Step 3: Topological sort all known variables + // + // This will allow us to: + // - Identify and skip self-referential variables + // - Perform variable substitutions of dependencies first + // + let cyclic = new Set() + let sorted = topologicalSort(dependencies, { + onCircularDependency: (path) => { + for (let name of path) { + cyclic.add(name) + } + }, + }) + + // + // Step 4: Variable substitution + // + for (let name of sorted) { + for (let { tokens, node } of locations.get(name)) { + let start = tokens.indexOf(node.start) + let end = node.end ? tokens.indexOf(node.end) : tokens.length + + // This doesn't exist in the list + // This shouldn't actually happen, right? + if (start === -1 || end === -1) continue + + // If we don't know the value of this variable, or it's value is cyclic + // then we'll need to replace it with the fallback if one is available + let value = list.get(name) + if (!value || cyclic.has(name)) { + // No fallback available + if (node.fallback.length === 0) continue + + // TODO: Need a test to verify this happens recursively + let r = node.fallback.flatMap(tokensIn) + + // TODO: This shouldn't happen, can we validate this? + if (!isTokenComma(r[0])) continue + + value = r.slice(1) + } + + tokens.splice(start, end - start + 1, ...(value ?? [])) + } + } + + // Temp fix: + for (let [name, tokens] of list) list.set(name, css.tokens(css.tokensToString(tokens))) + + // let tokens = list.get('--known') ?? [] + + // console.log('tokens: ', css.tokensToString(tokens)) + // console.log('components: ', css.componentsToString(css.componentsFromTokens(tokens))) + + // console.log('reparse tokens...') + // tokens = css.tokens(css.tokensToString(tokens)) + + // console.log('tokens: ', css.tokensToString(tokens)) + // console.log('components: ', css.componentsToString(css.componentsFromTokens(tokens))) + + // + // Step 5: Parsing all variables values + // + let parsed = new Map() + for (let [name, tokens] of list) parsed.set(name, css.componentsFromTokens(tokens)) + + return { variables: parsed, cyclic } +} + +function isVariableFunction(t: CSSToken): t is TokenFunction { + return isTokenFunction(t) && (t[1] === 'var(' || t[1] === 'theme(' || t[1] === '--theme(') +} + +function tokensIn(node: AnyNode): CSSToken[] { + if (node.kind === 'other') { + return node.tokens + } + + if (node.kind === 'fn') { + return [node.start, node.name, ...node.fallback.flatMap(tokensIn), node.end] + } + + return [] +} diff --git a/packages/tailwindcss-language-service/src/util/rewriting/walk.ts b/packages/tailwindcss-language-service/src/util/rewriting/walk.ts new file mode 100644 index 000000000..7ca31714e --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/rewriting/walk.ts @@ -0,0 +1,44 @@ +import { ComponentValue, isFunctionNode, isSimpleBlockNode } from '@csstools/css-parser-algorithms' + +export interface VisitFn { + (value: ComponentValue, siblings: ComponentValue[]): ComponentValue[] | null +} + +export interface Visitor { + enter?: VisitFn + exit?: VisitFn +} + +export function walk(list: ComponentValue[], visit: Visitor): void { + let seen = new Set() + + for (let i = 0; i < list.length; ++i) { + let node = list[i] + if (seen.has(node)) continue + seen.add(node) + + let replacement = visit.enter?.(node, list) + + // If the nodes have been replaced then we need to visit the new nodes + // before visiting children + if (replacement) { + list.splice(i, 1, ...replacement) + i -= 1 + continue + } + + if (isFunctionNode(node) || isSimpleBlockNode(node)) { + walk(node.value, visit) + } + + replacement = visit.exit?.(node, list) + + // If the nodes have been replace then we need to visit the new nodes + // before visiting children + if (replacement) { + list.splice(i, 1, ...replacement) + i -= 1 + continue + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9780811ee..e45541569 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,18 +251,24 @@ importers: packages/tailwindcss-language-service: dependencies: + '@csstools/color-helpers': + specifier: ^5.1.0 + version: 5.1.0 '@csstools/css-calc': - specifier: 2.1.2 - version: 2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + specifier: ^2.1.4 + version: 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': + specifier: 3.1.0 + version: 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': - specifier: 3.0.4 - version: 3.0.4(@csstools/css-tokenizer@3.0.3) + specifier: ^3.0.5 + version: 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': - specifier: 3.0.3 - version: 3.0.3 + specifier: ^3.0.4 + version: 3.0.4 '@csstools/media-query-list-parser': - specifier: 2.0.4 - version: 2.0.4(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + specifier: ^4.0.3 + version: 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@types/culori': specifier: ^2.1.0 version: 2.1.1 @@ -463,29 +469,40 @@ packages: resolution: {integrity: sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==} engines: {node: '>=6.9.0'} - '@csstools/css-calc@2.1.2': - resolution: {integrity: sha512-TklMyb3uBB28b5uQdxjReG4L80NxAqgrECqLZFQbyLekwwlcDDS8r3f07DKqeo8C4926Br0gf/ZDe17Zv4wIuw==} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} engines: {node: '>=18'} peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.4 - '@csstools/css-tokenizer': ^3.0.3 + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-parser-algorithms@3.0.4': - resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==} + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} engines: {node: '>=18'} peerDependencies: - '@csstools/css-tokenizer': ^3.0.3 + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-tokenizer@3.0.3': - resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==} + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 - '@csstools/media-query-list-parser@2.0.4': - resolution: {integrity: sha512-GyYot6jHgcSDZZ+tLSnrzkR7aJhF2ZW6d+CXH66mjy5WpAQhZD4HDke2OQ36SivGRWlZJpAz7TzbW6OKlEpxAA==} - engines: {node: ^14 || ^16 || >=18} + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/media-query-list-parser@4.0.3': + resolution: {integrity: sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==} + engines: {node: '>=18'} peerDependencies: - '@csstools/css-parser-algorithms': ^2.1.1 - '@csstools/css-tokenizer': ^2.1.1 + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 '@esbuild/aix-ppc64@0.25.5': resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} @@ -2811,21 +2828,30 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@csstools/css-calc@2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: - '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) - '@csstools/css-tokenizer': 3.0.3 + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 - '@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)': + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': dependencies: - '@csstools/css-tokenizer': 3.0.3 + '@csstools/css-tokenizer': 3.0.4 - '@csstools/css-tokenizer@3.0.3': {} + '@csstools/css-tokenizer@3.0.4': {} - '@csstools/media-query-list-parser@2.0.4(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + '@csstools/media-query-list-parser@4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': dependencies: - '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) - '@csstools/css-tokenizer': 3.0.3 + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 '@esbuild/aix-ppc64@0.25.5': optional: true