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:
parent
52fd421cc9
commit
df6209ab8b
7 changed files with 438 additions and 24 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
82
packages/tailwindcss/src/canonicalize-calc-expressions.ts
Normal file
82
packages/tailwindcss/src/canonicalize-calc-expressions.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue