Skip to content

Commit 4fb66e1

Browse files
Implement better calc(…) evaluation for completions and equivalents (#1316)
Fixes #1314 - [x] Investigate the change to the TS config as I feel like it should not be necessary - [x] Manually verify the stated case from the issue above is addressed Hovers: <img width="428" alt="Screenshot 2025-04-14 at 13 29 26" src="https://github.com/user-attachments/assets/4270a4a0-c6f1-4804-bc58-19e1221faf93" /> Completions: <img width="153" alt="Screenshot 2025-04-14 at 13 30 21" src="https://github.com/user-attachments/assets/982847ab-ea57-4a52-bb34-6af7d21bd99e" />
1 parent e88d6dc commit 4fb66e1

File tree

5 files changed

+60
-76
lines changed

5 files changed

+60
-76
lines changed

packages/tailwindcss-language-service/package.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
"test": "vitest"
1414
},
1515
"dependencies": {
16-
"@csstools/css-parser-algorithms": "2.1.1",
17-
"@csstools/css-tokenizer": "2.1.1",
16+
"@csstools/css-calc": "2.1.2",
17+
"@csstools/css-parser-algorithms": "3.0.4",
18+
"@csstools/css-tokenizer": "3.0.3",
1819
"@csstools/media-query-list-parser": "2.0.4",
1920
"@types/culori": "^2.1.0",
2021
"@types/moo": "0.5.3",
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,30 @@
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-
}
1+
import { stringify, tokenize } from '@csstools/css-tokenizer'
2+
import { isFunctionNode, parseComponentValue } from '@csstools/css-parser-algorithms'
3+
import { calcFromComponentValues } from '@csstools/css-calc'
164

175
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
6+
let tokens = tokenize({ css: `calc(${str})` })
207

21-
let parts = str.split(/\s+([+*/-])\s+/)
8+
let components = parseComponentValue(tokens, {})
9+
if (!components) return null
2210

23-
if (parts.length === 1) return null
24-
if (parts.length !== 3) return null
11+
let result = calcFromComponentValues([[components]], {
12+
// Ensure evaluation of random() is deterministic
13+
randomSeed: 1,
2514

26-
let a = parseLength(parts[0])
27-
let b = parseLength(parts[2])
15+
// Limit precision to keep values environment independent
16+
precision: 4,
17+
})
2818

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-
}
19+
// The result array is the same shape as the original so we're guaranteed to
20+
// have an element here
21+
let node = result[0][0]
4322

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]
23+
// If we have a top-level `calc(…)` node then the evaluation did not resolve
24+
// to a single value and we consider it to be incomplete
25+
if (isFunctionNode(node)) {
26+
if (node.name[1] === 'calc(') return null
5327
}
5428

55-
return null
29+
return stringify(...node.tokens())
5630
}

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

+2
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ test('Evaluating CSS calc expressions', () => {
9595
expect(replaceCssCalc('calc(1.25 / 0.875)', (node) => evaluateExpression(node.value))).toBe(
9696
'1.4286',
9797
)
98+
99+
expect(replaceCssCalc('calc(1/4 * 100%)', (node) => evaluateExpression(node.value))).toBe('25%')
98100
})
99101

100102
test('Inlining calc expressions using the design system', () => {

packages/tailwindcss-language-service/tsconfig.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"include": ["src", "../../types"],
33
"compilerOptions": {
4-
"module": "NodeNext",
4+
"module": "ES2022",
55
"lib": ["ES2022"],
66
"target": "ES2022",
77
"importHelpers": true,
@@ -13,7 +13,7 @@
1313
"noUnusedParameters": false,
1414
"noImplicitReturns": true,
1515
"noFallthroughCasesInSwitch": true,
16-
"moduleResolution": "NodeNext",
16+
"moduleResolution": "Bundler",
1717
"skipLibCheck": true,
1818
"jsx": "react",
1919
"esModuleInterop": true

pnpm-lock.yaml

+33-26
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)