diff --git a/README.md b/README.md index b419f9f..e7539d6 100755 --- a/README.md +++ b/README.md @@ -120,7 +120,8 @@ postcss().use(calc({ precision: 10 })); #### `precision` (default: `5`) Allows you to define the precision for decimal numbers. Set it to `false` to -disable rounding. +disable rounding and preserve full IEEE-754 floating-point precision (emitting +the shortest round-tripping decimal representation). ```js var out = postcss() diff --git a/scripts/benchmark-serialization.js b/scripts/benchmark-serialization.js index 4bafe00..b2ef912 100644 --- a/scripts/benchmark-serialization.js +++ b/scripts/benchmark-serialization.js @@ -3,7 +3,7 @@ // through both the worktree serializer and the serializer from HEAD. import { execFileSync } from 'node:child_process'; import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -144,16 +144,12 @@ function benchmarkPair(worktreeSerializer, headSerializer, node, materialize) { /** @return {Promise} */ async function loadHeadSerializer() { const root = mkdtempSync(join(tmpdir(), 'postcss-calc-serialize-')); - const lib = join(root, 'lib'); - mkdirSync(lib); - for (const file of ['serialize.js', 'node.js', 'opaque.js', 'limits.js']) { - const source = execFileSync('git', ['show', `HEAD:src/lib/${file}`], { - encoding: 'utf8', - }); - writeFileSync(join(lib, file), source); - } try { - return await import(pathToFileURL(join(lib, 'serialize.js')).href); + const archive = execFileSync('git', ['archive', 'HEAD', '--', 'src/lib']); + execFileSync('tar', ['-x', '-f', '-', '-C', root], { input: archive }); + return await import( + pathToFileURL(join(root, 'src', 'lib', 'serialize.js')).href + ); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/src/lib/analyze.js b/src/lib/analyze.js index 2ec3e0a..4ec7771 100644 --- a/src/lib/analyze.js +++ b/src/lib/analyze.js @@ -12,7 +12,12 @@ import { assertDepth } from './limits.js'; /** @type {CalculationType} */ const unknownType = { kind: 'unknown' }; /** @type {CalculationType} */ const failureType = { kind: 'failure' }; -/** @param {Node} node @return {Analysis} */ +/** + * Analyze the original complete tree and return its root summary. Analysis + * validates and classifies the tree; it is not a rewrite plan. + * @param {Node} node + * @return {Analysis} + */ function analyze(node) { const result = analyzeType(node); return { @@ -49,18 +54,32 @@ function analyzeType(node, depth = 0) { /** @param {Extract} node @param {number} depth @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */ function analyzeSum(node, depth) { - let type = numberType; + let type = null; + let hasUnknown = false; let valid = true; - let hasTerm = false; let hasUnresolved = false; for (const term of node.terms) { const child = analyzeType(term.node, depth + 1); - type = hasTerm ? addTypes(type, child.type) : child.type; - hasTerm = true; valid = valid && child.valid; hasUnresolved = hasUnresolved || child.unresolved; + if (isFailure(child.type)) { + type = failureType; + } else if (child.type.kind === 'unknown') { + hasUnknown = true; + } else if (type === null) { + type = child.type; + } else if (!isFailure(type)) { + type = addTypes(type, child.type); + } } - return finish(type, valid, hasUnresolved); + if (type !== null && isFailure(type)) { + return finish(failureType, false, hasUnresolved); + } + return finish( + type ?? (hasUnknown ? unknownType : numberType), + valid, + hasUnresolved + ); } /** @param {Extract} node @param {number} depth @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */ @@ -119,14 +138,19 @@ function analyzeProduct(node, depth) { /** @param {Extract} node @param {number} depth @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */ function analyzeCall(node, depth) { const name = node.name.toLowerCase(); - const childResults = node.args.map((arg) => analyzeType(arg, depth + 1)); - const childTypes = childResults.map((child) => child.type); - const valid = childResults.every((child) => child.valid); const definition = mathFunctions.get(name); - const unresolvedArgs = childResults.some( - (child, index) => - !definition?.isKeyword?.(node.args[index], index) && child.unresolved - ); + /** @type {CalculationType[]} */ + const childTypes = []; + let valid = true; + let unresolvedArgs = false; + for (let index = 0; index < node.args.length; index++) { + const child = analyzeType(node.args[index], depth + 1); + childTypes.push(child.type); + valid = valid && child.valid; + if (!definition?.isKeyword?.(node.args[index], index) && child.unresolved) { + unresolvedArgs = true; + } + } if (!definition) return finish(unknownType, valid, true); const type = definition.analyze(childTypes, node.args); const unresolvedType = type.kind === 'unknown'; diff --git a/src/lib/compile.js b/src/lib/compile.js index 757f7d5..2a0afbf 100644 --- a/src/lib/compile.js +++ b/src/lib/compile.js @@ -10,7 +10,9 @@ import { analyze } from './analyze.js'; /** @typedef {{options: ResolvedReduceCalcOptions, value: string, tokens: CSSToken[], index: BlockIndex}} CompileContext */ /** - * Parse, analyze, and simplify one candidate. + * Parse, analyze, and simplify one candidate. Analysis is the validity/status + * gate over the original tree; simplification then runs independently as a + * composable AST transformation that may synthesize nodes. * * @param {Candidate} candidate * @param {CompileContext} ctx diff --git a/src/lib/functions.js b/src/lib/functions.js index cf2d050..ba89acc 100644 --- a/src/lib/functions.js +++ b/src/lib/functions.js @@ -51,8 +51,12 @@ function addTypes(a, b) { */ function numberArguments(args, min, max) { if (args.length < min || args.length > max) return failureType; - if (args.some((arg) => arg.kind === 'dimension')) return failureType; - return args.some((arg) => arg.kind === 'unknown') ? unknownType : numberType; + let hasUnknown = false; + for (const arg of args) { + if (arg.kind === 'dimension') return failureType; + hasUnknown = hasUnknown || arg.kind === 'unknown'; + } + return hasUnknown ? unknownType : numberType; } /** @@ -66,8 +70,8 @@ function numberArguments(args, min, max) { function matchingArguments(args, min, max) { if (args.length < min || args.length > max) return failureType; let result = args[0]; - for (const arg of args.slice(1)) { - result = addTypes(result, arg); + for (let index = 1; index < args.length; index++) { + result = addTypes(result, args[index]); if (isFailure(result)) return failureType; } return result; @@ -98,15 +102,28 @@ function analyzeIdentity(args) { return args.length === 1 ? args[0] : failureType; } +/** @param {CalculationType[]} args @return {CalculationType} */ +function analyzeSign(args) { + return args.length === 1 ? numberType : failureType; +} + /** @param {CalculationType[]} args @param {Node[]} nodes @return {CalculationType} */ function analyzeRound(args, nodes) { const strategy = nodes[0]; const hasStrategy = strategy?.type === 'Ident' && ROUND_STRATEGIES.has(strategy.name.toLowerCase()); - const values = args.slice(hasStrategy ? 1 : 0); - if (values.length === 1) return numberArguments(values, 1, 1); - return matchingArguments(values, 2, 2); + const start = hasStrategy ? 1 : 0; + const valueCount = args.length - start; + if (valueCount === 1) { + const value = args[start]; + if (value.kind === 'dimension') return failureType; + if (value.kind === 'unknown') return unknownType; + return numberType; + } + if (valueCount !== 2) return failureType; + const type = addTypes(args[start], args[start + 1]); + return isFailure(type) ? failureType : type; } /** @param {CalculationType[]} args @return {CalculationType} */ @@ -139,10 +156,15 @@ function isClampKeyword(node, index) { /** @param {CalculationType[]} args @param {Node[]} nodes @return {CalculationType} */ function analyzeClamp(args, nodes) { if (args.length !== 3) return failureType; - const values = args.filter( - (_, index) => !isClampKeyword(nodes[index], index) - ); - return matchingArguments(values, 1, 3); + let valueCount = 0; + /** @type {CalculationType} */ let result = failureType; + for (let index = 0; index < args.length; index++) { + if (isClampKeyword(nodes[index], index)) continue; + result = valueCount === 0 ? args[index] : addTypes(result, args[index]); + valueCount++; + } + if (valueCount < 1 || isFailure(result)) return failureType; + return result; } const mathFunctions = new Map( @@ -182,7 +204,7 @@ const mathFunctions = new Map( [ 'sign', { - analyze: analyzeIdentity, + analyze: analyzeSign, simplify: (_name, args) => simplifySign(args), }, ], diff --git a/src/lib/serialize.js b/src/lib/serialize.js index ff6e1b6..fc32c98 100644 --- a/src/lib/serialize.js +++ b/src/lib/serialize.js @@ -28,13 +28,66 @@ const ATOMIC_PRECEDENCE = 3; const UNARY_PRECEDENCE = ATOMIC_PRECEDENCE; const NOISE_FLOOR = 1e-12; -/** @param {number} v @param {number | false} prec @return {number} */ +/** + * Decimal rounding with "round half away from zero" (e.g. 1.005 at precision 2 -> 1.01). + * + * Binary floating-point (IEEE-754) cannot represent many decimal fractions exactly + * (e.g. 1.005 is binary 1.004999999999999893...), causing arithmetic formulas like + * `Math.round(v * 100) / 100` to round down to 1.00. Exponential notation string shifting + * (`1.005e2` -> `100.5`) lets the ECMAScript string-to-number parser read the exact + * intended decimal value before rounding. + * + * @param {number} v + * @param {number | false} prec + * @return {number} + */ function round(v, prec) { - if (prec === false) return v; - const m = Math.pow(10, prec); - const rounded = Math.round(v * m) / m; - if (rounded === 0 && Math.abs(v) > NOISE_FLOOR) { - return Number(v.toPrecision(Math.max(prec, 1))); + if (prec === false || !Number.isFinite(v)) return v; + if (Object.is(v, -0) || v === 0) return v; + const abs = Math.abs(v); + // Numbers >= MAX_SAFE_INTEGER (2^53 - 1) cannot represent fractional values, and + // integers already have 0 fractional places. Bypassing them avoids float drift. + if (abs >= Number.MAX_SAFE_INTEGER || Number.isInteger(v)) return v; + + // Clamp precision to [0, 100] integer to prevent NaN from fractional precisions + // or exponent overflows into Infinity/NaN (e.g. exponent + prec > 308). + const p = Math.min(100, Math.max(0, Math.trunc(prec))); + const sign = v < 0 ? -1 : 1; + let rounded; + + if (p === 0) { + // Fast path: rounding to integer with "round half away from zero". + rounded = sign * Math.round(abs); + } else { + // Avoid .split('e') allocations: for numbers between 1e-6 and MAX_SAFE_INTEGER, + // String(abs) never contains exponential notation ('e'). + const absStr = String(abs); + const eIdx = absStr.indexOf('e'); + let shifted; + if (eIdx === -1) { + shifted = Math.round(Number(absStr + 'e' + p)); + } else { + const mantissa = absStr.slice(0, eIdx); + const exponent = Number(absStr.slice(eIdx + 1)); + shifted = Math.round(Number(mantissa + 'e' + (exponent + p))); + } + + // shifted is an integer. It only contains exponential notation ('e') if >= 1e21. + if (shifted >= 1e21) { + const shiftedStr = String(shifted); + const seIdx = shiftedStr.indexOf('e'); + const sMantissa = shiftedStr.slice(0, seIdx); + const sExponent = Number(shiftedStr.slice(seIdx + 1)); + rounded = sign * Number(sMantissa + 'e' + (sExponent - p)); + } else { + rounded = sign * Number(shifted + 'e-' + p); + } + } + + // Preserve non-zero values smaller than precision (e.g. 1/1000000) from collapsing + // to zero, while still snapping true floating-point dust (< 1e-12) to zero. + if (rounded === 0 && abs > NOISE_FLOOR) { + return Number(v.toPrecision(Math.max(p, 1))); } return rounded; } diff --git a/src/lib/simplify.js b/src/lib/simplify.js index 375917d..9b6f77d 100644 --- a/src/lib/simplify.js +++ b/src/lib/simplify.js @@ -18,6 +18,8 @@ import { assertDepth } from './limits.js'; */ /** + * Simplify is an independent, composable AST transformation. It may + * synthesize canonical nodes while preserving the Node -> Node contract. * @param {Node} node * @param {number} [depth] * @return {Node} diff --git a/src/lib/simplify/round.js b/src/lib/simplify/round.js index 0df9814..92e7da8 100644 --- a/src/lib/simplify/round.js +++ b/src/lib/simplify/round.js @@ -44,11 +44,11 @@ function simplifyRound(args) { // to applyRound, where floor*b===ceil*b===±∞ collapses back to A // (§10.3.1 "result is the same infinity"). if (Number.isNaN(b)) { - return num(Number.NaN); + return foldResult(fold, Number.NaN); } if (!Number.isFinite(b)) { if (!Number.isFinite(a)) { - return num(Number.NaN); + return foldResult(fold, Number.NaN); } let result; if (strategy === 'up' && a > 0) { @@ -63,7 +63,7 @@ function simplifyRound(args) { const result = applyRound(strategy, a, b); if (Number.isNaN(result)) { - return num(Number.NaN); + return foldResult(fold, Number.NaN); } return foldResult(fold, result); } diff --git a/test/integration/math-operations.test.js b/test/integration/math-operations.test.js index 824ea59..a386e92 100644 --- a/test/integration/math-operations.test.js +++ b/test/integration/math-operations.test.js @@ -298,4 +298,31 @@ describe('Precision', () => { 'precision for nested calc', testValue('calc(calc(100% / 3) * 3)', 'calc(100%)') ); + + test( + 'accurate midpoint rounding for dimensions', + testValue('calc(1.005px)', 'calc(1.01px)', { precision: 2 }) + ); + + test( + 'accurate midpoint rounding for negative dimensions', + testValue('calc(-1.005px)', 'calc(-1.01px)', { precision: 2 }) + ); + + test( + 'precision false retains lossless float for division', + testValue('calc(1/3)', 'calc(.3333333333333333)', { precision: false }) + ); + + test( + 'canonical reciprocal coefficient with opaque term at default precision', + testValue('calc(var(--x) / 3)', 'calc(.33333 * var(--x))') + ); + + test( + 'canonical reciprocal coefficient with opaque term at precision false', + testValue('calc(var(--x) / 3)', 'calc(.3333333333333333 * var(--x))', { + precision: false, + }) + ); }); diff --git a/test/property/properties.test.js b/test/property/properties.test.js index f629970..4762187 100644 --- a/test/property/properties.test.js +++ b/test/property/properties.test.js @@ -8,11 +8,13 @@ // test exists to assert the design hasn't drifted rather than to drive bug // hunting. We still keep them in CI as a guardrail. import { test } from 'node:test'; +import assert from 'node:assert/strict'; import fc from 'fast-check'; import { tokenize } from '@csstools/css-tokenizer'; import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import { simplify } from '../../src/lib/simplify.js'; +import { analyze } from '../../src/lib/analyze.js'; import { serialize } from '../../src/lib/serialize.js'; import { checkCalculationType } from '../../src/lib/calculation-type.js'; import { @@ -38,6 +40,64 @@ test('property: simplify is idempotent', () => { { numRuns: NUM_RUNS } ); }); +// --- Analysis/simplification contract ----------------------------------- +// Analysis summarizes the original tree, while simplification may refine +// coarse unknown types. It must not invalidate a valid tree, change a known +// type, or introduce unresolved state. +test('property: simplification preserves analysis invariants', () => { + fc.assert( + fc.property(astArb(4), (ast) => { + const before = analyze(ast); + if (!before.valid) return true; + const after = analyze(simplify(ast)); + assert.deepEqual(after.valid, true); + if (before.type !== 'unknown') { + assert.deepEqual(after.type, before.type); + } + if (!before.unresolved) { + assert.deepEqual(after.unresolved, false); + } + return true; + }), + { numRuns: NUM_RUNS } + ); +}); + +test('property: simplification preserves analysis invariants on degenerate trees', () => { + fc.assert( + fc.property(astArbWithDegenerate(3), (ast) => { + const before = analyze(ast); + if (!before.valid) return true; + const after = analyze(simplify(ast)); + assert.deepEqual(after.valid, true); + if (before.type !== 'unknown') { + assert.deepEqual(after.type, before.type); + } + if (!before.unresolved) { + assert.deepEqual(after.unresolved, false); + } + return true; + }), + { numRuns: NUM_RUNS } + ); +}); + +test('property: percentage division can refine its coarse type', () => { + 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', + valid: true, + unresolved: true, + }); + assert.deepEqual(after, { + type: 'number', + valid: true, + unresolved: false, + }); +}); // --- Parse-serialize round-trip ------------------------------------------ // serialize(simplify(x)) parsed+simplified back must be indistinguishable // from the first simplified form at the string level. diff --git a/test/unit/analyze.test.js b/test/unit/analyze.test.js index cf47d4a..a81bf43 100644 --- a/test/unit/analyze.test.js +++ b/test/unit/analyze.test.js @@ -5,6 +5,7 @@ import { checkCalculationType } from '../../src/lib/calculation-type.js'; import { call, num } from '../../src/lib/node.js'; import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; +import reduceCalc from '../../src/reduce.js'; import { tokenize } from '@csstools/css-tokenizer'; function analyzeSource(source) { @@ -48,6 +49,142 @@ test('analyze: tracks unresolved values without confusing grammar keywords', () }); }); +test('analyze: sign() always returns number at valid arity', () => { + assert.deepEqual(analyzeSource('sign(10px)'), { + type: 'number', + valid: true, + unresolved: false, + }); + assert.deepEqual(analyzeSource('sign(10%)'), { + type: 'number', + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('sign(var(--x))'), { + type: 'number', + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('sign()'), { + type: 'unknown', + valid: false, + unresolved: false, + }); + assert.deepEqual(analyzeSource('sign(1px, 2px)'), { + type: 'unknown', + valid: false, + unresolved: false, + }); +}); + +test('reduceCalc: accepts a calculation containing sign() of a dimension', () => { + assert.equal(reduceCalc('calc(sign(10px) + 1)'), 'calc(2)'); +}); + +test('reduceCalc: preserves an invalid sum hidden by an unresolved term', () => { + assert.equal( + reduceCalc('calc(0% * 0px / 0px + 0px + -1 * 0)'), + 'calc(0% * 0px / 0px + 0px + -1 * 0)' + ); +}); + +test('reduceCalc: preserves an invalid sum of incompatible types through an unresolved term', () => { + assert.equal( + reduceCalc('calc(2 * (0% + -1) + round(0turn, 1turn))'), + 'calc(2 * (0% + -1) + round(0turn, 1turn))' + ); +}); + +test('analyze: rejects sum when unresolved term is constrained to a type incompatible with other terms', () => { + assert.deepEqual(analyzeSource('2 * (0% + -1) + round(0turn, 1turn)'), { + type: 'unknown', + valid: false, + unresolved: true, + }); +}); + +test('analyze: resolves sum type when unresolved term is constrained by a dimension', () => { + assert.deepEqual(analyzeSource('10% + 20px'), { + type: { dimension: 'length' }, + valid: true, + unresolved: true, + }); +}); + +test('analyze: rejects sum when multiple incompatible dimensions surround an unresolved term', () => { + assert.deepEqual(analyzeSource('10px + var(--x) + 5s'), { + type: 'unknown', + valid: false, + unresolved: true, + }); +}); + +test('analyze: round() validates arity, types, and single-argument rules', () => { + assert.deepEqual(analyzeSource('round(5)'), { + type: 'number', + valid: true, + unresolved: false, + }); + assert.deepEqual(analyzeSource('round(up, 5)'), { + type: 'number', + valid: true, + unresolved: false, + }); + assert.deepEqual(analyzeSource('round(var(--x))'), { + type: 'unknown', + valid: true, + unresolved: true, + }); + assert.deepEqual(analyzeSource('round(10px)'), { + type: 'unknown', + valid: false, + unresolved: false, + }); + assert.deepEqual(analyzeSource('round(up, 10px)'), { + type: 'unknown', + valid: false, + unresolved: false, + }); + assert.deepEqual(analyzeSource('round(10px, 20s)'), { + type: 'unknown', + valid: false, + unresolved: false, + }); + assert.deepEqual(analyzeSource('round(10px, 20px)'), { + type: { dimension: 'length' }, + valid: true, + unresolved: false, + }); + assert.deepEqual(analyzeSource('round()'), { + type: 'unknown', + valid: false, + unresolved: false, + }); + assert.deepEqual(analyzeSource('round(1px, 2px, 3px)'), { + type: 'unknown', + valid: false, + unresolved: false, + }); +}); + +test('analyze: clamp() validates keywords and matching types', () => { + assert.deepEqual(analyzeSource('clamp(none, 10px, none)'), { + type: { dimension: 'length' }, + valid: true, + unresolved: false, + }); + assert.deepEqual(analyzeSource('clamp(10s, 10px, 20px)'), { + type: 'unknown', + valid: false, + unresolved: false, + }); + assert.deepEqual(analyzeSource('clamp(10px, 20px)'), { + type: 'unknown', + valid: false, + unresolved: false, + }); +}); + test('analyze: checks invalid product children after opaque factors', () => { const expected = { type: 'unknown', diff --git a/test/unit/serialize.test.js b/test/unit/serialize.test.js index 2c7a97d..16e0609 100644 --- a/test/unit/serialize.test.js +++ b/test/unit/serialize.test.js @@ -128,6 +128,84 @@ describe('serialize: numbers', () => { serialize(dim(1.123456789, 'px'), { precision: false }), 'calc(1.123456789px)' ); + assert.equal( + serialize(num(1 / 3), { precision: false }), + 'calc(.3333333333333333)' + ); + assert.equal( + serialize(dim(1 / 3, 'px'), { precision: false }), + 'calc(.3333333333333333px)' + ); + }); + + test('serialize: rounds decimal midpoints away from zero accurately', () => { + assert.equal(serialize(num(1.005), { precision: 2 }), 'calc(1.01)'); + assert.equal(serialize(num(-1.005), { precision: 2 }), 'calc(-1.01)'); + assert.equal(serialize(dim(1.005, 'px'), { precision: 2 }), 'calc(1.01px)'); + assert.equal( + serialize(dim(-1.005, 'px'), { precision: 2 }), + 'calc(-1.01px)' + ); + assert.equal(serialize(num(1.000005), { precision: 5 }), 'calc(1.00001)'); + assert.equal(serialize(num(-1.000005), { precision: 5 }), 'calc(-1.00001)'); + }); + + test('serialize: precision 0 rounds to integers away from zero', () => { + assert.equal(serialize(num(1.5), { precision: 0 }), 'calc(2)'); + assert.equal(serialize(num(-1.5), { precision: 0 }), 'calc(-2)'); + assert.equal(serialize(dim(1.2, 'px'), { precision: 0 }), 'calc(1px)'); + assert.equal(serialize(dim(-1.2, 'px'), { precision: 0 }), 'calc(-1px)'); + }); + + test('serialize: negative and fractional precisions are clamped and truncated', () => { + assert.equal(serialize(num(1.5), { precision: -1 }), 'calc(2)'); + assert.equal(serialize(num(-1.5), { precision: -2 }), 'calc(-2)'); + assert.equal(serialize(num(1.005), { precision: 2.5 }), 'calc(1.01)'); + assert.equal( + serialize(dim(1.005, 'px'), { precision: 2.9 }), + 'calc(1.01px)' + ); + }); + + test('serialize: boundary precisions avoid NaN overflow', () => { + assert.equal(serialize(num(1), { precision: 20 }), 'calc(1)'); + assert.equal(serialize(dim(1, 'px'), { precision: 100 }), 'calc(1px)'); + assert.equal(serialize(num(1), { precision: 310 }), 'calc(1)'); + assert.equal(serialize(dim(1, 'px'), { precision: 310 }), 'calc(1px)'); + assert.equal(serialize(num(1.5), { precision: 25 }), 'calc(1.5)'); + assert.equal(serialize(dim(1.5, 'px'), { precision: 25 }), 'calc(1.5px)'); + }); + + test('serialize: handles input magnitudes in scientific notation, MAX_SAFE_INTEGER, and noise floor', () => { + assert.equal(serialize(num(1e-7), { precision: 5 }), 'calc(1e-7)'); + assert.equal(serialize(num(1e-15), { precision: 5 }), 'calc(0)'); + assert.equal(serialize(num(1e21), { precision: 5 }), 'calc(1e+21)'); + assert.equal( + serialize(num(Number.MAX_SAFE_INTEGER), { precision: 2 }), + 'calc(9007199254740991)' + ); + assert.equal( + serialize(dim(Number.MAX_SAFE_INTEGER, 'px'), { precision: 2 }), + 'calc(9007199254740991px)' + ); + }); + + test('serialize: preserves signed zero vs zero under custom precision', () => { + const negNested = call('min', [num(-0), num(1)]); + const posNested = call('min', [num(0), num(1)]); + assert.equal( + serialize(negNested, { precision: 2 }), + 'min(calc(-1 * 0), 1)' + ); + assert.equal(serialize(posNested, { precision: 2 }), 'min(0, 1)'); + + const negDim = call('min', [dim(-0, 'px'), dim(1, 'px')]); + const posDim = call('min', [dim(0, 'px'), dim(1, 'px')]); + assert.equal( + serialize(negDim, { precision: 2 }), + 'min(calc(-1 * 0px), 1px)' + ); + assert.equal(serialize(posDim, { precision: 2 }), 'min(0px, 1px)'); }); test('serialize: omits the leading zero from fractional numbers', () => { diff --git a/test/unit/simplify/general-spec.test.js b/test/unit/simplify/general-spec.test.js index 5e6430d..22fdef0 100644 --- a/test/unit/simplify/general-spec.test.js +++ b/test/unit/simplify/general-spec.test.js @@ -55,6 +55,7 @@ describe('CSS stepped-value and sign functions', () => { assert.equal(out('round(up, 5, 0)'), 'calc(NaN)'); assert.equal(out('round(down, 5, 0)'), 'calc(NaN)'); assert.equal(out('round(to-zero, 5, 0)'), 'calc(NaN)'); + assert.equal(out('round(5px, 0px)'), 'calc(NaN * 1px)'); }); test('spec §10.7.1: round(finite, ±infinity) is strategy-dependent', () => { diff --git a/test/unit/simplify/round.test.js b/test/unit/simplify/round.test.js index 1c6e702..bddd00f 100644 --- a/test/unit/simplify/round.test.js +++ b/test/unit/simplify/round.test.js @@ -54,11 +54,20 @@ describe('round()', () => { test('round: B = 0 → NaN', () => { assert.equal(out('round(5, 0)'), 'calc(NaN)'); assert.equal(out('round(up, 5, 0)'), 'calc(NaN)'); - // Dim arg currently drops unit: `round(5px, 0px)` → `calc(NaN)`. The - // unit-preserving `calc(NaN * 1px)` form would require simplifyRound to - // emit `dim(NaN, unit)` instead of bare Num(NaN); deferred. - assert.equal(out('round(5px, 0px)'), 'calc(NaN)'); - assert.equal(out('round(down, 10deg, 0deg)'), 'calc(NaN)'); + assert.equal(out('round(5px, 0px)'), 'calc(NaN * 1px)'); + assert.equal(out('round(down, 10deg, 0deg)'), 'calc(NaN * 1deg)'); + assert.equal(out('round(5px, NaN * 1px)'), 'calc(NaN * 1px)'); + assert.equal(out('round(NaN * 1px, 5px)'), 'calc(NaN * 1px)'); + assert.equal( + out('round(infinity * 1px, infinity * 1px)'), + 'calc(NaN * 1px)' + ); + assert.equal( + out('round(-infinity * 1px, infinity * 1px)'), + 'calc(NaN * 1px)' + ); + assert.equal(out('calc(round(5px, NaN * 1px) + 10px)'), 'calc(NaN * 1px)'); + assert.equal(out('calc(round(5px, 0px) + 10px)'), 'calc(NaN * 1px)'); }); test('round: opaque var() arg → opaque', () => { diff --git a/types/lib/analyze.d.ts b/types/lib/analyze.d.ts index 267ba71..b5dc792 100644 --- a/types/lib/analyze.d.ts +++ b/types/lib/analyze.d.ts @@ -8,6 +8,11 @@ export type Analysis = { valid: boolean; unresolved: boolean; }; -/** @param {Node} node @return {Analysis} */ +/** + * Analyze the original complete tree and return its root summary. Analysis + * validates and classifies the tree; it is not a rewrite plan. + * @param {Node} node + * @return {Analysis} + */ declare function analyze(node: Node): Analysis; export { analyze }; diff --git a/types/lib/compile.d.ts b/types/lib/compile.d.ts index 2e89167..4cfc8e8 100644 --- a/types/lib/compile.d.ts +++ b/types/lib/compile.d.ts @@ -16,7 +16,9 @@ export type CompileContext = { /** @typedef {ReturnType} BlockIndex */ /** @typedef {{options: ResolvedReduceCalcOptions, value: string, tokens: CSSToken[], index: BlockIndex}} CompileContext */ /** - * Parse, analyze, and simplify one candidate. + * Parse, analyze, and simplify one candidate. Analysis is the validity/status + * gate over the original tree; simplification then runs independently as a + * composable AST transformation that may synthesize nodes. * * @param {Candidate} candidate * @param {CompileContext} ctx diff --git a/types/lib/simplify.d.ts b/types/lib/simplify.d.ts index e7d708e..0705122 100644 --- a/types/lib/simplify.d.ts +++ b/types/lib/simplify.d.ts @@ -8,6 +8,8 @@ export type SimplifyFn = (node: Node) => Node; * @typedef {(node: Node) => Node} SimplifyFn */ /** + * Simplify is an independent, composable AST transformation. It may + * synthesize canonical nodes while preserving the Node -> Node contract. * @param {Node} node * @param {number} [depth] * @return {Node}