From 902443a0e1567b1355480b5b44f7fb71021c9761 Mon Sep 17 00:00:00 2001 From: Ludovico Fischer Date: Sun, 20 Sep 2026 13:38:05 +0200 Subject: [PATCH] fix: improve unit handling in percentages and atan2 function --- src/lib/analyze.js | 130 ++++++++++----- src/lib/functions.js | 50 ++++-- test/conformance/wpt-core.test.js | 16 ++ test/helpers/ast-arbitraries.js | 6 + test/property/properties.test.js | 4 +- test/unit/analyze.test.js | 254 ++++++++++++++++++++++++++++++ types/lib/analyze.d.ts | 27 ++++ types/lib/functions.d.ts | 20 ++- 8 files changed, 451 insertions(+), 56 deletions(-) diff --git a/src/lib/analyze.js b/src/lib/analyze.js index 7c063a5..cdf4d36 100644 --- a/src/lib/analyze.js +++ b/src/lib/analyze.js @@ -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} 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 @@ -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); @@ -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) { @@ -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)) { @@ -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 @param {number} depth @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */ @@ -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); @@ -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; @@ -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 && @@ -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) { @@ -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; } diff --git a/src/lib/functions.js b/src/lib/functions.js index ba89acc..9440eb8 100644 --- a/src/lib/functions.js +++ b/src/lib/functions.js @@ -14,13 +14,23 @@ 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} */ @@ -28,10 +38,23 @@ 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; @@ -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} */ @@ -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, }; diff --git a/test/conformance/wpt-core.test.js b/test/conformance/wpt-core.test.js index 559d898..b447595 100644 --- a/test/conformance/wpt-core.test.js +++ b/test/conformance/wpt-core.test.js @@ -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 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 diff --git a/test/helpers/ast-arbitraries.js b/test/helpers/ast-arbitraries.js index 0731de6..9549bf0 100644 --- a/test/helpers/ast-arbitraries.js +++ b/test/helpers/ast-arbitraries.js @@ -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 diff --git a/test/property/properties.test.js b/test/property/properties.test.js index 810cba1..12cc6fe 100644 --- a/test/property/properties.test.js +++ b/test/property/properties.test.js @@ -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, }); diff --git a/test/unit/analyze.test.js b/test/unit/analyze.test.js index c4e3717..871e0ee 100644 --- a/test/unit/analyze.test.js +++ b/test/unit/analyze.test.js @@ -87,6 +87,21 @@ test('reduceCalc: preserves an invalid sum hidden by an unresolved term', () => ); }); +test('reduceCalc: preserves a length added to a percentage ratio', () => { + assert.equal( + reduceCalc('calc(0% / 0% + 0px + 0px + 0px)'), + 'calc(0% / 0% + 0px + 0px + 0px)' + ); +}); + +test('reduceCalc: reduces a valid percentage ratio to its scalar quotient', () => { + assert.equal(reduceCalc('calc(10% / 5%)'), 'calc(2)'); + // Percentage-ness survives sum and call wrappers, so these are the same + // ratio and reduce to the same scalar. + assert.equal(reduceCalc('calc(calc(10%) / 5%)'), 'calc(2)'); + assert.equal(reduceCalc('calc((10% + 5%) / 5%)'), 'calc(3)'); +}); + test('reduceCalc: preserves an invalid sum of incompatible types through an unresolved term', () => { assert.equal( reduceCalc('calc(2 * (0% + -1) + round(0turn, 1turn))'), @@ -128,6 +143,245 @@ test('analyze: a percentage product retains its known numerator dimension', () = }); }); +test('analyze: a percentage ratio is a number before simplification', () => { + assert.deepEqual(analyzeSource('10% / 5%'), { + type: 'number', + valid: true, + unresolved: true, + }); + // The ratio is a number, so adding a length is a definite type error even + // though the original percentage operands are contextual. + assert.deepEqual(analyzeSource('10% / 5% + 1px'), { + type: 'unknown', + valid: false, + unresolved: true, + }); + // A non-percentage numerator must survive the cancellation of `% / %`. + assert.deepEqual(analyzeSource('10% / 5% * var(--x)'), { + type: 'unknown', + valid: true, + unresolved: true, + }); +}); + +test('analyze: a percentage ratio is a number through sum and call wrappers', () => { + // Percentages typed through wrappers resolve in the same context as their + // peers, so the contextual type still cancels across `% / %`. + for (const numerator of ['calc(10%)', '(10% + 5%)', 'min(10%, 20%)']) { + assert.deepEqual(analyzeSource(`${numerator} / 5%`), { + type: 'number', + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource(`${numerator} / 5% + 1px`), { + type: 'unknown', + valid: false, + unresolved: true, + }); + } + // An opaque sibling argument could resolve outside the percentage context, + // so the wrapped value is no longer known to be a percentage. + assert.deepEqual(analyzeSource('min(10%, var(--x)) / 5%'), { + type: 'unknown', + valid: true, + unresolved: true, + }); +}); + +test('analyze: only paired percentage factors cancel in a product', () => { + // One pair cancels and one numerator percentage remains contextual. + assert.deepEqual(analyzeSource('10% * 20% / 5%'), { + type: 'unknown', + valid: true, + unresolved: true, + }); + // One pair cancels and one denominator percentage remains contextual. + assert.deepEqual(analyzeSource('10% / 5% / 2%'), { + type: 'unknown', + valid: true, + unresolved: true, + }); + // Two pairs cancel completely, leaving a bare number. + assert.deepEqual(analyzeSource('(10% * 20%) / (5% * 2%)'), { + type: 'number', + valid: true, + unresolved: true, + }); +}); + +test('analyze: percentage-preserving builtins propagate the contextual type', () => { + // mod(), round(), and hypot() return their arguments' type through + // matchingArguments()/addTypes(), and clamp() with a `none` keyword at a + // MIN/MAX position skips the keyword, so each keeps its arguments' pure + // percentage type and the surrounding product cancels `% / %` to a number. + for (const value of [ + 'mod(10%, 5%)', + 'round(10%, 5%)', + 'hypot(10%, 20%)', + 'clamp(none, 10%, 30%)', + 'clamp(10%, 30%, none)', + ]) { + assert.deepEqual(analyzeSource(`${value} / 5%`), { + type: 'number', + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource(`${value} / 5% + 1px`), { + type: 'unknown', + valid: false, + unresolved: true, + }); + } + // A `none` in the middle argument is not recognized as a keyword, so it is + // an opaque term that breaks the same-percentage-context guarantee. + assert.deepEqual(analyzeSource('clamp(10%, none, 30%) / 5%'), { + type: 'unknown', + valid: true, + unresolved: true, + }); +}); + +test('reduceCalc: reduces percentage ratios through percentage-preserving builtins', () => { + // Each wrapped percentage still cancels against the denominator, so the + // ratios reduce to the same scalar-quotient form as a bare ratio. + assert.equal( + reduceCalc('calc(mod(10%, 5%) / 5%)'), + 'calc(1 / 5% * mod(10%, 5%))' + ); + assert.equal( + reduceCalc('calc(round(10%, 5%) / 5%)'), + 'calc(1 / 5% * round(10%, 5%))' + ); + assert.equal( + reduceCalc('calc(hypot(10%, 20%) / 10%)'), + 'calc(1 / 10% * hypot(10%, 20%))' + ); + assert.equal( + reduceCalc('calc(clamp(none, 10%, 30%) / 10%)'), + 'calc(1 / 10% * min(10%, 30%))' + ); +}); + +test('reduceCalc: serializes non-finite percentage ratios', () => { + // Both operands resolve in the same percentage context, so the quotient is + // a definite number even though its value cannot be known statically. + assert.equal(reduceCalc('calc(0% / 0%)'), 'calc(NaN)'); + assert.equal(reduceCalc('calc(10% / 0%)'), 'calc(infinity)'); +}); + +test('reduceCalc: adds a percentage to a reduced percentage ratio', () => { + // The ratio is known to be a number, so the sum keeps only the remaining + // contextual percentage instead of preserving both operands. + assert.equal(reduceCalc('calc(10% / 5% + 1%)'), 'calc(2 + 1%)'); + assert.deepEqual(analyzeSource('10% / 5% + 1%'), { + type: 'number', + valid: true, + unresolved: true, + }); + // The coarse typing still rejects a concrete dimension beside the ratio. + assert.deepEqual(analyzeSource('10% / 5% + 1% + 1px'), { + type: 'unknown', + valid: false, + unresolved: true, + }); +}); + +test('analyze: a non-percentage denominator survives percentage ratio cancellation', () => { + assert.deepEqual(analyzeSource('10% / 5% / var(--x)'), { + type: 'unknown', + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('10% / (5% * var(--x))'), { + type: 'unknown', + valid: true, + unresolved: true, + }); +}); + +test('analyze: a percentage ratio combined with a concrete dimension', () => { + assert.deepEqual(analyzeSource('10% / 5% * 10px'), { + type: { dimension: 'length' }, + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('10% / 5% * 10px + 20px'), { + type: { dimension: 'length' }, + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('10% / 5% * 10px + 1'), { + type: 'unknown', + valid: false, + unresolved: true, + }); +}); + +test('analyze: atan2 never leaks its arguments percentage type', () => { + // The atan2() type table gives «["angle" → 1]»; an unresolved result is + // plain unknown, so a surrounding product cannot cancel it against `%`. + assert.deepEqual(analyzeSource('atan2(10%, 5%)'), { + type: 'unknown', + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('atan2(10%, 5%) / 10%'), { + type: 'unknown', + valid: true, + unresolved: true, + }); +}); + +test('reduceCalc: treats an atan2 percentage ratio like an opaque ratio', () => { + // Both forms are unknown-typed ratios, so both reduce instead of the + // concrete one regressing to a preserved invalid sum. + assert.equal( + reduceCalc('calc(atan2(10%, 5%) / 10% + 1px)'), + 'calc(1px + 1 / 10% * atan2(10%, 5%))' + ); + assert.equal( + reduceCalc('calc(atan2(var(--x), 5%) / 10% + 1px)'), + 'calc(1px + 1 / 10% * atan2(var(--x), 5%))' + ); +}); + +test('analyze: a percentage-preserving builtin keeps its contextual type', () => { + // abs() passes its argument type through, so the ratio still cancels to + // a number exactly like a bare `10% / 10%`. + assert.deepEqual(analyzeSource('abs(10%) / 10%'), { + type: 'number', + valid: true, + unresolved: true, + }); +}); + +test('analyze: a percentage mixed with an opaque term does not cancel', () => { + // The sum loses the guarantee that its value resolves in the percentage + // context, so the surrounding product must not cancel it against `%`. + assert.deepEqual(analyzeSource('(10% + var(--x)) / 5%'), { + type: 'unknown', + valid: true, + unresolved: true, + }); +}); + +test('analyze: an unpaired percentage in a product stays unknown', () => { + // A product never returns a percentage, so its leftover percentage cannot + // participate in a later cancellation; the conservative unknown type only + // ever under-validates, never over-cancels. + assert.deepEqual(analyzeSource('10% * 2'), { + type: 'unknown', + valid: true, + unresolved: true, + }); + // Percentages only cancel as a pair within the same product. + assert.deepEqual(analyzeSource('10% * 2 / 5%'), { + type: 'number', + valid: true, + unresolved: true, + }); +}); + test('analyze: opaque numerator products retain known dimension constraints', () => { assert.deepEqual(analyzeSource('var(--x) * 10px'), { type: { dimension: 'length' }, diff --git a/types/lib/analyze.d.ts b/types/lib/analyze.d.ts index b5dc792..e1c1a7f 100644 --- a/types/lib/analyze.d.ts +++ b/types/lib/analyze.d.ts @@ -1,5 +1,8 @@ export type Node = import('./node.js').Node; export type CalculationType = import('./functions.js').CalculationType; +export type DimensionType = Extract; export type AnalysisType = 'number' | 'unknown' | { dimension: string | null; }; @@ -8,6 +11,30 @@ export type Analysis = { valid: boolean; unresolved: boolean; }; +export type ProductFactors = { + valid: boolean; + structurallyValid: boolean; + hasUnresolved: boolean; + numerator: DimensionType | null; + denominator: DimensionType | null; + hasOpaqueNumerator: boolean; + hasOpaqueDenominator: boolean; +}; +/** @typedef {import('./node.js').Node} Node */ +/** @typedef {import('./functions.js').CalculationType} CalculationType */ +/** @typedef {Extract} DimensionType */ +/** @typedef {'number' | 'unknown' | {dimension: string | null}} AnalysisType */ +/** @typedef {{type: AnalysisType, valid: boolean, unresolved: boolean}} Analysis */ +/** + * @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 * validates and classifies the tree; it is not a rewrite plan. diff --git a/types/lib/functions.d.ts b/types/lib/functions.d.ts index 9eb37f1..5ca4013 100644 --- a/types/lib/functions.d.ts +++ b/types/lib/functions.d.ts @@ -6,6 +6,7 @@ export type CalculationType = { base: string | null; } | { kind: 'unknown'; + percent?: true; } | { kind: 'failure'; }; @@ -17,8 +18,25 @@ export type MathFunction = { isKeyword?: (node: Node, index: number) => boolean; calculation?: boolean; }; +/** @typedef {import('./node.js').Node} Node */ +/** + * `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} */ declare const numberType: CalculationType; +/** @type {CalculationType} */ declare const unknownType: CalculationType; +/** @type {CalculationType} */ declare const percentageType: CalculationType; +/** @type {CalculationType} */ declare const failureType: CalculationType; /** @param {CalculationType} type @return {boolean} */ declare function isFailure(type: CalculationType): boolean; +/** @param {CalculationType} type @return {boolean} */ +declare function isPercentage(type: CalculationType): boolean; /** @param {CalculationType} a @param {CalculationType} b @return {CalculationType} */ declare function addTypes(a: CalculationType, b: CalculationType): CalculationType; declare const mathFunctions: Map; @@ -37,4 +55,4 @@ declare function isCalculationFunction(name: string): boolean; declare function isSupportedMathFunction(name: string): boolean; /** @param {string} value @return {boolean} */ declare function hasPotentialMathFunction(value: string): boolean; -export { addTypes, mathFunctions, lookupMathFunction, QUICK_MATH_TEST, isFailure, isCalculationFunction, isSupportedMathFunction, hasPotentialMathFunction, }; +export { addTypes, failureType, hasPotentialMathFunction, isCalculationFunction, isFailure, isPercentage, isSupportedMathFunction, lookupMathFunction, mathFunctions, numberType, percentageType, QUICK_MATH_TEST, unknownType, };