Skip to content
Merged

fix: #327

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 87 additions & 43 deletions src/lib/analyze.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
import { baseOf } from './convertUnits.js';
import { addTypes, isFailure, mathFunctions } from './functions.js';
import {
addTypes,
failureType,
isFailure,
isPercentage,
mathFunctions,
numberType,
percentageType,
unknownType,
} from './functions.js';
import { assertDepth } from './limits.js';

/** @typedef {import('./node.js').Node} Node */
/** @typedef {import('./functions.js').CalculationType} CalculationType */
/** @typedef {Extract<CalculationType, {kind: 'dimension'}>} DimensionType */

/** @typedef {'number' | 'unknown' | {dimension: string | null}} AnalysisType */
/** @typedef {{type: AnalysisType, valid: boolean, unresolved: boolean}} Analysis */

/** @type {CalculationType} */ const numberType = { kind: 'number' };
/** @type {CalculationType} */ const unknownType = { kind: 'unknown' };
/** @type {CalculationType} */ const failureType = { kind: 'failure' };
/**
* @typedef {Object} ProductFactors
* @property {boolean} valid
* @property {boolean} structurallyValid
* @property {boolean} hasUnresolved
* @property {DimensionType | null} numerator
* @property {DimensionType | null} denominator
* @property {boolean} hasOpaqueNumerator
* @property {boolean} hasOpaqueDenominator
*/

/**
* Analyze the original complete tree and return its root summary. Analysis
Expand All @@ -34,10 +50,11 @@ function analyzeType(node, depth = 0) {
case 'Num':
return resolved(numberType);
case 'Dim':
// Percentages are contextual. Unknown units are opaque, while known
// families can still reject px + seconds.
// Percentages are contextual; their percent-ness is tracked so a
// `% / %` product cancels to a number. Unknown units are opaque, while
// known families can still reject px + seconds.
return node.unit === '%'
? finish(unknownType, true, true)
? finish(percentageType, true, true)
: resolved({ kind: 'dimension', base: baseOf(node.unit) });
case 'Ident':
return markUnresolved(unknownType);
Expand All @@ -56,6 +73,7 @@ function analyzeType(node, depth = 0) {
function analyzeSum(node, depth) {
let type = null;
let hasUnknown = false;
let hasPercentage = false;
let valid = true;
let hasUnresolved = false;
for (const term of node.terms) {
Expand All @@ -65,7 +83,11 @@ function analyzeSum(node, depth) {
if (isFailure(child.type)) {
type = failureType;
} else if (child.type.kind === 'unknown') {
hasUnknown = true;
// A pure percentage sum stays percentage-typed so a surrounding
// product can cancel `% / %`; any other opaque term must widen the
// sum back to unknown.
if (isPercentage(child.type)) hasPercentage = true;
else hasUnknown = true;
} else if (type === null) {
type = child.type;
} else if (!isFailure(type)) {
Expand All @@ -75,11 +97,10 @@ function analyzeSum(node, depth) {
if (type !== null && isFailure(type)) {
return finish(failureType, false, hasUnresolved);
}
return finish(
type ?? (hasUnknown ? unknownType : numberType),
valid,
hasUnresolved
);
let fallback = numberType;
if (hasUnknown) fallback = unknownType;
else if (hasPercentage) fallback = percentageType;
return finish(type ?? fallback, valid, hasUnresolved);
}

/** @param {Extract<Node, {type: 'Product'}>} node @param {number} depth @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */
Expand All @@ -88,8 +109,13 @@ function analyzeProduct(node, depth) {
let denominator = null;
let valid = true;
let structurallyValid = true;
let hasUnknownNumerator = false;
let hasUnknownDenominator = false;
// Opaque factors (unknowns and pure percentages) are counted per side so
// the pass below can decide whether any unknown remains after cancelling
// `% / %` pairs.
let opaqueNumerator = 0;
let opaqueDenominator = 0;
let percentageNumerator = 0;
let percentageDenominator = 0;
let hasUnresolved = false;
for (const factor of node.factors) {
const child = analyzeType(factor.node, depth + 1);
Expand All @@ -99,8 +125,13 @@ function analyzeProduct(node, depth) {
continue;
}
if (child.type.kind === 'unknown') {
if (factor.exponent === 1) hasUnknownNumerator = true;
else hasUnknownDenominator = true;
if (factor.exponent === 1) {
opaqueNumerator++;
if (isPercentage(child.type)) percentageNumerator++;
} else {
opaqueDenominator++;
if (isPercentage(child.type)) percentageDenominator++;
}
continue;
}
if (child.type.kind !== 'dimension') continue;
Expand All @@ -112,10 +143,36 @@ function analyzeProduct(node, depth) {
else denominator = child.type;
}
}
// A percentage divided by a percentage is always a plain number: both
// operands resolve in the same context, so their contextual type cancels.
// Consume one such pair before judging the remaining unknowns so a
// surrounding sum does not mistake `% / %` for a length-compatible term.
const cancelled = Math.min(percentageNumerator, percentageDenominator);
const hasOpaqueNumerator = opaqueNumerator - cancelled > 0;
const hasOpaqueDenominator = opaqueDenominator - cancelled > 0;
return finishProduct({
valid,
structurallyValid,
hasUnresolved,
numerator,
denominator,
hasOpaqueNumerator,
hasOpaqueDenominator,
});
}

/**
* Classify a product once its factors are analyzed. Opaque factors can supply
* missing type information, but they cannot make an already-invalid
* combination of known dimensions valid.
* @param {ProductFactors} factors
* @return {{type: CalculationType, valid: boolean, unresolved: boolean}}
*/
function finishProduct(factors) {
const { valid, structurallyValid, hasUnresolved } = factors;
if (!valid) return finish(failureType, false, hasUnresolved);
// Opaque factors can supply missing type information, but they cannot make
// an already-invalid combination of known dimensions valid.
if (!structurallyValid) return finish(failureType, false, hasUnresolved);
const { numerator, denominator } = factors;
if (
numerator !== null &&
denominator !== null &&
Expand All @@ -127,18 +184,13 @@ function analyzeProduct(node, depth) {
// only leave that dimension in place (when it resolves to a number) or make
// the product invalid. It can never make the product a bare number. Keep
// that known constraint so a surrounding sum can reject `1px * 1% + 1`.
const constrained = constrainedNumerator(
hasUnknownNumerator,
hasUnknownDenominator,
numerator,
denominator
);
const constrained = constrainedNumerator(factors);
if (constrained !== null) {
return finish(constrained, true, hasUnresolved);
}
// Other opaque factors may supply type information that changes how the
// known dimensions combine once the known factors are structurally valid.
if (hasUnknownNumerator || hasUnknownDenominator) {
if (factors.hasOpaqueNumerator || factors.hasOpaqueDenominator) {
return finish(unknownType, true, hasUnresolved);
}
if (numerator !== null && denominator !== null) {
Expand All @@ -153,23 +205,15 @@ function analyzeProduct(node, depth) {
}

/**
* @param {boolean} hasUnknownNumerator
* @param {boolean} hasUnknownDenominator
* @param {CalculationType | null} numerator
* @param {CalculationType | null} denominator
* @return {CalculationType | null}
* @param {ProductFactors} factors
* @return {DimensionType | null}
*/
function constrainedNumerator(
hasUnknownNumerator,
hasUnknownDenominator,
numerator,
denominator
) {
return hasUnknownNumerator &&
!hasUnknownDenominator &&
numerator !== null &&
denominator === null
? numerator
function constrainedNumerator(factors) {
return factors.hasOpaqueNumerator &&
!factors.hasOpaqueDenominator &&
factors.numerator !== null &&
factors.denominator === null
? factors.numerator
: null;
}

Expand Down
50 changes: 40 additions & 10 deletions src/lib/functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,47 @@ import { simplifyLog } from './simplify/log.js';
import { simplifyHypot } from './simplify/hypot.js';

/** @typedef {import('./node.js').Node} Node */
/** @typedef {{kind: 'number'} | {kind: 'dimension', base: string | null} | {kind: 'unknown'} | {kind: 'failure'}} CalculationType */
/**
* `percent` marks a value that resolves in the same percentage context as its
* peers (a pure percentage). It is only produced at leaves, by abs(), and by
* homogeneous sums and calls — never by a product, where an unpaired
* percentage could no longer cancel against anything.
* @typedef {{kind: 'number'} | {kind: 'dimension', base: string | null} | {kind: 'unknown', percent?: true} | {kind: 'failure'}} CalculationType
*/
/** @typedef {(name: string, args: Node[]) => Node} MathSimplifier */
/** @typedef {(args: CalculationType[], nodes: Node[]) => CalculationType} TypeAnalyzer */
/** @typedef {{analyze: TypeAnalyzer, simplify?: MathSimplifier, isKeyword?: (node: Node, index: number) => boolean, calculation?: boolean}} MathFunction */

/** @type {CalculationType} */ const numberType = { kind: 'number' };
/** @type {CalculationType} */ const unknownType = { kind: 'unknown' };
/** @type {CalculationType} */ const percentageType = {
kind: 'unknown',
percent: true,
};
/** @type {CalculationType} */ const failureType = { kind: 'failure' };

/** @param {CalculationType} type @return {boolean} */
function isFailure(type) {
return type.kind === 'failure';
}

/** @param {CalculationType} type @return {boolean} */
function isPercentage(type) {
return type.kind === 'unknown' && type.percent === true;
}

/** @param {CalculationType} a @param {CalculationType} b @return {CalculationType} */
function addTypes(a, b) {
if (isFailure(a) || isFailure(b)) return failureType;
if (a.kind === 'unknown' || b.kind === 'unknown') return unknownType;
if (a.kind === 'unknown' || b.kind === 'unknown') {
// A pure percentage keeps its contextual type so a surrounding product
// can cancel `% / %`; mixing it with any other opaque operand loses the
// guarantee that it resolves in the same context as its peers. A known
// number + percentage sum is likewise kept unknown and valid: the spec
// resolves the percentage against its surrounding context, which this
// coarse type model does not track.
return isPercentage(a) && isPercentage(b) ? percentageType : unknownType;
}
if (a.kind === 'number' && b.kind === 'number') return numberType;
if (a.kind === 'dimension' && b.kind === 'dimension') {
if (a.base === null || b.base === null) return unknownType;
Expand Down Expand Up @@ -129,9 +152,11 @@ function analyzeRound(args, nodes) {
/** @param {CalculationType[]} args @return {CalculationType} */
function analyzeAtan2(args) {
const type = matchingArguments(args, 2, 2);
return isFailure(type) || type.kind === 'unknown'
? type
: { kind: 'dimension', base: 'angle' };
if (isFailure(type)) return type;
// The type table gives atan2() «["angle" → 1]»; an unresolved result is
// plain unknown, never the percentage of its arguments.
if (type.kind === 'unknown') return unknownType;
return { kind: 'dimension', base: 'angle' };
}

/** @param {CalculationType[]} args @return {CalculationType} */
Expand Down Expand Up @@ -354,11 +379,16 @@ function hasPotentialMathFunction(value) {

export {
addTypes,
mathFunctions,
lookupMathFunction,
QUICK_MATH_TEST,
isFailure,
failureType,
hasPotentialMathFunction,
isCalculationFunction,
isFailure,
isPercentage,
isSupportedMathFunction,
hasPotentialMathFunction,
lookupMathFunction,
mathFunctions,
numberType,
percentageType,
QUICK_MATH_TEST,
unknownType,
};
16 changes: 16 additions & 0 deletions test/conformance/wpt-core.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,22 @@ describe('WPT min/max percentages', () => {
});
});

// --- typed_arithmetic.html -----------------------------------------------
// https://github.com/web-platform-tests/wpt/blob/master/css/css-values/typed_arithmetic.html
// CSS Values 4 §10.9: percentages in one calculation share a context, so a
// `% / %` ratio is a plain <number> and reduces to its scalar quotient.
describe('WPT typed arithmetic', () => {
test('WPT typed-arithmetic: percent over percent is one', () => {
// WPT specified: `1`.
assert.equal(out('calc(10% / 10%)'), 'calc(1)');
});

test('WPT typed-arithmetic: percent over percent keeps its quotient', () => {
// WPT specified: `0.5`.
assert.equal(out('calc(10% / 20%)'), 'calc(.5)');
});
});

// --- calc-serialization-002.html (subset) --------------------------------
// https://github.com/web-platform-tests/wpt/blob/master/css/css-values/calc-serialization-002.html
// Most cases here use Chrome's canonical reordering + px-normalization so
Expand Down
6 changes: 6 additions & 0 deletions test/helpers/ast-arbitraries.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
import fc from 'fast-check';
import { call, dim, ident, mkSum, mkProduct, num } from '../../src/lib/node.js';
import { serialize } from '../../src/lib/serialize.js';
// '%' participates in leaf generation, so the differential generator can
// build `% / %` products, which we reduce to a number (CSS Values 4 §10.9)
// while @csstools/css-calc leaves them opaque. That divergence is
// intentional and invisible to checkAgreement: it canonicalizes both
// outputs through our own pipeline. Explicit %/% expectations live in the
// analyze unit tests and the WPT typed-arithmetic conformance cases.
const KNOWN_UNITS = ['px', 'em', 'rem', 'vw', 's', 'ms', 'deg', 'turn', '%'];
const numLeaf = fc.integer({ min: -100, max: 100 }).map((v) => num(v));
const dimLeaf = fc
Expand Down
4 changes: 2 additions & 2 deletions test/property/properties.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,13 @@ test('property: simplification preserves analysis invariants on degenerate trees
);
});

test('property: percentage division can refine its coarse type', () => {
test('property: percentage division is typed as a number before simplification', () => {
const tokens = tokenize({ css: '10% / 5%' });
const ast = parse(tokens, 0, tokens.length, indexBlocks(tokens));
const before = analyze(ast);
const after = analyze(simplify(ast));
assert.deepEqual(before, {
type: 'unknown',
type: 'number',
valid: true,
unresolved: true,
});
Expand Down
Loading
Loading