diff --git a/packages/tailwindcss-language-service/package.json b/packages/tailwindcss-language-service/package.json index 2a1e3079..4a11e6c7 100644 --- a/packages/tailwindcss-language-service/package.json +++ b/packages/tailwindcss-language-service/package.json @@ -13,8 +13,9 @@ "test": "vitest" }, "dependencies": { - "@csstools/css-parser-algorithms": "2.1.1", - "@csstools/css-tokenizer": "2.1.1", + "@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", "@types/culori": "^2.1.0", "@types/moo": "0.5.3", diff --git a/packages/tailwindcss-language-service/src/util/default-map.ts b/packages/tailwindcss-language-service/src/util/default-map.ts new file mode 100644 index 00000000..a045b828 --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/default-map.ts @@ -0,0 +1,20 @@ +/** + * A Map that can generate default values for keys that don't exist. + * Generated default values are added to the map to avoid recomputation. + */ +export class DefaultMap extends Map { + constructor(private factory: (key: T, self: DefaultMap) => V) { + super() + } + + get(key: T): V { + let value = super.get(key) + + if (value === undefined) { + value = this.factory(key, this) + this.set(key, value) + } + + return value + } +} diff --git a/packages/tailwindcss-language-service/src/util/find.test.ts b/packages/tailwindcss-language-service/src/util/find.test.ts index 839fb6d0..170217c0 100644 --- a/packages/tailwindcss-language-service/src/util/find.test.ts +++ b/packages/tailwindcss-language-service/src/util/find.test.ts @@ -1,5 +1,5 @@ import { test } from 'vitest' -import { findClassListsInHtmlRange } from './find' +import { findClassListsInHtmlRange, findClassNameAtPosition } from './find' import { js, html, pug, createDocument } from './test-utils' test('class regex works in astro', async ({ expect }) => { @@ -791,3 +791,87 @@ test('classAttributes find class lists inside Vue bindings', async ({ expect }) }, ]) }) + +test('Can find class name inside JS/TS functions in + `, + }) + + let className = await findClassNameAtPosition(file.state, file.doc, { + line: 1, + character: 23, + }) + + expect(className).toEqual({ + className: 'flex', + range: { + start: { line: 1, character: 22 }, + end: { line: 1, character: 26 }, + }, + relativeRange: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 4 }, + }, + classList: { + classList: 'flex relative', + important: undefined, + range: { + start: { character: 22, line: 1 }, + end: { character: 35, line: 1 }, + }, + }, + }) +}) + +test('Can find class name inside JS/TS functions in + `, + }) + + let className = await findClassNameAtPosition(file.state, file.doc, { + line: 1, + character: 23, + }) + + expect(className).toEqual({ + className: 'flex', + range: { + start: { line: 1, character: 22 }, + end: { line: 1, character: 26 }, + }, + relativeRange: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 4 }, + }, + classList: { + classList: 'flex relative', + important: undefined, + range: { + start: { character: 22, line: 1 }, + end: { character: 35, line: 1 }, + }, + }, + }) +}) diff --git a/packages/tailwindcss-language-service/src/util/find.ts b/packages/tailwindcss-language-service/src/util/find.ts index 9118403d..53ad7195 100644 --- a/packages/tailwindcss-language-service/src/util/find.ts +++ b/packages/tailwindcss-language-service/src/util/find.ts @@ -5,7 +5,7 @@ import lineColumn from 'line-column' import { isCssContext, isCssDoc } from './css' import { isHtmlContext, isVueDoc } from './html' import { isWithinRange } from './isWithinRange' -import { isJsxContext } from './js' +import { isJsContext } from './js' import { dedupeByRange, flatten } from './array' import { getClassAttributeLexer, getComputedClassAttributeLexer } from './lexers' import { getLanguageBoundaries } from './getLanguageBoundaries' @@ -526,7 +526,7 @@ export async function findClassNameAtPosition( classNames = await findClassNamesInRange(state, doc, searchRange, 'css') } else if (isHtmlContext(state, doc, position)) { classNames = await findClassNamesInRange(state, doc, searchRange, 'html') - } else if (isJsxContext(state, doc, position)) { + } else if (isJsContext(state, doc, position)) { classNames = await findClassNamesInRange(state, doc, searchRange, 'jsx') } else { classNames = await findClassNamesInRange(state, doc, searchRange) diff --git a/packages/tailwindcss-language-service/src/util/rewriting/calc.ts b/packages/tailwindcss-language-service/src/util/rewriting/calc.ts index 2420800f..e9949014 100644 --- a/packages/tailwindcss-language-service/src/util/rewriting/calc.ts +++ b/packages/tailwindcss-language-service/src/util/rewriting/calc.ts @@ -1,56 +1,30 @@ -function parseLength(length: string): [number, string] | null { - let regex = /^(-?\d*\.?\d+)([a-z%]*)$/i - let match = length.match(regex) - - if (!match) return null - - let numberPart = parseFloat(match[1]) - if (isNaN(numberPart)) return null - - return [numberPart, match[2]] -} - -function round(n: number, precision: number): number { - return Math.round(n * Math.pow(10, precision)) / Math.pow(10, precision) -} +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 { - // We're only interested simple calc expressions of the form - // A + B, A - B, A * B, A / B + let tokens = tokenize({ css: `calc(${str})` }) - let parts = str.split(/\s+([+*/-])\s+/) + let components = parseComponentValue(tokens, {}) + if (!components) return null - if (parts.length === 1) return null - if (parts.length !== 3) return null + let result = calcFromComponentValues([[components]], { + // Ensure evaluation of random() is deterministic + randomSeed: 1, - let a = parseLength(parts[0]) - let b = parseLength(parts[2]) + // Limit precision to keep values environment independent + precision: 4, + }) - // Not parsable - if (!a || !b) { - return null - } - - // Addition and subtraction require the same units - if ((parts[1] === '+' || parts[1] === '-') && a[1] !== b[1]) { - return null - } - - // Multiplication and division require at least one unit to be empty - if ((parts[1] === '*' || parts[1] === '/') && a[1] !== '' && b[1] !== '') { - return null - } + // The result array is the same shape as the original so we're guaranteed to + // have an element here + let node = result[0][0] - switch (parts[1]) { - case '+': - return round(a[0] + b[0], 4).toString() + a[1] - case '*': - return round(a[0] * b[0], 4).toString() + a[1] - case '-': - return round(a[0] - b[0], 4).toString() + a[1] - case '/': - return round(a[0] / b[0], 4).toString() + a[1] + // 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 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 00000000..f19b14ac --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/rewriting/color.test.ts @@ -0,0 +1,44 @@ +import * as culori from 'culori' +import { expect, test } from 'vitest' +import { colorFromString, colorMix, colorMixFromString } 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('colorMixFromString', () => { + expect(colorMixFromString('color-mix(in srgb, #f00 50%, transparent)')).toEqual({ + mode: 'rgb', + r: 1, + g: 0, + b: 0, + alpha: 0.5, + }) +}) 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 00000000..9d01aa55 --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/rewriting/color.ts @@ -0,0 +1,155 @@ +import * as culori from 'culori' +import { + ComponentValue, + isFunctionNode, + isTokenNode, + isWhitespaceNode, + parseComponentValue, +} from '@csstools/css-parser-algorithms' +import { + isTokenComma, + isTokenHash, + isTokenIdent, + isTokenNumber, + isTokenNumeric, + isTokenPercentage, + stringify, + tokenize, +} from '@csstools/css-tokenizer' + +const COLOR_FN = /^(rgba?|hsla?|hwb|(ok)?(lab|lch)|color)$/i + +export type KeywordColor = 'currentColor' +export type ParsedColor = culori.Color | KeywordColor | null + +export function colorFromString(value: string): ParsedColor { + let tokens = tokenize({ css: value }) + let cv = parseComponentValue(tokens) + let color = colorFromComponentValue(cv) + + return color +} + +export function colorFromComponentValue(cv: ComponentValue): ParsedColor { + if (isTokenNode(cv)) { + if (isTokenIdent(cv.value)) { + let str = cv.value[4].value.toLowerCase() + + if (str === 'currentcolor') return 'currentColor' + + if (str === 'transparent') { + // We omit rgb channels instead of using transparent black because we + // use `culori.interpolate` to mix colors and it handles `transparent` + // differently from the spec (all channels are mixed, not just alpha) + return culori.parse('rgb(none none none / 0.5)') + } + + 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 { + let color = colorFromString(value) + let equivalent = computeEquivalentColor(color) + + return equivalent ?? value +} + +function computeEquivalentColor(color: ParsedColor): string | null { + if (!color) return null + if (typeof color === 'string') return null + if (!culori.inGamut('rgb')(color)) return null + + if (color.alpha === undefined || color.alpha === 1) { + return culori.formatHex(color) + } + + return culori.formatHex8(color) +} + +export function colorMixFromString(value: string): ParsedColor { + let tokens = tokenize({ css: value }) + let cv = parseComponentValue(tokens) + let color = colorMixFromComponentValue(cv) + + return color +} + +export function colorMixFromComponentValue(cv: ComponentValue): ParsedColor { + if (!isFunctionNode(cv)) return null + if (cv.getName() !== 'color-mix') return null + + let state: 'in' | 'colorspace' | 'colors' = 'in' + let colorspace: string = '' + let colors: Array = [] + + for (let i = 0; i < cv.value.length; ++i) { + let value = cv.value[i] + + if (isWhitespaceNode(value)) continue + + if (state === 'in') { + if (isTokenNode(value)) { + if (isTokenIdent(value.value)) { + if (value.value[4].value === 'in') { + state = 'colorspace' + } + } + } + } else if (state === 'colorspace') { + if (isTokenNode(value)) { + if (isTokenIdent(value.value)) { + if (colorspace !== '') return null + + colorspace = value.value[4].value + } else if (isTokenComma(value.value)) { + state = 'colors' + } + } + } else if (state === 'colors') { + if (isTokenNode(value)) { + if (isTokenPercentage(value.value)) { + colors.push(value.value[4].value / 100) + continue + } else if (isTokenNumber(value.value)) { + colors.push(value.value[4].value) + continue + } else if (isTokenComma(value.value)) { + continue + } + } + + let color = colorFromComponentValue(value) + if (!color) return null + if (typeof color === 'string') return null + colors.push(color) + } + } + + let t = culori.interpolate(colors, colorspace as any) + + return t(0.5) ?? 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 6eb840ef..57d2615b 100644 --- a/packages/tailwindcss-language-service/src/util/rewriting/index.test.ts +++ b/packages/tailwindcss-language-service/src/util/rewriting/index.test.ts @@ -1,12 +1,7 @@ import { expect, test } from 'vitest' -import { - addThemeValues, - evaluateExpression, - replaceCssCalc, - replaceCssVarsWithFallbacks, -} from './index' import { State, TailwindCssSettings } from '../state' import { DesignSystem } from '../v4' +import { process, ProcessOptions } from './process' test('replacing CSS variables with their fallbacks (when they have them)', () => { let map = new Map([ @@ -22,79 +17,85 @@ test('replacing CSS variables with their fallbacks (when they have them)', () => ['--escaped\\,name', 'green'], ]) - let state: State = { - enabled: true, - designSystem: { - theme: { prefix: null } as any, - resolveThemeValue: (name) => map.get(name) ?? null, - } as DesignSystem, + let opts: ProcessOptions = { + style: 'full-evaluation', + fontSize: 16, + state: { + enabled: true, + designSystem: { + theme: { prefix: null } as any, + resolveThemeValue: (name) => map.get(name) ?? null, + } as DesignSystem, + } as State, } - expect(replaceCssVarsWithFallbacks(state, 'var(--foo, red)')).toBe(' red') - expect(replaceCssVarsWithFallbacks(state, 'var(--foo, )')).toBe(' ') + expect(process('var(--foo, red)', opts)).toBe(' red') + expect(process('var(--foo, )', opts)).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))') + expect(process('rgb(var(--foo, 255 0 0))', opts)).toBe('rgb( 255 0 0)') + expect(process('rgb(var(--foo, var(--bar)))', opts)).toBe('rgb( var(--bar))') - 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(var(--bar, var(--baz), var(--qux), var(--thing))))', opts)).toBe( + 'rgb( var(--qux), var(--thing))', + ) - expect( - replaceCssVarsWithFallbacks( - state, - 'rgb(var(--one, var(--bar, var(--baz), var(--qux), var(--thing))))', - ), - ).toBe('rgb( var(--baz), var(--qux), var(--thing))') + expect(process('rgb(var(--one, var(--bar, var(--baz), var(--qux), var(--thing))))', opts)).toBe( + 'rgb( var(--baz), var(--qux), var(--thing))', + ) expect( - replaceCssVarsWithFallbacks( - state, + process( 'color-mix(in srgb, var(--color-primary, oklch(0 0 0 / 2.5)), var(--color-secondary, oklch(0 0 0 / 2.5)), 50%)', + opts, ), ).toBe('color-mix(in srgb, oklch(0 0 0 / 2.5), oklch(0 0 0 / 2.5), 50%)') // Known theme keys are replaced with their values - expect(replaceCssVarsWithFallbacks(state, 'var(--known)')).toBe('blue') + expect(process('var(--known)', opts)).toBe('blue') // Escaped commas are not treated as separators - expect(replaceCssVarsWithFallbacks(state, 'var(--escaped\\,name)')).toBe('green') + expect(process('var(--escaped\\,name)', opts)).toBe('green') // Values from the theme take precedence over fallbacks - expect(replaceCssVarsWithFallbacks(state, 'var(--known, red)')).toBe('blue') + expect(process('var(--known, red)', opts)).toBe('blue') // Unknown theme keys use a fallback if provided - expect(replaceCssVarsWithFallbacks(state, 'var(--unknown, red)')).toBe(' red') + expect(process('var(--unknown, red)', opts)).toBe(' red') // Unknown theme keys without fallbacks are not replaced - expect(replaceCssVarsWithFallbacks(state, 'var(--unknown)')).toBe('var(--unknown)') + expect(process('var(--unknown)', opts)).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))', opts)).toBe('red') + expect(process('var(--level-1)', opts)).toBe('blue') + expect(process('var(--level-2)', opts)).toBe('blue') + expect(process('var(--level-3)', opts)).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)') + expect(process('var(--circular-1)', opts)).toBe('var(--circular-3)') + expect(process('var(--circular-2)', opts)).toBe('var(--circular-1)') + expect(process('var(--circular-3)', opts)).toBe('var(--circular-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)', - ) + let opts: ProcessOptions = { + style: 'full-evaluation', + fontSize: 16, + state: { + enabled: true, + designSystem: { + theme: { prefix: null } as any, + resolveThemeValue: (name) => null, + } as DesignSystem, + } as State, + } - expect(replaceCssCalc('calc(1.25 / 0.875)', (node) => evaluateExpression(node.value))).toBe( - '1.4286', - ) + expect(process('calc(1px + 1px)', opts)).toBe('2px') + expect(process('calc(1px * 4)', opts)).toBe('4px') + expect(process('calc(1px / 4)', opts)).toBe('0.25px') + expect(process('calc(1rem + 1px)', opts)).toBe('calc(1rem /* 1rem = 16px */ + 1px)') + expect(process('calc(1.25 / 0.875)', opts)).toBe('1.4286') + expect(process('calc(1/4 * 100%)', opts)).toBe('25%') }) test('Inlining calc expressions using the design system', () => { @@ -103,50 +104,88 @@ test('Inlining calc expressions using the design system', () => { ['--color-red-500', 'oklch(0.637 0.237 25.331)'], ]) - let state: State = { - enabled: true, - designSystem: { - theme: { prefix: null } as any, - resolveThemeValue: (name) => map.get(name) ?? null, - } as DesignSystem, + let opts: ProcessOptions = { + style: 'user-presetable', + fontSize: 16, + state: { + enabled: true, + designSystem: { + theme: { prefix: null } as any, + resolveThemeValue: (name) => map.get(name) ?? null, + } as DesignSystem, + } as State, } - let settings: TailwindCssSettings = { - rootFontSize: 10, - } as any - // Inlining calc expressions // + pixel equivalents - expect(addThemeValues('calc(var(--spacing) * 4)', state, settings)).toBe( + expect(process('calc(var(--spacing) * 4)', opts)).toBe( 'calc(var(--spacing) * 4) /* 1rem = 10px */', ) - expect(addThemeValues('calc(var(--spacing) / 4)', state, settings)).toBe( + expect(process('calc(var(--spacing) / 4)', opts)).toBe( 'calc(var(--spacing) / 4) /* 0.0625rem = 0.625px */', ) - expect(addThemeValues('calc(var(--spacing) * 1)', state, settings)).toBe( + expect(process('calc(var(--spacing) * 1)', opts)).toBe( 'calc(var(--spacing) * 1) /* 0.25rem = 2.5px */', ) - expect(addThemeValues('calc(var(--spacing) * -1)', state, settings)).toBe( + expect(process('calc(var(--spacing) * -1)', opts)).toBe( 'calc(var(--spacing) * -1) /* -0.25rem = -2.5px */', ) - expect(addThemeValues('calc(var(--spacing) + 1rem)', state, settings)).toBe( + expect(process('calc(var(--spacing) + 1rem)', opts)).toBe( 'calc(var(--spacing) + 1rem) /* 1.25rem = 12.5px */', ) - expect(addThemeValues('calc(var(--spacing) - 1rem)', state, settings)).toBe( + expect(process('calc(var(--spacing) - 1rem)', opts)).toBe( 'calc(var(--spacing) - 1rem) /* -0.75rem = -7.5px */', ) - expect(addThemeValues('calc(var(--spacing) + 1px)', state, settings)).toBe( + expect(process('calc(var(--spacing) + 1px)', opts)).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)', opts)).toBe( 'var(--color-red-500) /* oklch(0.637 0.237 25.331) = #fb2c36 */', ) }) + +test('wip', () => { + let map = 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)'], + ]) + + let opts: ProcessOptions = { + style: 'full-evaluation', + fontSize: 16, + state: { + enabled: true, + designSystem: { + theme: { prefix: null } as any, + resolveThemeValue: (name) => map.get(name) ?? null, + } as DesignSystem, + } as State, + } + + expect(process('var(--known)', opts)).toBe('1px solid a b c d e f 21px f e d c b a') + expect(process('var(--known-2)', opts)).toBe('rgba(0, 255, 0, 0.25)') + + expect(process('var(--tw-text-shadow-alpha)', opts)).toBe('100%') + expect(process('var(--tw-drop-shadow-alpha)', opts)).toBe('100%') + expect(process('var(--tw-shadow-alpha)', opts)).toBe('100%') + expect(process('1rem', opts)).toBe('1rem /* 1rem = 16px */') +}) 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 00000000..2f631c1d --- /dev/null +++ b/packages/tailwindcss-language-service/src/util/rewriting/process.ts @@ -0,0 +1,268 @@ +// Process CSS values +import { + CSSToken, + isTokenComma, + isTokenDimension, + isTokenIdent, + isTokenPercentage, + stringify, + tokenize, + TokenPercentage, + TokenType, +} from '@csstools/css-tokenizer' +import { + isFunctionNode, + parseComponentValue, + parseCommaSeparatedListOfComponentValues, + ComponentValue, + isTokenNode, + TokenNode, + parseListOfComponentValues, + isWhitespaceNode, + FunctionNode, + CommentNode, + WhitespaceNode, +} from '@csstools/css-parser-algorithms' +import { State } from '../state' +import walk, { VisitFn, Visitor } from './walk' +import { resolveVariableValue } from './lookup' +import { DefaultMap } from '../default-map' +import { calcFromComponentValues } from '@csstools/css-calc' +import * as culori from 'culori' + +export interface ProcessOptions { + state: State + + /** + * The font size to use for `rem` and `em` values + */ + fontSize: number | null + + style: 'user-presetable' | 'theme-evaluation' | 'full-evaluation' +} + +interface Context { + state: State + tokens: DefaultMap + values: DefaultMap + + /** + * The font size to use for `rem` and `em` values + */ + fontSize: number | null + + /** + * A list of expanded theme variables + */ + seen: Set +} + +export function process(value: string, opts: ProcessOptions): string { + let tokens = new DefaultMap((css) => tokenize({ css })) + + let values = new DefaultMap((css) => parseListOfComponentValues(tokens.get(css))) + let ctx: Context = { + seen: new Set(), + state: opts.state, + tokens, + values, + fontSize: opts.fontSize, + } + + let lists = parseCommaSeparatedListOfComponentValues(tokens.get(value)) + + // 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(…) + + let visitors = [ + // + evaluateFunctions(ctx), + addPixelEquivalents(ctx), + ] + + for (let list of lists) { + walk(list, { + exit(node) { + for (let visit of visitors) { + let result = visit(node) + if (result) return result + } + }, + }) + } + + return lists.map((list) => list.map((value) => stringify(...value.tokens())).join('')).join(',') +} + +function evaluateFunctions(ctx: Context): VisitFn { + return (node) => { + if (!isFunctionNode(node)) return + if (node.value.length === 0) return + + let compute = FNS[node.getName()] + if (!compute) return + + return compute(node, ctx) + } +} + +function addPixelEquivalents(ctx: Context): VisitFn { + return (node) => { + if (!ctx.fontSize) return + if (!isTokenNode(node)) return + if (!isTokenDimension(node.value)) return + + let extra = node.value[4] + if (extra.unit !== 'em' && extra.unit !== 'rem') return + + 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, + ]), + ] + } +} + +const FNS: Record ComponentValue[] | undefined> = { + // Replace light-dark(x, y) with the light color + 'light-dark': evaluateLightDark, + calc: evaluateCalc, + var: resolveThemeVariable, + theme: resolveThemeVariable, + 'color-mix': evaluateColorMix, +} + +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[] | undefined { + let solved = calcFromComponentValues([[fn]], { + // Ensure evaluation of random() is deterministic + randomSeed: 1, + + // Limit precision to keep values environment independent + precision: 4, + }) + + return solved[0] +} + +function evaluateColorMix(fn: FunctionNode, ctx: Context): ComponentValue[] | undefined { + let state: 'colorspace' | 'a' | 'b' | 'done' = 'colorspace' + + let colorValues: ComponentValue[] = [] + let alphaValue: number | null = null + + for (let i = 0; i < fn.value.length; ++i) { + let value = fn.value[i] + + if (state === 'colorspace') { + if (isTokenNode(value) && value.value[0] === 'comma-token') { + state = 'a' + } + } + + // + else if (state === 'a') { + if (isWhitespaceNode(value)) continue + + if (isTokenNode(value) && isTokenPercentage(value.value)) { + alphaValue = value.value[4].value + state = 'b' + } else { + colorValues.push(value) + } + } + + // + else if (state === 'b') { + if (isWhitespaceNode(value)) continue + if (!isTokenNode(value)) continue + if (!isTokenIdent(value.value)) continue + if (value.value[1] !== 'transparent') continue + + state = 'done' + } + + // + else if (state === 'done') { + if (isWhitespaceNode(value)) continue + + return + } + } + + if (alphaValue === null) return + if (colorValues.length === 0) return + + let colorStr = stringify(...colorValues.flatMap((v) => v.tokens())) + if (colorStr.startsWith('var(')) return + + let parsed = culori.parse(colorStr) + if (!parsed) return + + let alpha = Number(alphaValue) / 100 + if (Number.isNaN(alpha)) return + + alpha *= parsed.alpha ?? 1 + + let color = culori.formatRgb({ ...parsed, alpha }) + + return ctx.values.get(color) +} + +function resolveThemeVariable(fn: FunctionNode, ctx: Context): ComponentValue[] | undefined { + 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 themeValue = resolveVariableValue(ctx.state.designSystem, name) + if (themeValue) return ctx.values.get(themeValue) + + // If it's one of these predefined alpha variables it's always 100% + if ( + name === '--tw-text-shadow-alpha' || + name === '--tw-drop-shadow-alpha' || + name === '--tw-shadow-alpha' + ) { + return ctx.values.get('100%') + } + } + + // 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) + } + } +} 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 00000000..ef8d50dd --- /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): ComponentValue[] | undefined | void +} + +export interface Visitor { + enter?: VisitFn + exit?: VisitFn +} + +export default function walk(list: ComponentValue[], visit: Visitor) { + 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) + + // 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) + + // 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/packages/tailwindcss-language-service/src/util/state.ts b/packages/tailwindcss-language-service/src/util/state.ts index 754f2a49..26cc2ed0 100644 --- a/packages/tailwindcss-language-service/src/util/state.ts +++ b/packages/tailwindcss-language-service/src/util/state.ts @@ -228,6 +228,7 @@ export function createState( return { enabled: true, features: [], + blocklist: [], ...partial, editor: { get connection(): Connection { diff --git a/packages/tailwindcss-language-service/tsconfig.json b/packages/tailwindcss-language-service/tsconfig.json index 883356e7..46eb230c 100644 --- a/packages/tailwindcss-language-service/tsconfig.json +++ b/packages/tailwindcss-language-service/tsconfig.json @@ -1,7 +1,7 @@ { "include": ["src", "../../types"], "compilerOptions": { - "module": "NodeNext", + "module": "ES2022", "lib": ["ES2022"], "target": "ES2022", "importHelpers": true, @@ -13,7 +13,7 @@ "noUnusedParameters": false, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, - "moduleResolution": "NodeNext", + "moduleResolution": "Bundler", "skipLibCheck": true, "jsx": "react", "esModuleInterop": true diff --git a/packages/vscode-tailwindcss/CHANGELOG.md b/packages/vscode-tailwindcss/CHANGELOG.md index 4c9240f6..4162495c 100644 --- a/packages/vscode-tailwindcss/CHANGELOG.md +++ b/packages/vscode-tailwindcss/CHANGELOG.md @@ -3,6 +3,7 @@ ## Prerelease - Warn when using a blocklisted class in v4 ([#1310](https://github.com/tailwindlabs/tailwindcss-intellisense/pull/1310)) +- Support class function hovers in Svelte and HTML `