Skip to content

Commit 1aab149

Browse files
committed
Handle calc expressions and equivalents directly
1 parent e2becb8 commit 1aab149

File tree

5 files changed

+285
-11
lines changed

5 files changed

+285
-11
lines changed
Lines changed: 103 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,114 @@
1-
import type { State } from '../state'
1+
import type { State, TailwindCssSettings } from '../state'
22

3-
import { replaceCssVars } from './replacements'
3+
import { evaluateExpression } from './calc'
4+
import { replaceCssVars, replaceCssCalc, Range } from './replacements'
5+
import { addPixelEquivalentsToValue } from '../pixelEquivalents'
6+
import { applyComments, Comment } from '../comments'
7+
import { getEquivalentColor } from '../colorEquivalents'
48

5-
export function addThemeValues(css: string, state: State) {
9+
export function addThemeValues(css: string, state: State, settings: TailwindCssSettings) {
610
if (!state.designSystem) return css
711

8-
// Add fallbacks to variables with their theme values
9-
// Ideally these would just be commentss like
10-
// `var(--foo) /* 3rem = 48px */` or
11-
// `calc(var(--spacing) * 5) /* 1.25rem = 20px */`
12-
css = replaceCssVars(css, ({ name }) => {
12+
let comments: Comment[] = []
13+
let replaced: Range[] = []
14+
15+
css = replaceCssCalc(css, (expr) => {
16+
let inlined = replaceCssVars(expr.value, ({ name }) => {
17+
if (!name.startsWith('--')) return null
18+
19+
let value = state.designSystem.resolveThemeValue?.(name) ?? null
20+
if (value === null) return null
21+
22+
// Inline CSS calc expressions in theme values
23+
value = replaceCssCalc(value, (expr) => evaluateExpression(expr.value))
24+
25+
return value
26+
})
27+
28+
let evaluated = evaluateExpression(inlined)
29+
30+
// No changes were made so we can just return the original expression
31+
if (expr.value === evaluated) return null
32+
if (!evaluated) return null
33+
34+
replaced.push(expr.range)
35+
36+
let px = addPixelEquivalentsToValue(evaluated, settings.rootFontSize, false)
37+
if (px !== evaluated) {
38+
comments.push({
39+
index: expr.range.end + 1,
40+
value: `${evaluated} = ${px}`,
41+
})
42+
43+
return null
44+
}
45+
46+
let color = getEquivalentColor(evaluated)
47+
if (color !== evaluated) {
48+
comments.push({
49+
index: expr.range.end + 1,
50+
value: `${evaluated} = ${color}`,
51+
})
52+
53+
return null
54+
}
55+
56+
comments.push({
57+
index: expr.range.end + 1,
58+
value: evaluated,
59+
})
60+
61+
return null
62+
})
63+
64+
css = replaceCssVars(css, ({ name, range }) => {
1365
if (!name.startsWith('--')) return null
1466

67+
for (let r of replaced) {
68+
if (r.start <= range.start && r.end >= range.end) {
69+
return null
70+
}
71+
}
72+
1573
let value = state.designSystem.resolveThemeValue?.(name) ?? null
1674
if (value === null) return null
1775

18-
return `var(${name}, ${value})`
76+
let px = addPixelEquivalentsToValue(value, settings.rootFontSize, false)
77+
if (px !== value) {
78+
comments.push({
79+
index: range.end + 1,
80+
value: `${value} = ${px}`,
81+
})
82+
83+
return null
84+
}
85+
86+
let color = getEquivalentColor(value)
87+
if (color !== value) {
88+
comments.push({
89+
index: range.end + 1,
90+
value: `${value} = ${color}`,
91+
})
92+
93+
return null
94+
}
95+
96+
// Inline CSS calc expressions in theme values
97+
value = replaceCssCalc(value, (expr) => {
98+
let evaluated = evaluateExpression(expr.value)
99+
if (!evaluated) return null
100+
if (evaluated === expr.value) return null
101+
102+
return `calc(${expr.value}) ≈ ${evaluated}`
103+
})
104+
105+
comments.push({
106+
index: range.end + 1,
107+
value,
108+
})
109+
110+
return null
19111
})
112+
113+
return applyComments(css, comments)
20114
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
function parseLength(length: string): [number, string] | null {
2+
let regex = /^(-?\d*\.?\d+)([a-z%]*)$/i
3+
let match = length.match(regex)
4+
5+
if (!match) return null
6+
7+
let numberPart = parseFloat(match[1])
8+
if (isNaN(numberPart)) return null
9+
10+
return [numberPart, match[2]]
11+
}
12+
13+
function round(n: number, precision: number): number {
14+
return Math.round(n * Math.pow(10, precision)) / Math.pow(10, precision)
15+
}
16+
17+
export function evaluateExpression(str: string): string | null {
18+
// We're only interested simple calc expressions of the form
19+
// A + B, A - B, A * B, A / B
20+
21+
let parts = str.split(/\s+([+*/-])\s+/)
22+
23+
if (parts.length === 1) return null
24+
if (parts.length !== 3) return null
25+
26+
let a = parseLength(parts[0])
27+
let b = parseLength(parts[2])
28+
29+
// Not parsable
30+
if (!a || !b) {
31+
return null
32+
}
33+
34+
// Addition and subtraction require the same units
35+
if ((parts[1] === '+' || parts[1] === '-') && a[1] !== b[1]) {
36+
return null
37+
}
38+
39+
// Multiplication and division require at least one unit to be empty
40+
if ((parts[1] === '*' || parts[1] === '/') && a[1] !== '' && b[1] !== '') {
41+
return null
42+
}
43+
44+
switch (parts[1]) {
45+
case '+':
46+
return round(a[0] + b[0], 4).toString() + a[1]
47+
case '*':
48+
return round(a[0] * b[0], 4).toString() + a[1]
49+
case '-':
50+
return round(a[0] - b[0], 4).toString() + a[1]
51+
case '/':
52+
return round(a[0] / b[0], 4).toString() + a[1]
53+
}
54+
55+
return null
56+
}

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

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { expect, test } from 'vitest'
2-
import { replaceCssVarsWithFallbacks } from './index'
3-
import { State } from '../state'
2+
import {
3+
addThemeValues,
4+
evaluateExpression,
5+
replaceCssCalc,
6+
replaceCssVarsWithFallbacks,
7+
} from './index'
8+
import { State, TailwindCssSettings } from '../state'
49
import { DesignSystem } from '../v4'
510

611
test('replacing CSS variables with their fallbacks (when they have them)', () => {
@@ -52,3 +57,69 @@ test('replacing CSS variables with their fallbacks (when they have them)', () =>
5257
// Unknown theme keys without fallbacks are not replaced
5358
expect(replaceCssVarsWithFallbacks(state, 'var(--unknown)')).toBe('var(--unknown)')
5459
})
60+
61+
test('Evaluating CSS calc expressions', () => {
62+
expect(replaceCssCalc('calc(1px + 1px)', (node) => evaluateExpression(node.value))).toBe('2px')
63+
expect(replaceCssCalc('calc(1px * 4)', (node) => evaluateExpression(node.value))).toBe('4px')
64+
expect(replaceCssCalc('calc(1px / 4)', (node) => evaluateExpression(node.value))).toBe('0.25px')
65+
expect(replaceCssCalc('calc(1rem + 1px)', (node) => evaluateExpression(node.value))).toBe(
66+
'calc(1rem + 1px)',
67+
)
68+
69+
expect(replaceCssCalc('calc(1.25 / 0.875)', (node) => evaluateExpression(node.value))).toBe(
70+
'1.4286',
71+
)
72+
})
73+
74+
test('Inlining calc expressions using the design system', () => {
75+
let map = new Map<string, string>([
76+
['--spacing', '0.25rem'],
77+
['--color-red-500', 'oklch(0.637 0.237 25.331)'],
78+
])
79+
80+
let state: State = {
81+
enabled: true,
82+
designSystem: {
83+
resolveThemeValue: (name) => map.get(name) ?? null,
84+
} as DesignSystem,
85+
}
86+
87+
let settings: TailwindCssSettings = {
88+
rootFontSize: 10,
89+
} as any
90+
91+
// Inlining calc expressions
92+
// + pixel equivalents
93+
expect(addThemeValues('calc(var(--spacing) * 4)', state, settings)).toBe(
94+
'calc(var(--spacing) * 4) /* 1rem = 10px */',
95+
)
96+
97+
expect(addThemeValues('calc(var(--spacing) / 4)', state, settings)).toBe(
98+
'calc(var(--spacing) / 4) /* 0.0625rem = 0.625px */',
99+
)
100+
101+
expect(addThemeValues('calc(var(--spacing) * 1)', state, settings)).toBe(
102+
'calc(var(--spacing) * 1) /* 0.25rem = 2.5px */',
103+
)
104+
105+
expect(addThemeValues('calc(var(--spacing) * -1)', state, settings)).toBe(
106+
'calc(var(--spacing) * -1) /* -0.25rem = -2.5px */',
107+
)
108+
109+
expect(addThemeValues('calc(var(--spacing) + 1rem)', state, settings)).toBe(
110+
'calc(var(--spacing) + 1rem) /* 1.25rem = 12.5px */',
111+
)
112+
113+
expect(addThemeValues('calc(var(--spacing) - 1rem)', state, settings)).toBe(
114+
'calc(var(--spacing) - 1rem) /* -0.75rem = -7.5px */',
115+
)
116+
117+
expect(addThemeValues('calc(var(--spacing) + 1px)', state, settings)).toBe(
118+
'calc(var(--spacing) /* 0.25rem = 2.5px */ + 1px)',
119+
)
120+
121+
// Color equivalents
122+
expect(addThemeValues('var(--color-red-500)', state, settings)).toBe(
123+
'var(--color-red-500) /* oklch(0.637 0.237 25.331) = #fb2c36 */',
124+
)
125+
})
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './replacements'
22
export * from './var-fallbacks'
3+
export * from './calc'
34
export * from './add-theme-values'

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,55 @@ export function replaceCssVars(str: string, replace: CssVarReplacer): string {
6868

6969
return str
7070
}
71+
72+
/**
73+
* A calc(…) expression in a CSS value
74+
*/
75+
export interface CalcExpression {
76+
kind: 'calc-expression'
77+
range: Range
78+
value: string
79+
}
80+
81+
export type CssCalcReplacer = (node: CalcExpression) => string | null
82+
83+
/**
84+
* Replace all calc expression in a string using the replacer function
85+
*/
86+
export function replaceCssCalc(str: string, replace: CssCalcReplacer): string {
87+
for (let i = 0; i < str.length; ++i) {
88+
if (!str.startsWith('calc(', i)) continue
89+
90+
let depth = 0
91+
92+
for (let j = i + 5; i < str.length; ++j) {
93+
if (str[j] === '(') {
94+
depth++
95+
} else if (str[j] === ')' && depth > 0) {
96+
depth--
97+
} else if (str[j] === ')' && depth === 0) {
98+
let expr = str.slice(i + 5, j)
99+
100+
let replacement = replace({
101+
kind: 'calc-expression',
102+
value: expr,
103+
range: {
104+
start: i,
105+
end: j,
106+
},
107+
})
108+
109+
if (replacement !== null) {
110+
str = str.slice(0, i) + replacement + str.slice(j + 1)
111+
}
112+
113+
// We don't want to skip past anything here because `replacement`
114+
// might contain more var(…) calls in which case `i` will already
115+
// be pointing at the right spot to start looking for them
116+
break
117+
}
118+
}
119+
}
120+
121+
return str
122+
}

0 commit comments

Comments
 (0)