Canonicalize negative arbitrary values (#19858)

This PR adds a few more canonicalizations for some cases I noticed on
our templates.

When dealing with arbitrary values, and the utility is a "negative"
utility, then we will try to put the `-` inside of the arbitrary value:

```diff
- -left-[9rem]
+ left-[-9rem]
```

The idea is that the arbitrary value is already an escape hatch for when
a value is not available by default. The `-` in front uses an implicit
`calc(<expression> * -1)` which might be confusion if you have an value
like this already.

This also can allow for some further optimizations. For example
```diff
- -mt-[492px]
  ↓↓↓↓↓↓↓                           Into a simpler arbitrary value
+ mt-[-492px]
  ↓↓↓↓↓↓↓                           Into a bare value
+ mt-123
```

This PR also improve the constant folding of calc expressions a bit more
such that nested calc expressions with 2 constants and an unknown can be
folded. Bit of a mouthful, but it allows us to handle this:
```diff
- mt-[calc(-1*calc(-1*var(--foo)))]
  ↓↓↓↓↓↓↓                           The -1 * -1 becomes a no-op
+ mt-[var(--foo)]
  ↓↓↓↓↓↓↓                           Into the shorthand for CSS variables
+ mt-(--foo)
```

Now that we can handle moving the `-` into the arbitrary value, there
are also cases where we can get the `-` _out_ of the arbitrary value:
```diff
- mt-[calc(-1*var(--foo))]
  ↓↓↓↓↓↓↓                           Simplify calc, move `-` to the front
+ -mt-[var(--foo)]
  ↓↓↓↓↓↓↓                           Into the shorthand for CSS variables
+ -mt-(--foo)
```

Another missing piece that this PR adds is the concept of canonicalizing
or normalizing calc expressions. This is a separate step used when
calculating the signature for each utility. This allows us to normalize
`calc(-1*var(--foo))` and `calc(var(--foo)*-1)`. Without this they would
not be considered the same, but not it will.

It's only used when comparing values, it won't unify the actual
arbitrary values with this logic (at least for now).

With the additional constant folding logic and the canonicalization when
comparing signatures it unlocks the necessary power to perform the above
transformations.

## Test plan

1. Existing tests still pass
2. Added additional tests for the constant folding logic
3. Added tests for the canonicalization of calc expressions
4. Added new tests where we move the `-` inside the value, or move the
`-` outside of the arbitrary value.
This commit is contained in:
Robin Malfait 2026-03-26 14:44:09 +01:00 • committed by GitHub
parent 52fd421cc9
commit df6209ab8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 438 additions and 24 deletions

View file

@ -30,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Canonicalization: migrate `overflow-ellipsis` into `text-ellipsis` ([#19849](https://github.com/tailwindlabs/tailwindcss/pull/19849))
- Canonicalization: migrate `start-full` → `inset-s-full`, `start-auto` → `inset-s-auto`, `start-px` → `inset-s-px`, and `start-<number>` → `inset-s-<number>` as well as negative versions ([#19849](https://github.com/tailwindlabs/tailwindcss/pull/19849))
- Canonicalization: migrate `end-full` → `inset-e-full`, `end-auto` → `inset-e-auto`, `end-px` → `inset-e-px`, and `end-<number>` → `inset-e-<number>` as well as negative versions ([#19849](https://github.com/tailwindlabs/tailwindcss/pull/19849))
- Canonicalization: move the `-` sign inside the arbitrary value `-left-[9rem]` → `left-[-9rem]` ([#19858](https://github.com/tailwindlabs/tailwindcss/pull/19858))
- Canonicalization: move the `-` sign outside the arbitrary value `ml-[calc(-1*var(--width))]` → `-ml-(--width)` ([#19858](https://github.com/tailwindlabs/tailwindcss/pull/19858))
## [4.2.2] - 2026-03-18

View file

@ -0,0 +1,24 @@
import { expect, it } from 'vitest'
import { canonicalizeCalcExpressions } from './canonicalize-calc-expressions'
it.each([
['calc(-1 * var(--foo))', 'calc(var(--foo) * -1)'],
['calc(1rem + var(--foo))', 'calc(var(--foo) + 1rem)'],
['calc(2rem * calc(3px * var(--foo)))', 'calc(calc(var(--foo) * 3px) * 2rem)'],
['calc(var(--b) + var(--a))', 'calc(var(--a) + var(--b))'],
['calc(3px * 2rem)', 'calc(2rem * 3px)'],
['calc(5px * 3px)', 'calc(3px * 5px)'],
['calc(1 * 1rem)', 'calc(1rem * 1)'],
['calc(10 + 2)', 'calc(2 + 10)'],
])('`%s` → `%s` (%#)', (input, expected) => {
expect(canonicalizeCalcExpressions(input)).toBe(expected)
})
it.each([
['calc(1rem - var(--foo))'],
['calc(1rem / 2)'],
['calc(var(--a) + 1rem)'],
['calc(2rem * 3px)'],
])('should keep `%s` (%#)', (input) => {
expect(canonicalizeCalcExpressions(input)).toBe(input)
})

View file

@ -0,0 +1,82 @@
import { dimensions } from './utils/dimensions'
import * as ValueParser from './value-parser'
import { walk, WalkAction } from './walk'
// Assumption: We already assume that we receive somewhat valid `calc()`
// expressions. So we will see `calc(1 + 1)` and not `calc(1+1)`
export function canonicalizeCalcExpressions(input: string): string {
let [canonicalized, valueAst] = canonicalizeCalcExpressionsAst(ValueParser.parse(input))
return canonicalized ? ValueParser.toCss(valueAst) : input
}
export function canonicalizeCalcExpressionsAst(
ast: ValueParser.ValueAstNode[],
): [canonicalized: boolean, ast: ValueParser.ValueAstNode[]] {
let canonicalized = false
walk(ast, {
exit(valueNode) {
// We are only interested in binary expressions in `calc(…)` and `(…)`,
// and only with the `*` or `+` operators.
if (valueNode.kind !== 'function') return
if (valueNode.value !== 'calc' && valueNode.value !== '') return
if (valueNode.nodes.length !== 5) return
if (valueNode.nodes[2].kind !== 'word') return
if (valueNode.nodes[2].value !== '*' && valueNode.nodes[2].value !== '+') return
let lhs = valueNode.nodes[0]
let rhs = valueNode.nodes[4]
if (shouldSwap(lhs, rhs)) {
canonicalized = true
let replacement: ValueParser.ValueFunctionNode = {
kind: 'function',
value: valueNode.value,
nodes: [
rhs, // Now lhs
valueNode.nodes[1], // Separator
valueNode.nodes[2], // Operator
valueNode.nodes[3], // Separator
lhs, // Now rhs
],
}
return WalkAction.ReplaceSkip(replacement)
}
},
})
return [canonicalized, ast]
}
function shouldSwap(lhs: ValueParser.ValueAstNode, rhs: ValueParser.ValueAstNode): boolean {
let lhsDimension = lhs.kind === 'word' ? dimensions.get(lhs.value) : null
let rhsDimension = rhs.kind === 'word' ? dimensions.get(rhs.value) : null
if (lhsDimension !== null && rhsDimension === null) return true
if (lhsDimension === null && rhsDimension !== null) return false
if (lhsDimension !== null && rhsDimension !== null) {
let [lhsValue, lhsUnit] = lhsDimension
let [rhsValue, rhsUnit] = rhsDimension
// Within dimensions, keep unit-bearing values ahead of unitless numbers so
// `1rem` sorts before `1`.
if (lhsUnit === null && rhsUnit !== null) return true
if (lhsUnit !== null && rhsUnit === null) return false
// Then sort dimensions numerically, and finally by unit for ties.
if (lhsValue !== rhsValue) {
return lhsValue - rhsValue > 0
}
if (lhsUnit !== rhsUnit) {
return (lhsUnit ?? '').localeCompare(rhsUnit ?? '') > 0
}
}
// Both nodes are unknown values (not dimensions), sort them as strings
return ValueParser.toCss([lhs]).localeCompare(ValueParser.toCss([rhs])) > 0
}

View file

@ -446,6 +446,28 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s',
// Arbitrary percentage value must be a whole number. Should not migrate to
// a bare value.
['from-[2.5%]', 'from-[2.5%]'],
// Negative arbitrary values can be simplified
// 1. Try to move the sign _inside_ the arbitrary value
// 2. Try to move the sign _out_ of the arbitrary value
['-mt-[12rem]', '-mt-48'], // Arbitrary value → bare value
['-mt-[-12rem]', 'mt-48'], // Double negation
['-mt-[12.34rem]', 'mt-[-12.34rem]'], // Move `-` inside
['-mt-[-12.34rem]', 'mt-[12.34rem]'], // Move `-` inside, double negation
['-mt-[12.34px]', 'mt-[-12.34px]'],
['-mt-[-12.34px]', 'mt-[12.34px]'],
['-mt-[492px]', '-mt-123'], // Moving inside, allows us to migrate to a bare value
['-mt-[calc(-1*492px)]', 'mt-123'], // Double negation and constant folding into bare value
['-mt-[-492px]', 'mt-123'],
['-mt-[calc(-1*-492px)]', '-mt-123'], // Constant folding with calc expressions
['-mt-(--my-var)', '-mt-(--my-var)'], // Keep as-is
['-mt-[var(--my-var)]', '-mt-(--my-var)'], // Keep as-is, but convert to shorthand
['mt-[calc(var(--my-var)*-1)]', '-mt-(--my-var)'], // Move `-` out
['mt-[calc(-1*var(--my-var))]', '-mt-(--my-var)'], // Move `-` out
['-mt-[calc(var(--my-var)*-1)]', 'mt-(--my-var)'], // Move `-` out
['-mt-[calc(-1*var(--my-var))]', 'mt-(--my-var)'], // Move `-` out
['mt-[calc(-1*calc(-1*var(--my-var)))]', 'mt-(--my-var)'], // Move `-` out
['-mt-[calc(-1*calc(-1*var(--my-var)))]', '-mt-(--my-var)'], // Move `-` out
])(testName, { timeout }, async (candidate, expected) => {
let input = css`
@import 'tailwindcss';

View file

@ -11,8 +11,9 @@ import {
type NamedUtilityValue,
type Variant,
} from './candidate'
import { canonicalizeCalcExpressionsAst } from './canonicalize-calc-expressions'
import { keyPathToCssProperty } from './compat/apply-config-to-theme'
import { constantFoldDeclaration } from './constant-fold-declaration'
import { constantFoldDeclaration, constantFoldDeclarationAst } from './constant-fold-declaration'
import type { DesignSystem as BaseDesignSystem } from './design-system'
import { CompileAstFlags } from './design-system'
import { expandDeclaration } from './expand-declaration'
@ -77,6 +78,10 @@ interface DesignSystem extends BaseDesignSystem {
number | null, // Rem value
DefaultMap<SignatureFeatures, SignatureOptions>
>
[COMPARE_CANDIDATES_KEY]: DefaultMap<
SignatureOptions,
(a: Candidate | string, b: Candidate | string) => boolean
>
[INTERNAL_OPTIONS_KEY]: DefaultMap<
SignatureOptions,
DefaultMap<Features, InternalCanonicalizeOptions>
@ -126,6 +131,7 @@ export function prepareDesignSystemStorage(
designSystem.storage[PRE_COMPUTED_UTILITIES_KEY] ??= createPreComputedUtilitiesCache(designSystem)
designSystem.storage[VARIANT_SIGNATURE_KEY] ??= createVariantSignatureCache(designSystem)
designSystem.storage[PRE_COMPUTED_VARIANTS_KEY] ??= createPreComputedVariantsCache(designSystem)
designSystem.storage[COMPARE_CANDIDATES_KEY] ??= createSignatureComparison(designSystem)
return designSystem
}
@ -139,6 +145,25 @@ function createSignatureOptionsCache(): DesignSystem['storage'][typeof SIGNATURE
})
}
const COMPARE_CANDIDATES_KEY = Symbol()
function createSignatureComparison(designSystem: DesignSystem) {
return new DefaultMap((options: SignatureOptions) => {
let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options)
return function hasSameSignature(a: Candidate | string, b: Candidate | string): boolean {
let aCandidateString = typeof a === 'string' ? a : designSystem.printCandidate(a)
let aSignature = signatures.get(aCandidateString)
if (typeof aSignature !== 'string') return false
let bCandidateString = typeof b === 'string' ? b : designSystem.printCandidate(b)
let bSignature = signatures.get(bCandidateString)
if (typeof bSignature !== 'string') return false
return aSignature === bSignature
}
})
}
export function createSignatureOptions(
baseDesignSystem: BaseDesignSystem,
options?: CanonicalizeOptions,
@ -574,6 +599,7 @@ const UTILITY_CANONICALIZATIONS: UtilityCanonicalizationFunction[] = [
bgGradientToLinear,
themeToVarUtility,
calcToSpacingFunction,
optimizeArbitraryValueExpressions,
arbitraryUtilities,
bareValueUtilities,
deprecatedUtilities,
@ -1092,6 +1118,7 @@ function arbitraryUtilities(candidate: Candidate, options: InternalCanonicalizeO
let designSystem = options.designSystem
let utilities = designSystem.storage[PRE_COMPUTED_UTILITIES_KEY].get(options.signatureOptions)
let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions)
let hasSameSignature = designSystem.storage[COMPARE_CANDIDATES_KEY].get(options.signatureOptions)
let targetCandidateString = designSystem.printCandidate(candidate)
@ -1101,9 +1128,7 @@ function arbitraryUtilities(candidate: Candidate, options: InternalCanonicalizeO
// Try a few options to find a suitable replacement utility
for (let replacementCandidate of tryReplacements(targetSignature, candidate)) {
let replacementString = designSystem.printCandidate(replacementCandidate)
let replacementSignature = signatures.get(replacementString)
if (replacementSignature !== targetSignature) {
if (!hasSameSignature(candidate, replacementCandidate)) {
continue
}
@ -1340,6 +1365,7 @@ function bareValueUtilities(candidate: Candidate, options: InternalCanonicalizeO
let designSystem = options.designSystem
let utilities = designSystem.storage[PRE_COMPUTED_UTILITIES_KEY].get(options.signatureOptions)
let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions)
let hasSameSignature = designSystem.storage[COMPARE_CANDIDATES_KEY].get(options.signatureOptions)
let targetCandidateString = designSystem.printCandidate(candidate)
@ -1349,9 +1375,7 @@ function bareValueUtilities(candidate: Candidate, options: InternalCanonicalizeO
// Try a few options to find a suitable replacement utility
for (let replacementCandidate of tryReplacements(targetSignature, candidate)) {
let replacementString = designSystem.printCandidate(replacementCandidate)
let replacementSignature = signatures.get(replacementString)
if (replacementSignature !== targetSignature) {
if (!hasSameSignature(candidate, replacementCandidate)) {
continue
}
@ -1454,19 +1478,14 @@ function deprecatedUtilities(
options: InternalCanonicalizeOptions,
): Candidate {
let designSystem = options.designSystem
let signatures = designSystem.storage[UTILITY_SIGNATURE_KEY].get(options.signatureOptions)
let hasSameSignature = designSystem.storage[COMPARE_CANDIDATES_KEY].get(options.signatureOptions)
let targetCandidateString = printUnprefixedCandidate(designSystem, candidate)
let legacySignature = signatures.get(targetCandidateString)
if (typeof legacySignature !== 'string') return candidate
for (let replacementString of tryDeprecatedUtilities(targetCandidateString)) {
let replacementSignature = signatures.get(replacementString)
if (typeof replacementSignature !== 'string') continue
// Not the same signature, not safe to migrate
if (legacySignature !== replacementSignature) continue
if (!hasSameSignature(candidate, replacementString)) {
continue
}
let [replacement] = parseCandidate(designSystem, replacementString)
return replacement
@ -2081,6 +2100,95 @@ function modernizeArbitraryValuesVariant(
return result
}
// ---
function optimizeArbitraryValueExpressions(
candidate: Candidate,
options: InternalCanonicalizeOptions,
): Candidate {
if (candidate.kind !== 'functional' || candidate.value?.kind !== 'arbitrary') {
return candidate
}
let designSystem = options.designSystem
let hasSameSignature = designSystem.storage[COMPARE_CANDIDATES_KEY].get(options.signatureOptions)
let valueAst = ValueParser.parse(candidate.value.value)
// Start by constant folding the value expression, when dealing with `calc(…)`
if (valueAst.length === 1 && valueAst[0].kind === 'function' && valueAst[0].value === 'calc') {
let [folded, foldedValueAst] = constantFoldDeclarationAst(valueAst)
if (folded) {
let replacement = cloneCandidate(candidate)
replacement.value!.value = ValueParser.toCss(foldedValueAst)
if (hasSameSignature(candidate, replacement)) {
candidate = replacement
valueAst = foldedValueAst
}
}
}
// Move `-` sign into the arbitrary value itself
if (candidate.root[0] === '-') {
// We're dealing with a `var(…)`, keep as-is
if (valueAst.length === 1 && valueAst[0].kind === 'function' && valueAst[0].value === 'var') {
return candidate
}
// Move `* -1` inside, and try to constant fold to see if it's even worth
// updating the candidate or not.
let expressionAst = ValueParser.parse(`calc(${candidate.value!.value} * -1)`)
let [folded, foldedExpressionAst] = constantFoldDeclarationAst(expressionAst)
if (folded) {
let replacement = cloneCandidate(candidate)
replacement.root = replacement.root.slice(1) // Drop the leading `-`
replacement.value!.value = ValueParser.toCss(foldedExpressionAst)
if (hasSameSignature(candidate, replacement)) {
candidate = replacement
valueAst = foldedExpressionAst
}
}
}
// Move `-` sign out of the arbitrary value
if (valueAst.length === 1 && valueAst[0].kind === 'function' && valueAst[0].value === 'calc') {
let calcArgs = valueAst[0].nodes
// `calc(arg * -1)` or `calc(-1 * arg)`
if (
calcArgs.length === 5 &&
calcArgs[1].kind === 'separator' &&
calcArgs[1].value === ' ' &&
calcArgs[2].kind === 'word' &&
calcArgs[2].value === '*' &&
calcArgs[3].kind === 'separator' &&
calcArgs[3].value === ' '
) {
let arg =
calcArgs[4].kind === 'word' && calcArgs[4].value === '-1'
? calcArgs[0]
: calcArgs[0].kind === 'word' && calcArgs[0].value === '-1'
? calcArgs[4]
: null
if (arg) {
let replacement = cloneCandidate(candidate)
replacement.root = `-${candidate.root}`
replacement.value!.value = ValueParser.toCss([arg])
if (hasSameSignature(candidate, replacement)) {
candidate = replacement
}
}
}
}
return candidate
}
// ----
// Optimize the modifier
@ -2274,6 +2382,8 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si
node.value = resolveVariablesInValue(node.value, designSystem)
}
let valueAst = ValueParser.parse(node.value)
// Very basic `calc(…)` constant folding to handle the spacing scale
// multiplier:
//
@ -2282,7 +2392,17 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si
// → `calc(0.25rem * 4)` ← this is the case we will see
// after inlining the variable
// → `1rem`
node.value = constantFoldDeclaration(node.value, rem)
let [folded, foldedValueAst] = constantFoldDeclarationAst(valueAst, rem)
// Normalize `calc(…)` expressions such that arguments that are
// associatively equivalent are rendered in the same way.
//
// `calc(var(--foo) * -1)` === `calc(-1 * var(--foo))`
let [normalized, canonicalizedValueAst] = canonicalizeCalcExpressionsAst(foldedValueAst)
if (folded || normalized) {
node.value = ValueParser.toCss(canonicalizedValueAst)
}
// We will normalize the `node.value`, this is the same kind of logic
// we use when printing arbitrary values. It will remove unnecessary

View file

@ -24,9 +24,30 @@ it.each([
['calc(3rem * 6)', '18rem'],
['calc(5rem / 2)', '2.5rem'],
// Negating with units
['calc(2rem * -1)', '-2rem'],
['calc(-1 * 2rem)', '-2rem'],
// Nested partial evaluation
['calc(calc(1 + 2) + 2rem)', 'calc(3 + 2rem)'],
// Nested multiplication with unknown values
['calc(2 * calc(3 * var(--foo)))', 'calc(6 * var(--foo))'],
['calc(calc(3 * var(--foo)) * 2)', 'calc(6 * var(--foo))'],
['calc(2rem * calc(3 * var(--foo)))', 'calc(6rem * var(--foo))'],
// Nested addition with unknown values
['calc(1rem + calc(2rem + var(--foo)))', 'calc(3rem + var(--foo))'],
['calc(calc(2rem + var(--foo)) + 1rem)', 'calc(3rem + var(--foo))'],
// Nested multiplication can collapse away entirely
['calc(-1 * calc(-1 * var(--foo)))', 'var(--foo)'],
['calc(calc(-1 * var(--foo)) * -1)', 'var(--foo)'],
['calc(-1 * calc(var(--foo) * -1))', 'var(--foo)'],
['calc(-1 * (-1 * var(--foo)))', 'var(--foo)'],
['calc(1 * var(--foo))', 'var(--foo)'],
['calc(var(--foo) * 1)', 'var(--foo)'],
// Evaluation only handles two operands right now, this can change in the future
['calc(1 + 2 + 3)', 'calc(1 + 2 + 3)'],
])('should constant fold `%s` into `%s`', (input, expected) => {
@ -40,6 +61,7 @@ it.each([
['calc(3rem * 3dvw)'],
['calc(3rem * 2dvh)'],
['calc(5rem / 17px)'],
['calc(2rem * calc(3px * var(--foo)))'],
])('should not constant fold different units `%s`', (input) => {
expect(constantFoldDeclaration(input)).toBe(input)
})

View file

@ -6,10 +6,18 @@ import { walk, WalkAction } from './walk'
// Assumption: We already assume that we receive somewhat valid `calc()`
// expressions. So we will see `calc(1 + 1)` and not `calc(1+1)`
export function constantFoldDeclaration(input: string, rem: number | null = null): string {
let folded = false
let valueAst = ValueParser.parse(input)
let [folded, valueAst] = constantFoldDeclarationAst(ValueParser.parse(input), rem)
walk(valueAst, {
return folded ? ValueParser.toCss(valueAst) : input
}
export function constantFoldDeclarationAst(
ast: ValueParser.ValueAstNode[],
rem: number | null = null,
): [folded: boolean, ast: ValueParser.ValueAstNode[]] {
let folded = false
walk(ast, {
exit(valueNode) {
// Canonicalize dimensions to their simplest form. This includes:
// - Convert `-0`, `+0`, `0.0`, … to `0`
@ -40,10 +48,14 @@ export function constantFoldDeclaration(input: string, rem: number | null = null
// { kind: 'word', value: '256' } 4
// ]
if (valueNode.nodes.length !== 5) return
if (valueNode.nodes[2].kind !== 'word') return
let lhs = dimensions.get(valueNode.nodes[0].value)
let lhsNode = valueNode.nodes[0]
let operator = valueNode.nodes[2].value
let rhs = dimensions.get(valueNode.nodes[4].value)
let rhsNode = valueNode.nodes[4]
let lhs = lhsNode.kind === 'word' ? dimensions.get(lhsNode.value) : null
let rhs = rhsNode.kind === 'word' ? dimensions.get(rhsNode.value) : null
// Nullify entire expression when multiplying by `0`, e.g.: `calc(0 * 100vw)` -> `0`
//
@ -57,6 +69,134 @@ export function constantFoldDeclaration(input: string, rem: number | null = null
return WalkAction.ReplaceSkip(ValueParser.word('0'))
}
if (operator === '*') {
// Multiplying by `1` can always unwrap the other side, even when that
// side is an expression like `var(--foo)` that we can't fully fold.
if (lhs?.[0] === 1 && lhs?.[1] === null) {
folded = true
return WalkAction.ReplaceSkip(rhsNode)
}
if (rhs?.[0] === 1 && rhs?.[1] === null) {
folded = true
return WalkAction.ReplaceSkip(lhsNode)
}
}
if (operator === '*' || operator === '+') {
// If only one side is known, and the unknown side is itself another
// binary expression with the same operator, try to combine the two
// known parts and keep the unknown part in place.
//
// E.g.:
//
// - `calc(2rem * calc(3 * var(--foo)))` → `calc(6rem * var(--foo))`
// - `calc(2rem + calc(3rem + var(--foo)))` → `calc(5rem + var(--foo))`
//
// At this point `lhs` and `rhs` are dimensions ([value, unit] |
// null). The `null` one is the unknown node.
let constant = lhs ?? rhs
let nestedNode = lhs === null ? lhsNode : rhs === null ? rhsNode : null
if (
constant !== null &&
nestedNode !== null &&
nestedNode.kind === 'function' &&
(nestedNode.value === 'calc' || nestedNode.value === '') &&
nestedNode.nodes.length === 5 &&
nestedNode.nodes[2].kind === 'word' &&
nestedNode.nodes[2].value === operator
) {
let nestedLhsNode = nestedNode.nodes[0]
let nestedRhsNode = nestedNode.nodes[4]
let nestedLhs =
nestedLhsNode.kind === 'word' ? dimensions.get(nestedLhsNode.value) : null
let nestedRhs =
nestedRhsNode.kind === 'word' ? dimensions.get(nestedRhsNode.value) : null
let known = nestedLhs ?? nestedRhs
let unknown =
nestedLhs === null ? nestedLhsNode : nestedRhs === null ? nestedRhsNode : null
if (known !== null && unknown !== null) {
// `*` requires both values being unitless, or one of them. Both
// values having a unit is invalid.
//
// - `2 * 3` → valid
// - `4rem * 5` → valid
// - `6 * 7rem` → valid
// - `8rem * 9rem` → invalid
if (
operator === '*' &&
!(
(constant[1] === null && known[1] === null) || // Both can be unitless
(constant[1] === null && known[1] !== null) || // One of them can be unitless, but the other can't
(constant[1] !== null && known[1] === null) // One of them can be unitless, but the other can't
)
) {
return
}
// `+` requires that the units are the same. Adding a unitless
// value to a value with a unit is not allowed.
//
// - `2 + 3` → valid
// - `4rem + 5` → invalid
// - `6 + 7rem` → invalid
// - `8rem + 9rem` → valid
if (
operator === '+' &&
!(
(constant[1] === known[1]) // Only same unit is allowed
)
) {
return
}
// Re-associate nested expressions so we can still fold the known
// part of `x op (y op z)` when `z` is unknown.
//
// Examples:
// - `calc(2 * calc(3 * var(--foo)))` -> `calc(6 * var(--foo))`
// - `calc(1rem + calc(2rem + var(--foo)))` -> `calc(3rem + var(--foo))`
let combined: string
switch (operator) {
case '*': {
combined = `${constant[0] * known[0]}${constant[1] ?? known[1] ?? ''}`
break
}
case '+': {
combined = `${constant[0] + known[0]}${constant[1] ?? known[1] ?? ''}`
break
}
default:
return
}
folded = true
if (operator === '*' && combined === '1') {
return WalkAction.ReplaceSkip(unknown)
}
let replacement: ValueParser.ValueFunctionNode = {
kind: 'function',
value: valueNode.value,
nodes: [
ValueParser.word(combined),
valueNode.nodes[1],
valueNode.nodes[2],
valueNode.nodes[3],
unknown,
],
}
return WalkAction.ReplaceSkip(replacement)
}
}
}
// We're not dealing with dimensions, so we can't fold this
if (lhs === null || rhs === null) {
return
@ -70,7 +210,9 @@ export function constantFoldDeclaration(input: string, rem: number | null = null
(lhs[1] !== null && rhs[1] === null) // Unit * Unitless, e.g.: `1rem * 2`
) {
folded = true
return WalkAction.ReplaceSkip(ValueParser.word(`${lhs[0] * rhs[0]}${lhs[1] ?? ''}`))
return WalkAction.ReplaceSkip(
ValueParser.word(`${lhs[0] * rhs[0]}${lhs[1] ?? rhs[1] ?? ''}`),
)
}
break
}
@ -111,7 +253,7 @@ export function constantFoldDeclaration(input: string, rem: number | null = null
},
})
return folded ? ValueParser.toCss(valueAst) : input
return [folded, ast]
}
function canonicalizeDimension(input: string, rem: number | null = null): string | null {