From e4ba86c2ca29ac2d65224d41eae54fe4461e4766 Mon Sep 17 00:00:00 2001 From: Ludovico Fischer Date: Thu, 1 Oct 2026 13:37:43 +0200 Subject: [PATCH 1/8] test: reduce duplication --- test/helpers/arbitraries.js | 2 + test/helpers/ast-arbitraries.js | 8 ++ test/helpers/benchmark-artifact.js | 56 ++++++++++++++ test/helpers/laws.js | 7 ++ test/helpers/parse-source.js | 4 + test/property/algebraic-exponential.test.js | 9 +-- test/property/algebraic-mod-rem.test.js | 15 +--- test/property/algebraic-round.test.js | 38 ++++------ test/property/algebraic-sign.test.js | 13 +--- test/property/algebraic-trigonometry.test.js | 9 +-- test/unit/compare-parser-benchmarks.test.js | 79 ++++++-------------- test/unit/corpus-benchmark.test.js | 38 +--------- test/unit/parser-core.test.js | 7 +- test/unit/parser-opaque.test.js | 7 +- test/unit/serialize-core.test.js | 4 +- test/unit/serialize-precision.test.js | 36 ++------- test/unit/simplify/round.test.js | 7 -- 17 files changed, 138 insertions(+), 201 deletions(-) create mode 100644 test/helpers/laws.js diff --git a/test/helpers/arbitraries.js b/test/helpers/arbitraries.js index ae3b462..e23186b 100644 --- a/test/helpers/arbitraries.js +++ b/test/helpers/arbitraries.js @@ -2,7 +2,9 @@ export { astArb, astArbWithDegenerate, astToCalc, + finiteNum, numericAstArb, + positiveNum, trigExpFlatArb, } from './ast-arbitraries.js'; export { diff --git a/test/helpers/ast-arbitraries.js b/test/helpers/ast-arbitraries.js index 9549bf0..7ed04c6 100644 --- a/test/helpers/ast-arbitraries.js +++ b/test/helpers/ast-arbitraries.js @@ -252,3 +252,11 @@ export function astToCalc(ast) { const inner = serialize(ast, { precision: false }); return inner.startsWith('calc(') ? inner : `calc(${inner})`; } + +// Finite numeric leaf [-1000, 1000] (including 0) — domain for algebraic laws. +export const finiteNum = fc + .integer({ min: -1000, max: 1000 }) + .map((v) => num(v)); + +// Strictly positive numeric leaf [1, 1000] — domain for divisors, moduli, and steps. +export const positiveNum = fc.integer({ min: 1, max: 1000 }).map((v) => num(v)); diff --git a/test/helpers/benchmark-artifact.js b/test/helpers/benchmark-artifact.js index c546436..92ecdb6 100644 --- a/test/helpers/benchmark-artifact.js +++ b/test/helpers/benchmark-artifact.js @@ -59,6 +59,62 @@ export function corpusCorrectness() { inputHash: 'inputs', }; } +/** + * Build a synthetic corpus artifact matching schema 2. + * @param {object} [options] + * @param {string[]} [options.groups] + * @param {(replicate: number) => number} [options.ratioForReplicate] + * @param {object} [options.config] + * @param {number} [options.seed] + * @param {object} [options.corpus] + * @param {object} [options.correctness] + */ +export function syntheticCorpusArtifact({ + groups = ['exact', 'sum'], + ratioForReplicate = () => 1, + config = CORPUS_DECISION_CONFIG, + seed = 123, + corpus = { lengthStrata: {}, rootShapeCounts: { sum: 2 } }, + correctness = corpusCorrectness(), +} = {}) { + const replicates = Array.from({ length: 20 }, (_, replicate) => ({ + replicate, + calibrationOrder: replicate < 10 ? 'ours-first' : 'reference-first', + permutation: [0, 1], + batches: Array.from({ length: 6 }, (unusedBatch, batch) => ({ + order: batch < 3 ? 'ours-first' : 'reference-first', + measurements: groups.map((group) => { + const ratio = ratioForReplicate(replicate); + return { + group, + repetitions: 1, + calibrationOrder: replicate < 10 ? 'ours-first' : 'reference-first', + calibrationSamplesMs: [{ oursMs: ratio, referenceMs: 1 }], + ours: { + ms: ratio, + elapsedMs: ratio, + checksum: group === 'exact' ? 1 : 2, + }, + reference: { + ms: 1, + elapsedMs: 1, + checksum: group === 'exact' ? 1 : 2, + }, + }; + }), + })), + })); + + return { + schema: 2, + benchmark: 'corpus', + seed, + config, + corpus, + correctness, + replicates, + }; +} /** * Build a parser artifact from already-generated observations. `rows` contains diff --git a/test/helpers/laws.js b/test/helpers/laws.js new file mode 100644 index 0000000..ddc17b1 --- /dev/null +++ b/test/helpers/laws.js @@ -0,0 +1,7 @@ +import { simplify } from '../../src/lib/simplify.js'; +import { serialize } from '../../src/lib/serialize.js'; + +export const NUM_RUNS = 500; + +/** Serialize simplified AST at algebraic property precision (10 decimal places). */ +export const outAst = (node) => serialize(simplify(node), { precision: 10 }); diff --git a/test/helpers/parse-source.js b/test/helpers/parse-source.js index 403e57f..4fc007a 100644 --- a/test/helpers/parse-source.js +++ b/test/helpers/parse-source.js @@ -3,7 +3,11 @@ import { tokenize } from '@csstools/css-tokenizer'; import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; +import { sexpr } from './sexpr.js'; + export const parseSource = (css) => { const tokens = tokenize({ css }); return parse(tokens, 0, tokens.length, indexBlocks(tokens)); }; + +export const parseSexpr = (css) => sexpr(parseSource(css)); diff --git a/test/property/algebraic-exponential.test.js b/test/property/algebraic-exponential.test.js index e4c6ae0..ea9960b 100644 --- a/test/property/algebraic-exponential.test.js +++ b/test/property/algebraic-exponential.test.js @@ -9,14 +9,9 @@ // non-degenerate values. import { describe, test } from 'node:test'; import fc from 'fast-check'; -import { simplify } from '../../src/lib/simplify.js'; -import { serialize } from '../../src/lib/serialize.js'; -import { numeric } from '../helpers/numeric.js'; import { call, num } from '../../src/lib/node.js'; - -const NUM_RUNS = 500; - -const out = (n) => serialize(simplify(n), { precision: 10 }); +import { numeric } from '../helpers/numeric.js'; +import { NUM_RUNS, outAst as out } from '../helpers/laws.js'; // --- pow / sqrt / log / exp / hypot laws (§10.5) ---------------------- test('law: pow(x, 1) ≡ x for finite x', () => { diff --git a/test/property/algebraic-mod-rem.test.js b/test/property/algebraic-mod-rem.test.js index a25b76c..30bbb75 100644 --- a/test/property/algebraic-mod-rem.test.js +++ b/test/property/algebraic-mod-rem.test.js @@ -9,19 +9,10 @@ // non-degenerate values. import { describe, test } from 'node:test'; import fc from 'fast-check'; -import { simplify } from '../../src/lib/simplify.js'; -import { serialize } from '../../src/lib/serialize.js'; -import { numeric } from '../helpers/numeric.js'; import { call, num, ident } from '../../src/lib/node.js'; - -const NUM_RUNS = 500; - -const out = (n) => serialize(simplify(n), { precision: 10 }); - -// Finite, non-zero numeric leaf — domain for most laws. -const finiteNum = fc.integer({ min: -1000, max: 1000 }).map(num); - -const positiveNum = fc.integer({ min: 1, max: 1000 }).map(num); +import { numeric } from '../helpers/numeric.js'; +import { NUM_RUNS, outAst as out } from '../helpers/laws.js'; +import { finiteNum, positiveNum } from '../helpers/arbitraries.js'; // --- mod / rem laws ------------------------------------------------------ test('law: mod range — 0 ≤ mod(x, B) < B (for B > 0, finite x)', () => { diff --git a/test/property/algebraic-round.test.js b/test/property/algebraic-round.test.js index a0b845e..3c45411 100644 --- a/test/property/algebraic-round.test.js +++ b/test/property/algebraic-round.test.js @@ -9,19 +9,13 @@ // non-degenerate values. import { describe, test } from 'node:test'; import fc from 'fast-check'; -import { simplify } from '../../src/lib/simplify.js'; -import { serialize } from '../../src/lib/serialize.js'; -import { numeric } from '../helpers/numeric.js'; import { call, num, ident } from '../../src/lib/node.js'; +import { numeric } from '../helpers/numeric.js'; +import { NUM_RUNS, outAst as out } from '../helpers/laws.js'; +import { finiteNum, positiveNum } from '../helpers/arbitraries.js'; -const NUM_RUNS = 500; - -const out = (n) => serialize(simplify(n), { precision: 10 }); - -// Finite, non-zero numeric leaf — domain for most laws. -const finiteNum = fc.integer({ min: -1000, max: 1000 }).map(num); - -const positiveNum = fc.integer({ min: 1, max: 1000 }).map(num); +const evalRound = (strategy, x, b) => + numeric(out(call('round', [ident(strategy), x, b]))); // --- round laws ---------------------------------------------------------- test('law: round is idempotent on the same step — round(round(x, B), B) ≡ round(x, B)', () => { @@ -47,9 +41,9 @@ describe('round() laws', () => { test('law: round monotone in strategy — up ≥ nearest ≥ down', () => { fc.assert( fc.property(finiteNum, positiveNum, (x, b) => { - const up = numeric(out(call('round', [ident('up'), x, b]))); - const nearest = numeric(out(call('round', [ident('nearest'), x, b]))); - const down = numeric(out(call('round', [ident('down'), x, b]))); + const up = evalRound('up', x, b); + const nearest = evalRound('nearest', x, b); + const down = evalRound('down', x, b); return up >= nearest && nearest >= down; }), { numRuns: NUM_RUNS } @@ -59,9 +53,9 @@ describe('round() laws', () => { test('law: round to-zero ∈ {up, down} and minimizes |result|', () => { fc.assert( fc.property(finiteNum, positiveNum, (x, b) => { - const up = numeric(out(call('round', [ident('up'), x, b]))); - const down = numeric(out(call('round', [ident('down'), x, b]))); - const tz = numeric(out(call('round', [ident('to-zero'), x, b]))); + const up = evalRound('up', x, b); + const down = evalRound('down', x, b); + const tz = evalRound('to-zero', x, b); const inSet = tz === up || tz === down; const minimal = Math.abs(tz) <= Math.abs(up) && Math.abs(tz) <= Math.abs(down); @@ -78,7 +72,7 @@ describe('round() laws', () => { finiteNum, positiveNum, (strategy, x, b) => { - const r = numeric(out(call('round', [ident(strategy), x, b]))); + const r = evalRound(strategy, x, b); const q = r / b.value; // Allow tiny FP drift: integer means q ≡ round(q) within EPSILON. return Math.abs(q - Math.round(q)) < 1e-9; @@ -95,7 +89,7 @@ describe('round() laws', () => { finiteNum, positiveNum, (strategy, x, b) => { - const r = numeric(out(call('round', [ident(strategy), x, b]))); + const r = evalRound(strategy, x, b); return Math.abs(r - x.value) <= b.value + 1e-9; } ), @@ -106,9 +100,9 @@ describe('round() laws', () => { test('law: nearest minimizes |result − x| (with tie → upper)', () => { fc.assert( fc.property(finiteNum, positiveNum, (x, b) => { - const up = numeric(out(call('round', [ident('up'), x, b]))); - const down = numeric(out(call('round', [ident('down'), x, b]))); - const nearest = numeric(out(call('round', [ident('nearest'), x, b]))); + const up = evalRound('up', x, b); + const down = evalRound('down', x, b); + const nearest = evalRound('nearest', x, b); const dUp = Math.abs(up - x.value); const dDown = Math.abs(down - x.value); return dUp <= dDown ? nearest === up : nearest === down; diff --git a/test/property/algebraic-sign.test.js b/test/property/algebraic-sign.test.js index 8784a9e..07cdd5b 100644 --- a/test/property/algebraic-sign.test.js +++ b/test/property/algebraic-sign.test.js @@ -9,17 +9,10 @@ // non-degenerate values. import { describe, test } from 'node:test'; import fc from 'fast-check'; -import { simplify } from '../../src/lib/simplify.js'; -import { serialize } from '../../src/lib/serialize.js'; -import { numeric, scalarText } from '../helpers/numeric.js'; import { call, num, dim } from '../../src/lib/node.js'; - -const NUM_RUNS = 500; - -const out = (n) => serialize(simplify(n), { precision: 10 }); - -// Finite, non-zero numeric leaf — domain for most laws. -const finiteNum = fc.integer({ min: -1000, max: 1000 }).map(num); +import { numeric, scalarText } from '../helpers/numeric.js'; +import { NUM_RUNS, outAst as out } from '../helpers/laws.js'; +import { finiteNum } from '../helpers/arbitraries.js'; const finiteNonzeroNum = fc .integer({ min: -1000, max: 1000 }) diff --git a/test/property/algebraic-trigonometry.test.js b/test/property/algebraic-trigonometry.test.js index a9b7e0f..71aee83 100644 --- a/test/property/algebraic-trigonometry.test.js +++ b/test/property/algebraic-trigonometry.test.js @@ -9,14 +9,9 @@ // non-degenerate values. import { describe, test } from 'node:test'; import fc from 'fast-check'; -import { simplify } from '../../src/lib/simplify.js'; -import { serialize } from '../../src/lib/serialize.js'; -import { numeric } from '../helpers/numeric.js'; import { call, num } from '../../src/lib/node.js'; - -const NUM_RUNS = 500; - -const out = (n) => serialize(simplify(n), { precision: 10 }); +import { numeric } from '../helpers/numeric.js'; +import { NUM_RUNS, outAst as out } from '../helpers/laws.js'; // --- trig laws (§10.4) --------------------------------------------------- // diff --git a/test/unit/compare-parser-benchmarks.test.js b/test/unit/compare-parser-benchmarks.test.js index fb29b74..6e6a0e2 100644 --- a/test/unit/compare-parser-benchmarks.test.js +++ b/test/unit/compare-parser-benchmarks.test.js @@ -10,22 +10,11 @@ import { reanalyzeParserBenchmark, } from '../../scripts/benchmark/compare-parser-benchmarks.js'; import { - CORPUS_DECISION_CONFIG, - corpusCorrectness, + syntheticCorpusArtifact, syntheticParserArtifact, } from '../helpers/benchmark-artifact.js'; -test('exitCodeFor maps benchmark analysis statuses to exit codes', () => { - assert.equal(exitCodeFor('pass'), 0); - assert.equal(exitCodeFor('regression'), 1); - assert.equal(exitCodeFor('postcss-calc faster'), 0); - assert.equal(exitCodeFor('postcss-calc slower'), 0); - assert.equal(exitCodeFor('correctness-failure'), 3); - assert.equal(exitCodeFor('inconclusive'), 2); - assert.equal(exitCodeFor('unknown'), 2); -}); - -test('reanalyzes schema-v2 parser benchmark artifacts', () => { +function withTempParserArtifact(fn) { const directory = mkdtempSync(join(tmpdir(), 'postcss-calc-benchmark-')); try { const artifact = syntheticParserArtifact({ @@ -37,7 +26,24 @@ test('reanalyzes schema-v2 parser benchmark artifacts', () => { }); const artifactPath = join(directory, 'parser-artifact.json'); writeFileSync(artifactPath, JSON.stringify(artifact)); + return fn(artifactPath, directory); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +test('exitCodeFor maps benchmark analysis statuses to exit codes', () => { + assert.equal(exitCodeFor('pass'), 0); + assert.equal(exitCodeFor('regression'), 1); + assert.equal(exitCodeFor('postcss-calc faster'), 0); + assert.equal(exitCodeFor('postcss-calc slower'), 0); + assert.equal(exitCodeFor('correctness-failure'), 3); + assert.equal(exitCodeFor('inconclusive'), 2); + assert.equal(exitCodeFor('unknown'), 2); +}); +test('reanalyzes schema-v2 parser benchmark artifacts', () => { + withTempParserArtifact((artifactPath) => { const result = reanalyzeParserBenchmark(artifactPath); assert.equal(result.schema, 2); assert.equal(result.benchmark, 'parser-simulation'); @@ -45,39 +51,13 @@ test('reanalyzes schema-v2 parser benchmark artifacts', () => { const analysisOnly = compareParserBenchmarks(artifactPath); assert.deepEqual(analysisOnly, result.analysis); - } finally { - rmSync(directory, { recursive: true, force: true }); - } + }); }); test('reanalyzes schema-v2 corpus benchmark artifacts', () => { const directory = mkdtempSync(join(tmpdir(), 'postcss-calc-benchmark-')); try { - const groups = ['exact', 'sum']; - const artifact = { - schema: 2, - benchmark: 'corpus', - seed: 123, - config: CORPUS_DECISION_CONFIG, - corpus: { lengthStrata: {}, rootShapeCounts: { sum: 2 } }, - correctness: corpusCorrectness(), - replicates: Array.from({ length: 20 }, (_, replicate) => ({ - replicate, - calibrationOrder: replicate < 10 ? 'ours-first' : 'reference-first', - permutation: [0, 1], - batches: Array.from({ length: 6 }, (unusedBatch, batch) => ({ - order: batch < 3 ? 'ours-first' : 'reference-first', - measurements: groups.map((group) => ({ - group, - repetitions: 1, - calibrationOrder: replicate < 10 ? 'ours-first' : 'reference-first', - calibrationSamplesMs: [{ oursMs: 1, referenceMs: 1 }], - ours: { ms: 1, elapsedMs: 1, checksum: 1 }, - reference: { ms: 1, elapsedMs: 1, checksum: 1 }, - })), - })), - })), - }; + const artifact = syntheticCorpusArtifact(); const artifactPath = join(directory, 'corpus-artifact.json'); writeFileSync(artifactPath, JSON.stringify(artifact)); @@ -111,18 +91,7 @@ test('rejects non-schema-v2 artifacts and non-string paths', () => { }); test('CLI outputs analysis and exits with expected codes', () => { - const directory = mkdtempSync(join(tmpdir(), 'postcss-calc-benchmark-')); - try { - const artifact = syntheticParserArtifact({ - rows: Array.from({ length: 20 }, () => ({ - baseline: [1, 2], - candidate: [1, 2], - })), - workloadKeys: ['additive:cold-index:1000', 'additive:cold-index:2000'], - }); - const artifactPath = join(directory, 'parser-artifact.json'); - writeFileSync(artifactPath, JSON.stringify(artifact)); - + withTempParserArtifact((artifactPath, directory) => { const scriptPath = join( process.cwd(), 'scripts/benchmark/compare-parser-benchmarks.js' @@ -151,7 +120,5 @@ test('CLI outputs analysis and exits with expected codes', () => { ); assert.equal(invalidRun.status, 64); assert.match(invalidRun.stderr, /artifact must use schema 2/); - } finally { - rmSync(directory, { recursive: true, force: true }); - } + }); }); diff --git a/test/unit/corpus-benchmark.test.js b/test/unit/corpus-benchmark.test.js index aa25824..2c31205 100644 --- a/test/unit/corpus-benchmark.test.js +++ b/test/unit/corpus-benchmark.test.js @@ -12,41 +12,12 @@ import { import { CORPUS_DECISION_CONFIG, corpusCorrectness, + syntheticCorpusArtifact, } from '../helpers/benchmark-artifact.js'; function artifact(ratioForReplicate) { - const groups = ['exact', 'sum']; - const replicates = Array.from({ length: 20 }, (_, replicate) => ({ - replicate, - calibrationOrder: replicate < 10 ? 'ours-first' : 'reference-first', - permutation: [0, 1], - batches: Array.from({ length: 6 }, (unusedBatch, batch) => ({ - order: batch < 3 ? 'ours-first' : 'reference-first', - measurements: groups.map((group) => { - const ratio = ratioForReplicate(replicate); - return { - group, - repetitions: 1, - calibrationOrder: replicate < 10 ? 'ours-first' : 'reference-first', - calibrationSamplesMs: [{ oursMs: ratio, referenceMs: 1 }], - ours: { - ms: ratio, - elapsedMs: ratio, - checksum: group === 'exact' ? 1 : 2, - }, - reference: { - ms: 1, - elapsedMs: 1, - checksum: group === 'exact' ? 1 : 2, - }, - }; - }), - })), - })); - return { - schema: 2, - benchmark: 'corpus', - seed: 123, + return syntheticCorpusArtifact({ + ratioForReplicate, config: { requestedBlocks: 20, minimumBlocks: 20, @@ -59,8 +30,7 @@ function artifact(ratioForReplicate) { rootShapeCounts: { sum: 2 }, }, correctness: { accepted: 2 }, - replicates, - }; + }); } function strictArtifact(ratioForReplicate) { diff --git a/test/unit/parser-core.test.js b/test/unit/parser-core.test.js index b0e95ae..2916e55 100644 --- a/test/unit/parser-core.test.js +++ b/test/unit/parser-core.test.js @@ -8,12 +8,7 @@ import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import { serialize } from '../../src/lib/serialize.js'; import { sexpr } from '../helpers/sexpr.js'; -import { parseSource } from '../helpers/parse-source.js'; - -/** Parse input, return its S-expression. */ -const ast = (input) => { - return sexpr(parseSource(input)); -}; +import { parseSource, parseSexpr as ast } from '../helpers/parse-source.js'; test('parser: accepts a bounded range of a shared native token stream', () => { const tokens = tokenize({ css: 'prefix calc(/* gap */-2px + 3px) suffix' }); diff --git a/test/unit/parser-opaque.test.js b/test/unit/parser-opaque.test.js index 78ff0d3..2a55f83 100644 --- a/test/unit/parser-opaque.test.js +++ b/test/unit/parser-opaque.test.js @@ -7,13 +7,8 @@ import { tokenize } from '@csstools/css-tokenizer'; import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import { serialize } from '../../src/lib/serialize.js'; -import { sexpr } from '../helpers/sexpr.js'; -import { parseSource } from '../helpers/parse-source.js'; +import { parseSource, parseSexpr as ast } from '../helpers/parse-source.js'; -/** Parse input, return its S-expression. */ -const ast = (input) => { - return sexpr(parseSource(input)); -}; // --- Opaque non-math functions ------------------------------------------- // // Non-math functions use CSS component-value syntax rather than the math diff --git a/test/unit/serialize-core.test.js b/test/unit/serialize-core.test.js index d4b6ad0..d5593b4 100644 --- a/test/unit/serialize-core.test.js +++ b/test/unit/serialize-core.test.js @@ -1,6 +1,6 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import { serialize as serializeSource } from '../../src/lib/serialize.js'; +import { serialize } from '../../src/lib/serialize.js'; import { num, dim, @@ -11,8 +11,6 @@ import { mkProduct, } from '../../src/lib/node.js'; -const serialize = (node, opts = {}) => serializeSource(node, opts); - describe('serialize: core syntax and expressions', () => { test('serialize: single number uses standard calculation syntax', () => { assert.equal(serialize(num(42)), 'calc(42)'); diff --git a/test/unit/serialize-precision.test.js b/test/unit/serialize-precision.test.js index a97dd9b..8991187 100644 --- a/test/unit/serialize-precision.test.js +++ b/test/unit/serialize-precision.test.js @@ -1,6 +1,7 @@ import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; -import { serialize as serializeSource } from '../../src/lib/serialize.js'; +import { serialize } from '../../src/lib/serialize.js'; +import { parseSource } from '../helpers/parse-source.js'; import { num, dim, @@ -11,8 +12,6 @@ import { mkProduct, } from '../../src/lib/node.js'; -const serialize = (node, opts = {}) => serializeSource(node, opts); - describe('serialize: precision and rounding', () => { test('serialize: precision option applied to numbers and dimensions', () => { assert.equal( @@ -196,46 +195,21 @@ describe('serialize: precision and rounding', () => { }); describe('serialize: sub-precision negative terms in sums and grouped sums', () => { - test('sub-precision negative number term serializes as 0 in sums', () => { + test('sub-precision negative number term in sums respects precision', () => { const ast = mkSum([ { sign: 1, node: num(-1e-20) }, { sign: 1, node: opaqueCall('var', [ident('--x')]) }, ]); assert.equal(serialize(ast), 'calc(0 + var(--x))'); - }); - - test('sub-precision negative number term with precision: false retains negative value in sums', () => { - const ast = mkSum([ - { sign: 1, node: num(-1e-20) }, - { sign: 1, node: opaqueCall('var', [ident('--x')]) }, - ]); assert.equal( serialize(ast, { precision: false }), 'calc(-1e-20 + var(--x))' ); }); - test('sub-precision negative number term serializes as 0 in grouped sums', () => { - const ast = { - type: /** @type {const} */ ('Sum'), - grouped: true, - terms: [ - { sign: 1, node: num(-1e-20) }, - { sign: 1, node: opaqueCall('var', [ident('--x')]) }, - ], - }; + test('sub-precision negative number term in grouped sums respects precision', () => { + const ast = parseSource('(-1e-20 + var(--x))'); assert.equal(serialize(ast), 'calc(0 + var(--x))'); - }); - - test('sub-precision negative number term with precision: false retains grouped negative sum inversion', () => { - const ast = { - type: /** @type {const} */ ('Sum'), - grouped: true, - terms: [ - { sign: 1, node: num(-1e-20) }, - { sign: 1, node: opaqueCall('var', [ident('--x')]) }, - ], - }; assert.equal( serialize(ast, { precision: false }), 'calc(-1 * (1e-20 - var(--x)))' diff --git a/test/unit/simplify/round.test.js b/test/unit/simplify/round.test.js index bddd00f..04c6950 100644 --- a/test/unit/simplify/round.test.js +++ b/test/unit/simplify/round.test.js @@ -115,13 +115,6 @@ describe('round()', () => { assert.equal(out('round(up, 1, 2, 3)'), 'round(up, 1, 2, 3)'); }); - test('round: A infinite, B finite → same infinity (§10.3.1 line 1022)', () => { - assert.equal(out('round(infinity, 10)'), 'calc(infinity)'); - assert.equal(out('round(calc(0 - infinity), 10)'), 'calc(-infinity)'); - assert.equal(out('round(up, infinity, 10)'), 'calc(infinity)'); - assert.equal(out('round(down, calc(0 - infinity), 10)'), 'calc(-infinity)'); - }); - test('round: A finite, B infinite → strategy-dependent (§10.3.1)', () => { // Multiples of an infinite step are {-∞, 0, +∞}. // up (ceiling) lands on +∞ for positive A; down (floor) lands on -∞ for From 700fb9f4269c612bceee6a2bc10faa7ede13748d Mon Sep 17 00:00:00 2001 From: Ludovico Fischer Date: Thu, 1 Oct 2026 14:07:22 +0200 Subject: [PATCH 2/8] fix: do not replace fractions with imprecise floating point numbers Fix #62, fix https://github.com/cssnano/cssnano/issues/1529 --- CHANGELOG.md | 14 ++ src/lib/compile.js | 2 +- src/lib/serialize/precision.js | 22 +-- src/lib/simplify.js | 10 +- src/lib/simplify/bucket.js | 27 +++- src/lib/simplify/cancel.js | 11 +- src/lib/simplify/clamp.js | 4 +- src/lib/simplify/exact.js | 18 +++ src/lib/simplify/fold.js | 16 ++- src/lib/simplify/min-max.js | 4 +- src/lib/simplify/product.js | 169 +++++++++++++++++++---- src/lib/simplify/sum.js | 8 +- test/integration/css-units.test.js | 28 ++-- test/integration/exact-division.test.js | 102 ++++++++++++++ test/integration/math-operations.test.js | 8 +- test/unit/plugin-diagnostics.test.js | 12 +- test/unit/reduceCalc-options.test.js | 18 ++- test/unit/serialize-precision.test.js | 37 ++++- test/unit/simplify/min-max.test.js | 5 +- types/lib/simplify.d.ts | 4 +- types/lib/simplify/bucket.d.ts | 5 +- types/lib/simplify/cancel.d.ts | 4 +- types/lib/simplify/exact.d.ts | 9 ++ types/lib/simplify/fold.d.ts | 15 +- types/lib/simplify/product.d.ts | 10 +- types/lib/simplify/sum.d.ts | 3 +- 26 files changed, 462 insertions(+), 103 deletions(-) create mode 100644 src/lib/simplify/exact.js create mode 100644 test/integration/exact-division.test.js create mode 100644 types/lib/simplify/exact.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 447b080..f5b3085 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. +## Unreleased + +### Bug fixes + +- fold a division or unit conversion only when the result is exact at the + configured `precision`. `calc(100% / 3)` stays `calc(100% / 3)` instead of + becoming `calc(33.33333%)`, which drifted when repeated (#62), and + `calc(1cm + 1px)` is no longer converted to an approximate `1.02646cm`. + Use `precision: false` to fold every division. +- keep `precision` significant digits for values below 1, so + `calc(1px / 150000)` is no longer rounded to `.00001px` +- `min()`, `max()` and `clamp()` return the chosen argument, keeping its + original unit + ## 11.2.1 (2026-09-20) ### Bug fixes diff --git a/src/lib/compile.js b/src/lib/compile.js index 2a0afbf..cffe100 100644 --- a/src/lib/compile.js +++ b/src/lib/compile.js @@ -34,7 +34,7 @@ function compileCandidate(candidate, ctx) { if (!analysis.valid) { throw new Error('Invalid CSS calculation type'); } - const tree = simplify(parsed); + const tree = simplify(parsed, ctx.options.precision); const original = analysis.unresolved && !candidate.calculation ? ctx.value.slice(candidate.start, candidate.end) diff --git a/src/lib/serialize/precision.js b/src/lib/serialize/precision.js index 2ded0ea..e3ce967 100644 --- a/src/lib/serialize/precision.js +++ b/src/lib/serialize/precision.js @@ -92,16 +92,20 @@ function round(v, prec) { // 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; - // Fast path: rounding to integer with "round half away from zero". - const rounded = - p === 0 ? sign * Math.round(abs) : sign * roundDecimal(abs, 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))); + // Values below 1 keep `p` significant digits (at least one) rather than `p` + // decimals, so small magnitudes lose precision evenly. The exponent comes + // from the decimal text, not log10, so powers of ten cannot land off by one. + if (abs < 1) { + const exponent = Number(abs.toExponential().split('e')[1]); + const rounded = roundDecimal(abs, Math.max(p, 1) - 1 - exponent); + // Snap true floating-point dust (<= 1e-12) to zero, unless the precision + // is high enough to represent it as a fractional value. + if (abs <= NOISE_FLOOR && roundDecimal(abs, p) === 0) { + return sign === -1 ? -0 : 0; + } + return sign * rounded; } - return rounded; + return sign * roundDecimal(abs, p); } // §10.13 / §10.7.2: Infinity/NaN serialize as canonical keywords. diff --git a/src/lib/simplify.js b/src/lib/simplify.js index 9b6f77d..213d794 100644 --- a/src/lib/simplify.js +++ b/src/lib/simplify.js @@ -21,13 +21,15 @@ 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 | false} [precision] When set, divisions and unit + * conversions that are not exact at this precision stay symbolic. * @param {number} [depth] * @return {Node} */ -function simplify(node, depth = 0) { +function simplify(node, precision = false, depth = 0) { assertDepth(depth); /** @param {Node} value */ - const child = (value) => simplify(value, depth + 1); + const child = (value) => simplify(value, precision, depth + 1); switch (node.type) { case 'Num': case 'Dim': @@ -42,9 +44,9 @@ function simplify(node, depth = 0) { node.rawName ); case 'Sum': - return simplifySum(node, child); + return simplifySum(node, child, precision); case 'Product': - return simplifyProduct(node, child); + return simplifyProduct(node, child, precision); } } diff --git a/src/lib/simplify/bucket.js b/src/lib/simplify/bucket.js index 8de7d5e..def8549 100644 --- a/src/lib/simplify/bucket.js +++ b/src/lib/simplify/bucket.js @@ -5,6 +5,7 @@ */ import { convert } from '../convertUnits.js'; +import { isExact } from './exact.js'; /** * @typedef {object} UnitBucket @@ -18,9 +19,12 @@ import { convert } from '../convertUnits.js'; /** Mutates `buckets` in place — totals of survivor buckets accumulate the * converted values of merged neighbors. Caller must not reuse the input. * @param {UnitBucket[]} buckets + * @param {number | false} [precision] A conversion that is not exact at this + * precision is not merged. When only the reverse direction is exact, the + * survivor switches to the other bucket's unit. * @return {UnitBucket[]} */ -function mergeConvertibleBuckets(buckets) { +function mergeConvertibleBuckets(buckets, precision = false) { /** @type {Map} */ const representative = new Map(); /** @type {UnitBucket[]} */ const out = []; @@ -36,12 +40,25 @@ function mergeConvertibleBuckets(buckets) { continue; } const converted = convert(b.total, b.unit, first.unit); - if (converted === null) { - out.push(b); + if (converted !== null && isExact(converted, precision)) { + first.total += converted; + first.scale = Math.max(first.scale, Math.abs(converted)); + continue; + } + const reversed = convert(first.total, first.unit, b.unit); + const reversedScale = convert(first.scale, first.unit, b.unit); + if ( + reversed !== null && + reversedScale !== null && + isExact(reversed, precision) + ) { + first.unit = b.unit; + first.rawUnit = b.rawUnit; + first.total = reversed + b.total; + first.scale = Math.max(reversedScale, Math.abs(b.total)); continue; } - first.total += converted; - first.scale = Math.max(first.scale, Math.abs(converted)); + out.push(b); } return out; } diff --git a/src/lib/simplify/cancel.js b/src/lib/simplify/cancel.js index c6e8942..c82afd6 100644 --- a/src/lib/simplify/cancel.js +++ b/src/lib/simplify/cancel.js @@ -1,4 +1,5 @@ import { baseOf, convert } from '../convertUnits.js'; +import { isExact } from './exact.js'; /** * If `dims` contain exactly one numerator / one denominator pair with the @@ -9,9 +10,11 @@ import { baseOf, convert } from '../convertUnits.js'; * unreduced — consumers rarely rely on it and the spec doesn't require it. * @template {{ exponent: 1 | -1, value: number, unit: string }} D * @param {D[]} dims + * @param {number | false} [precision] A factor that is not exact at this + * precision is not cancelled, so the quotient stays symbolic. * @return {{ factor: number, remaining: D[] } | null} */ -function tryCancelPair(dims) { +function tryCancelPair(dims, precision = false) { if (dims.length !== 2) { return null; } @@ -31,7 +34,11 @@ function tryCancelPair(dims) { return null; } // denominator.value === 0 yields ±Infinity / NaN naturally (§10.9.1). - return { factor: converted / denominator.value, remaining: [] }; + const factor = converted / denominator.value; + if (!isExact(factor, precision)) { + return null; + } + return { factor, remaining: [] }; } export { tryCancelPair }; diff --git a/src/lib/simplify/clamp.js b/src/lib/simplify/clamp.js index da0148a..cb27430 100644 --- a/src/lib/simplify/clamp.js +++ b/src/lib/simplify/clamp.js @@ -1,5 +1,5 @@ import { call } from '../node.js'; -import { foldConstArgs, foldResult } from './fold.js'; +import { foldConstArgs, chosenArg } from './fold.js'; import { simplifyMinMax } from './min-max.js'; /** @typedef {import('../node.js').Node} Node */ @@ -29,7 +29,7 @@ function simplifyClamp(args) { // Spec §10.8: clamp(MIN, VAL, MAX) = max(MIN, min(VAL, MAX)). The // outer max(MIN, …) means MIN wins when MIN > MAX — not MAX. const clamped = Math.max(lo, Math.min(v, hi)); - return foldResult(fold, clamped); + return chosenArg(args, fold, clamped); } } return call('clamp', args); diff --git a/src/lib/simplify/exact.js b/src/lib/simplify/exact.js new file mode 100644 index 0000000..cb9897d --- /dev/null +++ b/src/lib/simplify/exact.js @@ -0,0 +1,18 @@ +import { round } from '../serialize/precision.js'; + +/** + * A value is exact at `precision` when rounding it for output loses nothing + * beyond float noise. + * @param {number} value + * @param {number | false} precision + * @return {boolean} + */ +function isExact(value, precision) { + if (precision === false || !Number.isFinite(value)) { + return true; + } + const snapped = Number(value.toPrecision(15)); + return round(snapped, precision) === snapped; +} + +export { isExact }; diff --git a/src/lib/simplify/fold.js b/src/lib/simplify/fold.js index a3c1b59..2058ede 100644 --- a/src/lib/simplify/fold.js +++ b/src/lib/simplify/fold.js @@ -16,6 +16,20 @@ function foldResult(fold, value) { return fold.unit === '' ? num(value) : dim(value, fold.unit); } +/** + * Return the argument that produced a folded min/max/clamp value, so the + * result keeps its own unit, spelling and zero sign. Falls back to a fresh + * node when no argument matches (NaN). + * @param {Node[]} args + * @param {{ values: number[], unit: string }} fold + * @param {number} value + * @return {Node} + */ +function chosenArg(args, fold, value) { + const index = fold.values.findIndex((v) => Object.is(v, value)); + return index === -1 ? foldResult(fold, value) : args[index]; +} + /** * @param {Node[]} args * @return {{ values: number[], unit: string } | null} @@ -75,4 +89,4 @@ function foldDimArgs(args, unit, base) { return { values, unit }; } -export { foldConstArgs, foldResult }; +export { foldConstArgs, foldResult, chosenArg }; diff --git a/src/lib/simplify/min-max.js b/src/lib/simplify/min-max.js index 701d27c..852b147 100644 --- a/src/lib/simplify/min-max.js +++ b/src/lib/simplify/min-max.js @@ -1,5 +1,5 @@ import { call } from '../node.js'; -import { foldConstArgs, foldResult } from './fold.js'; +import { foldConstArgs, chosenArg } from './fold.js'; /** @typedef {import('../node.js').Node} Node */ @@ -13,7 +13,7 @@ function simplifyMinMax(name, args) { if (fold !== null) { const fn = name.toLowerCase() === 'min' ? Math.min : Math.max; const value = fn(...fold.values); - return foldResult(fold, value); + return chosenArg(args, fold, value); } return call(name, args); } diff --git a/src/lib/simplify/product.js b/src/lib/simplify/product.js index 55e1199..8f7bc81 100644 --- a/src/lib/simplify/product.js +++ b/src/lib/simplify/product.js @@ -1,5 +1,6 @@ import { mkSum, mkProduct, num, dim } from '../node.js'; import { tryCancelPair } from './cancel.js'; +import { isExact } from './exact.js'; /** * @typedef {import('../node.js').Node} Node @@ -8,13 +9,121 @@ import { tryCancelPair } from './cancel.js'; * @typedef {import('../simplify.js').SimplifyFn} SimplifyFn */ +/** + * @param {number} a non-negative + * @param {number} b non-negative + * @return {number} + */ +function gcd(a, b) { + while (b !== 0) { + [a, b] = [b, a % b]; + } + return a || 1; +} + +/** + * @param {{exponent: 1 | -1, value: number}[]} chain + * @return {number} + */ +function chainValue(chain) { + let value = 1; + for (const f of chain) { + value = f.exponent === 1 ? value * f.value : value / f.value; + } + return value; +} + +/** + * Whether the opaque factors are a single Sum of only Num/Dim terms. + * @param {ProductFactor[]} opaque + * @return {boolean} + */ +function isScalarSum(opaque) { + return ( + opaque.length === 1 && + opaque[0].exponent === 1 && + opaque[0].node.type === 'Sum' && + opaque[0].node.terms.every( + (t) => t.node.type === 'Num' || t.node.type === 'Dim' + ) + ); +} + +/** + * Emit an inexact quotient as `numerator * dims * opaque / denominator`, with + * integer parts reduced by their gcd. A lone Dim absorbs the numerator. + * @param {number} numerator + * @param {number} denominator + * @param {{value: number, unit: string, rawUnit?: string} | null} loneDim + * @param {{exponent: 1 | -1, value: number, unit: string, rawUnit?: string}[]} dims + * @param {ProductFactor[]} opaque + * @param {number | false} precision + * @return {Node | null} null when a part would be rounded on output + */ +function rationalProduct( + numerator, + denominator, + loneDim, + dims, + opaque, + precision +) { + // Drop float noise (`1.9999999999999993 * 100`) so the parts print as + // written and the gcd below can reduce them. + let n = Number( + (loneDim === null ? numerator : numerator * loneDim.value).toPrecision(15) + ); + let d = Number(denominator.toPrecision(15)); + if (!isExact(n, precision) || !isExact(d, precision)) { + return null; + } + if (Number.isSafeInteger(n) && Number.isSafeInteger(d)) { + const g = gcd(Math.abs(n), Math.abs(d)); + n /= g; + d /= g; + } + if (d < 0) { + n = -n; + d = -d; + } + /** @type {ProductFactor[]} */ + const factors = []; + if (loneDim !== null) { + factors.push({ + exponent: 1, + node: dim(n, loneDim.unit, loneDim.rawUnit), + }); + } else { + if (n !== 1) { + factors.push({ exponent: 1, node: num(n) }); + } + for (const dm of dims) { + factors.push({ + exponent: dm.exponent, + node: dim(dm.value, dm.unit, dm.rawUnit), + }); + } + } + factors.push(...opaque); + if (d !== 1) { + factors.push({ exponent: -1, node: num(d) }); + } + return mkProduct(factors); +} + /** * @param {Product} product * @param {SimplifyFn} simplify + * @param {number | false} [precision] A quotient that is not exact at this + * precision stays symbolic (`100% / 3`) instead of being rounded. * @return {Node} */ -function simplifyProduct(product, simplify) { +function simplifyProduct(product, simplify, precision = false) { let coeff = 1; + // Num factors by exponent, so an inexact quotient can be re-emitted as a + // reduced `numerator / denominator`. + let numerator = 1; + let denominator = 1; /** @type {{exponent: 1 | -1, value: number, unit: string, rawUnit?: string}[]} */ const dims = []; /** @type {ProductFactor[]} */ @@ -47,8 +156,10 @@ function simplifyProduct(product, simplify) { if (n.type === 'Num') { if (exponent === 1) { coeff *= n.value; + numerator *= n.value; } else { coeff /= n.value; // §10.9.1: 1/0 → ±Infinity, 0/0 → NaN per IEEE-754 + denominator *= n.value; } scalarChain.push({ exponent, value: n.value }); return; @@ -67,25 +178,42 @@ function simplifyProduct(product, simplify) { // §10.2 typed division. Higher-power cancellation (`px^2 / px`) is left // unreduced — consumers don't rely on it and the spec doesn't require it. - const cancelled = tryCancelPair(dims); + const cancelled = tryCancelPair(dims, precision); if (cancelled !== null) { coeff *= cancelled.factor; + numerator *= cancelled.factor; } + const divides = denominator !== 1 || cancelled !== null; const remainingDims = cancelled ? cancelled.remaining : dims; + const loneDim = + remainingDims.length === 1 && + remainingDims[0].exponent === 1 && + opaque.length === 0 + ? remainingDims[0] + : null; + const value = loneDim === null ? coeff : chainValue(scalarChain); + + // An inexact quotient stays symbolic when its numerator and denominator + // print exactly; otherwise it is folded and rounded at serialization. + if (divides && Number.isFinite(value) && !isExact(value, precision)) { + const rational = rationalProduct( + numerator, + denominator, + loneDim, + remainingDims, + opaque, + precision + ); + if (rational !== null) { + return rational; + } + } // §10.10 distributive multiplication: `0.5 * (100vw - 10px)` → `50vw - 5px`. // Only distribute when every Sum term is Num/Dim — partial distribution // over opaque terms matches neither the legacy implementation nor csstools. - if ( - remainingDims.length === 0 && - opaque.length === 1 && - opaque[0].exponent === 1 && - opaque[0].node.type === 'Sum' && - opaque[0].node.terms.every( - (t) => t.node.type === 'Num' || t.node.type === 'Dim' - ) - ) { - const sum = opaque[0].node; + if (remainingDims.length === 0 && isScalarSum(opaque)) { + const sum = /** @type {import('../node.js').Sum} */ (opaque[0].node); const distributed = sum.terms.map((t) => ({ sign: t.sign, node: simplify( @@ -98,21 +226,8 @@ function simplifyProduct(product, simplify) { return mkSum(distributed); } - if ( - remainingDims.length === 1 && - remainingDims[0].exponent === 1 && - opaque.length === 0 - ) { - const d = remainingDims[0]; - let value = 1; - for (const f of scalarChain) { - if (f.exponent === 1) { - value = value * f.value; - } else { - value = value / f.value; - } - } - return dim(value, d.unit, d.rawUnit); + if (loneDim !== null) { + return dim(value, loneDim.unit, loneDim.rawUnit); } if (remainingDims.length === 0 && opaque.length === 0) { diff --git a/src/lib/simplify/sum.js b/src/lib/simplify/sum.js index 23ac0ff..59e1fc0 100644 --- a/src/lib/simplify/sum.js +++ b/src/lib/simplify/sum.js @@ -26,9 +26,10 @@ function denoise(total, scale) { /** * @param {Sum} sum * @param {SimplifyFn} simplify + * @param {number | false} [precision] * @return {Node} */ -function simplifySum(sum, simplify) { +function simplifySum(sum, simplify, precision = false) { // §10.10 two-phase dim handling: phase 1 buckets by exact unit (`1em + 1em` // → `2em`); phase 2 merges convertible same-base buckets into the first- // encountered unit. `100vh - 5rem - 10rem - 100px` → `-15rem` in phase 1, @@ -106,7 +107,10 @@ function simplifySum(sum, simplify) { const terms = hasNum ? [{ sign: /** @type {1} */ (1), node: num(denoise(numTotal, numScale)) }] : []; - for (const bucket of mergeConvertibleBuckets([...byUnit.values()])) { + for (const bucket of mergeConvertibleBuckets( + [...byUnit.values()], + precision + )) { terms.push({ sign: 1, node: dim( diff --git a/test/integration/css-units.test.js b/test/integration/css-units.test.js index 5d7e16b..b4ae730 100644 --- a/test/integration/css-units.test.js +++ b/test/integration/css-units.test.js @@ -48,7 +48,7 @@ describe('cq* units', () => { 'should add expressions with svh units', testValue( 'calc(98% - 1.5rem - (85svh/8.2 + 1.9rem + 1.65svh))', - 'calc(98% - 3.4rem - 12.01585svh)' + 'calc(98% - 1.5rem - (1.9rem + 1.65svh + 85svh / 8.2))' ) ); }); @@ -83,9 +83,9 @@ describe('Combine units', () => { }); describe('Convert units', () => { - test('convert units', testValue('calc(1cm + 1px)', 'calc(1.02646cm)')); + test('convert units', testValue('calc(1cm + 1px)', 'calc(1cm + 1px)')); - test('convert units (#1)', testValue('calc(1px + 1cm)', 'calc(38.79528px)')); + test('convert units (#1)', testValue('calc(1px + 1cm)', 'calc(1px + 1cm)')); // unit case lowercased. test( @@ -95,31 +95,31 @@ describe('Convert units', () => { test( 'convert units (#3)', - testValue('calc(100.9q + 10px)', 'calc(111.48333q)') + testValue('calc(100.9q + 10px)', 'calc(100.9q + 10px)') ); test( 'convert units (#4)', - testValue('calc(10px + 100.9q)', 'calc(105.33858px)') + testValue('calc(10px + 100.9q)', 'calc(10px + 100.9q)') ); - test('convert units (#5)', testValue('calc(10cm + 1px)', 'calc(10.02646cm)')); + test('convert units (#5)', testValue('calc(10cm + 1px)', 'calc(10cm + 1px)')); - test('convert units (#6)', testValue('calc(10mm + 1px)', 'calc(10.26458mm)')); + test('convert units (#6)', testValue('calc(10mm + 1px)', 'calc(10mm + 1px)')); - test('convert units (#7)', testValue('calc(10px + 1q)', 'calc(10.94488px)')); + test('convert units (#7)', testValue('calc(10px + 1q)', 'calc(10px + 1q)')); test('convert units (#8)', testValue('calc(10cm + 1q)', 'calc(10.025cm)')); test('convert units (#9)', testValue('calc(10mm + 1q)', 'calc(10.25mm)')); - test('convert units (#10)', testValue('calc(10in + 1q)', 'calc(10.00984in)')); + test('convert units (#10)', testValue('calc(10in + 1q)', 'calc(1017q)')); - test('convert units (#11)', testValue('calc(10pt + 1q)', 'calc(10.70866pt)')); + test('convert units (#11)', testValue('calc(10pt + 1q)', 'calc(10pt + 1q)')); - test('convert units (#12)', testValue('calc(10pc + 1q)', 'calc(10.05906pc)')); + test('convert units (#12)', testValue('calc(10pc + 1q)', 'calc(10pc + 1q)')); - test('convert units (#13)', testValue('calc(1q + 10px)', 'calc(11.58333q)')); + test('convert units (#13)', testValue('calc(1q + 10px)', 'calc(1q + 10px)')); test('convert units (#14)', testValue('calc(1q + 10cm)', 'calc(401q)')); @@ -127,9 +127,9 @@ describe('Convert units', () => { test('convert units (#16)', testValue('calc(1q + 10in)', 'calc(1017q)')); - test('convert units (#17)', testValue('calc(1q + 10pt)', 'calc(15.11111q)')); + test('convert units (#17)', testValue('calc(1q + 10pt)', 'calc(1q + 10pt)')); - test('convert units (#18)', testValue('calc(1q + 10pc)', 'calc(170.33333q)')); + test('convert units (#18)', testValue('calc(1q + 10pc)', 'calc(1q + 10pc)')); }); describe('Unknown units', () => { diff --git a/test/integration/exact-division.test.js b/test/integration/exact-division.test.js new file mode 100644 index 0000000..1e8efc8 --- /dev/null +++ b/test/integration/exact-division.test.js @@ -0,0 +1,102 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import reduceCalc from 'postcss-calc/reduce'; +import { testValue } from '../helpers/testValue.js'; + +describe('Exact-by-default division', () => { + const rows = [ + ['calc(100% / 4)', 'calc(25%)'], + ['calc(100% / 3)', 'calc(100% / 3)'], + ['calc(100% / 3 * 3)', 'calc(100%)'], + ['calc(10px / 3 + 2px + 1px)', 'calc(3px + 10px / 3)'], + ['calc(10px / 4 / 3)', 'calc(5px / 6)'], + ['calc(var(--n) / 150000)', 'calc(var(--n) / 150000)'], + ['calc((100% - 10px) / 3)', 'calc((100% - 10px) / 3)'], + ['calc(1px + 1pt)', 'calc(1.75pt)'], + ['calc(1cm + 1px)', 'calc(1cm + 1px)'], + ['max(1in, 100px)', 'calc(100px)'], + ['calc(1px / 70000)', 'calc(1px / 70000)'], + ['calc(1px / 96)', 'calc(1px / 96)'], + ['calc(1px / 150000)', 'calc(1px / 150000)'], + ['calc(.0123456px)', 'calc(.012346px)'], + ['calc(1 / 3)', 'calc(1 / 3)'], + ['calc(10px / 3 * 3)', 'calc(10px)'], + ['calc(10px / -3)', 'calc(-10px / 3)'], + ['calc(1px / 1in)', 'calc(1px / 1in)'], + ['calc(1in / 1px)', 'calc(96)'], + ['calc(var(--n) / 4)', 'calc(.25 * var(--n))'], + ['calc(2 * var(--n) / 3)', 'calc(2 * var(--n) / 3)'], + // Quotients whose parts would be rounded on output are folded instead. + ['calc(1em * 105 / 64)', 'calc(105em / 64)'], + ['calc(1rem * 2.828427125 / 2)', 'calc(1.41421rem)'], + ['calc(cos(220deg) * var(--rad) / 2)', 'calc(-.38302 * var(--rad))'], + ['calc(100rem * 1.9999999999999993 / 1024.0)', 'calc(25rem / 128)'], + ]; + for (const [input, expected] of rows) { + test(`${input} → ${expected}`, testValue(input, expected)); + } + + test('output is idempotent', () => { + for (const [input] of rows) { + const once = reduceCalc(input); + assert.equal(reduceCalc(once), once, input); + } + }); + + test('precision false folds every division', () => { + assert.equal( + reduceCalc('calc(100% / 3)', { precision: false }), + 'calc(33.333333333333336%)' + ); + assert.equal( + reduceCalc('calc(1px + 1pt)', { precision: false }), + 'calc(2.333333333333333px)' + ); + }); + + test('a higher precision folds quotients that are exact there', () => { + assert.equal( + reduceCalc('calc(1px / 96)', { precision: 10 }), + 'calc(1px / 96)' + ); + assert.equal( + reduceCalc('calc(1px / 256)', { precision: 5 }), + 'calc(1px / 256)' + ); + assert.equal( + reduceCalc('calc(1px / 256)', { precision: 8 }), + 'calc(.00390625px)' + ); + }); + + test('min, max and clamp keep the chosen argument, with or without precision', () => { + for (const precision of [5, false]) { + assert.equal(reduceCalc('max(1in, 100px)', { precision }), 'calc(100px)'); + assert.equal(reduceCalc('min(1in, 100px)', { precision }), 'calc(1in)'); + assert.equal( + reduceCalc('clamp(1px, 1in, 2in)', { precision }), + 'calc(1in)' + ); + } + }); + + test('min, max and clamp keep the sign of zero', () => { + // atan2 observes the sign of its first argument. + for (const input of [ + 'atan2(min(0, calc(-1 * 0)), -1)', + 'atan2(max(calc(-1 * 0), -1), -1)', + 'atan2(clamp(-1, calc(-1 * 0), 1), -1)', + ]) { + assert.equal(reduceCalc(input), 'calc(-180deg)', input); + } + }); + + test('a symbolic division does not warn about being unresolved', () => { + const warnings = []; + reduceCalc('calc(10px / 3)', { + warnWhenCannotResolve: true, + onWarn: (message) => warnings.push(message), + }); + assert.deepEqual(warnings, []); + }); +}); diff --git a/test/integration/math-operations.test.js b/test/integration/math-operations.test.js index 4860afb..6a12705 100644 --- a/test/integration/math-operations.test.js +++ b/test/integration/math-operations.test.js @@ -14,7 +14,7 @@ describe('Complex calculations', () => { 'should handle complex calculations (reduce-css-calc#45) (2)', testValue( 'calc(((((100% + (2 * 30px) + 63.5px) / 0.7537) - (100vw - 60px)) / 2) + 30px)', - 'calc(66.33939% + 141.92915px - 50vw)' + 'calc(30px + .5 * (-100vw + 60px + (100% + 123.5px) / .7537))' ) ); @@ -253,7 +253,7 @@ describe('Precision', () => { test( 'should handle precision correctly (2)', - testValue('calc(5/1000000)', 'calc(.00001)') + testValue('calc(5/1000000)', 'calc(.000005)') ); test( @@ -283,7 +283,7 @@ describe('Precision', () => { test( 'should limit a value smaller than the precision to that many significant digits', - testValue('calc(1/3000000)', 'calc(3.3333e-7)') + testValue('calc(.00000033333333 + 0)', 'calc(3.3333e-7)') ); test( @@ -319,7 +319,7 @@ describe('Precision', () => { test( 'canonical reciprocal coefficient with opaque term at default precision', - testValue('calc(var(--x) / 3)', 'calc(.33333 * var(--x))') + testValue('calc(var(--x) / 3)', 'calc(var(--x) / 3)') ); test( diff --git a/test/unit/plugin-diagnostics.test.js b/test/unit/plugin-diagnostics.test.js index 375c4ec..89a44a2 100644 --- a/test/unit/plugin-diagnostics.test.js +++ b/test/unit/plugin-diagnostics.test.js @@ -112,8 +112,10 @@ describe('plugin: parse error handling', () => { // --- precision ----------------------------------------------------------- describe('plugin: precision', () => { test('plugin: precision option applies to numeric output', async () => { - const { css } = await process('a{b:calc(1in + 10px)}', { precision: 2 }); - assert.equal(css, 'a{b:calc(1.1in)}'); + const { css } = await process('a{b:calc(1.23456px + 1px)}', { + precision: 2, + }); + assert.equal(css, 'a{b:calc(2.23px)}'); }); test('plugin: precision false keeps full float precision', async () => { @@ -124,7 +126,9 @@ describe('plugin: precision', () => { }); test('plugin: precision 0 rounds to whole numbers', async () => { - const { css } = await process('a{b:calc(1in + 10px)}', { precision: 0 }); - assert.equal(css, 'a{b:calc(1in)}'); + const { css } = await process('a{b:calc(1.23456px + 1px)}', { + precision: 0, + }); + assert.equal(css, 'a{b:calc(2px)}'); }); }); diff --git a/test/unit/reduceCalc-options.test.js b/test/unit/reduceCalc-options.test.js index a0dd980..3081d3d 100644 --- a/test/unit/reduceCalc-options.test.js +++ b/test/unit/reduceCalc-options.test.js @@ -12,8 +12,8 @@ const { reduceWithWarnings, assertIdempotent } = describe('reduceCalc: precision', () => { test('reduceCalc: precision option applies to numeric output', () => { assert.equal( - reduceCalc('calc(1in + 10px)', { precision: 2 }), - 'calc(1.1in)' + reduceCalc('calc(1.23456px + 1px)', { precision: 2 }), + 'calc(2.23px)' ); }); @@ -25,7 +25,10 @@ describe('reduceCalc: precision', () => { }); test('reduceCalc: precision 0 rounds to whole numbers', () => { - assert.equal(reduceCalc('calc(1in + 10px)', { precision: 0 }), 'calc(1in)'); + assert.equal( + reduceCalc('calc(1.23456px + 1px)', { precision: 0 }), + 'calc(2px)' + ); }); test('reduceCalc: precision rounds large fractional results without drift', () => { @@ -62,9 +65,12 @@ describe('reduceCalc: precision', () => { }); test('reduceCalc: precision rounds sub-1 midpoints away from zero', () => { - assert.equal(reduceCalc('calc(0.05 + 0)', { precision: 1 }), 'calc(.1)'); - assert.equal(reduceCalc('calc(0.005 + 0)', { precision: 2 }), 'calc(.01)'); - // 0.004 rounds to zero at 1 place but is above the noise floor. + assert.equal(reduceCalc('calc(0.15 + 0)', { precision: 1 }), 'calc(.2)'); + assert.equal( + reduceCalc('calc(0.0125 + 0)', { precision: 2 }), + 'calc(.013)' + ); + // Values below 1 keep `precision` significant digits. assert.equal(reduceCalc('calc(0.004 + 0)', { precision: 1 }), 'calc(.004)'); }); }); diff --git a/test/unit/serialize-precision.test.js b/test/unit/serialize-precision.test.js index 8991187..6122255 100644 --- a/test/unit/serialize-precision.test.js +++ b/test/unit/serialize-precision.test.js @@ -12,6 +12,9 @@ import { mkProduct, } from '../../src/lib/node.js'; +const rounded = (value) => + Number(serialize(num(value), { precision: 5 }).slice(5, -1)); + describe('serialize: precision and rounding', () => { test('serialize: precision option applied to numbers and dimensions', () => { assert.equal( @@ -84,15 +87,39 @@ describe('serialize: precision and rounding', () => { }); test('serialize: rounds sub-1 midpoints away from zero and preserves sub-precision values', () => { - assert.equal(serialize(num(0.05), { precision: 1 }), 'calc(.1)'); - assert.equal(serialize(num(-0.05), { precision: 1 }), 'calc(-.1)'); - assert.equal(serialize(num(0.005), { precision: 2 }), 'calc(.01)'); - // 0.004 rounds to zero at 1 place but exceeds the noise floor, so the - // value is preserved rather than collapsed to 0. + assert.equal(serialize(num(0.15), { precision: 1 }), 'calc(.2)'); + assert.equal(serialize(num(-0.15), { precision: 1 }), 'calc(-.2)'); + assert.equal(serialize(num(0.0125), { precision: 2 }), 'calc(.013)'); + // Values below 1 keep `precision` significant digits, so 0.004 survives + // a precision of 1 instead of collapsing to 0. assert.equal(serialize(num(0.004), { precision: 1 }), 'calc(.004)'); assert.equal(serialize(num(-0.004), { precision: 1 }), 'calc(-.004)'); }); + test('serialize: rounds values below 1 to a uniform number of significant digits', () => { + assert.equal(serialize(num(0.0123456), { precision: 5 }), 'calc(.012346)'); + assert.equal( + serialize(dim(1 / 150000, 'px'), { precision: 5 }), + 'calc(.0000066667px)' + ); + }); + + test('serialize: rounding never decreases as the input grows below 1', () => { + let previous = 0; + for (let exponent = -7; exponent < 0; exponent += 0.01) { + const value = 10 ** exponent; + const result = rounded(value); + assert.ok( + result >= previous, + `${value} rounded to ${result} < ${previous}` + ); + previous = result; + } + // No gap around 5e-6, where fixed decimals used to collapse values. + assert.ok(rounded(4.9e-6) > 0); + assert.ok(rounded(5.1e-6) >= rounded(4.9e-6)); + }); + test('serialize: leaves values unchanged when precision exceeds the shortest representation', () => { // The shortest decimal of 7341.0297734398655 has 14 fractional digits, // so rounding at precision 14 must return the value untouched instead of diff --git a/test/unit/simplify/min-max.test.js b/test/unit/simplify/min-max.test.js index a7f0f0b..7e37ee3 100644 --- a/test/unit/simplify/min-max.test.js +++ b/test/unit/simplify/min-max.test.js @@ -12,9 +12,8 @@ describe('min() and max() folding', () => { }); test('simplify: min converts units within a family before comparing', () => { - // 1in = 96px, so min(1in, 10px) = min(1in, .10417in) = .10417in. - // First arg's unit is canonical — consistent with the sum-bucket rule. - assert.equal(out('min(1in, 10px)'), 'calc(.10417in)'); + // 1in = 96px, so min(1in, 10px) is the 10px argument, returned as written. + assert.equal(out('min(1in, 10px)'), 'calc(10px)'); }); test('simplify: min preserved when types mix', () => { diff --git a/types/lib/simplify.d.ts b/types/lib/simplify.d.ts index 0705122..15882f3 100644 --- a/types/lib/simplify.d.ts +++ b/types/lib/simplify.d.ts @@ -11,8 +11,10 @@ export type SimplifyFn = (node: Node) => Node; * Simplify is an independent, composable AST transformation. It may * synthesize canonical nodes while preserving the Node -> Node contract. * @param {Node} node + * @param {number | false} [precision] When set, divisions and unit + * conversions that are not exact at this precision stay symbolic. * @param {number} [depth] * @return {Node} */ -declare function simplify(node: Node, depth?: number): Node; +declare function simplify(node: Node, precision?: number | false, depth?: number): Node; export { simplify }; diff --git a/types/lib/simplify/bucket.d.ts b/types/lib/simplify/bucket.d.ts index 2bd1c32..ea874a8 100644 --- a/types/lib/simplify/bucket.d.ts +++ b/types/lib/simplify/bucket.d.ts @@ -19,7 +19,10 @@ export type UnitBucket = { /** Mutates `buckets` in place — totals of survivor buckets accumulate the * converted values of merged neighbors. Caller must not reuse the input. * @param {UnitBucket[]} buckets + * @param {number | false} [precision] A conversion that is not exact at this + * precision is not merged. When only the reverse direction is exact, the + * survivor switches to the other bucket's unit. * @return {UnitBucket[]} */ -declare function mergeConvertibleBuckets(buckets: UnitBucket[]): UnitBucket[]; +declare function mergeConvertibleBuckets(buckets: UnitBucket[], precision?: number | false): UnitBucket[]; export { mergeConvertibleBuckets }; diff --git a/types/lib/simplify/cancel.d.ts b/types/lib/simplify/cancel.d.ts index 81c47b9..3e09ffb 100644 --- a/types/lib/simplify/cancel.d.ts +++ b/types/lib/simplify/cancel.d.ts @@ -7,13 +7,15 @@ * unreduced — consumers rarely rely on it and the spec doesn't require it. * @template {{ exponent: 1 | -1, value: number, unit: string }} D * @param {D[]} dims + * @param {number | false} [precision] A factor that is not exact at this + * precision is not cancelled, so the quotient stays symbolic. * @return {{ factor: number, remaining: D[] } | null} */ declare function tryCancelPair(dims: D[]): { +}>(dims: D[], precision?: number | false): { factor: number; remaining: D[]; } | null; diff --git a/types/lib/simplify/exact.d.ts b/types/lib/simplify/exact.d.ts new file mode 100644 index 0000000..f8a9989 --- /dev/null +++ b/types/lib/simplify/exact.d.ts @@ -0,0 +1,9 @@ +/** + * A value is exact at `precision` when rounding it for output loses nothing + * beyond float noise. + * @param {number} value + * @param {number | false} precision + * @return {boolean} + */ +declare function isExact(value: number, precision: number | false): boolean; +export { isExact }; diff --git a/types/lib/simplify/fold.d.ts b/types/lib/simplify/fold.d.ts index df7b560..9171969 100644 --- a/types/lib/simplify/fold.d.ts +++ b/types/lib/simplify/fold.d.ts @@ -15,6 +15,19 @@ export type BaseType = import('../convertUnits.js').BaseType; declare function foldResult(fold: { unit: string; }, value: number): Num | Dim; +/** + * Return the argument that produced a folded min/max/clamp value, so the + * result keeps its own unit, spelling and zero sign. Falls back to a fresh + * node when no argument matches (NaN). + * @param {Node[]} args + * @param {{ values: number[], unit: string }} fold + * @param {number} value + * @return {Node} + */ +declare function chosenArg(args: Node[], fold: { + values: number[]; + unit: string; +}, value: number): Node; /** * @param {Node[]} args * @return {{ values: number[], unit: string } | null} @@ -23,4 +36,4 @@ declare function foldConstArgs(args: Node[]): { values: number[]; unit: string; } | null; -export { foldConstArgs, foldResult }; +export { foldConstArgs, foldResult, chosenArg }; diff --git a/types/lib/simplify/product.d.ts b/types/lib/simplify/product.d.ts index d00e66b..e7bea33 100644 --- a/types/lib/simplify/product.d.ts +++ b/types/lib/simplify/product.d.ts @@ -2,16 +2,12 @@ export type Node = import('../node.js').Node; export type Product = import('../node.js').Product; export type ProductFactor = import('../node.js').ProductFactor; export type SimplifyFn = import('../simplify.js').SimplifyFn; -/** - * @typedef {import('../node.js').Node} Node - * @typedef {import('../node.js').Product} Product - * @typedef {import('../node.js').ProductFactor} ProductFactor - * @typedef {import('../simplify.js').SimplifyFn} SimplifyFn - */ /** * @param {Product} product * @param {SimplifyFn} simplify + * @param {number | false} [precision] A quotient that is not exact at this + * precision stays symbolic (`100% / 3`) instead of being rounded. * @return {Node} */ -declare function simplifyProduct(product: Product, simplify: SimplifyFn): Node; +declare function simplifyProduct(product: Product, simplify: SimplifyFn, precision?: number | false): Node; export { simplifyProduct }; diff --git a/types/lib/simplify/sum.d.ts b/types/lib/simplify/sum.d.ts index 0623654..e8ee29c 100644 --- a/types/lib/simplify/sum.d.ts +++ b/types/lib/simplify/sum.d.ts @@ -6,7 +6,8 @@ export type UnitBucket = import('./bucket.js').UnitBucket; /** * @param {Sum} sum * @param {SimplifyFn} simplify + * @param {number | false} [precision] * @return {Node} */ -declare function simplifySum(sum: Sum, simplify: SimplifyFn): Node; +declare function simplifySum(sum: Sum, simplify: SimplifyFn, precision?: number | false): Node; export { simplifySum }; From 1b9db9eed9c87236c9c11ec6b78b10091f962e10 Mon Sep 17 00:00:00 2001 From: Ludovico Fischer Date: Thu, 1 Oct 2026 15:00:36 +0200 Subject: [PATCH 3/8] fix: solve multiple bad minification of expressions with CSS functions --- CHANGELOG.md | 16 +++ src/lib/node.js | 65 ++++++++++- src/lib/parser.js | 14 ++- src/lib/serialize.js | 13 --- src/lib/serialize/expression.js | 22 ++-- src/lib/simplify.js | 21 +++- src/lib/simplify/product.js | 116 +++++++++++++++---- test/index-cases.test.cjs | 18 +-- test/index-options.test.cjs | 8 +- test/index.test.cjs | 4 +- test/integration/exact-division.test.js | 4 +- test/integration/math-operations.test.js | 25 ++-- test/integration/nested-calculations.test.js | 4 +- test/property/substitution.test.js | 72 ++++++++++++ test/unit/reduceCalc-core.test.js | 14 +-- test/unit/reduceCalc-substitution.test.js | 105 +++++++++++++++++ test/unit/serialize-precision.test.js | 6 +- test/unit/simplify/reportedBugs.test.js | 4 +- types/lib/node.d.ts | 19 ++- types/lib/simplify.d.ts | 7 -- 20 files changed, 444 insertions(+), 113 deletions(-) create mode 100644 test/property/substitution.test.js create mode 100644 test/unit/reduceCalc-substitution.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f5b3085..698311a 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,22 @@ All notable changes to this project will be documented in this file. See [commit `calc(1px / 150000)` is no longer rounded to `.00001px` - `min()`, `max()` and `clamp()` return the chosen argument, keeping its original unit +- treat `var()`, `env()`, `attr()` and other substitution functions as + positional barriers. They are replaced by raw tokens before the value is + computed, so with `--a: 1px + 2px`, `var(--a) * 2` means `1px + 2px * 2`. + Factors are no longer reordered or cancelled across them + (`var(--a) * 2` is no longer rewritten to `2 * var(--a)`, + `var(--a) / var(--a)` is not cancelled), and parentheses around them are kept + (`1px - (2 * var(--a))`) + Unrecognised functions such as `anchor-size()` are treated the same way, so + `2 * anchor-size(width) * .5` is no longer folded +- keep the parentheses of a nested `calc()` that contains unresolved values, so + `calc(var(--a) - calc(var(--b) - var(--c)))` is no longer rewritten to + `calc(var(--a) - var(--b) + var(--c))` +- do not flip every sign of a parenthesized sum that starts with a negative + term: `calc((var(--b) - 7 - 2))` is now `calc(-9 + var(--b))` instead of + `calc(-1 * (9 - var(--b)))`, which changed the value when `--b` expands to + several tokens ## 11.2.1 (2026-09-20) diff --git a/src/lib/node.js b/src/lib/node.js index 1e49306..fb58898 100644 --- a/src/lib/node.js +++ b/src/lib/node.js @@ -8,7 +8,11 @@ // - No ungrouped Sum directly contains another Sum (flattened on // construction). A grouped Sum is retained so a later negative sign // cannot be distributed across opaque terms. -// - No Product directly contains another Product (flattened). +// - No Product directly contains another ungrouped Product (flattened). A +// grouped Product is retained: it is a parenthesized group around a +// substitution function (`var()`, `env()`, ...), whose tokens must not +// merge with the surrounding factors. A grouped Product may hold a single +// factor. // - A Sum/Product with one positive element collapses to that element. // - A Sum/Product with no elements collapses to Num(0) / Num(1). // - Positive zero-valued Nums are dropped from all-number sums. They are @@ -28,7 +32,7 @@ * @typedef {{sign: 1 | -1, node: Node}} SumTerm Sign is always +1 when node is Num or Dim. * @typedef {{type: 'Sum', terms: SumTerm[], grouped?: boolean}} Sum * @typedef {{exponent: 1 | -1, node: Node}} ProductFactor exponent +1 = numerator, -1 = denominator. - * @typedef {{type: 'Product', factors: ProductFactor[]}} Product + * @typedef {{type: 'Product', factors: ProductFactor[], grouped?: boolean}} Product * @typedef {Num | Dim | Ident | Call | OpaqueCall | Sum | Product} Node */ @@ -185,7 +189,7 @@ function mkProduct(rawFactors) { */ function pushProductFactor(out, f) { const n = f.node; - if (n.type === 'Product') { + if (n.type === 'Product' && !n.grouped) { for (const inner of n.factors) { out.push({ exponent: /** @type {1 | -1} */ (f.exponent * inner.exponent), @@ -201,6 +205,48 @@ function pushProductFactor(out, f) { out.push(f); } +/** + * Mark a node as a source parenthesized group. A Sum or Product keeps its + * structure; any other node becomes a single-factor grouped Product. + * @param {Node} node + * @return {Node} + */ +function mkGroup(node) { + if (node.type === 'Sum' || node.type === 'Product') { + return { ...node, grouped: true }; + } + return { + type: 'Product', + factors: [{ exponent: 1, node }], + grouped: true, + }; +} + +/** + * Whether a node is a substitution function call (`var()`, `env()`, ...), or + * an ungrouped Product that directly contains one. Nested groups are not + * inspected: they already bound their own tokens. + * @param {Node} node + * @return {boolean} + */ +function isSubstitutionNode(node) { + if (node.type === 'OpaqueCall') return true; + if (node.type === 'Product' && !node.grouped) { + return node.factors.some((factor) => isSubstitutionNode(factor.node)); + } + return false; +} + +/** + * Group a parenthesized expression when its tokens must stay bound: a sum, or + * a product around a substitution function. + * @param {Node} node + * @return {Node} + */ +function groupSubstitution(node) { + return node.type === 'Sum' || isSubstitutionNode(node) ? mkGroup(node) : node; +} + /** * Negate any node, preserving canonical form. * @param {Node} node @@ -233,4 +279,15 @@ function negate(node) { return mkSum([{ sign: -1, node }]); } -export { num, dim, ident, call, opaqueCall, mkSum, mkProduct, negate }; +export { + num, + dim, + ident, + call, + opaqueCall, + mkSum, + mkProduct, + mkGroup, + groupSubstitution, + negate, +}; diff --git a/src/lib/parser.js b/src/lib/parser.js index 0ef9ddc..92f369d 100644 --- a/src/lib/parser.js +++ b/src/lib/parser.js @@ -1,6 +1,14 @@ // Pratt parser over native @csstools/css-tokenizer tokens. import { baseOf } from './convertUnits.js'; -import { call, dim, ident, mkProduct, mkSum, num } from './node.js'; +import { + call, + dim, + ident, + groupSubstitution, + mkProduct, + mkSum, + num, +} from './node.js'; import { isCalculationFunction, isSupportedMathFunction } from './functions.js'; import { assertDepth } from './limits.js'; import { parseOpaqueCall, parseVar } from './parser/opaque.js'; @@ -58,9 +66,7 @@ function parsePrefix(input, cursor, token, depth) { case '(': { const expression = parseExpr(input, cursor, 0, depth + 1); expectPunct(input, cursor, ')'); - return expression.type === 'Sum' - ? { ...expression, grouped: true } - : expression; + return groupSubstitution(expression); } } // No unary `+`/`-` production exists in the grammar; a diff --git a/src/lib/serialize.js b/src/lib/serialize.js index 31c4136..15b8169 100644 --- a/src/lib/serialize.js +++ b/src/lib/serialize.js @@ -17,8 +17,6 @@ import { emitMathResult, emitNode, emitLeadingNeg, - emitSumTerms, - termSign, } from './serialize/expression.js'; /** @@ -125,17 +123,6 @@ function planSerializeResult(result, opts) { /** @param {Node} node @param {ReturnType} session @return {void} */ function emitRootExpr(node, session) { - if ( - node.type === 'Sum' && - node.grouped && - node.terms.length > 1 && - termSign(node.terms[0], 1, session.precision) === -1 - ) { - session.buffer.push('-1 * ('); - emitSumTerms(node.terms, session, -1); - session.buffer.push(')'); - return; - } if (node.type === 'Sum' && node.terms.length === 1) { emitLeadingNeg(node.terms[0].node, session); return; diff --git a/src/lib/serialize/expression.js b/src/lib/serialize/expression.js index 5cd979c..3b673a5 100644 --- a/src/lib/serialize/expression.js +++ b/src/lib/serialize/expression.js @@ -25,8 +25,8 @@ import { * @typedef {import('./precision.js').SerializeSession} SerializeSession */ -// The AST is canonical: sums and products are flat, so these precedence -// levels cover every binary expression +// The AST is canonical: sums and products are flat, except grouped nodes that +// keep their parentheses, so these precedence levels cover every binary expression const SUM_PRECEDENCE = 1; const PRODUCT_PRECEDENCE = 2; const ATOMIC_PRECEDENCE = 3; @@ -50,7 +50,10 @@ function precedence(node) { function needsParentheses(node, parentPrecedence, groupedRequired) { return ( precedence(node) < parentPrecedence || - (node.type === 'Sum' && node.grouped === true && groupedRequired === true) + (node.type === 'Sum' && + node.grouped === true && + groupedRequired === true) || + (node.type === 'Product' && node.grouped === true && parentPrecedence > 0) ); } @@ -225,7 +228,7 @@ function emitSum(sum, session) { * @return {void} */ function emitLeadingNeg(node, session) { - if (node.type === 'Product') { + if (node.type === 'Product' && !node.grouped) { if ( node.factors.length > 0 && node.factors[0].exponent === 1 && @@ -311,17 +314,6 @@ function emitMathResult(node, session, wrapper) { } return; } - if ( - node.type === 'Sum' && - node.grouped && - node.terms.length > 1 && - termSign(node.terms[0], 1, session.precision) === -1 - ) { - session.buffer.push(wrapper, '(-1 * ('); - emitSumTerms(node.terms, session, -1); - session.buffer.push('))'); - return; - } if ( node.type === 'Ident' || node.type === 'Call' || diff --git a/src/lib/simplify.js b/src/lib/simplify.js index 213d794..449a831 100644 --- a/src/lib/simplify.js +++ b/src/lib/simplify.js @@ -6,7 +6,8 @@ import { simplifySum } from './simplify/sum.js'; import { simplifyProduct } from './simplify/product.js'; import { simplifyCall } from './simplify/call.js'; import { simplifyComponents } from './opaque.js'; -import { opaqueCall } from './node.js'; +import { groupSubstitution, opaqueCall } from './node.js'; +import { isCalculationFunction } from './functions.js'; import { assertDepth } from './limits.js'; /** @@ -17,6 +18,20 @@ import { assertDepth } from './limits.js'; * @typedef {(node: Node) => Node} SimplifyFn */ +/** + * A nested calc() is a parenthesized group: with unresolved tokens inside, + * its operands must not merge into the surrounding sum or product. + * @param {Node} value + * @param {(value: Node) => Node} child + * @return {Node} + */ +function operand(value, child) { + const result = child(value); + return value.type === 'Call' && isCalculationFunction(value.name) + ? groupSubstitution(result) + : result; +} + /** * Simplify is an independent, composable AST transformation. It may * synthesize canonical nodes while preserving the Node -> Node contract. @@ -44,9 +59,9 @@ function simplify(node, precision = false, depth = 0) { node.rawName ); case 'Sum': - return simplifySum(node, child, precision); + return simplifySum(node, (value) => operand(value, child), precision); case 'Product': - return simplifyProduct(node, child, precision); + return simplifyProduct(node, (value) => operand(value, child), precision); } } diff --git a/src/lib/simplify/product.js b/src/lib/simplify/product.js index 8f7bc81..04878d9 100644 --- a/src/lib/simplify/product.js +++ b/src/lib/simplify/product.js @@ -1,4 +1,4 @@ -import { mkSum, mkProduct, num, dim } from '../node.js'; +import { mkSum, mkProduct, mkGroup, num, dim } from '../node.js'; import { tryCancelPair } from './cancel.js'; import { isExact } from './exact.js'; @@ -112,13 +112,15 @@ function rationalProduct( } /** - * @param {Product} product + * Fold a run of factors that contains no substitution barrier. + * @param {ProductFactor[]} items Simplified factors, flattened by the caller. + * @param {number} start + * @param {number} end * @param {SimplifyFn} simplify - * @param {number | false} [precision] A quotient that is not exact at this - * precision stays symbolic (`100% / 3`) instead of being rounded. + * @param {number | false} precision * @return {Node} */ -function simplifyProduct(product, simplify, precision = false) { +function foldFactors(items, start, end, simplify, precision) { let coeff = 1; // Num factors by exponent, so an inexact quotient can be re-emitted as a // reduced `numerator / denominator`. @@ -137,22 +139,6 @@ function simplifyProduct(product, simplify, precision = false) { * @return {void} */ function processFactor(exponent, n) { - if (n.type === 'Product') { - for (const inner of n.factors) { - processFactor( - /** @type {1 | -1} */ (exponent * inner.exponent), - inner.node - ); - } - return; - } - // Canonical negation form (see node.js's `negate`); flatten through it - // like a nested Product so cancellation below can see what's inside. - if (n.type === 'Sum' && n.terms.length === 1) { - processFactor(exponent, num(-1)); - processFactor(exponent, n.terms[0].node); - return; - } if (n.type === 'Num') { if (exponent === 1) { coeff *= n.value; @@ -172,8 +158,8 @@ function simplifyProduct(product, simplify, precision = false) { opaque.push({ exponent, node: n }); } - for (const f of product.factors) { - processFactor(f.exponent, simplify(f.node)); + for (let i = start; i < end; i++) { + processFactor(items[i].exponent, items[i].node); } // §10.2 typed division. Higher-power cancellation (`px^2 / px`) is left @@ -250,4 +236,88 @@ function simplifyProduct(product, simplify, precision = false) { return mkProduct(factors); } +/** + * A substitution function (`var()`, `env()`, `attr()`, ...) is replaced by + * tokens, not by a value, so factors cannot move or cancel across it. Any + * function the parser does not recognise (`anchor()`, `foo()`, ...) is also an + * OpaqueCall and is treated the same, conservatively. A grouped Product is a + * parenthesized substitution and is equally opaque. + * @param {Node} node + * @return {boolean} + */ +function isBarrier(node) { + return ( + node.type === 'OpaqueCall' || + (node.type === 'Product' && node.grouped === true) + ); +} + +/** + * Flatten a simplified factor through nested ungrouped Products and the + * canonical negation form, so barriers are visible at the top level. + * @param {ProductFactor[]} out + * @param {1 | -1} exponent + * @param {Node} node + * @return {boolean} Whether a barrier was pushed. + */ +function flattenFactor(out, exponent, node) { + if (node.type === 'Product' && !node.grouped) { + let barrier = false; + for (const inner of node.factors) { + if ( + flattenFactor( + out, + /** @type {1 | -1} */ (exponent * inner.exponent), + inner.node + ) + ) + barrier = true; + } + return barrier; + } + if (node.type === 'Sum' && node.terms.length === 1) { + out.push({ exponent, node: num(-1) }); + return flattenFactor(out, exponent, node.terms[0].node); + } + out.push({ exponent, node }); + return isBarrier(node); +} + +/** + * @param {Product} product + * @param {SimplifyFn} simplify + * @param {number | false} [precision] A quotient that is not exact at this + * precision stays symbolic (`100% / 3`) instead of being rounded. + * @return {Node} + */ +function simplifyProduct(product, simplify, precision = false) { + /** @type {ProductFactor[]} */ + const items = []; + let hasBarrier = false; + for (const f of product.factors) { + if (flattenFactor(items, f.exponent, simplify(f.node))) hasBarrier = true; + } + if (!hasBarrier) + return foldFactors(items, 0, items.length, simplify, precision); + + // Fold each run between barriers on its own; never move or cancel a factor + // across a barrier, and keep each barrier's own operator. + /** @type {ProductFactor[]} */ + const factors = []; + let start = 0; + for (let i = 0; i <= items.length; i++) { + if (i < items.length && !isBarrier(items[i].node)) continue; + if (i - start === 1) factors.push(items[start]); + else if (i - start > 1) + factors.push({ + exponent: 1, + node: foldFactors(items, start, i, simplify, precision), + }); + if (i < items.length) factors.push(items[i]); + start = i + 1; + } + const result = mkProduct(factors); + return product.grouped === true ? mkGroup(result) : result; +} + export { simplifyProduct }; diff --git a/test/index-cases.test.cjs b/test/index-cases.test.cjs index 7a361ae..ab7c55f 100644 --- a/test/index-cases.test.cjs +++ b/test/index-cases.test.cjs @@ -10,10 +10,10 @@ const { describe('CSS custom properties', () => { test( 'should ignore calc with css variables (1)', - // spec-style spaces; canonical order puts the dim first. + // spec-style spaces; operand order is preserved around var(). testValue( 'calc(var(--mouseX) * 1px)', - /* 'calc(var(--mouseX)*1px)' */ 'calc(1px * var(--mouseX))' + /* 'calc(var(--mouseX)*1px)' */ 'calc(var(--mouseX) * 1px)' ) ); @@ -22,7 +22,7 @@ describe('CSS custom properties', () => { // spec-style spaces around `*`. testValue( 'calc(10px - (100px * var(--mouseX)))', - /* 'calc(10px - 100px*var(--mouseX))' */ 'calc(10px - 100px * var(--mouseX))' + /* 'calc(10px - 100px*var(--mouseX))' */ 'calc(10px - (100px * var(--mouseX)))' ) ); @@ -39,7 +39,7 @@ describe('CSS custom properties', () => { // spec-style spaces around `/`. testValue( 'calc(10px - (100px / var(--mouseX)))', - /* 'calc(10px - 100px/var(--mouseX))' */ 'calc(10px - 100px / var(--mouseX))' + /* 'calc(10px - 100px/var(--mouseX))' */ 'calc(10px - (100px / var(--mouseX)))' ) ); @@ -53,19 +53,19 @@ describe('CSS custom properties', () => { test( 'should ignore calc with css variables (6)', - // `/2` → `* .5` (reciprocal); coefficient first. + // division by a number is kept as written. testValue( 'calc(var(--popupHeight) / 2)', - /* 'calc(var(--popupHeight)/2)' */ 'calc(.5 * var(--popupHeight))' + /* 'calc(var(--popupHeight)/2)' */ 'calc(var(--popupHeight) / 2)' ) ); test( 'should ignore calc with css variables (7)', - // `/2` → `* .5` on both terms; coefficient first. + // division by a number is kept as written on both terms. testValue( 'calc(var(--popupHeight) / 2 + var(--popupWidth) / 2)', - 'calc(.5 * var(--popupHeight) + .5 * var(--popupWidth))' + 'calc(var(--popupHeight) / 2 + var(--popupWidth) / 2)' ) ); @@ -137,7 +137,7 @@ describe('Skip special functions', () => { 'should skip attr function', testCssDoesNotThrow( 'foo { width: calc(attr(size ch) * 1.1); }', - 'foo { width: calc(1.1 * attr(size ch)); }' + 'foo { width: calc(attr(size ch) * 1.1); }' ) ); }); diff --git a/test/index-options.test.cjs b/test/index-options.test.cjs index 808ff77..c6bcce9 100644 --- a/test/index-options.test.cjs +++ b/test/index-options.test.cjs @@ -23,10 +23,10 @@ test( describe('Ignore', () => { test( 'should ignore reducing custom property', - // `/8` → `* .125` (reciprocal); coefficient first. + // A division after a substitution stays where it was written. testCss( ':root { --foo: calc(var(--bar) / 8); }', - /* ':root { --foo: calc(var(--bar)/8); }' */ ':root { --foo: calc(.125 * var(--bar)); }' + /* ':root { --foo: calc(var(--bar)/8); }' */ ':root { --foo: calc(var(--bar) / 8); }' ) ); @@ -100,9 +100,9 @@ test( test( 'nested var (reduce-css-calc#50)', - // `/2` → `* .5` (reciprocal); coefficient first. + // division by a number is kept as written. testValue( 'calc(var(--xxx, var(--yyy)) / 2)', - 'calc(.5 * var(--xxx, var(--yyy)))' + 'calc(var(--xxx, var(--yyy)) / 2)' ) ); diff --git a/test/index.test.cjs b/test/index.test.cjs index 51f99c3..8f8659f 100644 --- a/test/index.test.cjs +++ b/test/index.test.cjs @@ -96,7 +96,7 @@ describe('Reduce', () => { test( 'should reduce multiplication', - // constant fold `2*2 → 4`; coefficient first. + // constant fold `2*2 → 4`; the grouped sum stays a single factor. testValue('calc(((var(--a) + 4px) * 2) * 2)', 'calc(4 * (4px + var(--a)))') ); @@ -111,7 +111,7 @@ describe('Reduce', () => { test( 'should reduce division', - // constant fold `1/2/2 → .25` + reciprocal; coefficient first. + // constant fold `1/2/2 → .25`; the grouped sum stays a single factor. testValue( 'calc(((var(--a) + 4px) / 2) / 2)', 'calc(.25 * (4px + var(--a)))' diff --git a/test/integration/exact-division.test.js b/test/integration/exact-division.test.js index 1e8efc8..e85a6a0 100644 --- a/test/integration/exact-division.test.js +++ b/test/integration/exact-division.test.js @@ -24,12 +24,12 @@ describe('Exact-by-default division', () => { ['calc(10px / -3)', 'calc(-10px / 3)'], ['calc(1px / 1in)', 'calc(1px / 1in)'], ['calc(1in / 1px)', 'calc(96)'], - ['calc(var(--n) / 4)', 'calc(.25 * var(--n))'], + ['calc(var(--n) / 4)', 'calc(var(--n) / 4)'], ['calc(2 * var(--n) / 3)', 'calc(2 * var(--n) / 3)'], // Quotients whose parts would be rounded on output are folded instead. ['calc(1em * 105 / 64)', 'calc(105em / 64)'], ['calc(1rem * 2.828427125 / 2)', 'calc(1.41421rem)'], - ['calc(cos(220deg) * var(--rad) / 2)', 'calc(-.38302 * var(--rad))'], + ['calc(cos(220deg) * var(--rad) / 2)', 'calc(-.76604 * var(--rad) / 2)'], ['calc(100rem * 1.9999999999999993 / 1024.0)', 'calc(25rem / 128)'], ]; for (const [input, expected] of rows) { diff --git a/test/integration/math-operations.test.js b/test/integration/math-operations.test.js index 6a12705..5f161f8 100644 --- a/test/integration/math-operations.test.js +++ b/test/integration/math-operations.test.js @@ -90,10 +90,10 @@ describe('Subtraction from zero', () => { test( 'should reduce substracted expression from zero (css-variable)', - // reciprocal; zero bucket kept; coefficient first. + // zero bucket kept; the group around the substitution stays. testValue( 'calc( 0px - (var(--foo, 4px) / 2))', - 'calc(0px - .5 * var(--foo, 4px))' + 'calc(0px - (var(--foo, 4px) / 2))' ) ); @@ -119,29 +119,32 @@ describe('Discard zero', () => { describe('Division precedence', () => { test( 'should preserve division precedence', - // spec-style spaces around `/`, redundant parens dropped. + // spec-style spaces around `/`; parens around var() are kept. testValue( 'calc(100%/(var(--aspect-ratio)))', - 'calc(100% / var(--aspect-ratio))' + 'calc(100% / (var(--aspect-ratio)))' ) ); test( 'should preserve division precedence (2)', - // `/16` → `* .0625` (reciprocal); coefficient first. + // `/16` is kept as written; parens around substitutions are kept. testValue( `calc( (var(--fluid-screen) - ((var(--fluid-min-width) / 16) * 1rem)) / ((var(--fluid-max-width) / 16) - (var(--fluid-min-width) / 16)) )`, - 'calc((var(--fluid-screen) - .0625 * 1rem * var(--fluid-min-width)) / (.0625 * var(--fluid-max-width) - .0625 * var(--fluid-min-width)))' + 'calc((var(--fluid-screen) - (var(--fluid-min-width) / 16) * 1rem) / ((var(--fluid-max-width) / 16) - (var(--fluid-min-width) / 16)))' ) ); test( 'should preserve division precedence (3)', - // `1/(10/x)` folds to `.1 * x` via reciprocal. - testValue('calc(1/(10/var(--dot-size)))', 'calc(.1 * var(--dot-size))') + // `1/(10/x)` is not folded: x may expand to arbitrary tokens. + testValue( + 'calc(1/(10/var(--dot-size)))', + 'calc(1 / (10 / var(--dot-size)))' + ) ); test( @@ -318,13 +321,13 @@ describe('Precision', () => { ); test( - 'canonical reciprocal coefficient with opaque term at default precision', + 'division of an opaque term is kept at default precision', testValue('calc(var(--x) / 3)', 'calc(var(--x) / 3)') ); test( - 'canonical reciprocal coefficient with opaque term at precision false', - testValue('calc(var(--x) / 3)', 'calc(.3333333333333333 * var(--x))', { + 'division of an opaque term is kept at precision false', + testValue('calc(var(--x) / 3)', 'calc(var(--x) / 3)', { precision: false, }) ); diff --git a/test/integration/nested-calculations.test.js b/test/integration/nested-calculations.test.js index b89892d..71f6dfe 100644 --- a/test/integration/nested-calculations.test.js +++ b/test/integration/nested-calculations.test.js @@ -43,7 +43,7 @@ describe('Nested calc functions', () => { 'should handle nested calc function (#4)', testValue( 'calc(var(--foo) - calc(var(--bar) - var(--baz)))', - 'calc(var(--foo) - var(--bar) + var(--baz))' + 'calc(var(--foo) - (var(--bar) - var(--baz)))' ) ); @@ -75,7 +75,7 @@ describe('Nested calc functions', () => { 'should handle nested calc function (#8)', testValue( 'calc(var(--foo) - calc(var(--bar) + var(--baz)))', - 'calc(var(--foo) - var(--bar) - var(--baz))' + 'calc(var(--foo) - (var(--bar) + var(--baz)))' ) ); diff --git a/test/property/substitution.test.js b/test/property/substitution.test.js new file mode 100644 index 0000000..168a1b1 --- /dev/null +++ b/test/property/substitution.test.js @@ -0,0 +1,72 @@ +// Substitution functions (`var()`, `env()`, `attr()`, ...) are replaced by raw +// tokens before the value is computed. Whatever tokens a substitution +// expands to, the reduced expression must evaluate like the original. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fc from 'fast-check'; +import reduceCalc from 'postcss-calc/reduce'; + +const tokenArb = fc.constantFrom( + '3', + '-3', + '0', + '-1 * 2', + '1 + 2', + '8 / 2', + '6 / 3 / 2', + '2 * 3 - 1', + '(1 + 2)', + '7 - 4 - 2' +); +const tokensArb = fc.record({ a: tokenArb, b: tokenArb, c: tokenArb }); + +const { expr } = fc.letrec((tie) => ({ + leaf: fc.oneof( + fc.integer({ min: -9, max: 9 }).map((n) => (n < 0 ? `(${n})` : String(n))), + fc.constant('.5'), + fc.constantFrom('var(--a)', 'var(--b)', 'var(--c)') + ), + expr: fc.oneof( + { depthSize: 'small', withCrossShrink: true }, + tie('leaf'), + fc + .tuple(tie('expr'), fc.constantFrom('+', '-', '*', '/'), tie('expr')) + .map(([l, op, r]) => `${l} ${op} ${r}`), + tie('expr').map((e) => `(${e})`), + tie('expr').map((e) => `calc(${e})`) + ), +})); + +/** + * @param {string} value + * @param {{a: string, b: string, c: string}} tokens + * @return {number} + */ +function evaluate(value, tokens) { + const expression = value + .replace(/calc\(/g, '(') + .replace(/var\(--([abc])\)/g, (_, name) => tokens[name]); + return Function(`"use strict"; return ${expression};`)(); +} + +test('property: reduced expressions evaluate like their token substitution', () => { + fc.assert( + fc.property(expr, tokensArb, (input, tokens) => { + const source = `calc(${input})`; + const output = reduceCalc(source, { precision: false }); + const expected = evaluate(source, tokens); + if ( + !Number.isFinite(expected) || + /[a-z]/.test(output.replace(/calc\(|var\(--[abc]\)/g, '')) + ) { + return; // degenerate result, serialized as a keyword + } + const actual = evaluate(output, tokens); + assert.ok( + Math.abs(actual - expected) <= 1e-9 * Math.max(1, Math.abs(expected)), + `${source} → ${output}: ${expected} vs ${actual} with ${JSON.stringify(tokens)}` + ); + }), + { numRuns: 2000 } + ); +}); diff --git a/test/unit/reduceCalc-core.test.js b/test/unit/reduceCalc-core.test.js index 6c83341..ba30636 100644 --- a/test/unit/reduceCalc-core.test.js +++ b/test/unit/reduceCalc-core.test.js @@ -175,14 +175,14 @@ describe('reduceCalc: basic pipeline', () => { test('negated grouped sum with non-leading sub-precision negative term serializes with positive sign', () => { assert.equal( reduceCalc('calc((-10px + var(--x) - 1e-20em))'), - 'calc(-1 * (10px + 0em - var(--x)))' + 'calc(-10px + 0em + var(--x))' ); }); test('negated grouped sum with non-leading sub-precision positive term serializes with positive sign', () => { assert.equal( reduceCalc('calc((-10px + var(--x) + 1e-20em))'), - 'calc(-1 * (10px + 0em - var(--x)))' + 'calc(-10px + 0em + var(--x))' ); }); @@ -196,7 +196,7 @@ describe('reduceCalc: basic pipeline', () => { test('precision: false retains grouped negative sum inversion', () => { assert.equal( reduceCalc('calc((-1e-20 + var(--x)))', { precision: false }), - 'calc(-1 * (1e-20 - var(--x)))' + 'calc(-1e-20 + var(--x))' ); }); @@ -242,17 +242,17 @@ describe('reduceCalc: basic pipeline', () => { ); }); - test('negated grouped sum serializes a negated positive zero term as arithmetic negative zero', () => { + test('grouped sum with a leading negative term keeps its signs and a positive zero term', () => { assert.equal( reduceCalc('calc((-1em + var(--x) + 0px))'), - 'calc(-1 * (1em + calc(-1 * 0px) - var(--x)))' + 'calc(-1em + 0px + var(--x))' ); }); - test('negated grouped sum serializes a negated negative zero term as positive zero', () => { + test('grouped sum with a leading negative term keeps its signs and a negative zero term', () => { assert.equal( reduceCalc('calc((-1em + var(--x) - 0px))'), - 'calc(-1 * (1em + 0px - var(--x)))' + 'calc(-1em + calc(-1 * 0px) + var(--x))' ); }); }); diff --git a/test/unit/reduceCalc-substitution.test.js b/test/unit/reduceCalc-substitution.test.js new file mode 100644 index 0000000..7e8a81b --- /dev/null +++ b/test/unit/reduceCalc-substitution.test.js @@ -0,0 +1,105 @@ +// Substitution functions (`var()`, `env()`, `attr()`, ...) are replaced by +// tokens, not by a value: with `--a: 1px + 2px`, `var(--a) * 6` means +// `1px + 2px * 6`. Factors must not move or cancel across them, and +// parentheses around them must survive. +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import reduceCalc from 'postcss-calc/reduce'; + +/** + * Token-substitute `1 + 2` for every substitution function and evaluate the + * unitless arithmetic. + * @param {string} value + * @return {number} + */ +function evaluate(value) { + const expression = value + .replace(/calc\(/g, '(') + .replace(/(?:var|env|attr)\([^()]*\)/g, '1 + 2'); + return Function(`"use strict"; return ${expression};`)(); +} + +const cases = [ + ['var(--a) * 2 * 3', 'calc(var(--a) * 6)'], + ['2 * 3 * var(--a)', 'calc(6 * var(--a))'], + ['2 * var(--a) * 3', 'calc(2 * var(--a) * 3)'], + ['2 * var(--a) / 2', 'calc(2 * var(--a) / 2)'], + ['2px * var(--a) / 1px', 'calc(2px * var(--a) / 1px)'], + ['2 * (var(--a))', 'calc(2 * (var(--a)))'], + ['2 * (var(--a) * 3)', 'calc(2 * (var(--a) * 3))'], + ['var(--a) * 2 - 1px', 'calc(-1px + var(--a) * 2)'], + ['var(--a) / 2 * 3', 'calc(var(--a) * 1.5)'], + ['6 / var(--a)', 'calc(6 / var(--a))'], + ['2 * var(--a) * 3 * var(--b) * 4', 'calc(2 * var(--a) * 3 * var(--b) * 4)'], + ['var(--a) * 2 * 3 * var(--b) * 4 * 5', 'calc(var(--a) * 6 * var(--b) * 20)'], + ['env(--a) * 2 * 3', 'calc(env(--a) * 6)'], + ['2 * 3 * attr(data-a)', 'calc(6 * attr(data-a))'], + ['2 * (env(--a))', 'calc(2 * (env(--a)))'], + ['2 * (attr(data-a) * 3)', 'calc(2 * (attr(data-a) * 3))'], + ['var(--a) / var(--a)', 'calc(var(--a) / var(--a))'], + ['var(--a) * 2 / var(--a)', 'calc(var(--a) * 2 / var(--a))'], + ['2 * 3 * sin(var(--a)) * 4', 'calc(24 * sin(var(--a)))'], + ['1px + --fn(1) * 3 * 2', 'calc(1px + --fn(1) * 6)'], + ['2 * --fn(1) * 3', 'calc(2 * --fn(1) * 3)'], + ['var(--x, calc(var(--a) * 2 * 3))', 'calc(var(--x, calc(var(--a) * 6)))'], + ['max((var(--a)), 1px)', 'calc(max(var(--a), 1px))'], + ['2 * VAR(--a) * 3', 'calc(2 * VAR(--a) * 3)'], + ['1px - (2 * var(--a))', 'calc(1px - (2 * var(--a)))'], + ['var(--a) / calc(var(--a))', 'calc(var(--a) / (var(--a)))'], + ['1px - calc(var(--a))', 'calc(1px - (var(--a)))'], + ['(var(--b) - 7 - 2)', 'calc(-9 + var(--b))'], + // Unrecognised functions are conservative barriers too. + ['2 * anchor-size(width) * .5', 'calc(2 * anchor-size(width) * .5)'], + ['2 * 3 * foo(1)', 'calc(6 * foo(1))'], + ['inherit(--a) * 2 * 3', 'calc(inherit(--a) * 6)'], + [ + '1px + if(style(--a): 1px; else: 2px) * 2 * 3', + 'calc(1px + if(style(--a): 1px; else: 2px) * 6)', + ], + [ + '2px * -webkit-calc(var(--a) + 1px)', + 'calc(2px * -webkit-calc(var(--a) + 1px))', + ], + ['1px - -webkit-calc(var(--a) - 1px)', 'calc(1px - (-1px + var(--a)))'], + ['1px - calc(-1 * var(--a))', 'calc(1px - (-1 * var(--a)))'], +]; + +describe('reduceCalc: substitution functions are positional barriers', () => { + for (const [input, expected] of cases) { + test(`calc(${input}) → ${expected}`, () => { + assert.equal(reduceCalc(`calc(${input})`), expected); + }); + } + + test('negation stays on the first term of a substituted sum', () => { + assert.equal(reduceCalc('calc(-1 * var(--a))'), 'calc(-1 * var(--a))'); + assert.equal(reduceCalc('calc(var(--a) * -1)'), 'calc(var(--a) * -1)'); + }); + + test('reduced results equal the token-substituted source', () => { + const inputs = [ + 'var(--a) * 2 * 3', + '2 * 3 * var(--a)', + '2 * var(--a) / 2', + '2 * (var(--a))', + '2 * (var(--a) * 3)', + 'var(--a) * 2 - 1', + 'var(--a) / 2 * 3', + '6 / var(--a)', + '2 * var(--a) * 3 * var(--a) * 4', + 'env(x) * 2 * 3', + '2 * (attr(x) * 3)', + 'var(--a) / var(--a)', + '1 - (2 * var(--a))', + '(var(--a) - 7 - 2)', + 'var(--a) / calc(var(--a))', + ]; + for (const input of inputs) { + assert.equal( + evaluate(reduceCalc(`calc(${input})`)), + evaluate(`calc(${input})`), + input + ); + } + }); +}); diff --git a/test/unit/serialize-precision.test.js b/test/unit/serialize-precision.test.js index 6122255..9a2135d 100644 --- a/test/unit/serialize-precision.test.js +++ b/test/unit/serialize-precision.test.js @@ -239,7 +239,7 @@ describe('serialize: precision and rounding', () => { assert.equal(serialize(ast), 'calc(0 + var(--x))'); assert.equal( serialize(ast, { precision: false }), - 'calc(-1 * (1e-20 - var(--x)))' + 'calc(-1e-20 + var(--x))' ); }); @@ -253,7 +253,7 @@ describe('serialize: precision and rounding', () => { { sign: 1, node: opaqueCall('var', [ident('--x')]) }, ], }; - assert.equal(serialize(ast), 'calc(-1 * (10px + 0em - var(--x)))'); + assert.equal(serialize(ast), 'calc(-10px + 0em + var(--x))'); }); test('negated grouped sum with non-leading sub-precision positive term serializes with positive sign', () => { @@ -266,7 +266,7 @@ describe('serialize: precision and rounding', () => { { sign: 1, node: opaqueCall('var', [ident('--x')]) }, ], }; - assert.equal(serialize(ast), 'calc(-1 * (10px + 0em - var(--x)))'); + assert.equal(serialize(ast), 'calc(-10px + 0em + var(--x))'); }); }); }); diff --git a/test/unit/simplify/reportedBugs.test.js b/test/unit/simplify/reportedBugs.test.js index eee3f03..983c6c7 100644 --- a/test/unit/simplify/reportedBugs.test.js +++ b/test/unit/simplify/reportedBugs.test.js @@ -31,8 +31,8 @@ test('converts nested vars', () => { ); }); -test('handles negative values at the end', () => { - assert.equal(out('calc(var(--my-var) * -1)'), 'calc(-1 * var(--my-var))'); +test('keeps negative values at the end after a substitution', () => { + assert.equal(out('calc(var(--my-var) * -1)'), 'calc(var(--my-var) * -1)'); }); describe('reported bug regressions', () => { diff --git a/types/lib/node.d.ts b/types/lib/node.d.ts index 17fe78d..4467042 100644 --- a/types/lib/node.d.ts +++ b/types/lib/node.d.ts @@ -42,6 +42,7 @@ export type ProductFactor = { export type Product = { type: 'Product'; factors: ProductFactor[]; + grouped?: boolean; }; export type Node = Num | Dim | Ident | Call | OpaqueCall | Sum | Product; /** @@ -54,7 +55,7 @@ export type Node = Num | Dim | Ident | Call | OpaqueCall | Sum | Product; * @typedef {{sign: 1 | -1, node: Node}} SumTerm Sign is always +1 when node is Num or Dim. * @typedef {{type: 'Sum', terms: SumTerm[], grouped?: boolean}} Sum * @typedef {{exponent: 1 | -1, node: Node}} ProductFactor exponent +1 = numerator, -1 = denominator. - * @typedef {{type: 'Product', factors: ProductFactor[]}} Product + * @typedef {{type: 'Product', factors: ProductFactor[], grouped?: boolean}} Product * @typedef {Num | Dim | Ident | Call | OpaqueCall | Sum | Product} Node */ /** @@ -99,10 +100,24 @@ declare function mkSum(rawTerms: SumTerm[]): Node; * @return {Node} */ declare function mkProduct(rawFactors: ProductFactor[]): Node; +/** + * Mark a node as a source parenthesized group. A Sum or Product keeps its + * structure; any other node becomes a single-factor grouped Product. + * @param {Node} node + * @return {Node} + */ +declare function mkGroup(node: Node): Node; +/** + * Group a parenthesized expression when its tokens must stay bound: a sum, or + * a product around a substitution function. + * @param {Node} node + * @return {Node} + */ +declare function groupSubstitution(node: Node): Node; /** * Negate any node, preserving canonical form. * @param {Node} node * @return {Node} */ declare function negate(node: Node): Node; -export { num, dim, ident, call, opaqueCall, mkSum, mkProduct, negate }; +export { num, dim, ident, call, opaqueCall, mkSum, mkProduct, mkGroup, groupSubstitution, negate, }; diff --git a/types/lib/simplify.d.ts b/types/lib/simplify.d.ts index 15882f3..8357875 100644 --- a/types/lib/simplify.d.ts +++ b/types/lib/simplify.d.ts @@ -1,12 +1,5 @@ export type Node = import('./node.js').Node; export type SimplifyFn = (node: Node) => Node; -/** - * @typedef {import('./node.js').Node} Node - * - * Recursive simplifier reference, threaded into Sum/Product/Call/OpaqueCall. Lets - * leaf fold modules avoid circular imports of the entry function. - * @typedef {(node: Node) => Node} SimplifyFn - */ /** * Simplify is an independent, composable AST transformation. It may * synthesize canonical nodes while preserving the Node -> Node contract. From 901cd4ff66ae404ecb0ec5a05cc978d9b40e99e3 Mon Sep 17 00:00:00 2001 From: Ludovico Fischer Date: Thu, 1 Oct 2026 17:01:47 +0200 Subject: [PATCH 4/8] refactor: simplify excessively nested blocks in sum serialization --- src/lib/serialize/expression.js | 58 +++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/src/lib/serialize/expression.js b/src/lib/serialize/expression.js index 3b673a5..324dd2c 100644 --- a/src/lib/serialize/expression.js +++ b/src/lib/serialize/expression.js @@ -169,6 +169,37 @@ function emitSumTerm(term, session, sign) { } } +/** + * @param {import('../node.js').Num | import('../node.js').Dim} termNode + * @param {1 | -1} sign + * @param {number} i + * @param {SerializeSession} session + * @return {void} + */ +function emitScalarSumTerm(termNode, sign, i, session) { + const buffer = session.buffer; + const effectiveVal = sign * termNode.value; + if (Object.is(effectiveVal, -0)) { + if (i > 0) buffer.push(' + '); + emitSignedZero(buffer, termNode); + return; + } + if (isDegenerate(effectiveVal)) { + if (i === 0 && sign === -1) buffer.push('-'); + else if (i > 0) buffer.push(sign === 1 ? ' + ' : ' - '); + emitScalar(termNode, session); + return; + } + const rounded = round(effectiveVal, session.precision); + if (rounded < 0) { + buffer.push(i === 0 ? '-' : ' - '); + emitRoundedScalar(termNode, buffer, -rounded); + } else { + if (i > 0) buffer.push(' + '); + emitRoundedScalar(termNode, buffer, rounded); + } +} + /** * @param {import('../node.js').SumTerm[]} terms * @param {SerializeSession} session @@ -180,34 +211,11 @@ function emitSumTerms(terms, session, multiplier = 1) { for (let i = 0; i < terms.length; i++) { const term = terms[i]; const termNode = term.node; + const sign = /** @type {1 | -1} */ (term.sign * multiplier); if (isScalar(termNode)) { - const effectiveVal = term.sign * multiplier * termNode.value; - if (Object.is(effectiveVal, -0)) { - if (i > 0) buffer.push(' + '); - emitSignedZero(buffer, termNode); - } else if (isDegenerate(effectiveVal)) { - const sign = /** @type {1 | -1} */ (term.sign * multiplier); - if (i === 0) { - if (sign === -1) buffer.push('-'); - emitScalar(termNode, session); - } else { - buffer.push(sign === 1 ? ' + ' : ' - '); - emitScalar(termNode, session); - } - } else { - const rounded = round(effectiveVal, session.precision); - if (rounded < 0) { - if (i === 0) buffer.push('-'); - else buffer.push(' - '); - emitRoundedScalar(termNode, buffer, -rounded); - } else { - if (i > 0) buffer.push(' + '); - emitRoundedScalar(termNode, buffer, rounded); - } - } + emitScalarSumTerm(termNode, sign, i, session); continue; } - const sign = /** @type {1 | -1} */ (term.sign * multiplier); if (i === 0) { emitSumTerm(term, session, sign); } else { From 6f3e63c480997c8f93ae433d78033157999f9d4d Mon Sep 17 00:00:00 2001 From: Ludovico Fischer Date: Thu, 1 Oct 2026 17:02:51 +0200 Subject: [PATCH 5/8] chore: refactor benchmark scripts and remove duplication --- package.json | 4 +- scripts/README.md | 8 +- .../benchmark/benchmark-nested-fallbacks.js | 47 ------ ...ithmetic-chains.js => benchmark-parser.js} | 13 +- scripts/benchmark/bootstrap.js | 13 +- scripts/benchmark/corpus-analysis.js | 33 +--- scripts/benchmark/parser-analysis.js | 10 +- scripts/benchmark/parser-benchmark.js | 7 +- scripts/benchmark/parser-scaling.js | 20 ++- scripts/benchmark/parser-workloads.js | 1 + scripts/benchmark/validate-corpus.js | 149 ++++++++++-------- 11 files changed, 127 insertions(+), 178 deletions(-) delete mode 100644 scripts/benchmark/benchmark-nested-fallbacks.js rename scripts/benchmark/{benchmark-arithmetic-chains.js => benchmark-parser.js} (75%) diff --git a/package.json b/package.json index d8cb159..deb0135 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "scripts": { "lint": "oxlint . && tsc && oxfmt --check", "fmt": "oxfmt", - "benchmark:arithmetic-chains": "node scripts/benchmark/benchmark-arithmetic-chains.js", - "benchmark:nested-fallbacks": "node scripts/benchmark/benchmark-nested-fallbacks.js", + "benchmark:arithmetic-chains": "node scripts/benchmark/benchmark-parser.js arithmetic-chains", + "benchmark:nested-fallbacks": "node scripts/benchmark/benchmark-parser.js nested-fallbacks", "benchmark:corpus": "node scripts/benchmark/benchmark-corpus.js", "benchmark:serialization": "node scripts/benchmark/benchmark-serialization.js", "test:benchmark": "node --test 'test/unit/benchmark-*.test.js' test/unit/compare-parser-benchmarks.test.js test/unit/corpus-benchmark.test.js", diff --git a/scripts/README.md b/scripts/README.md index b044851..f32a2a1 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -9,10 +9,10 @@ reanalysis. For a detailed explanation of the statistical methodology, experiment design, and software architecture, see [BENCHMARKS.md](../BENCHMARKS.md). -- **`benchmark/benchmark-arithmetic-chains.js`** — runs the fresh-process, paired parser - benchmark for arithmetic shapes. `benchmark/benchmark-nested-fallbacks.js` does the - same for nested `var()` fallbacks. Both accept `--baseline`, `--blocks`, - `--max-attempts`, `--seed`, and `--output`, and write schema-v2 artifacts under +- **`benchmark/benchmark-parser.js `** — runs the fresh-process, paired + parser benchmark. `arithmetic-chains` covers arithmetic shapes and + `nested-fallbacks` covers nested `var()` fallbacks. It accepts `--baseline`, + `--blocks`, `--max-attempts`, `--seed`, and `--output`, and writes schema-v2 artifacts under `reports/benchmarks/`. The default arithmetic grid uses four logarithmically spaced sizes with uniform doubling steps (`2,000` to `16,000`) and a tuned batch schedule so a controlled run completes under 5 minutes while preserving diff --git a/scripts/benchmark/benchmark-nested-fallbacks.js b/scripts/benchmark/benchmark-nested-fallbacks.js deleted file mode 100644 index f9eab02..0000000 --- a/scripts/benchmark/benchmark-nested-fallbacks.js +++ /dev/null @@ -1,47 +0,0 @@ -import { runParserBenchmark } from './parser-benchmark.js'; - -try { - const result = await runParserBenchmark({ - benchmark: 'nested-fallbacks', - ...parseOptions(process.argv.slice(2)), - }); - console.log(`Parser benchmark: ${result.artifact.analysis.status}`); - console.log(`Wrote ${result.path}`); - process.exitCode = exitCodeFor(result.artifact.analysis.status); -} catch (error) { - console.error(error instanceof Error ? error.message : error); - process.exitCode = - error instanceof TypeError || error instanceof RangeError ? 64 : 3; -} - -function exitCodeFor(status) { - if (status === 'pass') return 0; - if (status === 'regression') return 1; - return 2; -} - -function parseOptions(args) { - const options = {}; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === '--baseline') options.baseline = args[++i]; - else if (arg === '--blocks') options.blocks = Number(args[++i]); - else if (arg === '--max-attempts') options.maxAttempts = Number(args[++i]); - else if (arg === '--seed') options.seed = Number(args[++i]); - else if (arg === '--output') options.output = args[++i]; - else throw new TypeError(`invalid option: ${arg}`); - } - if ( - options.seed !== undefined && - (!Number.isInteger(options.seed) || - options.seed < 0 || - options.seed > 0xffffffff) - ) - throw new TypeError('invalid --seed'); - if ( - options.maxAttempts !== undefined && - (!Number.isInteger(options.maxAttempts) || options.maxAttempts < 1) - ) - throw new TypeError('invalid --max-attempts'); - return options; -} diff --git a/scripts/benchmark/benchmark-arithmetic-chains.js b/scripts/benchmark/benchmark-parser.js similarity index 75% rename from scripts/benchmark/benchmark-arithmetic-chains.js rename to scripts/benchmark/benchmark-parser.js index 831cac8..b07d571 100644 --- a/scripts/benchmark/benchmark-arithmetic-chains.js +++ b/scripts/benchmark/benchmark-parser.js @@ -1,9 +1,16 @@ -import { runParserBenchmark } from './parser-benchmark.js'; +// Fresh-process paired parser benchmark. Usage: +// benchmark-parser.js [options] +import { PARSER_BENCHMARKS, runParserBenchmark } from './parser-benchmark.js'; try { + const [benchmark, ...args] = process.argv.slice(2); + if (!PARSER_BENCHMARKS.includes(benchmark)) + throw new TypeError( + `expected benchmark name (${PARSER_BENCHMARKS.join(' or ')}), got: ${benchmark}` + ); const result = await runParserBenchmark({ - benchmark: 'arithmetic-chains', - ...parseOptions(process.argv.slice(2)), + benchmark, + ...parseOptions(args), }); console.log(`Parser benchmark: ${result.artifact.analysis.status}`); console.log(`Wrote ${result.path}`); diff --git a/scripts/benchmark/bootstrap.js b/scripts/benchmark/bootstrap.js index 5a5901a..00216ef 100644 --- a/scripts/benchmark/bootstrap.js +++ b/scripts/benchmark/bootstrap.js @@ -255,12 +255,13 @@ export function bootstrapStratifiedMaxT({ if (sampledSE === 0) { hasDegenerateEndpoint = true; const deviation = effect - observed[column]; - if (deviation === 0) statistic = 0; - else { - if (observedSE[column] === 0) - throw new RangeError( - 'nonzero bootstrap deviation has no positive standard error' - ); + if (deviation === 0) { + statistic = 0; + } else if (observedSE[column] === 0) { + throw new RangeError( + 'nonzero bootstrap deviation has no positive standard error' + ); + } else { statistic = deviation / observedSE[column]; degenerateFallbacks++; } diff --git a/scripts/benchmark/corpus-analysis.js b/scripts/benchmark/corpus-analysis.js index 16eac30..2512994 100644 --- a/scripts/benchmark/corpus-analysis.js +++ b/scripts/benchmark/corpus-analysis.js @@ -288,37 +288,8 @@ function assertStoredAnalysisMatches(stored, expected, path = 'analysis') { const expectedKeys = Object.keys(expected).sort(); if (JSON.stringify(storedKeys) !== JSON.stringify(expectedKeys)) throw new TypeError(`${path} does not match recomputed observations`); - for (const key of expectedKeys) { - const left = stored[key]; - const right = expected[key]; - if (typeof right === 'number') { - if ( - typeof left !== 'number' || - !Number.isFinite(left) || - Math.abs(left - right) > 1e-10 * Math.max(1, Math.abs(right)) - ) - throw new TypeError( - `${path}.${key} does not match recomputed observations` - ); - } else if (Array.isArray(right)) { - if (!Array.isArray(left) || left.length !== right.length) - throw new TypeError( - `${path}.${key} does not match recomputed observations` - ); - for (let index = 0; index < right.length; index++) - assertStoredValue( - left[index], - right[index], - `${path}.${key}[${index}]` - ); - } else if (right && typeof right === 'object') { - assertStoredAnalysisMatches(left, right, `${path}.${key}`); - } else if (left !== right) { - throw new TypeError( - `${path}.${key} does not match recomputed observations` - ); - } - } + for (const key of expectedKeys) + assertStoredValue(stored[key], expected[key], `${path}.${key}`); } function assertStoredValue(left, right, path) { diff --git a/scripts/benchmark/parser-analysis.js b/scripts/benchmark/parser-analysis.js index 172343e..6625917 100644 --- a/scripts/benchmark/parser-analysis.js +++ b/scripts/benchmark/parser-analysis.js @@ -15,6 +15,7 @@ import { addSlopeIntervals, analyzeGrowth, addGrowthIntervals, + bootstrapRatioSummary, largestSizeKeys, precisionSummary, } from './parser-scaling.js'; @@ -316,14 +317,7 @@ function addRuntimeIntervals( upperRatio: Math.exp(intervals.familyUpper), }; endpoint.meaningfulImprovement = intervals.upper <= Math.log(0.9); - endpoint.bootstrap95 = { - lowerRatio: Math.exp(intervals.lower), - upperRatio: Math.exp(intervals.upper), - resamples: bootstrap.resamples, - familyCount: bootstrap.familyCount, - degenerateResamples: bootstrap.degenerateResamples, - degenerateFallbacks: bootstrap.degenerateFallbacks, - }; + endpoint.bootstrap95 = bootstrapRatioSummary(intervals, bootstrap); endpoint.precision = precisionSummary( endpoint.observedLogRatioSd, bootstrap.standardErrors[index], diff --git a/scripts/benchmark/parser-benchmark.js b/scripts/benchmark/parser-benchmark.js index 527f4a1..fabccde 100644 --- a/scripts/benchmark/parser-benchmark.js +++ b/scripts/benchmark/parser-benchmark.js @@ -1,3 +1,8 @@ -export { parserWorkloads, SIZES, DEPTHS } from './parser-workloads.js'; +export { + parserWorkloads, + PARSER_BENCHMARKS, + SIZES, + DEPTHS, +} from './parser-workloads.js'; export { analyzeParser } from './parser-analysis.js'; export { runParserBenchmark } from './parser-runner.js'; diff --git a/scripts/benchmark/parser-scaling.js b/scripts/benchmark/parser-scaling.js index d8204de..c78ef70 100644 --- a/scripts/benchmark/parser-scaling.js +++ b/scripts/benchmark/parser-scaling.js @@ -26,6 +26,17 @@ export function precisionSummary( }; } +export function bootstrapRatioSummary(intervals, bootstrap) { + return { + lowerRatio: Math.exp(intervals.lower), + upperRatio: Math.exp(intervals.upper), + resamples: bootstrap.resamples, + familyCount: bootstrap.familyCount, + degenerateResamples: bootstrap.degenerateResamples, + degenerateFallbacks: bootstrap.degenerateFallbacks, + }; +} + export function parseKey(key) { const parts = key.split(':'); const size = Number(parts.at(-1)); @@ -215,14 +226,7 @@ export function addGrowthIntervals( lowerRatio: result.candidateLowerRatio, upperRatio: result.candidateUpperRatio, }; - result.bootstrap95 = { - lowerRatio: Math.exp(intervals.lower), - upperRatio: Math.exp(intervals.upper), - resamples: bootstrap.resamples, - familyCount: bootstrap.familyCount, - degenerateResamples: bootstrap.degenerateResamples, - degenerateFallbacks: bootstrap.degenerateFallbacks, - }; + result.bootstrap95 = bootstrapRatioSummary(intervals, bootstrap); result.precision = precisionSummary( variationMetrics(growthData.claimRows[index]).sd, standardError, diff --git a/scripts/benchmark/parser-workloads.js b/scripts/benchmark/parser-workloads.js index 9a23a30..dfcaaf6 100644 --- a/scripts/benchmark/parser-workloads.js +++ b/scripts/benchmark/parser-workloads.js @@ -3,6 +3,7 @@ // while holding the default 20-block run to under five minutes. export const SIZES = [2_000, 4_000, 8_000, 16_000]; export const DEPTHS = [16, 32, 64, 128, 256, 512]; +export const PARSER_BENCHMARKS = ['arithmetic-chains', 'nested-fallbacks']; // 16ms batch targets provide ample separation above the timer resolution floor // while keeping worker durations concise. diff --git a/scripts/benchmark/validate-corpus.js b/scripts/benchmark/validate-corpus.js index 1c97f7b..35f72b4 100644 --- a/scripts/benchmark/validate-corpus.js +++ b/scripts/benchmark/validate-corpus.js @@ -104,74 +104,15 @@ export function validateCorpusArtifact(artifact) { `corpus replicate ${index} batch ${batchIndex} has invalid groups` ); seenGroups.add(measurement.group); - if ( - !Number.isInteger(measurement.repetitions) || - measurement.repetitions <= 0 - ) - throw new TypeError( - `corpus replicate ${index} has invalid repetitions` - ); - const replicateGroup = `${replicate.replicate}:${measurement.group}`; - const previousRepetitions = repetitionsByGroup.get(replicateGroup); - if ( - previousRepetitions !== undefined && - previousRepetitions !== measurement.repetitions - ) - throw new TypeError( - `corpus group ${measurement.group} has inconsistent repetitions` - ); - repetitionsByGroup.set(replicateGroup, measurement.repetitions); - if ( - !Array.isArray(measurement.calibrationSamplesMs) || - measurement.calibrationSamplesMs.length === 0 - ) - throw new TypeError( - `corpus replicate ${index} has invalid calibration samples` - ); - for (const sample of measurement.calibrationSamplesMs) { - if ( - !sample || - !positiveFinite(sample.oursMs) || - !positiveFinite(sample.referenceMs) - ) - throw new TypeError( - `corpus replicate ${index} has invalid calibration timings` - ); - } - if ( - config.calibrationOrderBalanced === true && - measurement.calibrationOrder !== replicate.calibrationOrder - ) - throw new TypeError( - `corpus replicate ${index} has an inconsistent calibration order` - ); - for (const implementation of ['ours', 'reference']) { - const result = measurement[implementation]; - if ( - !result || - !positiveFinite(result.ms) || - !positiveFinite(result.elapsedMs) || - !Number.isInteger(result.checksum) || - result.checksum < 0 - ) - throw new TypeError( - `corpus replicate ${index} has nonpositive timings` - ); - const checksumKey = `${replicate.replicate}:${measurement.group}:${implementation}`; - const previousChecksum = checksumsByGroup.get(checksumKey); - if ( - previousChecksum !== undefined && - previousChecksum !== result.checksum - ) - throw new TypeError( - `corpus group ${measurement.group} has inconsistent checksums` - ); - checksumsByGroup.set(checksumKey, result.checksum); - const allChecksums = - allChecksumsByGroup.get(measurement.group) ?? new Set(); - allChecksums.add(result.checksum); - allChecksumsByGroup.set(measurement.group, allChecksums); - } + validateCorpusMeasurement( + measurement, + replicate, + index, + config, + repetitionsByGroup, + checksumsByGroup, + allChecksumsByGroup + ); } if (seenGroups.size !== expectedGroups.length) throw new TypeError( @@ -252,3 +193,75 @@ function isPermutation(values, length) { new Set(values).size === length ); } + +function validateCorpusMeasurement( + measurement, + replicate, + index, + config, + repetitionsByGroup, + checksumsByGroup, + allChecksumsByGroup +) { + if ( + !Number.isInteger(measurement.repetitions) || + measurement.repetitions <= 0 + ) + throw new TypeError(`corpus replicate ${index} has invalid repetitions`); + const replicateGroup = `${replicate.replicate}:${measurement.group}`; + const previousRepetitions = repetitionsByGroup.get(replicateGroup); + if ( + previousRepetitions !== undefined && + previousRepetitions !== measurement.repetitions + ) + throw new TypeError( + `corpus group ${measurement.group} has inconsistent repetitions` + ); + repetitionsByGroup.set(replicateGroup, measurement.repetitions); + if ( + !Array.isArray(measurement.calibrationSamplesMs) || + measurement.calibrationSamplesMs.length === 0 + ) + throw new TypeError( + `corpus replicate ${index} has invalid calibration samples` + ); + for (const sample of measurement.calibrationSamplesMs) { + if ( + !sample || + !positiveFinite(sample.oursMs) || + !positiveFinite(sample.referenceMs) + ) + throw new TypeError( + `corpus replicate ${index} has invalid calibration timings` + ); + } + if ( + config.calibrationOrderBalanced === true && + measurement.calibrationOrder !== replicate.calibrationOrder + ) + throw new TypeError( + `corpus replicate ${index} has an inconsistent calibration order` + ); + for (const implementation of ['ours', 'reference']) { + const result = measurement[implementation]; + if ( + !result || + !positiveFinite(result.ms) || + !positiveFinite(result.elapsedMs) || + !Number.isInteger(result.checksum) || + result.checksum < 0 + ) + throw new TypeError(`corpus replicate ${index} has nonpositive timings`); + const checksumKey = `${replicate.replicate}:${measurement.group}:${implementation}`; + const previousChecksum = checksumsByGroup.get(checksumKey); + if (previousChecksum !== undefined && previousChecksum !== result.checksum) + throw new TypeError( + `corpus group ${measurement.group} has inconsistent checksums` + ); + checksumsByGroup.set(checksumKey, result.checksum); + const allChecksums = + allChecksumsByGroup.get(measurement.group) ?? new Set(); + allChecksums.add(result.checksum); + allChecksumsByGroup.set(measurement.group, allChecksums); + } +} From a8f36358bdfa85f4d0c3a66520be7d3d5bbfdb75 Mon Sep 17 00:00:00 2001 From: Ludovico Fischer Date: Thu, 1 Oct 2026 17:03:13 +0200 Subject: [PATCH 6/8] chore: tighten linting rules --- .oxlintrc.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 0ce6dba..5e07bbd 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -6,11 +6,12 @@ }, "rules": { "array-callback-return": "error", - "complexity": ["error", { "max": 25 }], + "complexity": "error", "eqeqeq": ["error", "always", { "null": "ignore" }], "guard-for-in": "error", "import/no-duplicates": "error", "import/no-self-import": "error", + "max-depth": "error", "max-lines": ["error", { "max": 400 }], "no-bitwise": "error", "no-case-declarations": "error", From 17ad16b6a8afc8102f7dfe74215915e2cf086bf4 Mon Sep 17 00:00:00 2001 From: Ludovico Fischer Date: Thu, 1 Oct 2026 17:03:41 +0200 Subject: [PATCH 7/8] docs: update project description and add benchmark guide --- BENCHMARKS.md | 465 ++++++++++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 31 +++- README.md | 60 +++++-- 3 files changed, 539 insertions(+), 17 deletions(-) create mode 100644 BENCHMARKS.md diff --git a/BENCHMARKS.md b/BENCHMARKS.md new file mode 100644 index 0000000..6a21202 --- /dev/null +++ b/BENCHMARKS.md @@ -0,0 +1,465 @@ +# Benchmarking Architecture and Methodology + +This document explains the statistical foundations, experiment design, and +software architecture behind the `postcss-calc` benchmark suite. + +Contributors modifying hot parsing, analysis, simplification, or serialization +paths should understand these principles to interpret benchmark results, design +new benchmarks, and avoid introducing performance regressions. + +## 0. Read This First: What the Numbers Do and Do Not Mean + +- **A benchmark result is an estimate for one machine, one Node.js version, and + one synthetic workload set.** It is not a property of the code. Do not quote + a ratio such as "1.2x faster" without the interval, the workload, and the + environment. +- **The parser benchmarks time only `parse()`** on already-tokenized input + (`scripts/benchmark/parser-benchmark-worker.js`). Tokenization, analysis, + simplification, serialization, and PostCSS overhead are not measured. A + parser win can be invisible, or offset, in end-to-end use. +- **The parser workloads are synthetic stress shapes** (long operator chains, + deep `var()` fallbacks), chosen to expose complexity regressions. They are not + representative of typical stylesheets; `pnpm benchmark:corpus` is the + closest proxy to real input. +- **`pass` means "no regression detected at the declared margin and + precision"**, not "identical performance". `inconclusive` means the data + could not decide, not that nothing changed. Absence of evidence is not + evidence of absence. +- **Statistical guarantees are approximate.** The bootstrap procedures are + asymptotic, and a default run has only 20 blocks. Treat nominal coverage + (such as "95%") as a design target, which `pnpm test:benchmark:simulation` + checks against simulated noise, not as a promise about your machine. +- **A benchmark is a measurement, not a requirement.** Correctness, + readability, and the project's other priorities outweigh a small measured + difference inside the declared 10% margin. + +--- + +## 1. Why Benchmarking CSS Math is Hard + +Microbenchmarks for JavaScript compilers and parsers face several severe +sources of bias and noise: + +1. **JIT Compilation and Inline Cache (IC) Polymorphism**: + The V8 engine optimizes functions via TurboFan based on runtime type feedback + and call frequency. If baseline and candidate implementations run in the same + process, the second implementation runs in an engine already warmed by the + first, or suffers deoptimizations from polymorphic hidden classes. (Georges et + al. 2007 and Mytkowicz et al. 2009 show how large such effects can be.) +2. **Memory and Garbage Collection Contamination**: + Major GC cycles and heap fragmentation from previous runs artificially penalize + subsequent runs. +3. **Hardware and Environmental Drift**: + CPU frequency scaling, Intel Turbo Boost, AMD Precision Boost, thermal + throttling, and OS scheduling jitter can shift machine throughput by amounts + comparable to the effects being measured (often several percent or more) over + minute-long benchmark runs. +4. **Multiple Comparisons Problem**: + Testing dozens of workloads across various shapes and sizes increases the + probability of false-positive regressions or false-positive speedups by chance + alone. +5. **Algorithmic Complexity Degradation**: + A change might appear faster on small inputs due to lower constant overhead, + yet degrade asymptotic complexity from $O(N)$ to $O(N^2)$, causing catastrophic + blowups on large inputs. + +To mitigate these challenges, `postcss-calc` uses an uncertainty-aware +benchmarking system. It reduces these biases; it does not eliminate them. + +--- + +## 2. Statistical Foundations + +### Log-Ratio Estimand and Scale Transformation + +All paired comparisons are evaluated on the log-ratio scale: + +$$\ln\left(\frac{T_{\text{candidate}}}{T_{\text{baseline}}}\right) = \ln(T_{\text{candidate}}) - \ln(T_{\text{baseline}})$$ + +- **Symmetry**: On a linear scale, a $2\times$ slowdown is $+100\%$ ($+1.0$), while + a $2\times$ speedup is $-50\%$ ($-0.5$). On the log scale, a $2\times$ slowdown is + $+\ln 2 \approx +0.693$ and a $2\times$ speedup is $-\ln 2 \approx -0.693$, treating + improvements and regressions symmetrically. +- **Variance Stabilization**: Raw runtime differences ($\Delta \text{ms}$) scale with + input size (e.g., 2,000 vs. 16,000 nodes). The log transformation linearizes + multiplicative noise and stabilizes variance across orders of magnitude. +- **Geometric Mean**: Exponentiating the mean log ratio produces the **geometric + mean ratio**. Fleming & Wallace (1986) show that the geometric mean is the + appropriate way to average normalized ratios, because the arithmetic mean of + ratios depends on which implementation is chosen as the reference. It + summarizes relative change; it says nothing about absolute time. + +### Stratified Studentized Max-T Bootstrap + +Parser benchmarks use an equal-weighted two-stratum estimator over `baseline-first` +and `candidate-first` blocks (a _block_ is one baseline process plus one +candidate process, run back to back): + +$$\hat{\theta} = \frac{1}{2}\left(\bar{Y}_{\text{baseline-first}} + \bar{Y}_{\text{candidate-first}}\right)$$ + +The confidence intervals are derived via a **stratified, studentized max-T +bootstrap** (`bootstrapStratifiedMaxT` in `scripts/benchmark/bootstrap.js`): + +1. **Cluster Resampling**: Each complete block is the independent experimental + unit. Resampling selects entire rows, preserving the full empirical covariance + and correlation structure across all runtime endpoints, slopes, and growth + metrics (24 runtime endpoints for `arithmetic-chains`, 12 for + `nested-fallbacks`). +2. **Independent Stratum Sampling**: Resampling occurs independently within each + process-order stratum to maintain balance. +3. **Family-Wise Error Rate (FWER) Control**: For each bootstrap replicate $b$, the + maximum studentized deviation across all $P$ claims is calculated: + + $$T_{\max}^{*(b)} = \max_{j=1,\dots,P} \frac{|\hat{\theta}_j^{*(b)} - \hat{\theta}_j|}{\widehat{SE}_j^{*(b)}}$$ + + The $(1 - \alpha)$ percentile of $T_{\max}^*$ defines a single family critical + value $c_{1-\alpha}$. Simultaneous confidence intervals are formed as: + + $$[\hat{\theta}_j - c_{1-\alpha} \cdot \widehat{SE}_j,\; \hat{\theta}_j + c_{1-\alpha} \cdot \widehat{SE}_j]$$ + + This is designed so that, with approximately 95% confidence, **all** + simultaneous intervals cover their true values together. That controls the + family-wise error rate; it does not eliminate false alarms. It avoids the full + conservatism of a Bonferroni bound by using the observed correlation between + endpoints. Coverage is approximate and can fall below nominal with few blocks. + +4. **Why Studentize**: Studentized (bootstrap-t) intervals generally have better + coverage accuracy than plain percentile intervals because the pivot depends + less on the unknown variance (Hall, 1992; Davison & Hinkley, 1997). The + improvement is asymptotic; 20 blocks is a small sample, so do not read it as + a finite-sample guarantee. +5. **Degenerate Sample Handling**: When a resample contains identical observations + such that $\widehat{SE}^* = 0$, the statistic safely falls back to using the + observed sample standard error, tracking fallback occurrences without producing + `NaN` or throwing unhandled errors. +6. **Resamples and Seeds**: Each analysis uses 100,000 bootstrap resamples from + a seeded generator, so reanalysis of the same artifact is deterministic. + The seed makes the arithmetic reproducible; it does not make the + measurement reproducible. + +Gate decisions combine two intervals per endpoint. A **regression** is declared +when the simultaneous (family-adjusted) 95% lower bound exceeds the margin. A +**pass** requires the one-sided 95% upper bound to be at or below the margin at +every gated endpoint. Anything in between is `inconclusive`. Runtime is gated on +the largest size of each shape and mode, together with the slope and growth +claims below. + +### Two One-Sided Tests (TOST) vs. Superiority + +The corpus benchmark evaluates the whole `postcss-calc` reduction pipeline +against `@csstools/css-calc` on harvested real-world expressions (only those +both implementations accept with equivalent output) and reports two separate +verdicts. It is a report-only comparison, not a regression gate. Unlike the +parser benchmark, each replicate runs both implementations in the same child +process in randomized order, so the JIT-sharing caveat from Section 1 applies: + +1. **Statistical Superiority (95% Confidence)**: + - Faster: 95% upper bound $< 1.0$. + - Slower: 95% lower bound $> 1.0$. + - Inconclusive: 95% CI covers $1.0$. +2. **Practical Equivalence (90% TOST Confidence)**: + - Uses the Two One-Sided Tests (TOST) procedure (Schuirmann, 1987) with an + equivalence margin of $\Delta = 1.1$ (10%). The margin is a project choice, + not a statistical result; "equivalent" means "within 10%", not "identical". + - If the 90% confidence interval falls entirely within $[1/\Delta, \Delta] = + [1/1.1, 1.1] \approx [0.909, 1.100]$, the performance is classified as + `equivalent`. + - _Why 90%?_ Two simultaneous one-sided tests at $\alpha = 0.05$ mathematically + correspond to a $(1 - 2\alpha) = 90\%$ two-sided confidence interval. + +### Algorithmic Complexity and Scaling Gates + +To detect asymptotic regressions before they impact production: + +1. **Log-Log Slope Regression**: + `arithmetic-chains` runs sizes $N \in \{2000, 4000, 8000, 16000\}$ and + `nested-fallbacks` runs depths $\{16, 32, \dots, 512\}$. Regressing $\ln(T)$ on + $\ln(N)$ yields the empirical scaling exponent $\beta$ ($T \propto N^\beta$) + over that range. + - Linear algorithms have $\beta \approx 1.0$; quadratic algorithms have $\beta \approx 2.0$. + Cache, GC, and fixed-cost effects move $\beta$ over a narrow size range, so + $\beta$ compares baseline and candidate; it does not prove asymptotic + complexity. + - The slope increase $\Delta\beta = \beta_{\text{cand}} - \beta_{\text{base}}$ is + tested against $\log_2(\text{runtimeMargin}) = \log_2(1.1) \approx 0.1375$, + directly tying allowable slope degradation to the runtime margin across a + doubling step. +2. **Doubling Growth Gating**: + The empirical growth factor per doubling $(T_{2N} / T_N)$ is calculated. If the + lower 95% confidence bound exceeds `GROWTH_THRESHOLD = 2.5`, it is flagged as an + algorithmic regression. (A perfectly linear algorithm grows 2x per doubling; 2.5 + leaves headroom for noise and cache effects, so it catches clearly super-linear + behavior, not subtle drift.) + +### Uncertainty-Aware Precision Floor + +A benchmark cannot pass solely because variance was high and the confidence interval +was too wide to detect a difference. A verdict requires: + +1. Observed blocks $\ge \text{MIN\_VALID\_BLOCKS}$ (20 blocks). +2. Interval half-width $\le \ln(\text{precisionMargin})$. + +If noisy data prevents meeting the precision target, the result is marked +`inconclusive` rather than `pass`. + +--- + +## 3. Experiment Design + +### Fresh-Process Execution + +Every parser-benchmark replicate spawns one fresh Node.js process per revision +using `child_process.spawnSync`. + +- The child process loads only the specified revision (`baseline` or `candidate`). + The baseline is the `src/` tree of a git revision (default `HEAD`, set with + `--baseline`) extracted with `git archive`; the candidate is the current + working tree, including uncommitted changes. +- No module cache, JIT compilation profile, or memory allocation carries over + between revisions. +- Inside a child, all workloads run one after another in a shuffled order, so + workloads can still influence each other through the shared heap and JIT + state. The shuffle differs per block, so such effects average out instead of + always favoring one workload. +- The corpus benchmark differs: each replicate's child runs both + implementations (see Section 2). +- Communication with the parent harness occurs via structured JSON on stdin/stdout. + +### Counterbalanced Scheduling and Order Effects + +Even with fresh processes, temporal confounding (e.g., progressive thermal heating) +can systematically bias the revision that executes first. + +- **Counterbalancing**: Every run generates an equal number of `baseline-first` and + `candidate-first` blocks. +- **Randomized Schedule**: Blocks are ordered according to a deterministic shuffled + schedule. +- **Order Interaction Diagnostic**: For each endpoint, the difference between the + mean log ratios of `baseline-first` and `candidate-first` blocks is computed. + If any endpoint's point estimate satisfies $|\Delta_{\text{order}}| > + \ln(1.1)$, the run is declared `inconclusive`. This is a threshold on the + estimate, not a hypothesis test; the artifact also stores an ordinary 95% + interval for inspection. + +### Environmental Drift Detection and Balanced Retries + +Modern operating systems and CPUs dynamically modulate clock frequencies. + +1. **Control Workloads**: A constant reference workload (a 5,000-term sum) is + timed immediately before + (`controlBefore`) and immediately after (`controlAfter`) the actual workloads in + every child process. +2. **Drift Rejection**: If drift $|\frac{T_{\text{after}}}{T_{\text{before}}} - 1| > + 0.15$ (15%) in either child, the attempt is rejected. The control only detects + drift visible across the whole child run, not short spikes in the middle. The + rule depends on the control, not on the measured ratio, so it does not select + for a favorable outcome. +3. **Slot-Preserving Retries**: When an attempt is rejected due to drift, the harness + **retries the exact same schedule slot** (retaining the intended process order). + This prevents differential drift rates from skewing the balance between + `baseline-first` and `candidate-first` blocks. +4. **Predeclared Sensitivity Analysis**: Drift-rejected attempts are retained in the + artifact. The harness runs a parallel sensitivity analysis using all structurally + valid attempts. If the primary and sensitivity analyses disagree on the verdict, + the outcome is forced to `inconclusive`. The harness makes at most + `max(30, blocks)` attempts, so a very noisy machine ends with fewer than 20 + valid blocks and an `inconclusive` result instead of looping forever. + +### Adaptive Batching and Warmup Stabilization + +- **Timer Quantization**: Timer resolution and call overhead are mitigated by + adaptive batching. The calibration step doubles repetitions until a batch lasts + at least $0.6 \times \text{TARGET\_MS}$ (the parser benchmarks target 16ms; the + corpus benchmark defaults to 25ms). Each measurement is the per-call mean of a + batch, so it includes any GC pauses in that batch. +- **Warmup Stability**: Warmups repeat (4 to 8 batches for parser workloads) until + the relative span of the last three warmup batches is $\le 10\%$. This is a + heuristic for a stable timing level, not a guarantee that TurboFan has finished + optimizing or that no later deoptimization will occur. + +### Correctness Gating and Dead-Code Elimination Safeguards + +- **Structural AST Verification**: Every parse result is hashed into a canonical + digest (`digest(run())`). Baseline and candidate AST digests are compared for every + workload. Any mismatch aborts execution immediately with a `correctness-failure` + (exit code 3). A faster result with different output is a bug, not a win. +- **Dead-Code Elimination (DCE) Mitigation**: In optimizing JITs, expressions whose + results are discarded can be optimized away. Each timed batch keeps its last + result and walks it with `consume(value)` after the timer stops, so the final + parse is observably used without putting the traversal inside the timed loop. + This is a mitigation, not proof: earlier iterations in a batch are not + consumed. Large workloads make complete elimination unlikely, but keep it in + mind when adding very small benchmarks. + +--- + +## 4. Software Engineering and Architecture + +```text + +--------------------------------------------------+ + | Benchmark Orchestrator | + | (scripts/benchmark/benchmark-*.js) | + +--------------------------------------------------+ + | + +--------------------+--------------------+ + | | + v v + +---------------------------+ +---------------------------+ + | Child Worker Process | | Child Worker Process | + | (Baseline) | | (Candidate) | + | - Fresh V8 environment | | - Fresh V8 environment | + | - Pre/post drift control | | - Pre/post drift control | + | - AST structural digest | | - AST structural digest | + | - Adaptive batch timing | | - Adaptive batch timing | + +---------------------------+ +---------------------------+ + | | + +--------------------+--------------------+ + v + +---------------------------------+ + | Raw Observations Stream | + +---------------------------------+ + | + +--------------------+--------------------+ + | | + v v + +---------------------------+ +---------------------------+ + | Schema-v2 JSON Report | | Statistical Engine | + | - Complete raw data | | - Stratified max-T | + | - Environmental metadata | | - Paired log-ratios | + | - Git & harness hashes |<------------| - Slope & growth analysis | + | - Forensic provenance | | - TOST & FWER decisions | + +---------------------------+ +---------------------------+ + | + v + +---------------------------+ + | Offline Reanalyzer | + | (compare-parser- | + | benchmarks.js) | + | - Zero workload execution | + | - Consistency checks | + | - Deterministic audit | + +---------------------------+ +``` + +### No Benchmarking Framework + +The harness, statistics, bootstrap, and provenance code in `scripts/benchmark/` +use only Node.js built-in modules (`node:fs`, `node:crypto`, +`node:child_process`, `node:os`, `node:path`, `node:url`) and no third-party +benchmarking framework. The workers still import the project's own `src/` and +its dev dependencies (`@csstools/css-tokenizer`; `@csstools/css-calc` and +`postcss` for the corpus and plugin benchmarks), so installed dependencies +must match `pnpm-lock.yaml` for comparable runs. + +### Schema-v2 Artifact Contract and Forensic Provenance + +Benchmark outputs are stored as schema-v2 JSON artifacts under +`reports/benchmarks/`. Every artifact contains: + +- Exact decision parameters (`decisionConfigVersion: 3`, margins, confidence + levels, thresholds). +- Full forensic provenance: git commit SHAs, source tree SHA256 hashes, dependency + lockfile hash, harness script hashes, worktree dirty status, CPU model, CPU core + count, system load average, Node/V8 versions, and Linux CPU scaling governor. +- Complete raw observations: batch timings, calibration samples, warmup records, + drift measurements, and structural checksums for every attempt. + +### Offline Reanalysis and Consistency Checks + +`scripts/benchmark/compare-parser-benchmarks.js` reanalyzes existing schema-v2 artifacts +without re-running any workload. When auditing an artifact, the analyzer re-derives all +statistics and compares them against stored summaries down to $10^{-10}$ relative +tolerance. Any discrepancy causes an immediate validation failure. This detects accidental +corruption and hand edits; it is not cryptographic tamper-proofing, because +consistently edited raw data still produces a valid file. + +### Exit Code Standards + +The parser runner and the reanalyzer use these exit codes. The corpus benchmark +is report-only: it exits `0` after a successful run whatever the verdict (read +the printed verdict), `64` for invalid usage, and `3` for correctness or +infrastructure failures. + +| Exit Code | Meaning | +| :-------- | :------------------------------------------------------------------------------- | +| `0` | Pass (no regression detected, precision target met) / Superior | +| `1` | Regression (lower confidence bound exceeds permitted threshold) | +| `2` | Inconclusive (insufficient precision, order effect, or sensitivity disagreement) | +| `3` | Correctness failure, structural mismatch, or harness runtime error | +| `64` | Invalid usage or malformed artifact | + +--- + +## 5. Guide for Contributors + +### Available Benchmark Suites + +| Command | Workload | Primary Purpose | +| :------------------------------------------- | :------------------------------------------------------------------------- | :----------------------------------------------------------- | +| `pnpm benchmark:arithmetic-chains` | Additive, multiplicative, and alternating precedence chains (sizes 2k–16k) | Regression-gating for core parser and block indexing | +| `pnpm benchmark:nested-fallbacks` | Deeply nested `var()` fallbacks (depths 16–512) | Regression-gating for recursion and opaque call handling | +| `pnpm benchmark:corpus` | Real-world expressions harvested from GitHub vs. `@csstools/css-calc` | Comparative reporting (not a gate) and practical equivalence | +| `pnpm benchmark:serialization` | Wide sums, products, and nested calls across serializers | In-process local profiling for serializer changes | +| `pnpm test:benchmark` | Unit tests for statistics, bootstrap, and schema validation | Verifying benchmark harness logic | +| `pnpm test:benchmark:simulation` | Monte Carlo calibration across adversarial noise scenarios | Verifying bootstrap empirical coverage | +| `node scripts/benchmark/benchmark-plugin.js` | PostCSS processing of generated stylesheets (no package script) | In-process local profiling of the adapter | + +`benchmark:serialization` and `benchmark-plugin.js` run in a single process and +print medians only. They give no uncertainty interval and no baseline +comparison, so treat their output as a profiling hint, never as evidence that a +change is faster. Confirm with the paired benchmarks above. + +### Reading a Result + +1. Look at the status (`pass`, `regression`, `inconclusive`), then at the + intervals, not only the point estimate. An interval of $[0.97, 1.30]$ is + compatible with both no change and a 30% regression. +2. Check `rejections` and `orderEffect` in the artifact. Frequent drift + rejections or a large order effect mean the machine was unsuitable. +3. For `inconclusive`, rerun on a quieter machine or with more `--blocks` (an + even number, at least 20). Do not rerun until you get the verdict you want: + choose the block count before looking at the outcome. +4. A `pass` on synthetic parser workloads does not demonstrate an end-to-end + speedup, or the absence of regressions on other inputs. + +### Controlled Run Checklist + +To minimize drift rejections and achieve adequate statistical power: + +1. **System Power**: Run on AC power, never on battery. +2. **CPU Governor (Linux)**: Set the scaling governor to `performance`: + ```sh + echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor + ``` + The artifact records only the governor of `cpu0`. Disabling turbo/boost and + pinning the run to fixed cores (for example with `taskset`) can reduce noise + further, but is optional. +3. **Machine State**: Ensure the system is idle. Close browsers, background + compilations, and IDE indexing tasks. +4. **Known Baseline**: The baseline is `HEAD` unless `--baseline ` is + given, and the candidate is the working tree. Commit or stash unrelated + changes so the only difference is the change under test; the artifact records + the dirty status. Dependencies must match `pnpm-lock.yaml`. +5. **Reanalysis**: To re-check an artifact without re-running the benchmark: + ```sh + pnpm benchmark:reanalyze reports/benchmarks/.json + ``` + +Artifacts are written to `reports/benchmarks/` and are git-ignored. + +### References + +- Fleming, P. J. & Wallace, J. J. (1986). How not to lie with statistics: the + correct way to summarize benchmark results. _Communications of the ACM_ 29(3). +- Georges, A., Buytaert, D. & Eeckhout, L. (2007). Statistically rigorous Java + performance evaluation. _OOPSLA_. +- Mytkowicz, T., Diwan, A., Hauswirth, M. & Sweeney, P. F. (2009). Producing + wrong data without doing anything obviously wrong! _ASPLOS_. +- Kalibera, T. & Jones, R. (2013). Rigorous benchmarking in reasonable time. + _ISMM_. +- Schuirmann, D. J. (1987). A comparison of the two one-sided tests procedure and + the power approach for assessing the equivalence of average bioavailability. + _Journal of Pharmacokinetics and Biopharmaceutics_ 15. +- Hall, P. (1992). _The Bootstrap and Edgeworth Expansion_. Springer. +- Davison, A. C. & Hinkley, D. V. (1997). _Bootstrap Methods and their + Application_. Cambridge University Press. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ffd190..42094e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ Before submitting a new issue, be sure to make a cursory search to see if the en Before contributing any code to the project, be sure to either open a new issue in the issue tracker detailing what you intend to contribute, or comment on an existing issue if one exists. This allows us to: -- give feedback early on before significant effort has been put into the endevour. +- give feedback early on before significant effort has been put into the endeavour. - align your contribution with ongoing efforts. - make sure that there's no ongoing effort into the issue already. @@ -42,3 +42,32 @@ When submitting your pull request, make sure that you: - summarize your contribution. - list the issues that this contribution addresses. - include tests for your contribution. + +## Development + +The project uses [pnpm](https://pnpm.io/) and ES modules. Before submitting a +pull request, run: + +```sh +pnpm install +pnpm test +pnpm lint +``` + +`pnpm lint` runs oxlint, `tsc`, and `oxfmt --check`; `pnpm fmt` formats the +code. If your change touches parsing, analysis, or simplification, also run the +full differential corpus with `pnpm test:corpus:full`. + +## Benchmarks and Performance + +If your pull request touches hot parsing, analysis, simplification, or +serialization paths, run the relevant benchmarks. The paired parser benchmarks +compare your working tree against a baseline revision (`HEAD` by default), so +commit or stash unrelated changes first; there is no need to run them twice by +hand. + +Benchmark results are noisy estimates, not proof. Run them on an idle machine, +report the verdict together with the intervals and your environment, and treat +`inconclusive` as "unknown", not as "fine". Do not rerun until you get a +favorable verdict. See [BENCHMARKS.md](BENCHMARKS.md) for what each benchmark +does and does not measure, the methodology, and the controlled-run checklist. diff --git a/README.md b/README.md index e7539d6..21f59a3 100755 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ [![Support Chat][git-img]][git-url] [PostCSS Calc] lets you reduce `calc()` references whenever it's possible. -When multiple units are mixed together in the same expression, the `calc()` -statement is left as is, to fallback to the [W3C calc() implementation]. +When an expression mixes units that cannot be combined exactly (such as `px` +and `em`), or contains values only known at runtime (such as `var()`), the +unresolved part is left for the browser's [W3C calc() implementation]. ## Installation @@ -62,7 +63,7 @@ leaving all other text untouched. import reduceCalc from 'postcss-calc/reduce'; reduceCalc('calc(1in + 10px)'); -// => 'calc(1.10417in)' +// => 'calc(106px)' reduceCalc('min(50px, calc(2 * 40px))'); // => 'calc(50px)' @@ -123,6 +124,28 @@ Allows you to define the precision for decimal numbers. Set it to `false` to disable rounding and preserve full IEEE-754 floating-point precision (emitting the shortest round-tripping decimal representation). +Values below 1 keep `precision` significant digits (`.0123456px` becomes +`.012346px`), and larger values keep `precision` decimals. Divisions and unit +conversions are folded only when the result is exact at the precision; +otherwise they stay symbolic and everything around them is still simplified: + +```css +.a { + width: calc(100% / 4); +} /* calc(25%) */ +.b { + width: calc(100% / 3); +} /* calc(100% / 3), not 33.33333% */ +.c { + width: calc(1cm + 1px); +} /* calc(1cm + 1px) */ +.d { + width: calc(1px + 1pt); +} /* calc(1.75pt) */ +``` + +With `precision: false` nothing is rounded, so every division is folded. + ```js var out = postcss() .use(calc({ precision: 10 })) @@ -235,15 +258,10 @@ canonical-form decisions: requires the zero term because it carries the length-percentage type. - **Constant folding.** `calc(43 + pi)` now folds to `46.14159` (§10.7.1). Previously `pi` / `e` stayed symbolic. -- **Reciprocal conversion.** `calc(var(--x) / 2)` becomes - `calc(var(--x) * 0.5)`. The two are mathematically equivalent; - previously the division shape was kept. - **Distributive multiplication.** `calc(0.5 * (100vw - 10px))` becomes `calc(50vw - 5px)`. - **Unit case normalization.** `2PX` becomes `2px` (CSS units are case- insensitive; lowercase is conventional). -- **Calc unwrap (§10.6).** `calc(var(--foo))` becomes `var(--foo)` — a - `calc()` containing a single value is replaced by that value. - **Spec-style spaced operators.** `2px*var(--x)` is serialized as `2px * var(--x)`. The tokenizer is unaffected; only output spacing differs. @@ -265,13 +283,15 @@ To replace the value of CSS custom properties at build time, try [PostCSS Custom ## Contributing Work on a branch, install dev-dependencies, respect coding style & run tests -before submitting a bug fix or a feature. +before submitting a bug fix or a feature. See [CONTRIBUTING.md](CONTRIBUTING.md) +for the full guidelines. The project uses [pnpm](https://pnpm.io/). ```bash git clone git@github.com:postcss/postcss-calc.git git checkout -b patch-1 -npm install -npm test +pnpm install +pnpm test +pnpm lint ``` The normal test run uses a deterministic structural sample of the harvested @@ -282,11 +302,19 @@ when changing parsing/simplification behavior: pnpm test:corpus:full ``` -Profile parser chains with `pnpm benchmark:arithmetic-chains` or -`pnpm benchmark:nested-fallbacks`; both use 20 fresh paired blocks by default -and write ignored schema-v2 reports. Compare a saved report with -`node scripts/compare-parser-benchmarks.js `. Run the correctness-aware -corpus benchmark with `pnpm benchmark:corpus`. +Performance changes to the parser, analyzer, simplifier, or serializer should be +checked with the benchmarks. `pnpm benchmark:arithmetic-chains` and +`pnpm benchmark:nested-fallbacks` compare the working tree against `HEAD` using +20 fresh-process paired blocks by default and write git-ignored schema-v2 +reports under `reports/benchmarks/`. Re-check a saved report with +`pnpm benchmark:reanalyze `. `pnpm benchmark:corpus` compares the whole +pipeline with `@csstools/css-calc` on real-world expressions and is +report-only. + +These benchmarks time only the parser (or, for the corpus, the whole reducer) +on one machine, and a `pass` means "no regression detected at the declared +margin", not "no change". Read [BENCHMARKS.md](BENCHMARKS.md) before +interpreting results. The PostCSS benchmark awaits `postcss().process(...)`, and that await already triggers result stringification. It therefore does not add a redundant From af2c70f3b318e23ed77aaaf9e4d55a30c9ac4a27 Mon Sep 17 00:00:00 2001 From: Ludovico Fischer Date: Thu, 1 Oct 2026 19:23:19 +0200 Subject: [PATCH 8/8] fix: fix wrong rounding and update changelog --- CHANGELOG.md | 24 ++++++------- src/index.js | 3 +- src/lib/simplify/cancel.js | 47 +++++++++++++++++-------- src/lib/simplify/product.js | 38 +++++++++++++++++--- src/reduce.js | 3 +- test/index-options.test.cjs | 7 ++++ test/integration/exact-division.test.js | 37 ++++++++++++++++++- types/lib/simplify/cancel.d.ts | 18 +++++----- 8 files changed, 134 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 698311a..c59ac31 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,22 +6,20 @@ All notable changes to this project will be documented in this file. See [commit ### Bug fixes -- fold a division or unit conversion only when the result is exact at the +- transform a division or unit conversion only when the result is exact at the configured `precision`. `calc(100% / 3)` stays `calc(100% / 3)` instead of - becoming `calc(33.33333%)`, which drifted when repeated (#62), and - `calc(1cm + 1px)` is no longer converted to an approximate `1.02646cm`. - Use `precision: false` to fold every division. + becoming `calc(33.33333%)`, and `calc(1cm + 1px)` + is no longer converted to an approximate `1.02646cm`. + Use `precision: false` to transform every division. - keep `precision` significant digits for values below 1, so `calc(1px / 150000)` is no longer rounded to `.00001px` -- `min()`, `max()` and `clamp()` return the chosen argument, keeping its - original unit -- treat `var()`, `env()`, `attr()` and other substitution functions as - positional barriers. They are replaced by raw tokens before the value is - computed, so with `--a: 1px + 2px`, `var(--a) * 2` means `1px + 2px * 2`. - Factors are no longer reordered or cancelled across them - (`var(--a) * 2` is no longer rewritten to `2 * var(--a)`, - `var(--a) / var(--a)` is not cancelled), and parentheses around them are kept - (`1px - (2 * var(--a))`) +- `min()`, `max()` and `clamp()` preserve the original unit +- keep the order of terms and factors around `var()`, `env()`, `attr()` and + other substitution functions. These are replaced by raw tokens before the + value is computed, so with `--a: 1px + 2px`, `var(--a) * 2` means + `1px + 2px * 2`. Factors are no longer reordered or cancelled: + `var(--a) * 2` is not rewritten to `2 * var(--a)`, `var(--a) / var(--a)` is + not cancelled, and parentheses are kept (`1px - (2 * var(--a))`). Unrecognised functions such as `anchor-size()` are treated the same way, so `2 * anchor-size(width) * .5` is no longer folded - keep the parentheses of a nested `calc()` that contains unresolved values, so diff --git a/src/index.js b/src/index.js index d8be55c..9bcfe90 100644 --- a/src/index.js +++ b/src/index.js @@ -65,12 +65,13 @@ function applyTransform( function pluginCreator(opts) { /** @type {ResolvedOptions} */ const options = { - precision: 5, warnWhenCannotResolve: false, mediaQueries: false, selectors: false, unwrapSingleValue: false, ...opts, + // An explicit `precision: undefined` keeps the default. + precision: opts?.precision ?? 5, }; return { diff --git a/src/lib/simplify/cancel.js b/src/lib/simplify/cancel.js index c82afd6..2551a96 100644 --- a/src/lib/simplify/cancel.js +++ b/src/lib/simplify/cancel.js @@ -3,16 +3,17 @@ import { isExact } from './exact.js'; /** * If `dims` contain exactly one numerator / one denominator pair with the - * same base type and convertible units, return the numeric factor produced - * by cancelling them and the list of remaining (uncancelled) dims. - * Otherwise return null. Used by `simplifyProduct` for typed division - * (§10.2). More complex cancellation (e.g. `px^2 / px`) is left + * same base type and convertible units, return the two sides expressed in a + * common unit, so the caller can fold them into its own numerator and + * denominator. Otherwise return null. Used by `simplifyProduct` for typed + * division (§10.2). More complex cancellation (e.g. `px^2 / px`) is left * unreduced — consumers rarely rely on it and the spec doesn't require it. * @template {{ exponent: 1 | -1, value: number, unit: string }} D * @param {D[]} dims - * @param {number | false} [precision] A factor that is not exact at this - * precision is not cancelled, so the quotient stays symbolic. - * @return {{ factor: number, remaining: D[] } | null} + * @param {number | false} [precision] The denominator is converted into the + * numerator's unit when that is exact at this precision (`1px / 1in` → + * `1 / 96`); otherwise the numerator is converted (`1in / 1px` → `96 / 1`). + * @return {{ numerator: number, denominator: number, remaining: D[] } | null} */ function tryCancelPair(dims, precision = false) { if (dims.length !== 2) { @@ -29,16 +30,32 @@ function tryCancelPair(dims, precision = false) { if (!numBase || numBase !== denBase) { return null; } - const converted = convert(numerator.value, numerator.unit, denominator.unit); - if (converted === null) { - return null; - } + const converted = convert( + denominator.value, + denominator.unit, + numerator.unit + ); // denominator.value === 0 yields ±Infinity / NaN naturally (§10.9.1). - const factor = converted / denominator.value; - if (!isExact(factor, precision)) { - return null; + if (converted !== null && isExact(converted, precision)) { + return { + numerator: numerator.value, + denominator: converted, + remaining: [], + }; + } + const numConverted = convert( + numerator.value, + numerator.unit, + denominator.unit + ); + if (numConverted !== null && isExact(numConverted, precision)) { + return { + numerator: numConverted, + denominator: denominator.value, + remaining: [], + }; } - return { factor, remaining: [] }; + return null; } export { tryCancelPair }; diff --git a/src/lib/simplify/product.js b/src/lib/simplify/product.js index 04878d9..dd2455a 100644 --- a/src/lib/simplify/product.js +++ b/src/lib/simplify/product.js @@ -49,6 +49,34 @@ function isScalarSum(opaque) { ); } +/** + * Whether a folded quotient prints exactly. A quotient distributed over a + * scalar Sum is judged term by term, so `(3px + 6em) / 3` still folds. + * @param {number} value + * @param {ProductFactor[]} opaque + * @param {boolean} distributable + * @param {number | false} precision + * @return {boolean} + */ +function foldsExactly(value, opaque, distributable, precision) { + if (!Number.isFinite(value) || isExact(value, precision)) { + return true; + } + if (!distributable) { + return false; + } + const sum = /** @type {import('../node.js').Sum} */ (opaque[0].node); + return sum.terms.every((t) => + isExact( + value * + /** @type {import('../node.js').Num | import('../node.js').Dim} */ ( + t.node + ).value, + precision + ) + ); +} + /** * Emit an inexact quotient as `numerator * dims * opaque / denominator`, with * integer parts reduced by their gcd. A lone Dim absorbs the numerator. @@ -166,8 +194,9 @@ function foldFactors(items, start, end, simplify, precision) { // unreduced — consumers don't rely on it and the spec doesn't require it. const cancelled = tryCancelPair(dims, precision); if (cancelled !== null) { - coeff *= cancelled.factor; - numerator *= cancelled.factor; + coeff *= cancelled.numerator / cancelled.denominator; + numerator *= cancelled.numerator; + denominator *= cancelled.denominator; } const divides = denominator !== 1 || cancelled !== null; const remainingDims = cancelled ? cancelled.remaining : dims; @@ -178,10 +207,11 @@ function foldFactors(items, start, end, simplify, precision) { ? remainingDims[0] : null; const value = loneDim === null ? coeff : chainValue(scalarChain); + const distributable = remainingDims.length === 0 && isScalarSum(opaque); // An inexact quotient stays symbolic when its numerator and denominator // print exactly; otherwise it is folded and rounded at serialization. - if (divides && Number.isFinite(value) && !isExact(value, precision)) { + if (divides && !foldsExactly(value, opaque, distributable, precision)) { const rational = rationalProduct( numerator, denominator, @@ -198,7 +228,7 @@ function foldFactors(items, start, end, simplify, precision) { // §10.10 distributive multiplication: `0.5 * (100vw - 10px)` → `50vw - 5px`. // Only distribute when every Sum term is Num/Dim — partial distribution // over opaque terms matches neither the legacy implementation nor csstools. - if (remainingDims.length === 0 && isScalarSum(opaque)) { + if (distributable) { const sum = /** @type {import('../node.js').Sum} */ (opaque[0].node); const distributed = sum.terms.map((t) => ({ sign: t.sign, diff --git a/src/reduce.js b/src/reduce.js index e68cae2..da7b8e4 100644 --- a/src/reduce.js +++ b/src/reduce.js @@ -61,11 +61,12 @@ function reduceCalc(value, opts) { /** @type {ResolvedReduceCalcOptions} */ const options = { - precision: 5, warnWhenCannotResolve: false, unwrapSingleNegativeNumber: false, unwrapSingleValue: false, ...opts, + // An explicit `precision: undefined` keeps the default. + precision: opts?.precision ?? 5, }; /** @type {import('@csstools/css-tokenizer').CSSToken[]} */ let tokens; diff --git a/test/index-options.test.cjs b/test/index-options.test.cjs index c6bcce9..5cdec72 100644 --- a/test/index-options.test.cjs +++ b/test/index-options.test.cjs @@ -106,3 +106,10 @@ test( 'calc(var(--xxx, var(--yyy)) / 2)' ) ); + +test( + 'should keep the default precision when precision is undefined', + testCss('a{width:calc(100% / 3)}', 'a{width:calc(100% / 3)}', { + precision: undefined, + }) +); diff --git a/test/integration/exact-division.test.js b/test/integration/exact-division.test.js index e85a6a0..d842363 100644 --- a/test/integration/exact-division.test.js +++ b/test/integration/exact-division.test.js @@ -22,8 +22,25 @@ describe('Exact-by-default division', () => { ['calc(1 / 3)', 'calc(1 / 3)'], ['calc(10px / 3 * 3)', 'calc(10px)'], ['calc(10px / -3)', 'calc(-10px / 3)'], - ['calc(1px / 1in)', 'calc(1px / 1in)'], + ['calc(1px / 1in)', 'calc(1 / 96)'], ['calc(1in / 1px)', 'calc(96)'], + ['calc(1pt / 1pc)', 'calc(1 / 12)'], + ['calc(1pc / 1pt)', 'calc(12)'], + ['calc(2in / 1px)', 'calc(192)'], + ['calc(7px / 1in)', 'calc(7 / 96)'], + ['calc(1in / 7px)', 'calc(96 / 7)'], + ['calc(-1px / 1in)', 'calc(-1 / 96)'], + ['calc(1px / -1in)', 'calc(-1 / 96)'], + ['calc(1cm / 1px)', 'calc(1cm / 1px)'], + ['calc(1deg / 1rad)', 'calc(1deg / 1rad)'], + ['calc((100px + 60em) / 3)', 'calc((100px + 60em) / 3)'], + ['calc((3px + 5em) / 3)', 'calc((3px + 5em) / 3)'], + ['calc((3px + 6em) / -3)', 'calc(-1px - 2em)'], + ['calc((3px + var(--a)) / 3)', 'calc((3px + var(--a)) / 3)'], + ['calc(1px * 3 / 3px)', 'calc(1)'], + ['calc(1px / 3px * 3)', 'calc(1)'], + ['calc((3px + 6em) / 3)', 'calc(1px + 2em)'], + ['calc((30px + 60%) / 3)', 'calc(10px + 20%)'], ['calc(var(--n) / 4)', 'calc(var(--n) / 4)'], ['calc(2 * var(--n) / 3)', 'calc(2 * var(--n) / 3)'], // Quotients whose parts would be rounded on output are folded instead. @@ -54,6 +71,24 @@ describe('Exact-by-default division', () => { ); }); + test('precision undefined keeps the default', () => { + assert.equal( + reduceCalc('calc(100% / 3)', { precision: undefined }), + 'calc(100% / 3)' + ); + assert.equal( + reduceCalc('calc(1cm + 1px)', { precision: undefined }), + 'calc(1cm + 1px)' + ); + }); + + test('precision false folds inexact unit quotients', () => { + assert.equal( + reduceCalc('calc(1cm / 1px)', { precision: false }), + 'calc(37.79527559055118)' + ); + }); + test('a higher precision folds quotients that are exact there', () => { assert.equal( reduceCalc('calc(1px / 96)', { precision: 10 }), diff --git a/types/lib/simplify/cancel.d.ts b/types/lib/simplify/cancel.d.ts index 3e09ffb..e32d79f 100644 --- a/types/lib/simplify/cancel.d.ts +++ b/types/lib/simplify/cancel.d.ts @@ -1,22 +1,24 @@ /** * If `dims` contain exactly one numerator / one denominator pair with the - * same base type and convertible units, return the numeric factor produced - * by cancelling them and the list of remaining (uncancelled) dims. - * Otherwise return null. Used by `simplifyProduct` for typed division - * (§10.2). More complex cancellation (e.g. `px^2 / px`) is left + * same base type and convertible units, return the two sides expressed in a + * common unit, so the caller can fold them into its own numerator and + * denominator. Otherwise return null. Used by `simplifyProduct` for typed + * division (§10.2). More complex cancellation (e.g. `px^2 / px`) is left * unreduced — consumers rarely rely on it and the spec doesn't require it. * @template {{ exponent: 1 | -1, value: number, unit: string }} D * @param {D[]} dims - * @param {number | false} [precision] A factor that is not exact at this - * precision is not cancelled, so the quotient stays symbolic. - * @return {{ factor: number, remaining: D[] } | null} + * @param {number | false} [precision] The denominator is converted into the + * numerator's unit when that is exact at this precision (`1px / 1in` → + * `1 / 96`); otherwise the numerator is converted (`1in / 1px` → `96 / 1`). + * @return {{ numerator: number, denominator: number, remaining: D[] } | null} */ declare function tryCancelPair(dims: D[], precision?: number | false): { - factor: number; + numerator: number; + denominator: number; remaining: D[]; } | null; export { tryCancelPair };