diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bc36b4c7..89c462fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-` → `inset-s-` 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-` → `inset-e-` 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 diff --git a/packages/tailwindcss/src/canonicalize-calc-expressions.test.ts b/packages/tailwindcss/src/canonicalize-calc-expressions.test.ts new file mode 100644 index 000000000..f612e5f7b --- /dev/null +++ b/packages/tailwindcss/src/canonicalize-calc-expressions.test.ts @@ -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) +}) diff --git a/packages/tailwindcss/src/canonicalize-calc-expressions.ts b/packages/tailwindcss/src/canonicalize-calc-expressions.ts new file mode 100644 index 000000000..7e08f8622 --- /dev/null +++ b/packages/tailwindcss/src/canonicalize-calc-expressions.ts @@ -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 +} diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 810232e55..df029834f 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -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'; diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index c9f0274b3..f7f49e3a3 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -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 > + [COMPARE_CANDIDATES_KEY]: DefaultMap< + SignatureOptions, + (a: Candidate | string, b: Candidate | string) => boolean + > [INTERNAL_OPTIONS_KEY]: DefaultMap< SignatureOptions, DefaultMap @@ -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 diff --git a/packages/tailwindcss/src/constant-fold-declaration.test.ts b/packages/tailwindcss/src/constant-fold-declaration.test.ts index b95ed74a3..c87eb4dd6 100644 --- a/packages/tailwindcss/src/constant-fold-declaration.test.ts +++ b/packages/tailwindcss/src/constant-fold-declaration.test.ts @@ -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) }) diff --git a/packages/tailwindcss/src/constant-fold-declaration.ts b/packages/tailwindcss/src/constant-fold-declaration.ts index 9c33e73e9..bd401989a 100644 --- a/packages/tailwindcss/src/constant-fold-declaration.ts +++ b/packages/tailwindcss/src/constant-fold-declaration.ts @@ -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 {