diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cb8aaa..cbc4aef 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,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 + +- Resolved calculation roots now default to standard serialization inside + `calc()`. Use `unwrapSingleValue: true` to emit fully resolved finite scalar + results as bare values, since unwrapping can discard browser-applied range clamping or integer + rounding. + +### Migration + +The published `unwrapSingleNegativeNumber` option remains available as a +deprecated alias for `unwrapSingleValue`. + ## 11.1.2 (2026-09-11) ### Bug fixes diff --git a/README.md b/README.md index 9946543..b419f9f 100755 --- a/README.md +++ b/README.md @@ -43,10 +43,10 @@ you will get: ```css h1 { - font-size: 32px; + font-size: calc(32px); height: calc(100px - 2em); width: calc(2 * var(--base-width)); - margin-bottom: 24px; + margin-bottom: calc(24px); } ``` @@ -62,14 +62,15 @@ leaving all other text untouched. import reduceCalc from 'postcss-calc/reduce'; reduceCalc('calc(1in + 10px)'); -// => '1.10417in' +// => 'calc(1.10417in)' reduceCalc('min(50px, calc(2 * 40px))'); -// => '50px' +// => 'calc(50px)' ``` -It accepts `precision`, `unwrapSingleNegativeNumber`, `warnWhenCannotResolve`, `onParseError`, -and `onWarn`: +It accepts `precision`, `unwrapSingleValue`, the deprecated +`unwrapSingleNegativeNumber` alias, +`warnWhenCannotResolve`, `onParseError`, and `onWarn`: ```js const result = reduceCalc('calc(100% + var(--gap))', { @@ -87,21 +88,27 @@ by default; provide `onParseError` and/or `onWarn` if you want diagnostics. ### Standalone reducer options -#### `unwrapSingleNegativeNumber` (default: `false`) +#### `unwrapSingleValue` (default: `false`) -Controls whether a finite negative result is serialized as a bare value or -wrapped in `calc()`. Keep the default when reducing declaration values; set it -to `true` when the surrounding CSS context requires a bare negative value, such -as a selector: +Serializes a fully resolved finite scalar result without calculation syntax. +Keep the default for standard CSS so the browser can perform range clamping +and integer rounding. Set it to `true` for a non-standard context that requires +a bare value, such as a selector: ```js reduceCalc('calc(5px - 10px)'); // => 'calc(-5px)' -reduceCalc('calc(5px - 10px)', { unwrapNegativeNumbers: true }); +reduceCalc('calc(5px - 10px)', { unwrapSingleValue: true }); // => '-5px' + +reduceCalc('calc(1 / 2)', { unwrapSingleValue: true }); +// => '.5' ``` +The published `unwrapSingleNegativeNumber` option is retained as a deprecated +alias for `unwrapSingleValue`. + ### PostCSS plugin options These options apply when using the PostCSS plugin: @@ -121,6 +128,12 @@ var out = postcss() .process(css).css; ``` +#### `unwrapSingleValue` (default: `false`) + +Serializes fully resolved finite scalar results without calculation syntax. +This can discard browser-applied range clamping or integer rounding. Selectors +enable it automatically because selectors cannot contain `calc()`. + #### `warnWhenCannotResolve` (default: `false`) Adds warnings when calc() are not reduced to a single value. @@ -165,9 +178,9 @@ With `mediaQueries: true`, this becomes: Reduces `calc()` functions found in selectors. Selectors do not accept `calc()` functions, so the plugin replaces them with their reduced values. -Finite negative results are serialized as bare values because a selector cannot -contain a `calc()` function; the plugin enables `unwrapSingleNegativeNumber` automatically -for selectors. +Finite negative and fractional unitless results are serialized as bare values +because a selector cannot contain a `calc()` function; the plugin enables the +`unwrapSingleValue` automatically for selectors. ```js var out = postcss() @@ -268,7 +281,15 @@ when changing parsing/simplification behavior: pnpm test:corpus:full ``` -Profile long arithmetic parser chains with `pnpm benchmark:arithmetic-chains`. +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`. + +The PostCSS benchmark awaits `postcss().process(...)`, and that await already +triggers result stringification. It therefore does not add a redundant +`result.css` access. ## [Changelog](CHANGELOG.md) @@ -281,5 +302,5 @@ Profile long arithmetic parser chains with `pnpm benchmark:arithmetic-chains`. [PostCSS]: https://github.com/postcss [PostCSS Calc]: https://github.com/postcss/postcss-calc [PostCSS Custom Properties]: https://github.com/postcss/postcss-custom-properties -[tests]: test/index.js +[tests]: test/ [W3C calc() implementation]: https://www.w3.org/TR/css3-values/#calc-notation diff --git a/package.json b/package.json index 7f59936..234fc30 100644 --- a/package.json +++ b/package.json @@ -33,11 +33,16 @@ "scripts": { "lint": "oxlint . && tsc && oxfmt --check", "fmt": "oxfmt", - "benchmark:arithmetic-chains": "node scripts/benchmark-arithmetic-chains.mjs", - "benchmark:nested-fallbacks": "node scripts/benchmark-nested-fallbacks.mjs", - "test": "node --test --test-reporter=dot 'test/**/*.test.mjs' test/index.cjs test/convertUnit.cjs", - "test:mutation:corpus": "node test/mutation/corpus-selection.mjs", - "test:corpus:full": "POSTCSS_CALC_FULL_CORPUS=1 node --test test/conformance/corpus.test.mjs" + "benchmark:arithmetic-chains": "node scripts/benchmark-arithmetic-chains.js", + "benchmark:nested-fallbacks": "node scripts/benchmark-nested-fallbacks.js", + "benchmark:corpus": "node scripts/benchmark.js", + "benchmark:serialization": "node scripts/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", + "test:benchmark:simulation": "node test/benchmark/statistical-simulation.js", + "benchmark:reanalyze": "node scripts/compare-parser-benchmarks.js", + "test": "node --test --test-reporter=dot 'test/**/*.test.js' 'test/**/*.test.cjs'", + "test:mutation:corpus": "node test/mutation/corpus-selection.js", + "test:corpus:full": "POSTCSS_CALC_FULL_CORPUS=1 node --test test/conformance/corpus.test.js" }, "author": "Andy Jansson", "license": "MIT", @@ -52,7 +57,6 @@ }, "devDependencies": { "@csstools/css-calc": "^3.3.0", - "@rmenke/css-tokenizer-tests": "^1.2.0", "@types/node": "^26.5.1", "fast-check": "^4.10.0", "oxfmt": "^0.68.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01b62c5..f72f382 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,10 +172,7 @@ importers: devDependencies: '@csstools/css-calc': specifier: ^3.3.0 - version: 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@rmenke/css-tokenizer-tests': - specifier: ^1.2.0 - version: 1.2.0 + version: 3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@types/node': specifier: ^26.5.1 version: 26.5.1 @@ -197,8 +194,8 @@ importers: packages: - '@csstools/css-calc@3.3.0': - resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + '@csstools/css-calc@3.4.0': + resolution: {integrity: sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==} engines: {node: '>=20.19.0'} peerDependencies: '@csstools/css-parser-algorithms': ^4.0.0 @@ -458,9 +455,6 @@ packages: cpu: [x64] os: [win32] - '@rmenke/css-tokenizer-tests@1.2.0': - resolution: {integrity: sha512-XfdeXzW5QGc3inl69eid2FTLGY/514xs+VXQWlEzdUVm1QdU6MicU5S2hcEbHoC9WMzIMALTzxiZb49w+xJk0Q==} - '@types/node@26.5.1': resolution: {integrity: sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==} @@ -588,8 +582,8 @@ packages: resolution: {integrity: sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==} engines: {node: '>=12.17.0'} - nanoid@3.3.18: - resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -647,7 +641,7 @@ packages: snapshots: - '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-calc@3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 @@ -772,8 +766,6 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.83.0': optional: true - '@rmenke/css-tokenizer-tests@1.2.0': {} - '@types/node@26.5.1': dependencies: undici-types: 8.9.0 @@ -842,7 +834,7 @@ snapshots: dependencies: pure-rand: 8.4.2 - nanoid@3.3.18: {} + nanoid@3.3.19: {} oxfmt@0.68.0: dependencies: @@ -894,7 +886,7 @@ snapshots: postcss@8.5.28: dependencies: - nanoid: 3.3.18 + nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.1 diff --git a/scripts/README.md b/scripts/README.md index a695b0f..bcf8b41 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,23 +1,133 @@ -# scripts/ +# Benchmark scripts -None of these run in `pnpm test` or CI — run directly with `node scripts/.mjs`. +These scripts are deliberately outside the ordinary test suite. Run them on a +controlled machine with `node scripts/.js` (or the corresponding pnpm +command). Benchmark artifacts are schema-v2 JSON files and retain raw +observations, configuration, provenance, and enough information for offline +reanalysis. -- **`benchmark-arithmetic-chains.mjs`** — times parser construction and flattening across long arithmetic chains. -- **`benchmark-nested-fallbacks.mjs`** — measures parser scaling for nested var() fallbacks with increasing depths. -- **`harvest-github.mjs`** — scrapes real-world `calc()` expressions from +- **`benchmark-arithmetic-chains.js`** — runs the fresh-process, paired parser + benchmark for arithmetic shapes. `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 + `reports/benchmarks/`. +- Parser benchmark exit codes are `0` pass, `1` regression, `2` inconclusive, + `3` benchmark/correctness/infrastructure failure, and `64` invalid usage or + artifact. Twenty blocks are the minimum operational floor, not a guarantee + of adequate precision or power. The artifact reports observed variance, + interval width, and estimated blocks needed for the declared margin. A pass + requires every gated runtime, slope, and growth endpoint to meet its + predeclared precision target; the requested block count is never increased + from an observed effect during a run. +- **`compare-parser-benchmarks.js`** — reanalyzes one schema-v2 parser + artifact and applies the uncertainty-aware runtime, slope, and growth gates. +- **`benchmark-serialization.js`** — measures buffered serializer scaling for wide sums/products, nested calls, and nested opaque fallbacks. +- **`harvest-github.js`** — scrapes real-world `calc()` expressions from public GitHub into `test/corpus/github/expressions.txt`. -- **`split-corpus.mjs`** — splits that file into `github-pure.txt` (feeds - `benchmark.mjs`/`show-divergences.mjs` below), `preprocessor.txt`, and +- **`split-corpus.js`** — splits that file into `github-pure.txt` (feeds + `benchmark.js`/`show-divergences.js` below), `preprocessor.txt`, and `invalid.txt` (the latter two are used by real CI resilience tests). -- **`lib/corpus.mjs`** — shared loader for `github-pure.txt`. -- **`benchmark.mjs`** — times our pipeline against `@csstools/css-calc` over - the pure corpus. -- **`show-divergences.mjs`** — buckets where our output disagrees with +- **`lib/corpus.js`** — shared loader for `github-pure.txt`. +- **`benchmark.js`** — (`pnpm benchmark:corpus`) validates and times our + pipeline against `@csstools/css-calc` over the pure corpus in fresh + processes. It is report-only for speed; correctness and infrastructure + failures are nonzero. +- **`benchmark-plugin.js`** — measures PostCSS processing; awaiting + `.process(...)` already includes result serialization, so the benchmark does + not add a redundant `result.css` read. +- **`show-divergences.js`** — buckets where our output disagrees with `@csstools/css-calc` over the pure corpus, for manual triage. -- **`tokenizer-compat.mjs`** — shared helpers for diffing token streams - (not runnable on its own); used by `tokenizer-suite.mjs`. -- **`tokenizer-suite.mjs`** — runs the official `@rmenke/css-tokenizer-tests` - corpus through our tokenizer and reports pass/fail per category. -- **`randomizer.mjs`** — long-running fuzzer: generates `calc()` inputs at +- **`randomizer.js`** — long-running fuzzer: generates `calc()` inputs at increasing depth, compares against `@csstools/css-calc`, logs finds to `reports/randomizer-finds.jsonl`. + +## Claims and decision protocol + +| Benchmark | Claim type | Estimand / unit | Decision | +| ----------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Parser workloads | regression-gating | equal-weighted, order-stratified fresh-process blocks for each generated endpoint | stratified studentized max-T interval; a regression or pass requires the simultaneous bound and family-adjusted precision target; otherwise inconclusive | +| Corpus reducer | comparative-reporting | relative total runtime over the fixed set of unique harvested expressions accepted equivalently by both implementations | paired fresh-process replicates retain both order measurements; 95% superiority and separate 90% practical-margin intervals | +| Serializer script | local profiling | repeated alternating calls to worktree and `HEAD` serializers in one process; outputs are compared before timing | prints two medians per workload; no raw artifact or uncertainty interval | +| Adapter script | local profiling | repeated PostCSS processing by the current implementation in one process | prints one median per workload; no baseline comparison, raw artifact, uncertainty interval, or correctness gate | + +Parser artifacts use an equal-weighted two-stratum estimator over +`baseline-first` and `candidate-first` blocks. Their intervals are a +stratified, studentized max-T bootstrap: each complete block is the +independent experimental unit, resampling is independent within process-order +strata, and the family critical value is transformed with each endpoint's +observed standard error. Parser regressions require both a simultaneous bound +beyond the runtime threshold and a family-adjusted interval no wider than the +configured precision target; insufficient precision yields `inconclusive`. +When a bootstrap resample has zero standard error but a nonzero deviation, its +studentized statistic uses that endpoint's observed standard error. The +artifact records how many resamples used this fallback. + +The corpus benchmark runs each replicate in a fresh process. Each replicate +contains three observations in each execution order, and the bootstrap samples +complete replicate records so the two order measurements stay paired. The +corpus is unique-weighted, not frequency-weighted, and its results must not be +generalized to all real-world CSS. The serializer and adapter scripts are +profiling aids; their medians are not regression gates. + +Corpus correctness is checked against the public `reduceCalc()` API used by +the timer. An untimed pass canonicalizes each public output and its accepted +canonical result to a common form, then records a checksum over the exact +public output content. + +The corpus practical-equivalence margin is named in the artifact as +`equivalenceMargin: 1.1` (10%). A result is within the declared margin only +when its 90% interval lies wholly inside `[1 / 1.1, 1.1]`; this is not a claim +that the implementations are identical; the artifact labels this practical +field `equivalent` and retains the declared margin. The statistical verdict is separate: +`postcss-calc faster` requires a 95% upper bound below 1, +`postcss-calc slower` requires a 95% lower bound above 1, and every other case +is `inconclusive`. + +`precisionMargin` is recorded separately from the runtime and growth decision +thresholds. Precision is met only when the actual family-adjusted interval's +half-width is within the configured log-scale target and the minimum block +count is present. The decision configuration records this as +`precisionMethod: family-adjusted-interval-width`; the normal approximation +parameter used by the earlier protocol is no longer part of new artifacts. +`requestedBlocks` records the sample count requested before observations were +collected. + +Drift rejection and structural mismatch are separate. Structural mismatch is +an immediate correctness failure. Drift-rejected attempts remain in the +artifact; the primary analysis uses the predeclared accepted-block policy and +the sensitivity analysis uses all structurally valid attempts with the same +order-adjusted estimator. Both summaries are recorded, and disagreement makes +the result inconclusive. The drift threshold and order-interaction threshold +are named and recorded in the artifact rather than inferred during reanalysis. +Reanalysis executes no workload: raw observations plus the recorded decision +configuration are the sole source of truth. + +## Controlled-run checklist + +Before a long run, use an idle machine on AC power, a stable CPU governor, no +concurrent builds, and the same Node version for baseline and candidate. Record +warnings if the governor, load, or dirty worktree is unsuitable. Repeat the run +when control drift or rejection rates are high; the metadata records these +conditions but cannot fully control them. + +Useful verification commands: + +```sh +pnpm test:benchmark +pnpm test:benchmark:simulation +pnpm benchmark:reanalyze reports/benchmarks/.json +``` + +The normal test command should keep schema checks, analyzer tests, simulation +smoke tests, and synthetic slowdown fixtures short. The fixed-seed smoke +calibration runs 200 experiments; setting +`POSTCSS_CALC_FULL_CALIBRATION=1` runs the thousands-of-experiments, +production-like calibration outside normal CI. Full corpus and long benchmark +runs remain explicit operations. + +The fixed-seed simulation smoke test expects simultaneous 95% coverage between +0.90 and 0.99 over 200 experiments. This binomial tolerance is an operational +check, not a proof of coverage for every workload: endpoint correlation, skew, +temporal drift, order penalties, and outliers can differ in production. The +full calibration uses a tighter 0.925–0.975 range over 2,000 experiments and +should be rerun when changing the interval procedure. diff --git a/scripts/benchmark-arithmetic-chains.js b/scripts/benchmark-arithmetic-chains.js new file mode 100644 index 0000000..aea3950 --- /dev/null +++ b/scripts/benchmark-arithmetic-chains.js @@ -0,0 +1,47 @@ +import { runParserBenchmark } from './lib/parser-benchmark.js'; + +try { + const result = await runParserBenchmark({ + benchmark: 'arithmetic-chains', + ...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-arithmetic-chains.mjs b/scripts/benchmark-arithmetic-chains.mjs deleted file mode 100644 index 7b6a396..0000000 --- a/scripts/benchmark-arithmetic-chains.mjs +++ /dev/null @@ -1,63 +0,0 @@ -// Benchmark parser construction for the formerly quadratic arithmetic-chain -// path. This intentionally excludes tokenization so the timings isolate AST -// construction and flattening. -import { parse } from '../src/lib/parser.js'; -import { tokenize } from '../src/lib/tokenizer.js'; - -const SIZES = [1_000, 2_000, 4_000, 8_000]; -const WARMUP_RUNS = 5; -const SAMPLES = 9; -const PARSES_PER_SAMPLE = 10; - -/** @param {number[]} values */ -function median(values) { - const sorted = [...values].sort((a, b) => a - b); - return sorted[Math.floor(sorted.length / 2)]; -} - -/** - * @param {'additive' | 'multiplicative'} kind - * @param {number} size - */ -function benchmark(kind, size) { - const operator = kind === 'additive' ? ' + ' : ' * '; - // Factors of one collapse by design, so use two for the multiplicative - // case and keep the parsed Product representative of the full chain. - const term = kind === 'additive' ? '1' : '2'; - const tokens = tokenize(Array(size).fill(term).join(operator)); - - for (let i = 0; i < WARMUP_RUNS; i++) { - parse(tokens); - } - - const samples = []; - for (let sample = 0; sample < SAMPLES; sample++) { - const start = performance.now(); - for (let iteration = 0; iteration < PARSES_PER_SAMPLE; iteration++) { - parse(tokens); - } - samples.push((performance.now() - start) / PARSES_PER_SAMPLE); - } - return median(samples); -} - -console.log( - `Parser-only timing: ${WARMUP_RUNS} warmups, ${SAMPLES} median samples, ` + - `${PARSES_PER_SAMPLE} parses/sample\n` -); - -for (const kind of ['additive', 'multiplicative']) { - console.log(kind); - let previous = null; - for (const size of SIZES) { - const elapsedMs = benchmark(kind, size); - const growth = - previous === null ? '—' : `${(elapsedMs / previous).toFixed(2)}×`; - console.log( - ` ${size.toLocaleString().padStart(5)} terms ` + - `${elapsedMs.toFixed(3).padStart(8)} ms growth ${growth}` - ); - previous = elapsedMs; - } - console.log(); -} diff --git a/scripts/benchmark-nested-fallbacks.js b/scripts/benchmark-nested-fallbacks.js new file mode 100644 index 0000000..8dd8d64 --- /dev/null +++ b/scripts/benchmark-nested-fallbacks.js @@ -0,0 +1,47 @@ +import { runParserBenchmark } from './lib/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-nested-fallbacks.mjs b/scripts/benchmark-nested-fallbacks.mjs deleted file mode 100644 index 8da5f89..0000000 --- a/scripts/benchmark-nested-fallbacks.mjs +++ /dev/null @@ -1,65 +0,0 @@ -// Benchmark parser scaling for nested var() fallbacks. -// Tokenization is performed once per depth so timings isolate parsing cost. -import { parse } from '../src/lib/parser.js'; -import { tokenize } from '../src/lib/tokenizer.js'; - -const DEPTHS = [50, 100, 200, 400]; -const WARMUP_RUNS = 5; -const SAMPLES = 9; -const PARSES_PER_SAMPLE = 10; - -/** @param {number[]} values */ -function median(values) { - const sorted = [...values].sort((a, b) => a - b); - return sorted[Math.floor(sorted.length / 2)]; -} - -/** - * Build alternating calc(var(--x, ...)) expressions of given depth. - * @param {number} depth - * @return {string} - */ -function buildNestedFallbacks(depth) { - let expr = 'calc(1px + 2px)'; - for (let i = depth; i >= 1; i--) { - expr = `calc(var(--x${i}, ${expr}))`; - } - return expr; -} - -/** @param {number} depth */ -function benchmark(depth) { - const input = buildNestedFallbacks(depth); - const tokens = tokenize(input); - - for (let i = 0; i < WARMUP_RUNS; i++) { - parse(tokens); - } - - const samples = []; - for (let sample = 0; sample < SAMPLES; sample++) { - const start = performance.now(); - for (let iteration = 0; iteration < PARSES_PER_SAMPLE; iteration++) { - parse(tokens); - } - samples.push((performance.now() - start) / PARSES_PER_SAMPLE); - } - return median(samples); -} - -console.log( - `Nested var() fallback parser timing: ${WARMUP_RUNS} warmups, ${SAMPLES} median samples, ` + - `${PARSES_PER_SAMPLE} parses/sample\n` -); - -let previous = null; -for (const depth of DEPTHS) { - const elapsedMs = benchmark(depth); - const growth = - previous === null ? '—' : `${(elapsedMs / previous).toFixed(2)}×`; - console.log( - ` ${depth.toString().padStart(5)} depth ` + - `${elapsedMs.toFixed(3).padStart(8)} ms growth ${growth}` - ); - previous = elapsedMs; -} diff --git a/scripts/benchmark-plugin.mjs b/scripts/benchmark-plugin.js similarity index 96% rename from scripts/benchmark-plugin.mjs rename to scripts/benchmark-plugin.js index 364a3d0..2c12528 100644 --- a/scripts/benchmark-plugin.mjs +++ b/scripts/benchmark-plugin.js @@ -1,11 +1,12 @@ // Benchmark the PostCSS adapter on deterministic workloads. This measures -// adapter overhead as well as the calculation pipeline; benchmark.mjs keeps +// adapter overhead as well as the calculation pipeline; benchmark.js keeps // the parser/expression benchmark separate. import postcss from 'postcss'; import plugin from '../src/index.js'; const WARMUP_RUNS = 3; -const SAMPLES = 7; +// Keep an even count; the common harness migration uses these as paired blocks. +const SAMPLES = 20; const ITEMS_PER_WORKLOAD = 2_000; const includeSelectors = process.argv.includes('--selectors'); const includeMedia = process.argv.includes('--media'); diff --git a/scripts/benchmark-serialization.js b/scripts/benchmark-serialization.js new file mode 100644 index 0000000..4bafe00 --- /dev/null +++ b/scripts/benchmark-serialization.js @@ -0,0 +1,218 @@ +// Serializer-only benchmark. ASTs are built once, then reused so timings do +// not include tokenization, parsing, or simplification. Every case is run +// through both the worktree serializer and the serializer from HEAD. +import { execFileSync } from 'node:child_process'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { serialize as serializeWorktree } from '../src/lib/serialize.js'; +import { + num, + dim, + ident, + call, + opaqueCall, + mkSum, + mkProduct, +} from '../src/lib/node.js'; + +const WIDE_SIZES = [1_024, 16_384, 65_536]; +const OTHER_SIZES = [128, 256, 512]; +const WARMUP_RUNS = 3; +// Keep an even, independently timed sample count so process order is balanced. +const SAMPLES = 20; +const TARGET_SAMPLE_MS = 150; +const MAX_REPETITIONS = 1_000_000; +let consumedBytes = 0; + +/** @param {number[]} values @return {number} */ +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; +} + +/** @param {number} size @return {import('../src/lib/node.js').Node} */ +function wideSum(size) { + return mkSum( + Array.from({ length: size }, (_, index) => ({ + sign: /** @type {1 | -1} */ (index % 3 === 0 ? -1 : 1), + node: dim(index + 1, 'px'), + })) + ); +} + +/** @param {number} size @return {import('../src/lib/node.js').Node} */ +function wideProduct(size) { + return mkProduct( + Array.from({ length: size }, (_, index) => ({ + exponent: /** @type {1 | -1} */ (index % 5 === 0 ? -1 : 1), + node: num(index + 2), + })) + ); +} + +/** @param {number} size @return {import('../src/lib/node.js').Node} */ +function nestedCalls(size) { + let node = ident('--x'); + for (let i = 0; i < Math.max(2, Math.ceil(size / 4)); i++) { + node = call('min', [node, dim(i + 1, 'px')]); + } + return node; +} + +/** @param {number} size @return {import('../src/lib/node.js').Node} */ +function nestedOpaqueFallbacks(size) { + let node = opaqueCall('var', [ident('--x'), ', ', dim(1, 'px')]); + const depth = Math.max(2, Math.ceil(size / 4)); + for (let i = 0; i < depth; i++) { + node = opaqueCall('var', [ident(`--x${i}`), ', ', node]); + } + return node; +} + +/** + * @param {(node: import('../src/lib/node.js').Node, opts: {precision: false}) => string} serialize + * @param {import('../src/lib/node.js').Node} node + * @param {number} repetitions + * @param {boolean} materialize + * @return {number} elapsed milliseconds + */ +function measure(serialize, node, repetitions, materialize) { + const start = performance.now(); + for (let iteration = 0; iteration < repetitions; iteration++) { + const output = serialize(node, { precision: false }); + if (materialize) consumedBytes += Buffer.byteLength(output); + } + return performance.now() - start; +} + +/** @param {number} repetitions @return {number} */ +function clampRepetitions(repetitions) { + return Math.min(MAX_REPETITIONS, Math.max(1, Math.ceil(repetitions))); +} + +/** + * Run paired samples with the order alternating between serializers. Both + * serializers use the same repetition count for each sample, so their times + * are exposed to the same short-lived runtime effects. + * + * @param {(node: import('../src/lib/node.js').Node, opts: {precision: false}) => string} worktreeSerializer + * @param {(node: import('../src/lib/node.js').Node, opts: {precision: false}) => string} headSerializer + * @param {import('../src/lib/node.js').Node} node + * @param {boolean} materialize + * @return {{worktree: number, head: number}} + */ +function benchmarkPair(worktreeSerializer, headSerializer, node, materialize) { + for (let i = 0; i < WARMUP_RUNS; i++) { + worktreeSerializer(node, { precision: false }); + headSerializer(node, { precision: false }); + } + + const calibrationWorktree = measure(worktreeSerializer, node, 1, materialize); + const calibrationHead = measure(headSerializer, node, 1, materialize); + let repetitions = clampRepetitions( + TARGET_SAMPLE_MS / Math.max(calibrationWorktree, calibrationHead, 0.01) + ); + const worktreeSamples = []; + const headSamples = []; + + for (let sample = 0; sample < SAMPLES; sample++) { + const first = sample % 2 === 0 ? 'worktree' : 'head'; + const firstSerializer = + first === 'worktree' ? worktreeSerializer : headSerializer; + const secondSerializer = + first === 'worktree' ? headSerializer : worktreeSerializer; + const firstMs = measure(firstSerializer, node, repetitions, materialize); + const secondMs = measure(secondSerializer, node, repetitions, materialize); + const worktreeMs = first === 'worktree' ? firstMs : secondMs; + const headMs = first === 'worktree' ? secondMs : firstMs; + worktreeSamples.push(worktreeMs / repetitions); + headSamples.push(headMs / repetitions); + + const slowestMs = Math.max(firstMs, secondMs); + if (slowestMs > 0) { + repetitions = clampRepetitions( + repetitions * (TARGET_SAMPLE_MS / slowestMs) + ); + } + } + return { worktree: median(worktreeSamples), head: median(headSamples) }; +} + +/** @return {Promise} */ +async function loadHeadSerializer() { + const root = mkdtempSync(join(tmpdir(), 'postcss-calc-serialize-')); + const lib = join(root, 'lib'); + mkdirSync(lib); + for (const file of ['serialize.js', 'node.js', 'opaque.js', 'limits.js']) { + const source = execFileSync('git', ['show', `HEAD:src/lib/${file}`], { + encoding: 'utf8', + }); + writeFileSync(join(lib, file), source); + } + try { + return await import(pathToFileURL(join(lib, 'serialize.js')).href); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const headModule = await loadHeadSerializer(); +const serializeHead = headModule.serialize; +const cases = [ + ['wide sums', wideSum, WIDE_SIZES], + ['wide products', wideProduct, WIDE_SIZES], + ['nested calls', nestedCalls, OTHER_SIZES], + ['nested opaque fallbacks', nestedOpaqueFallbacks, OTHER_SIZES], +]; + +console.log( + `Serializer comparison: ${WARMUP_RUNS} warmups, ${SAMPLES} median samples; ` + + 'times are milliseconds per serialization\n' +); + +for (const [name, build, sizes] of cases) { + console.log(name); + let previousWorktree = null; + for (const size of sizes) { + const node = build(size); + const worktreeOutput = serializeWorktree(node, { precision: false }); + const headOutput = serializeHead(node, { precision: false }); + assert.equal( + worktreeOutput, + headOutput, + `${name} (${size}) differs between the worktree and HEAD serializers` + ); + const discarded = benchmarkPair( + serializeWorktree, + serializeHead, + node, + false + ); + const materialized = benchmarkPair( + serializeWorktree, + serializeHead, + node, + true + ); + const worktreeMs = discarded.worktree; + const headMs = discarded.head; + const materializedRatio = materialized.worktree / materialized.head; + const growth = + previousWorktree === null + ? '—' + : `${(worktreeMs / previousWorktree).toFixed(2)}×`; + const ratio = worktreeMs / headMs; + console.log( + ` ${size.toLocaleString().padStart(7)} nodes ` + + `discarded ${worktreeMs.toFixed(3)}/${headMs.toFixed(3)} ms ` + + `ratio ${ratio.toFixed(2)}× ` + + `materialized ${materialized.worktree.toFixed(3)}/${materialized.head.toFixed(3)} ms ` + + `ratio ${materializedRatio.toFixed(2)}× growth ${growth}` + ); + previousWorktree = worktreeMs; + } + console.log(); +} diff --git a/scripts/benchmark.js b/scripts/benchmark.js new file mode 100644 index 0000000..f1b1255 --- /dev/null +++ b/scripts/benchmark.js @@ -0,0 +1,16 @@ +// Correctness-aware fresh-process corpus benchmark against @csstools/css-calc. +import { runCorpusBenchmark, parseArgs } from './lib/corpus-benchmark.js'; + +try { + const result = runCorpusBenchmark(parseArgs(process.argv.slice(2))); + console.log(`Corpus benchmark: ${result.artifact.analysis.status}`); + console.log( + `Practical verdict: ${result.artifact.analysis.practical.status} ` + + `(margin ${result.artifact.config.equivalenceMargin})` + ); + console.log(`Estimand: ${result.artifact.correctness.estimand}`); + console.log(`Wrote ${result.path}`); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = error instanceof TypeError ? 64 : 3; +} diff --git a/scripts/benchmark.mjs b/scripts/benchmark.mjs deleted file mode 100644 index 93cab6d..0000000 --- a/scripts/benchmark.mjs +++ /dev/null @@ -1,112 +0,0 @@ -// Benchmark: postcss-calc (pratt) vs @csstools/css-calc on the harvested -// real-world corpus. -import { tokenize } from '../src/lib/tokenizer.js'; -import { parse } from '../src/lib/parser.js'; -import { simplify } from '../src/lib/simplify.js'; -import { serialize } from '../src/lib/serialize.js'; -import { calc as csstoolsCalc } from '@csstools/css-calc'; -import { loadCorpus } from './lib/corpus.mjs'; -const corpus = loadCorpus(); -const ours = (s) => { - try { - return serialize(simplify(parse(tokenize(s))), { precision: false }); - } catch { - return null; - } -}; -const theirs = (s) => { - try { - const r = csstoolsCalc(s); - return typeof r === 'string' ? r : null; - } catch { - return null; - } -}; - -const WARMUP_RUNS = 3; -const SAMPLES = 9; - -function run(fn) { - const start = performance.now(); - for (const s of corpus) fn(s); - return performance.now() - start; -} - -function countOutcomes(fn) { - let okCount = 0; - let rejectedCount = 0; - for (const s of corpus) { - if (fn(s) === null) rejectedCount++; - else okCount++; - } - return { okCount, rejectedCount }; -} - -function median(values) { - const sorted = [...values].sort((a, b) => a - b); - return sorted[Math.floor(sorted.length / 2)]; -} - -function bench(name, fn, samples) { - return { - name, - totalMs: median(samples), - minMs: Math.min(...samples), - maxMs: Math.max(...samples), - ...countOutcomes(fn), - }; -} - -for (let i = 0; i < WARMUP_RUNS; i++) { - run(ours); - run(theirs); -} - -const ourSamples = []; -const theirSamples = []; -for (let i = 0; i < SAMPLES; i++) { - // Alternate the order so one implementation does not always pay the - // first-use/JIT/GC costs associated with being run first. - if (i % 2 === 0) { - ourSamples.push(run(ours)); - theirSamples.push(run(theirs)); - } else { - theirSamples.push(run(theirs)); - ourSamples.push(run(ours)); - } -} - -const a = bench('postcss-calc (pratt)', ours, ourSamples); -const b = bench('@csstools/css-calc ', theirs, theirSamples); - -console.log( - `Corpus: ${corpus.length.toLocaleString()} real-world calc() expressions` -); -console.log( - `Running ${WARMUP_RUNS} warmup + ${SAMPLES} alternating measured samples each…\n` -); - -const fmt = (s) => { - const perCallUs = (s.totalMs * 1000) / corpus.length; - const range = `${s.minMs.toFixed(1)}–${s.maxMs.toFixed(1)} ms`; - return [ - s.name, - `median ${s.totalMs.toFixed(1).padStart(6)} ms`, - `range ${range.padStart(13)}`, - `${perCallUs.toFixed(2).padStart(5)} µs/expr`, - `accepted ${s.okCount.toString().padStart(5)}`, - `rejected ${s.rejectedCount.toString().padStart(4)}`, - ].join(' '); -}; -console.log(fmt(a)); -console.log(fmt(b)); -const ratio = b.totalMs / a.totalMs; -const speedLabel = - ratio >= 1 - ? `${ratio.toFixed(2)}× faster` - : `${(1 / ratio).toFixed(2)}× slower`; -console.log(`\nSpeed: postcss-calc is ${speedLabel} than csstools.`); -console.log( - `Coverage: postcss-calc accepts ${a.okCount}, csstools accepts ${b.okCount} ` + - `(diff ${a.okCount - b.okCount > 0 ? '+' : ''}${a.okCount - b.okCount}).` -); diff --git a/scripts/compare-parser-benchmarks.js b/scripts/compare-parser-benchmarks.js new file mode 100644 index 0000000..93a3c54 --- /dev/null +++ b/scripts/compare-parser-benchmarks.js @@ -0,0 +1,283 @@ +// Reanalyze one schema-v2 parser benchmark artifact. The six-transcript +// function below remains as a small compatibility API for older local tests; +// the command-line interface is intentionally artifact-based now. +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import { analyzeParser } from './lib/parser-benchmark.js'; +import { analyzeCorpus } from './lib/corpus-benchmark.js'; +import { validateSchemaV2Artifact } from './lib/benchmark.js'; + +const usage = + 'Usage: node scripts/compare-parser-benchmarks.js \n' + + ' (legacy six-transcript arguments are accepted by the JS API only)'; + +function readArtifact(path) { + const artifact = JSON.parse(readFileSync(path, 'utf8')); + validateSchemaV2Artifact(artifact); + return artifact; +} + +export function reanalyzeParserBenchmark(path) { + const artifact = readArtifact(path); + const analysis = + artifact.benchmark === 'corpus' + ? analyzeCorpus(artifact) + : analyzeParser(artifact); + return { ...artifact, analysis }; +} + +/** @param {string} path @return {object} */ +function readResult(path) { + const line = readFileSync(path, 'utf8') + .split('\n') + .findLast((candidate) => candidate.startsWith('BENCHMARK_RESULT ')); + if (!line) throw new Error(`${path}: missing BENCHMARK_RESULT line`); + return JSON.parse(line.slice('BENCHMARK_RESULT '.length)); +} + +/** @param {number[]} values @return {number} */ +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; +} + +/** + * @param {string[] | string} files + * @return {{benchmark: string, summaries: object[], failures: string[]} | object} + */ +function compareParserBenchmarks(files) { + if (typeof files === 'string') + return reanalyzeParserBenchmark(files).analysis; + if (files.length !== 6) throw new Error(usage); + + const results = files.map(readResult); + const benchmark = results[0].benchmark; + if ( + results.some( + (result) => result.schema !== 1 || result.benchmark !== benchmark + ) + ) { + throw new Error( + 'all benchmark transcripts must have the same schema and benchmark' + ); + } + + const baseline = results.slice(0, 3); + const candidate = results.slice(3); + const keyOf = (measurement) => + benchmark === 'arithmetic-chains' + ? `${measurement.kind}:${measurement.mode}:${measurement.size}` + : `${measurement.mode}:${measurement.depth}`; + + let expectedKeys; + if (benchmark === 'arithmetic-chains') { + expectedKeys = ['additive', 'multiplicative'].flatMap((kind) => + ['cold-index', 'hot-shared-index'].flatMap((mode) => + [1_000, 2_000, 4_000, 8_000].map((size) => `${kind}:${mode}:${size}`) + ) + ); + } else if (benchmark === 'nested-fallbacks') { + expectedKeys = ['cold-index', 'hot-shared-index'].flatMap((mode) => + [50, 100, 200, 400].map((depth) => `${mode}:${depth}`) + ); + } else { + throw new Error(`unsupported benchmark: ${benchmark}`); + } + + /** @param {object} result @param {string} path */ + function validateMeasurements(result, path) { + if (!Array.isArray(result.measurements)) { + throw new TypeError(`${path}: measurements must be an array`); + } + + const expected = new Set(expectedKeys); + const seen = new Set(); + for (const measurement of result.measurements) { + if (measurement === null || typeof measurement !== 'object') { + throw new TypeError(`${path}: invalid measurement`); + } + const key = keyOf(measurement); + if (!expected.has(key)) { + throw new Error(`${path}: unexpected measurement key ${key}`); + } + if (seen.has(key)) { + throw new Error(`${path}: duplicate measurement key ${key}`); + } + if ( + typeof measurement.medianMs !== 'number' || + !Number.isFinite(measurement.medianMs) || + measurement.medianMs <= 0 + ) { + throw new TypeError( + `${path}: invalid medianMs for ${key} (must be finite and > 0)` + ); + } + seen.add(key); + } + + const missing = expectedKeys.filter((key) => !seen.has(key)); + if (missing.length > 0) { + throw new Error( + `${path}: missing measurement keys ${missing.join(', ')}` + ); + } + } + + // Validate every transcript before aggregating any measurements. This keeps + // missing, duplicate, and empty runs from silently disappearing in a Map. + for (let i = 0; i < results.length; i++) { + validateMeasurements(results[i], files[i]); + } + + /** @param {object[]} runs @return {Map} */ + function valuesByKey(runs) { + /** @type {Map} */ + const values = new Map(); + for (const run of runs) { + for (const measurement of run.measurements) { + const key = keyOf(measurement); + const samples = values.get(key) ?? []; + samples.push(measurement.medianMs); + values.set(key, samples); + } + } + return values; + } + + const baseValues = valuesByKey(baseline); + const candidateValues = valuesByKey(candidate); + const failures = []; + const summaries = []; + + for (const [key, values] of candidateValues) { + const baselineSamples = baseValues.get(key); + if ( + !baselineSamples || + baselineSamples.length !== 3 || + values.length !== 3 + ) { + failures.push(`${key}: expected three baseline and candidate samples`); + continue; + } + const baseMedian = median(baselineSamples); + const candidateMedian = median(values); + const ratio = candidateMedian / baseMedian; + if (!Number.isFinite(ratio)) { + failures.push(`${key}: non-finite ratio`); + continue; + } + summaries.push({ key, baseMedian, candidateMedian, ratio }); + + const parts = key.split(':'); + const size = Number(parts.at(-1)); + const largest = + benchmark === 'arithmetic-chains' ? size === 8_000 : size === 400; + if (largest && ratio > 1.1) { + failures.push(`${key}: ${ratio.toFixed(2)}x baseline (limit 1.10x)`); + } + } + + // Recompute growth from the three-run medians rather than trusting a single + // run's printed ratios. This makes the doubling gate auditable and resistant + // to a transient sample in one invocation. + const grouped = new Map(); + for (const summary of summaries) { + const parts = summary.key.split(':'); + const mode = benchmark === 'arithmetic-chains' ? parts[1] : parts[0]; + const family = benchmark === 'arithmetic-chains' ? parts[0] : ''; + const size = Number(parts.at(-1)); + const groupKey = + benchmark === 'arithmetic-chains' ? `${family}:${mode}` : mode; + const group = grouped.get(groupKey) ?? []; + group.push({ size, median: summary.candidateMedian }); + grouped.set(groupKey, group); + } + for (const [groupKey, points] of grouped) { + points.sort((a, b) => a.size - b.size); + for (let i = 1; i < points.length; i++) { + const growth = points[i].median / points[i - 1].median; + if (!Number.isFinite(growth)) { + failures.push( + `${groupKey} ${points[i - 1].size}->${points[i].size}: non-finite growth` + ); + continue; + } + if (growth > 2.5) { + failures.push( + `${groupKey} ${points[i - 1].size}->${points[i].size}: ${growth.toFixed(2)}x growth (limit 2.50x)` + ); + } + } + } + + return { benchmark, summaries, failures }; +} + +function printComparison(comparison) { + for (const summary of comparison.summaries) { + console.log( + `${summary.key.padEnd(38)} ${summary.baseMedian.toFixed(3).padStart(8)} ms -> ` + + `${summary.candidateMedian.toFixed(3).padStart(8)} ms ` + + `(${summary.ratio.toFixed(2)}x)` + ); + } + if (comparison.failures.length > 0) { + console.error('\nBenchmark gates failed:'); + for (const failure of comparison.failures) console.error(`- ${failure}`); + process.exitCode = 1; + } else { + console.log( + '\nBenchmark gates passed: largest medians <= 1.10x and every doubling <= 2.50x.' + ); + } +} + +const isMain = + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + const files = process.argv.slice(2); + if (files.length === 1) { + try { + const result = reanalyzeParserBenchmark(files[0]); + console.log(`Parser benchmark: ${result.analysis.status}`); + if (result.benchmark === 'corpus') { + console.log( + `Practical verdict: ${result.analysis.practical?.status ?? 'unknown'}` + ); + process.exitCode = exitCodeFor(result.analysis.status); + } + for (const endpoint of result.analysis.endpoints ?? []) { + console.log( + `${endpoint.key} ${endpoint.geometricMeanPairedRuntimeRatio?.toFixed(4) ?? endpoint.deltaSlope.toFixed(4)}` + ); + } + process.exitCode = exitCodeFor(result.analysis.status); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 64; + } + } else if (files.length !== 6) { + console.error(usage); + process.exitCode = 64; + } else { + try { + printComparison(compareParserBenchmarks(files)); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } + } +} + +function exitCodeFor(status) { + if (status === 'pass') return 0; + if (status === 'regression') return 1; + if (status === 'postcss-calc faster' || status === 'postcss-calc slower') + return 0; + if (status === 'correctness-failure') return 3; + return 2; +} + +export { compareParserBenchmarks, exitCodeFor }; diff --git a/scripts/corpus-benchmark-worker.js b/scripts/corpus-benchmark-worker.js new file mode 100644 index 0000000..aadb06f --- /dev/null +++ b/scripts/corpus-benchmark-worker.js @@ -0,0 +1,182 @@ +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { calc as referenceCalc } from '@csstools/css-calc'; +import { ourOutput as canonicalizeOutput } from './lib/corpus-policy.js'; +/* oxlint-disable no-bitwise */ + +const payload = JSON.parse( + process.argv[2] ?? (readFileSync(0, 'utf8') || '{}') +); +if (!['ours-first', 'reference-first'].includes(payload.calibrationOrder)) + throw new Error('invalid calibration order'); +const TARGET_MS = payload.targetBatchMs ?? 25; +const corpus = JSON.parse(readFileSync(payload.corpusFile, 'utf8')); +const sourceRoot = payload.sourceRoot; +const oursModule = await import(pathToFileURL(`${sourceRoot}/reduce.js`).href); +const reduceCalc = oursModule.default; +const entries = payload.permutation.map((index) => corpus[index]); +const groups = [ + 'exact', + 'sum', + 'product', + 'function-call', + 'opaque-call', + 'scalar', + 'source-length-q1', + 'source-length-q2', + 'source-length-q3', + 'source-length-q4', +]; + +function ours(input) { + return reduceCalc(input, { precision: 10 }); +} +function reference(input) { + return referenceCalc(input); +} +function measure(fn, values, repetitions) { + const start = performance.now(); + for (let repeat = 0; repeat < repetitions; repeat++) { + for (const entry of values) fn(entry.input); + } + const elapsedMs = performance.now() - start; + return { elapsedMs, ms: elapsedMs / repetitions }; +} + +function contentChecksum(outputs) { + let checksum = 2166136261; + for (const output of outputs) { + const value = String(output); + for (let index = 0; index < value.length; index++) + checksum = Math.imul(checksum ^ value.charCodeAt(index), 16777619) >>> 0; + checksum = Math.imul(checksum ^ 0xff, 16777619) >>> 0; + } + return checksum; +} + +function calibrate(values, calibrationOrder) { + let repetitions = 1; + const samples = []; + const measureInOrder = () => { + const first = calibrationOrder === 'ours-first' ? ours : reference; + const second = calibrationOrder === 'ours-first' ? reference : ours; + const firstSample = measure(first, values, repetitions); + const secondSample = measure(second, values, repetitions); + return calibrationOrder === 'ours-first' + ? { oursSample: firstSample, referenceSample: secondSample } + : { oursSample: secondSample, referenceSample: firstSample }; + }; + let { oursSample, referenceSample } = measureInOrder(); + samples.push({ + oursMs: oursSample.elapsedMs, + referenceMs: referenceSample.elapsedMs, + }); + let sample = Math.max(oursSample.elapsedMs, referenceSample.elapsedMs); + while (sample < TARGET_MS * 0.6 && repetitions < 1_000_000) { + repetitions *= 2; + ({ oursSample, referenceSample } = measureInOrder()); + samples.push({ + oursMs: oursSample.elapsedMs, + referenceMs: referenceSample.elapsedMs, + }); + sample = Math.max(oursSample.elapsedMs, referenceSample.elapsedMs); + } + return { repetitions, samples }; +} + +function lengthGroup(entry) { + if (entry.lengthStratum) return entry.lengthStratum; + const [q1, q2, q3] = payload.lengthQuartiles; + if (entry.sourceLength <= q1) return 'source-length-q1'; + if (entry.sourceLength <= q2) return 'source-length-q2'; + if (entry.sourceLength <= q3) return 'source-length-q3'; + return 'source-length-q4'; +} + +const valuesByGroup = new Map([['exact', entries]]); +for (const group of groups.slice(1)) + valuesByGroup.set( + group, + entries.filter((entry) => + group.startsWith('source-length-') + ? lengthGroup(entry) === group + : entry.shape === group + ) + ); +const configurations = []; +for (const group of groups) { + const values = valuesByGroup.get(group); + if (!values?.length) continue; + const calibration = calibrate(values, payload.calibrationOrder); + configurations.push({ + group, + values, + repetitions: calibration.repetitions, + calibrationSamplesMs: calibration.samples, + }); +} + +const verificationChecksums = new Map(); +for (const configuration of configurations) { + const oursOutputs = []; + const referenceOutputs = []; + for (const entry of configuration.values) { + const oursOutput = ours(entry.input); + const normalizedOutput = canonicalizeOutput(oursOutput); + const normalizedCanonical = canonicalizeOutput(entry.canonical); + if ( + normalizedOutput === null || + normalizedCanonical === null || + normalizedOutput !== normalizedCanonical + ) + throw new Error( + `public reduceCalc output differs from validated canonical result: ${entry.input}` + ); + oursOutputs.push(oursOutput); + referenceOutputs.push(reference(entry.input)); + } + verificationChecksums.set(configuration.group, { + ours: contentChecksum(oursOutputs), + reference: contentChecksum(referenceOutputs), + }); +} + +const batches = []; +for (const order of payload.orders) { + const measurements = []; + for (const configuration of configurations) { + const result = {}; + for (const implementation of order === 'ours-first' + ? ['ours', 'reference'] + : ['reference', 'ours']) { + const measured = measure( + implementation === 'ours' ? ours : reference, + configuration.values, + configuration.repetitions + ); + result[implementation] = { + ms: measured.ms, + elapsedMs: measured.elapsedMs, + checksum: verificationChecksums.get(configuration.group)[ + implementation + ], + }; + } + measurements.push({ + group: configuration.group, + repetitions: configuration.repetitions, + calibrationOrder: payload.calibrationOrder, + calibrationSamplesMs: configuration.calibrationSamplesMs, + ...result, + }); + } + batches.push({ order, measurements }); +} +process.stdout.write( + JSON.stringify({ + replicate: payload.replicate, + calibrationOrder: payload.calibrationOrder, + permutation: payload.permutation, + batches, + }) +); diff --git a/scripts/harvest-github.mjs b/scripts/harvest-github.js similarity index 100% rename from scripts/harvest-github.mjs rename to scripts/harvest-github.js diff --git a/scripts/lib/benchmark.js b/scripts/lib/benchmark.js new file mode 100644 index 0000000..ad4e2c8 --- /dev/null +++ b/scripts/lib/benchmark.js @@ -0,0 +1,1669 @@ +/* oxlint-disable no-bitwise, complexity */ +// Small, deterministic benchmark primitives. This module intentionally has +// no third-party dependencies: benchmark results should be reproducible with +// the package's normal development installation. +import { + readFileSync, + readdirSync, + statSync, + mkdtempSync, + mkdirSync, + rmSync, +} from 'node:fs'; +import { createHash } from 'node:crypto'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { cpus, loadavg, platform, release, arch } from 'node:os'; +import { join, relative } from 'node:path'; +import { + CORPUS_CATEGORIES, + NEUTRAL_CORPUS_CATEGORIES, +} from './corpus-policy.js'; + +export const TARGET_BATCH_MS = 25; +export const MIN_WARMUPS = 5; +export const MAX_WARMUPS = 10; +export const MEASURED_BATCHES = 6; +export const DRIFT_THRESHOLD = 0.15; +export const BOOTSTRAP_RESAMPLES = 100_000; +export const NON_REGRESSION_MARGIN = 1.1; +export const CORPUS_EQUIVALENCE_MARGIN = 1.1; +export const GROWTH_THRESHOLD = 2.5; +export const MIN_VALID_BLOCKS = 20; +export const DECISION_CONFIG_VERSION = 3; +export const PRECISION_METHOD = 'family-adjusted-interval-width'; +export const DECISION_INTERVAL_METHOD = + 'stratified-max-t-studentized-bootstrap'; +export const CORPUS_INTERVAL_METHOD = + 'paired-replicate-order-log-ratio-bootstrap'; + +// These are the fields that determine the interpretation of a schema-v2 +// artifact. Keep this list here rather than duplicating it in the parser and +// corpus analyzers: a reanalysis must have one authoritative contract. +export const DECISION_CONFIG_KEYS = [ + 'decisionConfigVersion', + 'requestedBlocks', + 'minimumBlocks', + 'maxAttempts', + 'targetBatchMs', + 'warmupMinimum', + 'warmupMaximum', + 'measuredBatchCount', + 'driftThreshold', + 'bootstrapResamples', + 'confidence', + 'runtimeNonRegressionMargin', + 'equivalenceMargin', + 'precisionMargin', + 'growthThreshold', + 'orderInteractionThreshold', + 'precisionMethod', + 'intervalMethod', +]; + +/** @param {object} config @param {string} kind */ +export function validateDecisionConfig(config, kind = 'artifact') { + if (!config || typeof config !== 'object') + throw new TypeError(`${kind} is missing decision configuration`); + for (const key of DECISION_CONFIG_KEYS) + if (!Object.hasOwn(config, key)) + throw new TypeError(`${kind} is missing decision parameter ${key}`); + if (config.decisionConfigVersion !== DECISION_CONFIG_VERSION) + throw new TypeError( + `${kind} has an invalid decision configuration version` + ); + for (const key of [ + 'requestedBlocks', + 'minimumBlocks', + 'maxAttempts', + 'measuredBatchCount', + 'bootstrapResamples', + ]) + if (!Number.isInteger(config[key]) || config[key] <= 0) + throw new TypeError(`${kind} has invalid decision parameter ${key}`); + if (config.requestedBlocks < config.minimumBlocks) + throw new TypeError(`${kind} has an invalid requested block count`); + if (config.maxAttempts < config.requestedBlocks) + throw new TypeError(`${kind} has an invalid maxAttempts`); + for (const key of ['warmupMinimum', 'warmupMaximum']) + if (!Number.isInteger(config[key]) || config[key] < 0) + throw new TypeError(`${kind} has invalid decision parameter ${key}`); + if (config.warmupMaximum < config.warmupMinimum) + throw new TypeError(`${kind} has an invalid warm-up range`); + for (const key of [ + 'targetBatchMs', + 'driftThreshold', + 'bootstrapResamples', + 'runtimeNonRegressionMargin', + 'equivalenceMargin', + 'precisionMargin', + 'growthThreshold', + 'orderInteractionThreshold', + ]) + if ( + typeof config[key] !== 'number' || + !Number.isFinite(config[key]) || + config[key] <= 0 + ) + throw new TypeError(`${kind} has invalid decision parameter ${key}`); + if ( + typeof config.confidence !== 'number' || + !Number.isFinite(config.confidence) || + config.confidence <= 0 || + config.confidence >= 1 + ) + throw new TypeError(`${kind} has invalid decision parameter confidence`); + if (config.runtimeNonRegressionMargin < 1 || config.equivalenceMargin < 1) + throw new TypeError(`${kind} has an invalid ratio margin`); + if (config.precisionMargin < 1 || config.growthThreshold <= 1) + throw new TypeError(`${kind} has an invalid precision or growth margin`); + if ( + ![DECISION_INTERVAL_METHOD, CORPUS_INTERVAL_METHOD].includes( + config.intervalMethod + ) + ) + throw new TypeError(`${kind} has an invalid interval method`); + if (config.precisionMethod !== PRECISION_METHOD) + throw new TypeError(`${kind} has an invalid precision method`); + return config; +} + +/** + * Migrate the pre-contract artifacts that were emitted by the first schema-v2 + * implementation. This is intentionally the only place where repository + * defaults are applied. New artifacts must carry decisionConfigVersion: 3. + */ +export function migrateLegacyDecisionConfig( + artifact, + kind = artifact?.benchmark === 'corpus' ? 'corpus' : 'parser' +) { + const source = artifact?.config ?? {}; + const blocks = artifact?.blocks?.length ?? artifact?.replicates?.length ?? 0; + const requestedBlocks = + source.requestedBlocks ?? source.blocks ?? source.replicates ?? blocks; + const base = { + decisionConfigVersion: DECISION_CONFIG_VERSION, + requestedBlocks, + minimumBlocks: source.minimumBlocks ?? MIN_VALID_BLOCKS, + maxAttempts: source.maxAttempts ?? Math.max(30, requestedBlocks), + targetBatchMs: source.targetBatchMs ?? TARGET_BATCH_MS, + warmupMinimum: + source.warmupMinimum ?? (kind === 'corpus' ? 0 : MIN_WARMUPS), + warmupMaximum: + source.warmupMaximum ?? (kind === 'corpus' ? 0 : MAX_WARMUPS), + measuredBatchCount: + source.measuredBatchCount ?? source.batches ?? MEASURED_BATCHES, + driftThreshold: source.driftThreshold ?? DRIFT_THRESHOLD, + bootstrapResamples: source.bootstrapResamples ?? BOOTSTRAP_RESAMPLES, + confidence: source.confidence ?? 0.95, + runtimeNonRegressionMargin: + source.runtimeNonRegressionMargin ?? NON_REGRESSION_MARGIN, + equivalenceMargin: + source.equivalenceMargin ?? + (kind === 'corpus' ? CORPUS_EQUIVALENCE_MARGIN : 1.1), + precisionMargin: source.precisionMargin ?? 1.1, + precisionMethod: PRECISION_METHOD, + growthThreshold: source.growthThreshold ?? GROWTH_THRESHOLD, + orderInteractionThreshold: + source.orderInteractionThreshold ?? Math.log(1.1), + intervalMethod: + source.intervalMethod ?? + (kind === 'corpus' ? CORPUS_INTERVAL_METHOD : DECISION_INTERVAL_METHOD), + }; + if (kind === 'corpus') + return { + ...base, + replicates: source.replicates ?? blocks, + batches: source.batches ?? 6, + calibrationOrderBalanced: source.calibrationOrderBalanced ?? false, + }; + return base; +} + +/** @param {object} artifact @param {string} kind @return {object} */ +export function decisionConfigForArtifact(artifact, kind) { + if (artifact?.config?.decisionConfigVersion === DECISION_CONFIG_VERSION) + return validateDecisionConfig(artifact.config, `${kind} artifact`); + if (artifact?.config?.decisionConfigVersion !== undefined) + throw new TypeError( + `${kind} artifact has an invalid decision configuration version` + ); + return validateDecisionConfig( + migrateLegacyDecisionConfig(artifact, kind), + `${kind} legacy artifact` + ); +} + +/** @param {unknown} seed @return {number} */ +export function normalizeSeed(seed) { + if (typeof seed === 'number') { + if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff) + throw new TypeError('seed must be an unsigned 32-bit integer'); + return seed >>> 0; + } + if (typeof seed === 'string' && /^\d+$/.test(seed)) { + const value = Number(seed); + if (Number.isSafeInteger(value) && value <= 0xffffffff) return value >>> 0; + } + throw new TypeError('seed must be an unsigned 32-bit integer'); +} + +/** @param {number} seed @return {() => number} */ +export function seededRandom(seed) { + let state = normalizeSeed(seed) || 0x9e3779b9; + return () => { + state = Math.imul(state ^ (state >>> 16), 0x21f0aaad); + state = Math.imul(state ^ (state >>> 15), 0x735a2d97); + state ^= state >>> 15; + return (state >>> 0) / 0x1_0000_0000; + }; +} + +/** @template T @param {readonly T[]} values @param {number} seed @return {T[]} */ +export function seededShuffle(values, seed) { + const result = [...values]; + const random = seededRandom(seed); + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} + +/** + * Return a shuffled, balanced process schedule. Values are deliberately + * explicit (`baseline-first`/`candidate-first`) so the artifact is auditable. + * + * @param {number} count + * @param {number} seed + */ +export function balancedOrder(count, seed) { + if (!Number.isInteger(count) || count < 2 || count % 2 !== 0) + throw new RangeError('a balanced schedule requires a positive even count'); + return seededShuffle( + Array.from({ length: count }, (_, index) => + index < count / 2 ? 'baseline-first' : 'candidate-first' + ), + seed + ); +} + +/** + * Return a deterministic, balanced schedule for the corpus calibration order. + * Odd counts differ by at most one; the first label receives the extra slot. + */ +export function balancedSchedule(count, first, second, seed) { + if (!Number.isInteger(count) || count <= 0) + throw new RangeError('schedule count must be positive'); + if ( + typeof first !== 'string' || + typeof second !== 'string' || + first === second + ) + throw new TypeError('schedule labels must be distinct strings'); + return seededShuffle( + Array.from({ length: count }, (_, index) => + index < Math.ceil(count / 2) ? first : second + ), + seed + ); +} + +/** @param {number} count @param {number} seed @return {number[]} */ +export function bootstrapIndices(count, seed) { + if (!Number.isInteger(count) || count <= 0) + throw new RangeError('cannot resample an empty collection'); + const random = seededRandom(seed); + return Array.from({ length: count }, () => Math.floor(random() * count)); +} + +/** @param {number[]} values @return {number} */ +function finiteValues(values) { + if (!Array.isArray(values) || values.length === 0) + throw new RangeError('expected a non-empty numeric array'); + if ( + values.some((value) => typeof value !== 'number' || !Number.isFinite(value)) + ) + throw new TypeError('values must be finite numbers'); + return values; +} + +/** @param {number[]} values @return {number} */ +export function median(values) { + const sorted = [...finiteValues(values)].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 + ? sorted[middle] + : (sorted[middle - 1] + sorted[middle]) / 2; +} + +/** @param {number[]} values @param {number} p @return {number} */ +export function percentile(values, p) { + const sorted = [...finiteValues(values)].sort((a, b) => a - b); + if (!Number.isFinite(p) || p < 0 || p > 1) + throw new RangeError('p must be in [0, 1]'); + const position = (sorted.length - 1) * p; + const lower = Math.floor(position); + const upper = Math.ceil(position); + return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower); +} + +/** @param {number[]} values @return {{median: number, q1: number, q3: number, min: number, max: number, sd: number, cv: number, relativeSpan: number}} */ +export function variationMetrics(values) { + const finite = finiteValues(values); + const mean = finite.reduce((sum, value) => sum + value, 0) / finite.length; + const variance = + finite.reduce((sum, value) => sum + (value - mean) ** 2, 0) / + Math.max(1, finite.length - 1); + return { + median: median(finite), + q1: percentile(finite, 0.25), + q3: percentile(finite, 0.75), + min: Math.min(...finite), + max: Math.max(...finite), + sd: Math.sqrt(variance), + cv: mean === 0 ? Infinity : Math.sqrt(variance) / Math.abs(mean), + relativeSpan: + mean === 0 + ? Infinity + : (Math.max(...finite) - Math.min(...finite)) / Math.abs(mean), + }; +} + +export function variation(values) { + return variationMetrics(values); +} + +/** @param {number[]} values @return {number} */ +export function geometricMean(values) { + const finite = finiteValues(values); + if (finite.some((value) => value <= 0)) + throw new RangeError('geometric mean requires positive values'); + return Math.exp( + finite.reduce((sum, value) => sum + Math.log(value), 0) / finite.length + ); +} + +/** @param {number} candidate @param {number} baseline @return {number} */ +export function logRatio(candidate, baseline) { + if ( + !(candidate > 0) || + !(baseline > 0) || + !Number.isFinite(candidate) || + !Number.isFinite(baseline) + ) + throw new RangeError('runtime ratios require positive finite timings'); + return Math.log(candidate / baseline); +} + +/** @param {number[]} values @return {{mean: number, sd: number, lower: number, upper: number}} */ +export function ordinaryInterval(values) { + const finite = finiteValues(values); + const mean = finite.reduce((sum, value) => sum + value, 0) / finite.length; + const sd = Math.sqrt( + finite.reduce((sum, value) => sum + (value - mean) ** 2, 0) / + Math.max(1, finite.length - 1) + ); + const half = (1.96 * sd) / Math.sqrt(finite.length); + return { mean, sd, lower: mean - half, upper: mean + half }; +} + +/** @param {number[]} values @return {{mean: number, sd: number, lower: number, upper: number}} */ +export function oneSidedInterval(values) { + const interval = ordinaryInterval(values); + const half = (1.6448536269514722 * interval.sd) / Math.sqrt(values.length); + return { + ...interval, + lower: interval.mean - half, + upper: interval.mean + half, + }; +} + +/** @param {number[]} values @return {{meanLog: number, ratio: number, ordinary: object, logValues: number[]}} */ +export function pairedRatioSummary(values) { + const logValues = finiteValues(values); + const ordinary = ordinaryInterval(logValues); + return { + meanLog: ordinary.mean, + ratio: Math.exp(ordinary.mean), + ordinary, + logValues, + }; +} + +/** @param {number[]} values @param {number} seed @param {number} [resamples] */ +export function bootstrapMeanInterval( + values, + seed, + resamples = BOOTSTRAP_RESAMPLES, + confidence = 0.95 +) { + const source = finiteValues(values); + if (!Number.isInteger(resamples) || resamples <= 0) + throw new RangeError('resamples must be positive'); + if (!(confidence > 0 && confidence < 1)) + throw new RangeError('confidence must be in (0, 1)'); + const random = seededRandom(seed); + const means = Array.from({ length: resamples }); + for (let sample = 0; sample < resamples; sample++) { + let sum = 0; + for (let i = 0; i < source.length; i++) + sum += source[Math.floor(random() * source.length)]; + means[sample] = sum / source.length; + } + return { + lower: percentile(means, (1 - confidence) / 2), + upper: percentile(means, 1 - (1 - confidence) / 2), + resamples, + }; +} + +/** @param {number[]} logValues @param {number} seed @param {number} [resamples] */ +export function bootstrapRatioInterval( + logValues, + seed, + resamples = BOOTSTRAP_RESAMPLES, + confidence = 0.95 +) { + const interval = bootstrapMeanInterval( + logValues, + seed, + resamples, + confidence + ); + return { + ...interval, + lowerRatio: Math.exp(interval.lower), + upperRatio: Math.exp(interval.upper), + }; +} + +/** + * Bootstrap columns from the same row schedule. A row is one complete + * benchmark block, so all endpoints in a resample retain their correlation. + * The returned intervals are in the input (usually log-ratio) domain. + * + * @param {number[][]} rows + * @param {number} seed + * @param {number} [familyCount] + * @param {number} [resamples] + */ +export function bootstrapPairedIntervals( + rows, + seed, + familyCount = rows[0]?.length ?? 1, + resamples = BOOTSTRAP_RESAMPLES +) { + if (!Array.isArray(rows) || rows.length === 0) + throw new RangeError('cannot bootstrap an empty matrix'); + if (!Number.isInteger(resamples) || resamples <= 0) + throw new RangeError('resamples must be positive'); + const width = rows[0]?.length; + if (!Number.isInteger(width) || width <= 0) + throw new RangeError('cannot bootstrap a matrix without columns'); + if (familyCount !== width) + throw new RangeError('familyCount must equal the matrix width'); + for (const row of rows) { + if (!Array.isArray(row) || row.length !== width) { + throw new TypeError('bootstrap rows must have equal widths'); + } + finiteValues(row); + } + + const observed = Array(width).fill(0); + for (const row of rows) + for (let column = 0; column < width; column++) + observed[column] += row[column]; + for (let column = 0; column < width; column++) + observed[column] /= rows.length; + + const standardErrors = Array.from({ length: width }, (_, column) => { + const variance = + rows.reduce( + (sum, row) => sum + (row[column] - observed[column]) ** 2, + 0 + ) / Math.max(1, rows.length - 1); + return Math.sqrt(variance / rows.length); + }); + const distributions = Array.from({ length: width }, () => Array(resamples)); + const studentizedDeviations = Array(resamples); + const random = seededRandom(seed); + for (let sample = 0; sample < resamples; sample++) { + const sums = Array(width).fill(0); + for (let i = 0; i < rows.length; i++) { + const row = rows[Math.floor(random() * rows.length)]; + for (let column = 0; column < width; column++) + sums[column] += row[column]; + } + let maxDeviation = 0; + for (let column = 0; column < width; column++) { + const mean = sums[column] / rows.length; + distributions[column][sample] = mean; + const standardError = standardErrors[column]; + let deviation; + if (standardError === 0) + deviation = mean === observed[column] ? 0 : Infinity; + else deviation = Math.abs((mean - observed[column]) / standardError); + maxDeviation = Math.max(maxDeviation, deviation); + } + studentizedDeviations[sample] = maxDeviation; + } + + const familyCritical = percentile(studentizedDeviations, 0.95); + return { + familyCount, + resamples, + method: 'max-t-studentized-bootstrap', + standardErrors, + observed, + intervals: distributions.map((values, column) => ({ + lower: percentile(values, 0.025), + upper: percentile(values, 0.975), + oneSidedLower: percentile(values, 0.05), + oneSidedUpper: percentile(values, 0.95), + familyLower: observed[column] - familyCritical * standardErrors[column], + familyUpper: observed[column] + familyCritical * standardErrors[column], + familyCritical, + })), + }; +} + +/** + * Studentized max-T bootstrap for a two-stratum estimator. Each row is an + * independent experimental unit and is sampled as a complete row, which + * preserves correlation between endpoint columns. + * + * The observed estimator gives equal weight to the two strata. The bootstrap + * standard error is recomputed for every resample. If a resample has zero + * variance but its estimate differs from the observed estimate, its statistic + * uses the observed standard error for that endpoint. + * + * @param {{rows: number[][], strata: string[], seed: number, resamples?: number, confidence?: number}} options + */ +export function bootstrapStratifiedMaxT({ + rows, + strata, + seed, + resamples = BOOTSTRAP_RESAMPLES, + confidence = 0.95, +}) { + if (!Array.isArray(rows) || rows.length === 0) + throw new RangeError('cannot bootstrap an empty matrix'); + if (!Array.isArray(strata) || strata.length !== rows.length) + throw new RangeError('strata must match bootstrap rows'); + if (!Number.isInteger(resamples) || resamples <= 0) + throw new RangeError('resamples must be positive'); + if (!(confidence > 0 && confidence < 1)) + throw new RangeError('confidence must be in (0, 1)'); + const width = rows[0]?.length; + if (!Number.isInteger(width) || width <= 0) + throw new RangeError('cannot bootstrap a matrix without columns'); + for (const row of rows) { + if (!Array.isArray(row) || row.length !== width) + throw new TypeError('bootstrap rows must have equal widths'); + finiteValues(row); + } + const labels = [...new Set(strata)]; + if (labels.length !== 2) + throw new RangeError('stratified max-T requires exactly two strata'); + const ordered = labels.sort(); + const strataRows = ordered.map((label) => + rows + .map((row, index) => ({ row, label: strata[index] })) + .filter((item) => item.label === label) + .map((item) => item.row) + .sort(compareRows) + ); + if (strataRows.some((group) => group.length === 0)) + throw new RangeError('cannot resample an empty stratum'); + + const observedByStratum = strataRows.map((group) => + columnMeans(group, width) + ); + const observed = Array.from( + { length: width }, + (_, column) => + observedByStratum.reduce((sum, means) => sum + means[column], 0) / + observedByStratum.length + ); + const observedSE = standardErrorsForSample( + observedByStratum, + strataRows, + width + ); + const distributions = Array.from({ length: width }, () => []); + const studentizedMax = []; + let degenerateResamples = 0; + let degenerateFallbacks = 0; + const random = seededRandom(seed); + + for (let sample = 0; sample < resamples; sample++) { + const sampled = strataRows.map((group) => + Array.from( + { length: group.length }, + () => group[Math.floor(random() * group.length)] + ) + ); + const sampledMeans = sampled.map((group) => columnMeans(group, width)); + const effects = Array.from( + { length: width }, + (_, column) => + sampledMeans.reduce((sum, means) => sum + means[column], 0) / + sampledMeans.length + ); + const sampledSE = standardErrorsForSample(sampledMeans, sampled, width); + let maxT = 0; + let hasDegenerateEndpoint = false; + for (let column = 0; column < width; column++) { + distributions[column].push(effects[column]); + let statistic; + if (sampledSE[column] === 0) { + hasDegenerateEndpoint = true; + const deviation = effects[column] - observed[column]; + if (deviation === 0) statistic = 0; + else { + if (observedSE[column] === 0) + throw new RangeError( + 'nonzero bootstrap deviation has no positive standard error' + ); + statistic = deviation / observedSE[column]; + degenerateFallbacks++; + } + } else { + statistic = (effects[column] - observed[column]) / sampledSE[column]; + } + maxT = Math.max(maxT, Math.abs(statistic)); + } + if (hasDegenerateEndpoint) degenerateResamples++; + studentizedMax.push(maxT); + } + + const familyCritical = percentile(studentizedMax, confidence); + return { + method: DECISION_INTERVAL_METHOD, + strata: ordered, + familyCount: width, + resamples, + confidence, + observed, + standardErrors: observedSE, + familyCritical, + degenerateResamples, + degenerateFallbacks, + intervals: distributions.map((values, column) => { + const alpha = (1 - confidence) / 2; + const halfWidth = + observedSE[column] === 0 ? 0 : familyCritical * observedSE[column]; + return { + lower: percentile(values, alpha), + upper: percentile(values, 1 - alpha), + oneSidedLower: observed[column] - halfWidth, + oneSidedUpper: observed[column] + halfWidth, + familyLower: observed[column] - halfWidth, + familyUpper: observed[column] + halfWidth, + familyCritical, + }; + }), + }; +} + +function compareRows(left, right) { + for (let index = 0; index < left.length; index++) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} + +function columnMeans(rows, width) { + const means = Array(width).fill(0); + for (const row of rows) + for (let column = 0; column < width; column++) means[column] += row[column]; + return means.map((sum) => sum / rows.length); +} + +function standardErrorsForSample(stratumMeans, sampledRows, width) { + return Array.from({ length: width }, (_, column) => { + let variance = 0; + for (let stratum = 0; stratum < sampledRows.length; stratum++) { + const rows = sampledRows[stratum]; + const mean = stratumMeans[stratum][column]; + const within = + rows.reduce((sum, row) => sum + (row[column] - mean) ** 2, 0) / + Math.max(1, rows.length - 1); + variance += within / rows.length; + } + return Math.sqrt(variance / stratumMeans.length ** 2); + }); +} + +/** @param {number[]} x @param {number[]} y @return {{alpha: number, beta: number, r2: number, n: number}} */ +export function linearRegression(x, y) { + if ( + !Array.isArray(x) || + !Array.isArray(y) || + x.length !== y.length || + x.length < 2 + ) + throw new RangeError( + 'linear regression requires paired arrays of length at least two' + ); + finiteValues(x); + finiteValues(y); + const xMean = x.reduce((sum, value) => sum + value, 0) / x.length; + const yMean = y.reduce((sum, value) => sum + value, 0) / y.length; + const ssX = x.reduce((sum, value) => sum + (value - xMean) ** 2, 0); + if (ssX === 0) + throw new RangeError('linear regression requires varying x values'); + const covariance = x.reduce( + (sum, value, index) => sum + (value - xMean) * (y[index] - yMean), + 0 + ); + const beta = covariance / ssX; + const alpha = yMean - beta * xMean; + const ssY = y.reduce((sum, value) => sum + (value - yMean) ** 2, 0); + const residual = y.reduce( + (sum, value, index) => sum + (value - (alpha + beta * x[index])) ** 2, + 0 + ); + return { alpha, beta, r2: ssY === 0 ? 1 : 1 - residual / ssY, n: x.length }; +} + +export function regression(x, y) { + return linearRegression(x, y); +} + +/** @param {number[]} values @param {number} familyCount */ +export function familyAdjustedBounds(values, familyCount = 1) { + const interval = ordinaryInterval(values); + const z = normalQuantile(1 - 0.05 / Math.max(1, familyCount)); + const half = (z * interval.sd) / Math.sqrt(values.length); + return { + lower: interval.mean - half, + upper: interval.mean + half, + z, + lowerRatio: Math.exp(interval.mean - half), + upperRatio: Math.exp(interval.mean + half), + }; +} + +/** Acklam's inverse-normal approximation, sufficient for benchmark intervals. */ +function normalQuantile(p) { + const a = [ + -39.6968302866538, 220.946098424521, -275.928510446969, 138.357751867269, + -30.6647980661472, 2.50662827745924, + ]; + const b = [ + -54.4760987982241, 161.585836858041, -155.698979859887, 66.8013118877197, + -13.2806815528857, + ]; + const c = [ + -0.00778489400243029, -0.322396458041136, -2.40075827716184, + -2.54973253934373, 4.37466414146497, 2.93816398269878, + ]; + const d = [ + 0.00778469570904146, 0.32246712907004, 2.445134137143, 3.75440866190742, + ]; + const plow = 0.02425; + const phigh = 1 - plow; + if (p <= 0 || p >= 1) + throw new RangeError('normal quantile requires p in (0, 1)'); + if (p < plow) { + const q = Math.sqrt(-2 * Math.log(p)); + const numerator = horner(c, q); + const denominator = horner([...d, 1], q); + return numerator / denominator; + } + if (p > phigh) { + const q = Math.sqrt(-2 * Math.log(1 - p)); + const numerator = horner(c, q); + const denominator = horner([...d, 1], q); + return -numerator / denominator; + } + const q = p - 0.5; + const r = q * q; + const numerator = horner(a, r) * q; + const denominator = horner([...b, 1], r); + return numerator / denominator; +} + +/** @param {number[]} coefficients @param {number} value */ +function horner(coefficients, value) { + return coefficients.reduce( + (result, coefficient) => result * value + coefficient, + 0 + ); +} + +/** @param {string} file @return {string} */ +export function sha256File(file) { + return createHash('sha256').update(readFileSync(file)).digest('hex'); +} + +/** @param {string} directory @return {string} */ +export function sourceTreeHash(directory) { + const files = []; + function visit(current) { + for (const name of readdirSync(current).sort()) { + const file = join(current, name); + const stat = statSync(file); + if (stat.isDirectory()) visit(file); + else files.push([relative(directory, file), readFileSync(file)]); + } + } + visit(directory); + return hashSourceFiles(files); +} + +function hashSourceFiles(files) { + const hash = createHash('sha256'); + for (const [name, data] of files) + hash.update(name).update('\0').update(data).update('\0'); + return hash.digest('hex'); +} + +function benchmarkHarnessFiles(root) { + const files = []; + const scripts = join(root, 'scripts'); + const lib = join(scripts, 'lib'); + function addTree(directory, prefix) { + for (const name of readdirSync(directory).sort()) { + const file = join(directory, name); + const stat = statSync(file); + if (stat.isDirectory()) addTree(file, `${prefix}/${name}`); + else files.push([`${prefix}/${name}`, readFileSync(file)]); + } + } + addTree(lib, 'scripts/lib'); + for (const name of readdirSync(scripts).sort()) { + if (!/^benchmark(?:-.+)?\.js$/.test(name) && !name.endsWith('worker.js')) + continue; + const file = join(scripts, name); + if (statSync(file).isFile()) + files.push([`scripts/${name}`, readFileSync(file)]); + } + return files.sort(([a], [b]) => a.localeCompare(b)); +} + +export function benchmarkHarnessHash(root) { + return hashSourceFiles(benchmarkHarnessFiles(root)); +} + +function git(root, args) { + return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); +} + +function gitBuffer(root, args) { + return execFileSync('git', args, { cwd: root }); +} + +function gitSourceTreeHash(root, ref) { + const paths = git(root, ['ls-tree', '-r', '--name-only', ref, '--', 'src']) + .split('\n') + .filter(Boolean); + return hashSourceFiles( + paths.map((path) => [ + path.slice('src/'.length), + gitBuffer(root, ['show', `${ref}:${path}`]), + ]) + ); +} + +/** @param {string} root @param {string} baselineRef */ +export function collectBenchmarkProvenance( + root, + { + baselineRef = 'HEAD', + benchmark = 'unknown', + corpusPath, + command = process.argv.join(' '), + } = {} +) { + const currentCommit = git(root, ['rev-parse', 'HEAD']); + const baselineCommit = git(root, ['rev-parse', baselineRef]); + const lockfile = join(root, 'pnpm-lock.yaml'); + let governor = null; + try { + governor = readFileSync( + '/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor', + 'utf8' + ).trim(); + } catch { + // Linux CPU governor is optional on other operating systems. + } + return { + createdAt: new Date().toISOString(), + command, + benchmark, + benchmarkHarnessHash: benchmarkHarnessHash(root), + baselineCommit, + worktreeCommit: currentCommit, + baselineSourceHash: gitSourceTreeHash(root, baselineRef), + worktreeSourceHash: sourceTreeHash(join(root, 'src')), + baselineSourceTreeHash: gitSourceTreeHash(root, baselineRef), + worktreeSourceTreeHash: sourceTreeHash(join(root, 'src')), + sourceTreeHash: sourceTreeHash(join(root, 'src')), + lockfileHash: sha256File(lockfile), + corpusHash: corpusPath ? sha256File(corpusPath) : null, + dirty: git(root, ['status', '--porcelain']) !== '', + node: process.version, + v8: process.versions.v8, + cpu: cpus()[0]?.model ?? 'unknown', + cpuCount: cpus().length, + platform: `${platform()} ${release()} ${arch()}`, + os: `${platform()} ${release()} ${arch()}`, + loadAverage: loadavg(), + linuxCpuGovernor: governor, + }; +} + +export function collectEnvironment(root, baselineRef = 'HEAD') { + return collectBenchmarkProvenance(root, { baselineRef }); +} + +/** @param {string} root @param {string} ref @return {{directory: string, sourceRoot: string, commit: string, cleanup: () => void}} */ +export function materializeBaseline(root, ref) { + // Keep the temporary tree below the project so ESM's normal package + // resolution can reach the current checkout's installed dependencies. + const directory = mkdtempSync(join(root, '.postcss-calc-baseline-')); + mkdirSync(join(directory, 'src'), { recursive: true }); + try { + const archive = execFileSync('git', ['archive', ref, '--', 'src'], { + cwd: root, + }); + execFileSync('tar', ['-x', '-f', '-', '-C', directory], { input: archive }); + return { + directory, + sourceRoot: join(directory, 'src'), + commit: git(root, ['rev-parse', ref]), + cleanup: () => rmSync(directory, { recursive: true, force: true }), + }; + } catch (error) { + rmSync(directory, { recursive: true, force: true }); + throw error; + } +} + +/** @param {string} worker @param {object} payload @param {string} cwd */ +export function runChild(worker, payload, cwd) { + const result = spawnSync(process.execPath, [worker], { + cwd, + encoding: 'utf8', + input: JSON.stringify(payload), + maxBuffer: 64 * 1024 * 1024, + env: { ...process.env }, + }); + if (result.error) throw result.error; + if (result.status !== 0) + throw new Error( + `benchmark child failed (${result.status}): ${result.stderr || result.stdout}` + ); + try { + return JSON.parse(result.stdout); + } catch { + throw new Error( + `benchmark child returned invalid JSON: ${result.stdout.slice(0, 500)}` + ); + } +} + +/** @param {unknown} artifact @return {object} */ +export function validateSchemaV2Artifact(artifact) { + if (!artifact || typeof artifact !== 'object' || artifact.schema !== 2) + throw new TypeError('artifact must use schema 2'); + if ( + typeof artifact.seed !== 'number' || + !Number.isInteger(artifact.seed) || + artifact.seed < 0 || + artifact.seed > 0xffffffff + ) + throw new TypeError('artifact has an invalid seed'); + if (artifact.benchmark === 'corpus') return validateCorpusArtifact(artifact); + if (!Array.isArray(artifact.blocks)) + throw new TypeError('artifact must contain blocks'); + const config = + artifact.config?.decisionConfigVersion === DECISION_CONFIG_VERSION + ? validateDecisionConfig(artifact.config, 'parser artifact') + : migrateLegacyDecisionConfig(artifact, 'parser'); + const requestedBlocks = config.requestedBlocks; + const maxAttempts = config.maxAttempts; + if ( + !Number.isInteger(requestedBlocks) || + requestedBlocks < MIN_VALID_BLOCKS || + requestedBlocks % 2 !== 0 + ) + throw new TypeError('artifact has an invalid requested block count'); + if (!Number.isInteger(maxAttempts) || maxAttempts < requestedBlocks) + throw new TypeError('artifact has an invalid maxAttempts'); + const minimumBlocks = config.minimumBlocks; + const underFloor = artifact.blocks.length < minimumBlocks; + const isInconclusiveUnderfloorArtifact = + underFloor && + artifact.analysis?.status === 'inconclusive' && + Array.isArray(artifact.attempts) && + artifact.attempts.length > 0; + const isCorrectnessFailure = + artifact.analysis?.status === 'correctness-failure'; + if (underFloor && !isInconclusiveUnderfloorArtifact && !isCorrectnessFailure) + throw new TypeError( + `artifact has fewer than ${minimumBlocks} valid blocks` + ); + const expected = new Set(artifact.workloadKeys ?? []); + if (expected.size === 0) + throw new TypeError('artifact must contain workload keys'); + for (const [blockIndex, block] of artifact.blocks.entries()) + validateParserRecord(block, `block ${blockIndex}`, expected); + if (artifact.attempts !== undefined) { + if (!Array.isArray(artifact.attempts) || artifact.attempts.length === 0) + throw new TypeError('artifact attempts must be a non-empty array'); + if (artifact.attempts.length > maxAttempts) + throw new TypeError('artifact contains more attempts than maxAttempts'); + for (const [attemptIndex, attempt] of artifact.attempts.entries()) { + validateParserRecord( + attempt, + `attempt ${attemptIndex}`, + expected, + true, + config.driftThreshold + ); + if (attempt.index !== attemptIndex) + throw new TypeError( + `attempt ${attemptIndex} has an inconsistent index` + ); + } + const accepted = artifact.attempts.filter((attempt) => !attempt.rejected); + if (accepted.length !== artifact.blocks.length) + throw new TypeError('artifact attempts and blocks are inconsistent'); + for (const [index, block] of artifact.blocks.entries()) { + const attempt = accepted[index]; + if (JSON.stringify(attempt) !== JSON.stringify(block)) + throw new TypeError('artifact attempts and blocks are inconsistent'); + } + } else if (underFloor) { + throw new TypeError('under-floor artifact must retain attempts'); + } + if (artifact.blocks.length > requestedBlocks) + throw new TypeError('artifact contains more blocks than requested'); + if (artifact.blocks.length === requestedBlocks) { + const orders = artifact.blocks.map((block) => block.processOrder); + if ( + orders.filter((order) => order === 'baseline-first').length !== + requestedBlocks / 2 + ) + throw new TypeError('accepted blocks have an unbalanced process order'); + } + if (artifact.blocks.length >= minimumBlocks) { + const orderCounts = { + 'baseline-first': artifact.blocks.filter( + (block) => block.processOrder === 'baseline-first' + ).length, + 'candidate-first': artifact.blocks.filter( + (block) => block.processOrder === 'candidate-first' + ).length, + }; + if (orderCounts['baseline-first'] !== orderCounts['candidate-first']) + throw new TypeError('accepted blocks have an unbalanced process order'); + } + return artifact; +} + +function validateParserRecord( + record, + label, + expected, + isAttempt = false, + driftThreshold = 0.15 +) { + if ( + !record || + typeof record !== 'object' || + !['baseline-first', 'candidate-first'].includes(record.processOrder) + ) + throw new TypeError(`${label} has an invalid process order`); + if ( + !Number.isInteger(record.seed) || + record.seed < 0 || + record.seed > 0xffffffff + ) + throw new TypeError(`${label} has an invalid seed`); + if (isAttempt && !Number.isInteger(record.index)) + throw new TypeError(`${label} has an invalid index`); + if ( + !Array.isArray(record.workloadOrder) || + record.workloadOrder.length !== expected.size || + new Set(record.workloadOrder).size !== expected.size || + record.workloadOrder.some((key) => !expected.has(key)) + ) + throw new TypeError(`${label} has mismatched workload keys`); + if (!Array.isArray(record.revisions) || record.revisions.length !== 2) + throw new TypeError(`${label} must contain two revisions`); + const revisions = new Set( + record.revisions.map((revision) => revision?.revision) + ); + if ( + revisions.size !== 2 || + !revisions.has('baseline') || + !revisions.has('candidate') + ) + throw new TypeError(`${label} must contain baseline and candidate`); + if (isAttempt) { + if (typeof record.rejected !== 'boolean') + throw new TypeError(`${label} has an invalid rejection flag`); + if ( + !Array.isArray(record.rejectionReasons) || + record.rejectionReasons.some( + (reason) => !['drift', 'structural-mismatch'].includes(reason) + ) || + new Set(record.rejectionReasons).size !== + record.rejectionReasons.length || + record.rejected !== Boolean(record.rejectionReasons.length) || + record.rejectionReason !== (record.rejectionReasons.join('+') || null) + ) + throw new TypeError(`${label} has an invalid rejection reason`); + if ( + !Array.isArray(record.drift) || + record.drift.length !== 2 || + record.drift.some( + (value) => + typeof value !== 'number' || !Number.isFinite(value) || value < 0 + ) + ) + throw new TypeError(`${label} has invalid drift`); + if ( + !Array.isArray(record.structuralMismatches) || + record.structuralMismatches.some((key) => typeof key !== 'string') + ) + throw new TypeError(`${label} has invalid structural mismatches`); + if ( + Boolean(record.structuralMismatches.length) !== + record.rejectionReasons.includes('structural-mismatch') + ) + throw new TypeError(`${label} has inconsistent structural rejection`); + if ( + record.drift.some((value) => value > driftThreshold) !== + record.rejectionReasons.includes('drift') + ) + throw new TypeError(`${label} has inconsistent drift rejection`); + } + for (const revision of record.revisions) + validateParserRevision(revision, label, expected, record.processOrder); + if (isAttempt) { + const expectedDrift = record.revisions.map((revision) => + Math.abs( + revision.controlAfter.medianMs / revision.controlBefore.medianMs - 1 + ) + ); + if ( + record.drift.some( + (value, index) => Math.abs(value - expectedDrift[index]) > 1e-12 + ) + ) + throw new TypeError(`${label} has inconsistent drift`); + const structural = new Map( + record.revisions.flatMap((revision) => + revision.workloads.map((workload) => [ + `${revision.revision}:${workload.key}`, + workload.structural, + ]) + ) + ); + const mismatches = [...expected].filter( + (key) => + structural.get(`baseline:${key}`) !== structural.get(`candidate:${key}`) + ); + if ( + JSON.stringify(mismatches) !== JSON.stringify(record.structuralMismatches) + ) + throw new TypeError(`${label} has inconsistent structural mismatches`); + } +} + +function validateParserRevision(revision, label, expected, processOrder) { + if (!revision || !['baseline', 'candidate'].includes(revision.revision)) + throw new TypeError(`${label} has invalid revisions`); + if ( + !['baseline-first', 'candidate-first'].includes(revision.processOrder) || + revision.processOrder !== processOrder + ) + throw new TypeError(`${label} has invalid revision process order`); + validateControl(revision.controlBefore, `${label} controlBefore`); + validateControl(revision.controlAfter, `${label} controlAfter`); + if (!Array.isArray(revision.workloads)) + throw new TypeError(`${label} has invalid workloads`); + const seen = new Set(); + for (const workload of revision.workloads) { + if ( + !workload || + typeof workload.key !== 'string' || + !expected.has(workload.key) || + seen.has(workload.key) + ) + throw new TypeError(`${label} has mismatched workload keys`); + seen.add(workload.key); + validateParserWorkload(workload, `${label} ${workload.key}`); + } + if (seen.size !== expected.size) + throw new TypeError(`${label} has missing workload keys`); +} + +function validateControl(control, label) { + if ( + !control || + typeof control !== 'object' || + !positiveFinite(control.medianMs) || + !Array.isArray(control.samplesMs) || + control.samplesMs.length === 0 || + control.samplesMs.some((value) => !positiveFinite(value)) + ) + throw new TypeError(`${label} has invalid timings`); +} + +function validateParserWorkload(workload, label) { + if (!Number.isInteger(workload.repetitions) || workload.repetitions <= 0) + throw new TypeError(`${label} has invalid repetitions`); + for (const [name, values] of [ + ['calibrationSamplesMs', workload.calibrationSamplesMs], + ['warmups', workload.warmups], + ['warmupElapsedMs', workload.warmupElapsedMs], + ['measured', workload.measured], + ['measuredElapsedMs', workload.measuredElapsedMs], + ]) { + if ( + !Array.isArray(values) || + values.length === 0 || + values.some((value) => !positiveFinite(value)) + ) + throw new TypeError( + `${label} has ${ + name === 'measured' || name === 'warmups' + ? 'nonpositive timings' + : `invalid ${name}` + }` + ); + } + if ( + workload.warmups.length !== workload.warmupElapsedMs.length || + workload.measured.length !== workload.measuredElapsedMs.length + ) + throw new TypeError(`${label} has inconsistent elapsed timings`); + if ( + typeof workload.structural !== 'string' || + !workload.structural || + workload.checksum !== workload.structural || + !Number.isInteger(workload.consumed) || + workload.consumed < 0 + ) + throw new TypeError(`${label} has invalid structural digest or checksum`); +} + +function positiveFinite(value) { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function validateCorpusArtifact(artifact) { + if ( + !Array.isArray(artifact.replicates) || + artifact.replicates.length < MIN_VALID_BLOCKS + ) + throw new TypeError('corpus artifact has insufficient valid replicates'); + const config = + artifact.config?.decisionConfigVersion === DECISION_CONFIG_VERSION + ? validateDecisionConfig(artifact.config, 'corpus artifact') + : migrateLegacyDecisionConfig(artifact, 'corpus'); + if ( + (config.replicates ?? artifact.replicates.length) !== + artifact.replicates.length || + (config.batches ?? 6) !== 6 + ) + throw new TypeError( + 'corpus artifact has inconsistent replicate configuration' + ); + if (artifact.config?.decisionConfigVersion === DECISION_CONFIG_VERSION) + validateCorpusCorrectness(artifact); + else if ( + !artifact.correctness || + !Number.isInteger(artifact.correctness.accepted) || + artifact.correctness.accepted <= 0 + ) + throw new TypeError('corpus artifact has invalid correctness metadata'); + const expectedGroups = corpusGroups(artifact); + if (expectedGroups.length < 2) + throw new TypeError('corpus artifact has no complete comparison groups'); + const repetitionsByGroup = new Map(); + const checksumsByGroup = new Map(); + const allChecksumsByGroup = new Map(); + const replicateIds = new Set(); + for (const [index, replicate] of artifact.replicates.entries()) { + if ( + !Number.isInteger(replicate?.replicate) || + replicate.replicate < 0 || + replicateIds.has(replicate.replicate) + ) + throw new TypeError(`corpus replicate ${index} has an invalid id`); + replicateIds.add(replicate.replicate); + if (config.calibrationOrderBalanced === true) { + if ( + !['ours-first', 'reference-first'].includes(replicate.calibrationOrder) + ) + throw new TypeError( + `corpus replicate ${index} has an invalid calibration order` + ); + for (const expectedOrder of ['ours-first', 'reference-first']) { + const count = artifact.replicates.filter( + (item) => item.calibrationOrder === expectedOrder + ).length; + if (Math.abs(count - artifact.replicates.length / 2) > 1) + throw new TypeError('corpus calibration orders are unbalanced'); + } + } + if ( + !Array.isArray(replicate.permutation) || + !isPermutation(replicate.permutation, artifact.correctness.accepted) + ) + throw new TypeError( + `corpus replicate ${index} has an invalid permutation` + ); + if (!Array.isArray(replicate.batches) || replicate.batches.length !== 6) + throw new TypeError(`corpus replicate ${index} must contain six batches`); + const orderCounts = { 'ours-first': 0, 'reference-first': 0 }; + for (const [batchIndex, batch] of replicate.batches.entries()) { + if ( + !batch || + !['ours-first', 'reference-first'].includes(batch.order) || + !Array.isArray(batch.measurements) + ) + throw new TypeError( + `corpus replicate ${index} has invalid batch order` + ); + orderCounts[batch.order]++; + const seenGroups = new Set(); + if (batch.measurements.length !== expectedGroups.length) + throw new TypeError( + `corpus replicate ${index} batch ${batchIndex} has incomplete groups` + ); + for (const measurement of batch.measurements) { + if ( + !measurement || + typeof measurement.group !== 'string' || + !expectedGroups.includes(measurement.group) || + seenGroups.has(measurement.group) + ) + throw new TypeError( + `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); + } + } + if (seenGroups.size !== expectedGroups.length) + throw new TypeError( + `corpus replicate ${index} batch ${batchIndex} has missing groups` + ); + } + if (orderCounts['ours-first'] !== 3 || orderCounts['reference-first'] !== 3) + throw new TypeError( + `corpus replicate ${index} has unbalanced process orders` + ); + } + validateCorpusGroupSummaries(artifact, expectedGroups, allChecksumsByGroup); + return artifact; +} + +function validateCorpusCorrectness(artifact) { + const correctness = artifact.correctness; + if ( + !correctness || + !Number.isInteger(correctness.accepted) || + correctness.accepted <= 0 + ) + throw new TypeError('corpus artifact has invalid correctness metadata'); + if (!correctness.counts || !correctness.categoryHashes) + throw new TypeError('corpus artifact is missing corpus category metadata'); + for (const category of CORPUS_CATEGORIES) { + if ( + !Number.isInteger(correctness.counts[category]) || + correctness.counts[category] < 0 + ) + throw new TypeError(`corpus artifact has invalid ${category} count`); + if (typeof correctness.categoryHashes[category] !== 'string') + throw new TypeError(`corpus artifact has invalid ${category} hash`); + if ( + category !== 'accepted' && + !NEUTRAL_CORPUS_CATEGORIES.has(category) && + correctness.counts[category] !== 0 + ) + throw new TypeError( + `corpus artifact contains non-neutral ${category} inputs` + ); + } + if (correctness.counts.accepted !== correctness.accepted) + throw new TypeError('corpus artifact has inconsistent accepted counts'); + if ( + typeof correctness.inputHash !== 'string' || + correctness.inputHash.length === 0 + ) + throw new TypeError('corpus artifact has an invalid input hash'); +} + +function corpusGroups(artifact) { + const strata = artifact.corpus?.lengthStrata; + const shapes = artifact.corpus?.rootShapeCounts; + if ( + !strata || + !shapes || + typeof strata !== 'object' || + typeof shapes !== 'object' + ) + throw new TypeError('corpus artifact is missing group metadata'); + const groups = ['exact']; + for (const [group, count] of Object.entries(shapes).sort()) + if (Number.isInteger(count) && count > 0) groups.push(group); + for (const [group, count] of Object.entries(strata).sort()) + if (Number.isInteger(count) && count > 0) groups.push(group); + return groups; +} + +function isPermutation(values, length) { + return ( + Number.isInteger(length) && + Array.isArray(values) && + values.length === length && + values.every( + (value) => Number.isInteger(value) && value >= 0 && value < length + ) && + new Set(values).size === length + ); +} + +function validateCorpusGroupSummaries( + artifact, + expectedGroups, + allChecksumsByGroup +) { + const groups = artifact.analysis?.groups; + if (artifact.analysis === undefined) return; + if (!groups || typeof groups !== 'object' || Array.isArray(groups)) + throw new TypeError('corpus artifact is missing group summaries'); + const names = Object.keys(groups).sort(); + if (JSON.stringify(names) !== JSON.stringify([...expectedGroups].sort())) + throw new TypeError('corpus artifact has incomplete group summaries'); + for (const group of expectedGroups) { + const summary = groups[group]; + const rawRatios = corpusRawRatios(artifact, group); + if ( + !summary || + summary.replicates !== artifact.replicates.length || + !Array.isArray(summary.ratios) || + summary.ratios.length !== artifact.replicates.length || + summary.ratios.some((ratio) => !positiveFinite(ratio)) || + !Array.isArray(summary.checksums) || + summary.checksums.length === 0 || + summary.checksums.some( + (checksum) => !Number.isInteger(checksum) || checksum < 0 + ) + ) + throw new TypeError(`corpus group ${group} has invalid summary`); + if ( + rawRatios.length !== summary.ratios.length || + rawRatios.some( + (ratio, index) => Math.abs(ratio - summary.ratios[index]) > 1e-12 + ) + ) + throw new TypeError( + `corpus group ${group} summary is not derived from raw observations` + ); + const rawLogs = rawRatios.map(Math.log); + const rawMean = + rawLogs.reduce((sum, value) => sum + value, 0) / rawLogs.length; + if ( + summary.geometricMeanPairedRuntimeRatio !== undefined && + Math.abs(summary.geometricMeanPairedRuntimeRatio - Math.exp(rawMean)) > + 1e-12 + ) + throw new TypeError( + `corpus group ${group} summary mean is not derived from raw observations` + ); + if (summary.bootstrap95) { + const digest = createHash('sha256').update(group).digest('hex'); + const groupSeed = + (artifact.seed ^ Number.parseInt(digest.slice(0, 8), 16)) >>> 0; + const expectedBootstrap = bootstrapCorpusInterval( + corpusRawReplicatePairs(artifact, group), + groupSeed, + artifact.config?.bootstrapResamples ?? BOOTSTRAP_RESAMPLES, + 0.95 + ); + if ( + Math.abs( + summary.bootstrap95.lowerRatio - Math.exp(expectedBootstrap.lower) + ) > 1e-12 || + Math.abs( + summary.bootstrap95.upperRatio - Math.exp(expectedBootstrap.upper) + ) > 1e-12 + ) + throw new TypeError( + `corpus group ${group} interval is not derived from raw observations` + ); + if (summary.bootstrap90) { + const expectedNinety = bootstrapCorpusInterval( + corpusRawReplicatePairs(artifact, group), + (groupSeed ^ 0x9e3779b9) >>> 0, + artifact.config?.bootstrapResamples ?? BOOTSTRAP_RESAMPLES, + 0.9 + ); + if ( + Math.abs( + summary.bootstrap90.lowerRatio - Math.exp(expectedNinety.lower) + ) > 1e-12 || + Math.abs( + summary.bootstrap90.upperRatio - Math.exp(expectedNinety.upper) + ) > 1e-12 + ) + throw new TypeError( + `corpus group ${group} practical interval is not derived from raw observations` + ); + } + } + const expectedChecksums = allChecksumsByGroup.get(group) ?? new Set(); + if ( + summary.checksums.length !== expectedChecksums.size || + summary.checksums.some((checksum) => !expectedChecksums.has(checksum)) + ) + throw new TypeError(`corpus group ${group} has inconsistent checksums`); + } + if (artifact.analysis?.status) { + if ( + !['postcss-calc faster', 'postcss-calc slower', 'inconclusive'].includes( + artifact.analysis.status + ) + ) + throw new TypeError('corpus artifact has an invalid statistical status'); + if ( + artifact.analysis.statistical?.status !== undefined && + artifact.analysis.statistical.status !== artifact.analysis.status + ) + throw new TypeError( + 'corpus artifact has inconsistent statistical status' + ); + if ( + artifact.analysis.practical?.margin !== undefined && + artifact.analysis.practical.margin !== artifact.config.equivalenceMargin + ) + throw new TypeError( + 'corpus artifact has inconsistent equivalence margin' + ); + } +} + +function corpusRawRatios(artifact, group) { + return corpusRawReplicatePairs(artifact, group).map( + ([oursFirst, referenceFirst]) => + Math.exp((Math.log(oursFirst) + Math.log(referenceFirst)) / 2) + ); +} + +function corpusRawReplicatePairs(artifact, group) { + return artifact.replicates.map((replicate) => + ['ours-first', 'reference-first'].map((order) => { + const values = replicate.batches + .filter((batch) => batch.order === order) + .map((batch) => + batch.measurements.find((item) => item.group === group) + ); + return ( + median(values.map((value) => value.ours.ms)) / + median(values.map((value) => value.reference.ms)) + ); + }) + ); +} + +function bootstrapCorpusInterval(strata, seed, resamples, confidence) { + if (!Array.isArray(strata) || strata.length === 0) + throw new RangeError('cannot bootstrap an empty replicate set'); + if (strata.some((pair) => !Array.isArray(pair) || pair.length !== 2)) + throw new TypeError( + 'each corpus replicate must contain both order results' + ); + const random = seededRandom(seed); + const means = Array.from({ length: resamples }, () => 0); + for (let sample = 0; sample < resamples; sample++) { + for (let index = 0; index < strata.length; index++) { + const [oursFirst, referenceFirst] = + strata[Math.floor(random() * strata.length)]; + means[sample] += + (Math.log(oursFirst) + Math.log(referenceFirst)) / (2 * strata.length); + } + } + const alpha = (1 - confidence) / 2; + return { + lower: percentile(means, alpha), + upper: percentile(means, 1 - alpha), + }; +} + +export { normalQuantile }; diff --git a/scripts/lib/corpus-benchmark.js b/scripts/lib/corpus-benchmark.js new file mode 100644 index 0000000..55685a4 --- /dev/null +++ b/scripts/lib/corpus-benchmark.js @@ -0,0 +1,629 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadCorpus } from './corpus.js'; +/* oxlint-disable no-bitwise */ +import { rootShape, stableHash, validateCorpus } from './corpus-policy.js'; +import { + BOOTSTRAP_RESAMPLES, + CORPUS_EQUIVALENCE_MARGIN, + DECISION_CONFIG_VERSION, + DRIFT_THRESHOLD, + MEASURED_BATCHES, + MIN_VALID_BLOCKS, + PRECISION_METHOD, + TARGET_BATCH_MS, + balancedSchedule, + collectBenchmarkProvenance, + decisionConfigForArtifact, + CORPUS_INTERVAL_METHOD, + median, + normalizeSeed, + ordinaryInterval, + percentile, + runChild, + seededRandom, + seededShuffle, + variationMetrics, + validateSchemaV2Artifact, +} from './benchmark.js'; + +export { CORPUS_EQUIVALENCE_MARGIN } from './benchmark.js'; + +export const CORPUS_ESTIMAND = + 'Relative total runtime over the fixed set of unique harvested expressions accepted equivalently by both implementations.'; + +const WORKER = join( + dirname(dirname(fileURLToPath(import.meta.url))), + 'corpus-benchmark-worker.js' +); + +function parseArgs(args) { + const options = {}; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--seed') options.seed = Number(args[++i]); + else if (arg === '--replicates') options.replicates = 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.replicates !== undefined && + (!Number.isInteger(options.replicates) || options.replicates < 20) + ) + throw new TypeError('--replicates must be at least 20'); + return options; +} + +export function groupResults( + replicates, + seed = 0, + resamples = BOOTSTRAP_RESAMPLES +) { + const groups = new Map(); + for (const replicate of replicates) + for (const batch of replicate.batches) + for (const measurement of batch.measurements) { + const key = measurement.group; + if (!Number.isInteger(replicate.replicate)) + throw new TypeError('corpus worker omitted its replicate number'); + const values = groups.get(key) ?? []; + values.push({ + replicate: replicate.replicate, + order: batch.order, + ours: measurement.ours.ms, + reference: measurement.reference.ms, + oursChecksum: measurement.ours.checksum, + referenceChecksum: measurement.reference.checksum, + }); + groups.set(key, values); + } + const result = {}; + for (const [group, values] of groups) { + const byReplicate = new Map(); + for (const value of values) { + const current = byReplicate.get(value.replicate) ?? { + ours: [], + reference: [], + checksums: [], + byOrder: { 'ours-first': [], 'reference-first': [] }, + }; + current.ours.push(value.ours); + current.reference.push(value.reference); + current.checksums.push([value.oursChecksum, value.referenceChecksum]); + current.byOrder[value.order].push(value); + byReplicate.set(value.replicate, current); + } + const replicatePairs = [...byReplicate.values()].map((value) => { + const orderRatios = ['ours-first', 'reference-first'].map((order) => { + const measurements = value.byOrder[order]; + if (measurements.length === 0) + throw new RangeError( + `corpus replicate omitted ${order} measurements` + ); + return ( + median(measurements.map((item) => item.ours)) / + median(measurements.map((item) => item.reference)) + ); + }); + return orderRatios; + }); + const ratios = replicatePairs.map(([oursFirst, referenceFirst]) => + Math.exp((Math.log(oursFirst) + Math.log(referenceFirst)) / 2) + ); + const logs = ratios.map(Math.log); + const ordinary = ordinaryInterval(logs); + const groupSeed = + (seed ^ Number.parseInt(stableHash(group).slice(0, 8), 16)) >>> 0; + const byOrder = {}; + for (const [orderIndex, order] of [ + 'ours-first', + 'reference-first', + ].entries()) { + const orderRatios = replicatePairs.map((pair) => pair[orderIndex]); + const orderInterval = ordinaryInterval(orderRatios.map(Math.log)); + byOrder[order] = { + replicates: orderRatios.length, + ratios: orderRatios, + geometricMeanPairedRuntimeRatio: Math.exp(orderInterval.mean), + ordinary95: { + lowerRatio: Math.exp(orderInterval.lower), + upperRatio: Math.exp(orderInterval.upper), + }, + }; + } + const oursFirst = byOrder['ours-first'].ratios; + const referenceFirst = byOrder['reference-first'].ratios; + const pairedOrderLogs = oursFirst.map((ratio, index) => + Math.log(referenceFirst[index] / ratio) + ); + const orderEffect = ordinaryInterval(pairedOrderLogs); + const bootstrap = bootstrapPairedReplicateInterval( + replicatePairs.map((pair) => pair.map(Math.log)), + groupSeed, + resamples, + 0.95 + ); + result[group] = { + replicates: ratios.length, + geometricMeanPairedRuntimeRatio: Math.exp(ordinary.mean), + ordinary95: { + lowerRatio: Math.exp(ordinary.lower), + upperRatio: Math.exp(ordinary.upper), + }, + bootstrap95: { + lowerRatio: Math.exp(bootstrap.lower), + upperRatio: Math.exp(bootstrap.upper), + resamples: bootstrap.resamples, + }, + bootstrap90: (() => { + const interval = bootstrapPairedReplicateInterval( + replicatePairs.map((pair) => pair.map(Math.log)), + (groupSeed ^ 0x9e3779b9) >>> 0, + resamples, + 0.9 + ); + return { + lowerRatio: Math.exp(interval.lower), + upperRatio: Math.exp(interval.upper), + resamples: interval.resamples, + }; + })(), + bootstrapSeed: groupSeed, + bootstrapMethod: CORPUS_INTERVAL_METHOD, + byOrder, + orderEffect: { + logRatio: orderEffect.mean, + ratio: Math.exp(orderEffect.mean), + ordinary95: { + lowerRatio: Math.exp(orderEffect.lower), + upperRatio: Math.exp(orderEffect.upper), + }, + }, + withinProcessBatchVariation: withinProcessVariation(values), + betweenProcessVariation: variationMetrics(ratios), + ratios, + checksums: [ + ...new Set( + values.flatMap((value) => [ + value.oursChecksum, + value.referenceChecksum, + ]) + ), + ], + }; + } + return result; +} + +function bootstrapPairedReplicateInterval(pairs, seed, resamples, confidence) { + if (!Array.isArray(pairs) || pairs.length === 0) + throw new RangeError('cannot bootstrap an empty replicate set'); + if (pairs.some((pair) => !Array.isArray(pair) || pair.length !== 2)) + throw new TypeError( + 'each corpus replicate must contain both order results' + ); + const random = seededRandom(seed); + const means = Array.from({ length: resamples }, () => 0); + for (let sample = 0; sample < resamples; sample++) { + for (let index = 0; index < pairs.length; index++) { + const pair = pairs[Math.floor(random() * pairs.length)]; + means[sample] += (pair[0] + pair[1]) / (2 * pairs.length); + } + } + const alpha = (1 - confidence) / 2; + const lower = percentile(means, alpha); + const upper = percentile(means, 1 - alpha); + return { lower, upper, resamples }; +} + +export function runCorpusBenchmark({ + root = process.cwd(), + seed = 0x71c0ffee, + replicates = 20, + output, +} = {}) { + const normalizedSeed = normalizeSeed(seed); + const source = loadCorpus(); + const validation = validateCorpus(source); + const corpusPath = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'test/corpus/github-pure.txt' + ); + const config = { + decisionConfigVersion: DECISION_CONFIG_VERSION, + requestedBlocks: replicates, + minimumBlocks: MIN_VALID_BLOCKS, + maxAttempts: replicates, + targetBatchMs: TARGET_BATCH_MS, + warmupMinimum: 0, + warmupMaximum: 0, + measuredBatchCount: MEASURED_BATCHES, + driftThreshold: DRIFT_THRESHOLD, + bootstrapResamples: BOOTSTRAP_RESAMPLES, + confidence: 0.95, + runtimeNonRegressionMargin: 1.1, + equivalenceMargin: CORPUS_EQUIVALENCE_MARGIN, + precisionMargin: CORPUS_EQUIVALENCE_MARGIN, + precisionMethod: PRECISION_METHOD, + growthThreshold: 2.5, + orderInteractionThreshold: Math.log(1.1), + intervalMethod: CORPUS_INTERVAL_METHOD, + batches: 6, + calibrationOrderBalanced: true, + corpus: 'test/corpus/github-pure.txt', + precision: 10, + }; + const environment = collectBenchmarkProvenance(root, { + benchmark: 'corpus', + corpusPath, + command: process.argv.join(' '), + }); + let entries = validation.accepted.map((record) => ({ + input: record.input, + canonical: record.canonical, + shape: rootShape(record.input), + sourceLength: record.input.length, + })); + if (!entries.length) throw new Error('corpus validation accepted no inputs'); + const lengthOrder = [...entries].sort( + (a, b) => a.sourceLength - b.sourceLength || a.input.localeCompare(b.input) + ); + const lengthStratumByInput = new Map( + lengthOrder.map((entry, index) => [ + entry.input, + `source-length-q${Math.min(4, Math.floor((index * 4) / entries.length) + 1)}`, + ]) + ); + entries = entries.map((entry) => ({ + ...entry, + lengthStratum: lengthStratumByInput.get(entry.input), + })); + const lengthQuartiles = [0.25, 0.5, 0.75].map((p) => + percentile( + entries.map((entry) => entry.sourceLength), + p + ) + ); + const temp = mkdtempSync(join(root, '.corpus-benchmark-')); + const corpusFile = join(temp, 'validated.json'); + writeFileSync(corpusFile, JSON.stringify(entries)); + try { + const childResults = []; + const calibrationSchedule = balancedSchedule( + replicates, + 'ours-first', + 'reference-first', + normalizedSeed ^ 0x243f6a88 + ); + for (let replicate = 0; replicate < replicates; replicate++) { + const permutation = seededShuffle( + entries.map((_, index) => index), + (normalizedSeed + Math.imul(replicate + 1, 0x9e3779b9)) >>> 0 + ); + const orders = seededShuffle( + [ + 'ours-first', + 'ours-first', + 'ours-first', + 'reference-first', + 'reference-first', + 'reference-first', + ], + (normalizedSeed ^ replicate) >>> 0 + ); + const calibrationOrder = calibrationSchedule[replicate]; + childResults.push( + runChild( + WORKER, + { + sourceRoot: join(root, 'src'), + corpusFile, + permutation, + orders, + calibrationOrder, + targetBatchMs: config.targetBatchMs, + replicate, + lengthQuartiles, + }, + root + ) + ); + } + const groups = groupResults( + childResults, + normalizedSeed, + config.bootstrapResamples + ); + const exact = groups.exact; + const analysis = analyzeCorpusObservations({ + groups, + replicates: childResults, + config, + seed: normalizedSeed, + }); + const artifact = { + schema: 2, + benchmark: 'corpus', + seed: normalizedSeed, + environment, + config: { ...config, replicates }, + correctness: { + counts: validation.counts, + categoryHashes: validation.categoryHashes, + accepted: entries.length, + totalSourceRecords: source.length, + uniqueRecords: new Set(source).size, + acceptedComparisonRecords: entries.length, + excludedCounts: Object.fromEntries( + Object.entries(validation.counts).filter( + ([key]) => key !== 'accepted' + ) + ), + weighting: 'unique-weighted', + estimand: CORPUS_ESTIMAND, + corpusHash: stableHash(source.join('\n')), + inputHash: stableHash( + entries + .map((entry) => entry.input) + .sort() + .join('\n') + ), + }, + corpus: { + lengthQuartiles, + lengthStrata: Object.fromEntries( + [ + 'source-length-q1', + 'source-length-q2', + 'source-length-q3', + 'source-length-q4', + ].map((group) => [ + group, + entries.filter((entry) => entry.lengthStratum === group).length, + ]) + ), + rootShapeCounts: Object.fromEntries( + [...new Set(entries.map((entry) => entry.shape))] + .sort() + .map((shape) => [ + shape, + entries.filter((entry) => entry.shape === shape).length, + ]) + ), + }, + replicates: childResults, + analysis: { ...analysis, aggregate: exact, groups }, + }; + validateSchemaV2Artifact(artifact); + const path = output + ? resolve(root, output) + : join( + root, + 'reports/benchmarks', + `corpus-${Date.now()}-${normalizedSeed}.json` + ); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(artifact, null, 2)}\n`); + return { artifact, path }; + } finally { + rmSync(temp, { recursive: true, force: true }); + } +} + +function corpusGroupDecision(summary, config) { + const logs = summary.ratios.map(Math.log); + const sd = variationMetrics(logs).sd; + const intervalLower = Math.log(summary.bootstrap95.lowerRatio); + const intervalUpper = Math.log(summary.bootstrap95.upperRatio); + const estimate = Math.log(summary.geometricMeanPairedRuntimeRatio); + const intervalHalfWidth = Math.max( + estimate - intervalLower, + intervalUpper - estimate + ); + const targetHalfWidth = Math.log(config.precisionMargin); + const precisionMet = + summary.replicates >= config.minimumBlocks && + intervalHalfWidth <= targetHalfWidth; + let statistical = 'inconclusive'; + if (precisionMet && summary.bootstrap95.upperRatio < 1) + statistical = 'postcss-calc faster'; + else if (precisionMet && summary.bootstrap95.lowerRatio > 1) + statistical = 'postcss-calc slower'; + let practical = 'inconclusive'; + if (precisionMet) { + practical = + summary.bootstrap90.lowerRatio >= 1 / config.equivalenceMargin && + summary.bootstrap90.upperRatio <= config.equivalenceMargin + ? 'equivalent' + : 'not-equivalent'; + } + return { + statistical, + practical, + observedLogRatioSd: sd, + observedStandardDeviation: sd, + confidenceIntervalWidth: intervalUpper - intervalLower, + intervalHalfWidth, + targetHalfWidth, + minimumBlocks: config.minimumBlocks, + requestedBlocks: config.requestedBlocks, + observedBlocks: summary.replicates, + precisionTargetMet: precisionMet, + }; +} + +function analyzeCorpusObservations({ groups, replicates, config, seed }) { + const decisions = Object.fromEntries( + Object.entries(groups).map(([group, summary]) => [ + group, + corpusGroupDecision(summary, config), + ]) + ); + const exactDecision = decisions.exact; + const orderDiagnostic = + Math.abs(groups.exact.orderEffect.logRatio) > + config.orderInteractionThreshold; + const status = orderDiagnostic ? 'inconclusive' : exactDecision.statistical; + const practicalStatus = orderDiagnostic + ? 'inconclusive' + : exactDecision.practical; + return { + status, + statistical: { + status, + intervalMethod: config.intervalMethod, + confidence: 0.95, + equivalenceComparison: 'superiority-on-fixed-corpus', + }, + practical: { + status: practicalStatus, + margin: config.equivalenceMargin, + interpretation: + practicalStatus === 'equivalent' + ? 'within declared margin, not identical' + : practicalStatus, + intervalMethod: config.intervalMethod, + confidence: 0.9, + }, + decisions, + orderEffect: groups.exact.orderEffect, + diagnostics: { + orderInteraction: orderDiagnostic, + orderInteractionThreshold: config.orderInteractionThreshold, + replicates: replicates.length, + seed, + }, + }; +} + +/** Recompute corpus decisions from retained raw benchmark observations. */ +export function analyzeCorpus(artifact) { + if (!artifact || artifact.benchmark !== 'corpus') + throw new TypeError('expected a corpus artifact'); + if (artifact.config?.decisionConfigVersion === DECISION_CONFIG_VERSION) + validateSchemaV2Artifact(artifact); + const config = decisionConfigForArtifact(artifact, 'corpus'); + const groups = groupResults( + artifact.replicates, + artifact.seed, + config.bootstrapResamples + ); + const result = { + ...analyzeCorpusObservations({ + groups, + replicates: artifact.replicates, + config, + seed: artifact.seed, + }), + aggregate: groups.exact, + groups, + }; + if ( + artifact.config?.decisionConfigVersion === DECISION_CONFIG_VERSION && + artifact.analysis + ) + assertStoredAnalysisMatches(artifact.analysis, result); + return result; +} + +function assertStoredAnalysisMatches(stored, expected, path = 'analysis') { + if ( + !stored || + typeof stored !== 'object' || + Array.isArray(stored) || + !expected || + typeof expected !== 'object' || + Array.isArray(expected) + ) + throw new TypeError(`${path} is not a statistical summary object`); + const storedKeys = Object.keys(stored).sort(); + 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` + ); + } + } +} + +function assertStoredValue(left, right, path) { + 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} does not match recomputed observations`); + } else if (Array.isArray(right)) { + if (!Array.isArray(left) || left.length !== right.length) + throw new TypeError(`${path} does not match recomputed observations`); + for (let index = 0; index < right.length; index++) + assertStoredValue(left[index], right[index], `${path}[${index}]`); + } else if (right && typeof right === 'object') { + assertStoredAnalysisMatches(left, right, path); + } else if (left !== right) { + throw new TypeError(`${path} does not match recomputed observations`); + } +} + +export { parseArgs }; + +function withinProcessVariation(values) { + const normalized = []; + const byReplicate = new Map(); + for (const value of values) { + const current = byReplicate.get(value.replicate) ?? { + ours: [], + reference: [], + }; + current.ours.push(value.ours); + current.reference.push(value.reference); + byReplicate.set(value.replicate, current); + } + for (const value of byReplicate.values()) { + const oursCenter = median(value.ours); + const referenceCenter = median(value.reference); + normalized.push( + ...value.ours.map((sample) => sample / oursCenter), + ...value.reference.map((sample) => sample / referenceCenter) + ); + } + return variationMetrics(normalized).relativeSpan; +} diff --git a/scripts/lib/corpus-policy.js b/scripts/lib/corpus-policy.js new file mode 100644 index 0000000..fd9a363 --- /dev/null +++ b/scripts/lib/corpus-policy.js @@ -0,0 +1,152 @@ +// The single comparison policy used by the conformance corpus and the corpus +// benchmark. Keep precision and documented divergences in one place. +import { createHash } from 'node:crypto'; +import { calc as csstoolsCalc } from '@csstools/css-calc'; +import { tokenize } from '@csstools/css-tokenizer'; +import { indexBlocks } from '../../src/lib/block-index.js'; +import { parse } from '../../src/lib/parser.js'; +import { simplify } from '../../src/lib/simplify.js'; +import { serialize } from '../../src/lib/serialize.js'; + +export const COMPARE_PRECISION = 10; +export const KNOWN_DIVERGENCES = new Set([ + 'calc(sin(360deg) * var(--radius))', + 'calc(cos(270deg) * var(--radius))', + 'calc(sin(360deg) * var(--amplitude))', + 'calc(atan(.5) + 90deg - (var(--dir)*90deg))', + 'calc(1 / var(--√𝟤))', + 'calc(var(--➕) * -1)', + 'calc(var(--➕) * var(--✖️))', + 'calc(var(--➖) * var(--✖️))', +]); + +export const CORPUS_CATEGORIES = [ + 'accepted', + 'both-failed', + 'known-divergence', + 'malformed-input', + 'parser-rejected', + 'reference-rejected', + 'unexpected-divergence', +]; + +// These four harvested records are malformed CSS rather than unsupported +// calculation semantics. They remain in the corpus for accounting, but are +// deliberately excluded from differential timing and correctness verdicts. +export const MALFORMED_CORPUS_INPUTS = new Set([ + 'calc(-1 * var(0.125rem))', + 'calc(-1 * var(0.1875rem))', + 'calc(1s * var (--i))', + 'calc(var(-code-block-padding-v) * -1)', +]); + +// A reference-only rejection cannot be used as a timing comparison, but it +// is not evidence that our implementation is incorrect either. +export const NEUTRAL_CORPUS_CATEGORIES = new Set([ + 'both-failed', + 'known-divergence', + 'malformed-input', + 'reference-rejected', +]); + +export function ourOutput(input) { + try { + const tokens = tokenize({ css: input }); + return serialize( + simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))), + { precision: COMPARE_PRECISION } + ); + } catch { + return null; + } +} +export function referenceOutput(input) { + try { + const output = csstoolsCalc(input); + return typeof output === 'string' ? output : null; + } catch { + return null; + } +} + +export function classifyCorpusExpression(input) { + const ours = ourOutput(input); + const theirs = referenceOutput(input); + if (ours === null && theirs === null) + return { category: 'both-failed', ours, theirs }; + if (KNOWN_DIVERGENCES.has(input)) + return { category: 'known-divergence', ours, theirs }; + if (MALFORMED_CORPUS_INPUTS.has(input)) + return { category: 'malformed-input', ours, theirs }; + if (ours === null) return { category: 'parser-rejected', ours, theirs }; + if (theirs === null) return { category: 'reference-rejected', ours, theirs }; + if (ours === theirs) + return { category: 'accepted', ours, theirs, canonical: ours }; + const canonicalTheirs = ourOutput(theirs); + if (canonicalTheirs !== null && canonicalTheirs === ours) + return { category: 'accepted', ours, theirs, canonical: canonicalTheirs }; + return { category: 'unexpected-divergence', ours, theirs }; +} + +export function validateCorpus(inputs) { + const records = [...new Set(inputs)].map((input) => ({ + input, + ...classifyCorpusExpression(input), + })); + const unexpected = records.filter( + (record) => + record.category === 'unexpected-divergence' || + (!NEUTRAL_CORPUS_CATEGORIES.has(record.category) && + record.category !== 'accepted') + ); + if (unexpected.length) { + const first = unexpected[0]; + throw new Error( + `unexpected corpus divergence for ${first.input}: ${first.ours} != ${first.theirs}` + ); + } + const counts = Object.fromEntries( + CORPUS_CATEGORIES.map((category) => [ + category, + records.filter((record) => record.category === category).length, + ]) + ); + const categoryHashes = Object.fromEntries( + CORPUS_CATEGORIES.map((category) => [ + category, + stableHash( + records + .filter((record) => record.category === category) + .map((record) => record.input) + .sort() + .join('\n') + ), + ]) + ); + return { + records, + counts, + categoryHashes, + accepted: records.filter((record) => record.category === 'accepted'), + }; +} + +export function stableHash(value) { + return createHash('sha256').update(value).digest('hex'); +} + +export function rootShape(input) { + const tokens = tokenize({ css: input }); + const ast = parse(tokens, 0, tokens.length, indexBlocks(tokens)); + const root = + ast.type === 'Call' && + ast.name.toLowerCase().endsWith('calc') && + ast.args.length === 1 + ? ast.args[0] + : ast; + if (root.type === 'Sum') return 'sum'; + if (root.type === 'Product') return 'product'; + if (root.type === 'OpaqueCall') return 'opaque-call'; + if (root.type === 'Call') return 'function-call'; + return 'scalar'; +} diff --git a/scripts/lib/corpus.mjs b/scripts/lib/corpus.js similarity index 75% rename from scripts/lib/corpus.mjs rename to scripts/lib/corpus.js index 0bb7c91..f226b73 100644 --- a/scripts/lib/corpus.mjs +++ b/scripts/lib/corpus.js @@ -1,5 +1,5 @@ -// Shared corpus loader for the exploratory scripts (benchmark.mjs, -// show-divergences.mjs): reads the harvested real-world calc() corpus and +// Shared corpus loader for the exploratory scripts (benchmark.js, +// show-divergences.js): reads the harvested real-world calc() corpus and // splits it into trimmed, non-empty lines. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; diff --git a/scripts/lib/parser-benchmark.js b/scripts/lib/parser-benchmark.js new file mode 100644 index 0000000..ceca5b1 --- /dev/null +++ b/scripts/lib/parser-benchmark.js @@ -0,0 +1,875 @@ +/* oxlint-disable no-bitwise, complexity */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; +import { + bootstrapStratifiedMaxT, + collectBenchmarkProvenance, + decisionConfigForArtifact, + DECISION_CONFIG_VERSION, + DECISION_INTERVAL_METHOD, + DRIFT_THRESHOLD, + GROWTH_THRESHOLD, + MAX_WARMUPS, + MEASURED_BATCHES, + linearRegression, + logRatio, + materializeBaseline, + median, + MIN_VALID_BLOCKS, + NON_REGRESSION_MARGIN, + PRECISION_METHOD, + normalizeSeed, + ordinaryInterval, + runChild, + seededShuffle, + variationMetrics, + validateSchemaV2Artifact, +} from './benchmark.js'; + +const SIZES = [500, 1_000, 2_000, 4_000, 8_000, 16_000]; +const DEPTHS = [16, 32, 64, 128, 256, 512]; +const SCRIPT_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const WORKER = join(SCRIPT_ROOT, 'parser-benchmark-worker.js'); + +function arithmetic(kind, size) { + if (kind === 'additive') return Array(size).fill('1').join(' + '); + if (kind === 'multiplicative') return Array(size).fill('2').join(' * '); + const operators = [' + ', ' * ', ' - ', ' / ']; + const parts = ['1']; + for (let i = 1; i < size; i++) + parts.push(operators[(i - 1) % operators.length], String((i % 7) + 1)); + return parts.join(''); +} + +function nestedFallback(depth) { + let value = 'calc(1px + 2px)'; + for (let i = depth; i >= 1; i--) value = `calc(var(--x${i}, ${value}))`; + return value; +} + +export function parserWorkloads(benchmark) { + const result = []; + if (benchmark === 'arithmetic-chains') { + for (const shape of [ + 'additive', + 'multiplicative', + 'alternating-precedence', + ]) + for (const mode of ['cold-index', 'hot-shared-index']) + for (const size of SIZES) + result.push({ + key: `${shape}:${mode}:${size}`, + shape, + mode, + size, + source: arithmetic(shape, size), + }); + } else if (benchmark === 'nested-fallbacks') { + for (const mode of ['cold-index', 'hot-shared-index']) + for (const depth of DEPTHS) + result.push({ + key: `nested-fallbacks:${mode}:${depth}`, + shape: 'nested-fallbacks', + mode, + size: depth, + source: nestedFallback(depth), + }); + } else throw new Error(`unsupported benchmark: ${benchmark}`); + return result; +} + +function endpointKey(workload) { + return workload.key; +} + +function revisionResults(blocks, revision) { + const values = new Map(); + for (const block of blocks) { + const process = block.revisions.find((item) => item.revision === revision); + for (const workload of process.workloads) { + const list = values.get(workload.key) ?? []; + list.push(median(workload.measured)); + values.set(workload.key, list); + } + } + return values; +} + +function analyzeParser( + artifact, + { skipValidation = false, sensitivity = true } = {} +) { + if (!skipValidation) validateSchemaV2Artifact(artifact); + const blocks = artifact.blocks; + const config = decisionConfigForArtifact(artifact, 'parser'); + if (artifact.analysis?.status === 'correctness-failure') + return artifact.analysis; + if (blocks.length < MIN_VALID_BLOCKS) + return { + status: 'inconclusive', + validBlocks: blocks.length, + reason: `fewer than ${MIN_VALID_BLOCKS} valid blocks`, + }; + const baseline = revisionResults(blocks, 'baseline'); + const candidate = revisionResults(blocks, 'candidate'); + const keys = artifact.workloadKeys; + const logsByKey = keys.map((key) => { + const base = baseline.get(key); + const cand = candidate.get(key); + if ( + !base || + !cand || + base.length !== blocks.length || + cand.length !== blocks.length + ) + throw new TypeError(`missing paired observations for ${key}`); + return cand.map((value, index) => logRatio(value, base[index])); + }); + const runtimeRows = blocks.map((_, index) => + logsByKey.map((values) => values[index]) + ); + const endpoints = []; + for (const [keyIndex, key] of keys.entries()) { + const logs = logsByKey[keyIndex]; + const ordinary = ordinaryInterval(logs); + const endpoint = { + key, + geometricMeanPairedRuntimeRatio: Math.exp(ordinary.mean), + logRatio: ordinary.mean, + ordinary95: { + lowerRatio: Math.exp(ordinary.lower), + upperRatio: Math.exp(ordinary.upper), + }, + oneSided95: { + lowerRatio: null, + upperRatio: null, + }, + baselineVariation: variationFor(blocks, 'baseline', key), + candidateVariation: variationFor(blocks, 'candidate', key), + betweenProcessVariation: { + pairedRatio: variationMetrics(logs.map(Math.exp)), + }, + withinProcessBatchVariation: batchVariation(blocks, key), + byProcessOrder: processOrderSummaries(blocks, logs, key), + meaningfulImprovement: null, + ratios: logs.map(Math.exp), + }; + const sd = variationMetrics(logs).sd; + endpoint.observedLogRatioSd = sd; + endpoints.push(endpoint); + } + + const largest = endpoints.filter( + (endpoint) => + endpoint.key.endsWith(':16000') || endpoint.key.endsWith(':512') + ); + const slopes = analyzeSlopes(artifact, blocks, config); + const growthData = analyzeGrowth(artifact, blocks); + const familyRows = blocks.map((_, index) => [ + ...runtimeRows[index], + ...slopes.claimRows.map((values) => values[index]), + ...growthData.claimRows.map((values) => values[index]), + ]); + const familyBootstrap = bootstrapStratifiedMaxT({ + rows: familyRows, + strata: blocks.map((block) => block.processOrder), + seed: (artifact.seed ^ 0x6a09e667) >>> 0, + resamples: config.bootstrapResamples, + confidence: config.confidence, + }); + for (const [index, endpoint] of endpoints.entries()) { + endpoint.logRatio = familyBootstrap.observed[index]; + endpoint.geometricMeanPairedRuntimeRatio = Math.exp(endpoint.logRatio); + const half = 1.96 * familyBootstrap.standardErrors[index]; + endpoint.ordinary95 = { + lowerRatio: Math.exp(endpoint.logRatio - half), + upperRatio: Math.exp(endpoint.logRatio + half), + }; + addRuntimeIntervals( + endpoint, + familyBootstrap.intervals[index], + familyBootstrap, + config, + blocks.length, + index + ); + } + addSlopeIntervals(slopes, familyBootstrap, keys.length, config); + const growth = addGrowthIntervals( + growthData, + familyBootstrap, + keys.length + slopes.claimRows.length, + blocks, + config + ); + const orderEffect = processOrderEffect( + endpoints, + config.orderInteractionThreshold + ); + const runtimeStatus = applyPrecision( + verdict(largest, config.runtimeNonRegressionMargin), + largest + ); + const slopeStatus = applyPrecision( + verdict( + slopes.endpoints, + Math.log2(config.runtimeNonRegressionMargin), + true + ), + slopes.endpoints + ); + let growthStatus = 'pass'; + if (growth.some((item) => item.candidateLowerRatio > config.growthThreshold)) + growthStatus = 'regression'; + else if ( + growth.some((item) => item.candidateUpperRatio > config.growthThreshold) + ) + growthStatus = 'inconclusive'; + growthStatus = applyPrecision(growthStatus, growth); + let status; + if ( + runtimeStatus === 'regression' || + slopeStatus === 'regression' || + growthStatus === 'regression' + ) + status = 'regression'; + else if ( + runtimeStatus === 'pass' && + slopeStatus === 'pass' && + growthStatus === 'pass' + ) + status = 'pass'; + else status = 'inconclusive'; + if (orderEffect.diagnostic) status = 'inconclusive'; + const result = { + status, + intervalMethod: DECISION_INTERVAL_METHOD, + runtimeStatus, + slopeStatus, + growthStatus, + endpoints, + slopes, + growth, + orderEffect, + rejections: rejectionSummary(artifact.attempts), + rejectionCounts: rejectionSummary(artifact.attempts).byReason, + rejectionRate: rejectionSummary(artifact.attempts).rate, + observedBlocks: blocks.length, + validBlocks: blocks.length, + }; + if (sensitivity) { + const structurallyValid = Array.isArray(artifact.attempts) + ? artifact.attempts.filter( + (attempt) => attempt.structuralMismatches.length === 0 + ) + : blocks; + const orderCounts = new Set( + structurallyValid.map((attempt) => attempt.processOrder) + ); + if ( + structurallyValid.length >= MIN_VALID_BLOCKS && + orderCounts.size === 2 + ) { + result.sensitivity = analyzeParser( + { ...artifact, blocks: structurallyValid, analysis: undefined }, + { skipValidation: true, sensitivity: false } + ); + } else { + result.sensitivity = { + status: 'inconclusive', + validBlocks: structurallyValid.length, + reason: `fewer than ${MIN_VALID_BLOCKS} structurally valid blocks`, + }; + } + result.diagnostics = { + ...result.diagnostics, + rejectedAttempts: Array.isArray(artifact.attempts) + ? artifact.attempts.length - blocks.length + : 0, + structurallyValidAttempts: structurallyValid.length, + primaryAndSensitivityDisagree: + result.status !== result.sensitivity.status, + }; + if (result.diagnostics.primaryAndSensitivityDisagree) + result.status = 'inconclusive'; + } + return result; +} + +function rejectionSummary(attempts) { + if (!Array.isArray(attempts)) + return { attempts: 0, accepted: 0, rejected: 0, byReason: {}, rate: 0 }; + const byReason = {}; + for (const attempt of attempts) + for (const reason of attempt.rejectionReasons ?? []) + byReason[reason] = (byReason[reason] ?? 0) + 1; + const rejected = attempts.filter((attempt) => attempt.rejected).length; + return { + attempts: attempts.length, + accepted: attempts.length - rejected, + rejected, + byReason, + rate: rejected / attempts.length, + }; +} + +function processOrderSummaries(blocks, logs, key) { + const summaries = {}; + for (const order of ['baseline-first', 'candidate-first']) { + const values = logs.filter( + (_, index) => blocks[index].processOrder === order + ); + const interval = ordinaryInterval(values); + summaries[order] = { + replicates: values.length, + key, + geometricMeanPairedRuntimeRatio: Math.exp(interval.mean), + ordinary95: { + lowerRatio: Math.exp(interval.lower), + upperRatio: Math.exp(interval.upper), + }, + ratios: values.map(Math.exp), + }; + } + return summaries; +} + +function processOrderEffect(endpoints, threshold) { + const effects = endpoints.map((endpoint) => { + const baseline = endpoint.byProcessOrder['baseline-first'].ratios; + const candidate = endpoint.byProcessOrder['candidate-first'].ratios; + const baselineMean = ordinaryInterval(baseline.map(Math.log)); + const candidateMean = ordinaryInterval(candidate.map(Math.log)); + const orderLogRatio = candidateMean.mean - baselineMean.mean; + const standardError = Math.sqrt( + baselineMean.sd ** 2 / baseline.length + + candidateMean.sd ** 2 / candidate.length + ); + const half = 1.96 * standardError; + return { + key: endpoint.key, + logRatio: orderLogRatio, + ratio: Math.exp(orderLogRatio), + standardError, + ordinary95: { + lowerRatio: Math.exp(orderLogRatio - half), + upperRatio: Math.exp(orderLogRatio + half), + }, + }; + }); + return { + threshold, + endpoints: effects, + diagnostic: effects.some((effect) => Math.abs(effect.logRatio) > threshold), + }; +} + +function addRuntimeIntervals( + endpoint, + intervals, + bootstrap, + config, + blocks, + index +) { + endpoint.oneSided95 = { + lowerRatio: Math.exp(intervals.oneSidedLower), + upperRatio: Math.exp(intervals.oneSidedUpper), + }; + endpoint.familyAdjusted95 = { + lowerRatio: Math.exp(intervals.familyLower), + 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.precision = precisionSummary( + endpoint.observedLogRatioSd, + bootstrap.standardErrors[index], + config, + intervals.familyLower, + intervals.familyUpper, + endpoint.logRatio, + Math.log(config.precisionMargin), + blocks + ); +} + +function variationFor(blocks, revision, key) { + const values = blocks.map((block) => + median( + block.revisions + .find((item) => item.revision === revision) + .workloads.find((item) => item.key === key).measured + ) + ); + return { ...variationMetrics(values), observations: values }; +} + +function batchVariation(blocks, key) { + const values = []; + for (const block of blocks) + for (const revision of block.revisions) { + const workload = revision.workloads.find((item) => item.key === key); + const center = median(workload.measured); + values.push(...workload.measured.map((value) => value / center)); + } + return { ...variationMetrics(values), observations: values }; +} + +function precisionSummary( + observedStandardDeviation, + standardError, + config, + lower, + upper, + estimate, + targetHalfWidth, + observedBlocks +) { + const intervalHalfWidth = Math.max(estimate - lower, upper - estimate); + return { + observedStandardDeviation, + standardError, + confidenceIntervalWidth: upper - lower, + intervalHalfWidth, + targetHalfWidth, + minimumBlocks: config.minimumBlocks, + requestedBlocks: config.requestedBlocks, + observedBlocks, + targetMet: + observedBlocks >= config.minimumBlocks && + intervalHalfWidth <= targetHalfWidth, + }; +} + +function applyPrecision(status, endpoints) { + if (endpoints.length === 0) return status; + const precise = endpoints.every((endpoint) => endpoint.precision?.targetMet); + if (precise) return status; + return 'inconclusive'; +} + +function verdict(endpoints, margin, slope = false) { + if ( + endpoints.some( + (endpoint) => + (slope + ? endpoint.familyAdjusted95.lower + : endpoint.familyAdjusted95.lowerRatio) > margin + ) + ) + return 'regression'; + if ( + endpoints.every( + (endpoint) => + (slope ? endpoint.oneSided95.upper : endpoint.oneSided95.upperRatio) <= + margin + ) + ) + return 'pass'; + return 'inconclusive'; +} + +function analyzeSlopes(artifact, blocks, config) { + const workloads = artifact.workloadKeys + .map((key) => parseKey(key)) + .filter((item) => item.size); + const groups = new Map(); + for (const item of workloads) { + const group = `${item.shape}:${item.mode}`; + const values = groups.get(group) ?? { + shape: item.shape, + mode: item.mode, + sizes: [], + }; + values.sizes.push(item.size); + groups.set(group, values); + } + const raw = []; + for (const group of groups.values()) { + const deltas = []; + const baseSlopes = []; + const candidateSlopes = []; + for (const block of blocks) { + const base = group.sizes.map((size) => + median( + findWorkload( + block, + 'baseline', + `${group.shape}:${group.mode}:${size}` + ).measured + ) + ); + const cand = group.sizes.map((size) => + median( + findWorkload( + block, + 'candidate', + `${group.shape}:${group.mode}:${size}` + ).measured + ) + ); + const x = group.sizes.map(Math.log); + const b = linearRegression(x, base.map(Math.log)); + const c = linearRegression(x, cand.map(Math.log)); + baseSlopes.push(b.beta); + candidateSlopes.push(c.beta); + deltas.push(c.beta - b.beta); + } + raw.push({ + key: `${group.shape}:${group.mode}`, + baselineSlope: median(baseSlopes), + candidateSlope: median(candidateSlopes), + baselineSlopes: baseSlopes, + candidateSlopes, + deltas, + }); + } + return { + permittedIncrease: Math.log2(config.runtimeNonRegressionMargin), + endpoints: raw, + claimRows: raw.map((item) => item.deltas), + }; +} + +function addSlopeIntervals(slopes, bootstrap, offset, config) { + slopes.endpoints = slopes.endpoints.map((item, index) => { + const intervals = bootstrap.intervals[offset + index]; + const standardError = bootstrap.standardErrors[offset + index]; + return { + ...item, + deltaSlope: bootstrap.observed[offset + index], + ordinary95: { + lower: + bootstrap.observed[offset + index] - + 1.96 * bootstrap.standardErrors[offset + index], + upper: + bootstrap.observed[offset + index] + + 1.96 * bootstrap.standardErrors[offset + index], + }, + oneSided95: { + lower: intervals.oneSidedLower, + upper: intervals.oneSidedUpper, + }, + familyAdjusted95: { + lower: intervals.familyLower, + upper: intervals.familyUpper, + }, + bootstrap95: { + lower: intervals.lower, + upper: intervals.upper, + resamples: bootstrap.resamples, + familyCount: bootstrap.familyCount, + degenerateResamples: bootstrap.degenerateResamples, + degenerateFallbacks: bootstrap.degenerateFallbacks, + }, + precision: precisionSummary( + variationMetrics(item.deltas).sd, + standardError, + config, + intervals.familyLower, + intervals.familyUpper, + bootstrap.observed[offset + index], + Math.log2(config.precisionMargin), + item.deltas.length + ), + }; + }); +} + +function analyzeGrowth(artifact, blocks) { + const results = []; + const logs = []; + const groups = new Map(); + for (const key of artifact.workloadKeys) { + const item = parseKey(key); + const group = `${item.shape}:${item.mode}`; + const list = groups.get(group) ?? []; + list.push(item); + groups.set(group, list); + } + for (const [group, items] of groups) { + items.sort((a, b) => a.size - b.size); + for (let i = 1; i < items.length; i++) { + const base = []; + const cand = []; + for (const block of blocks) { + base.push( + median(findWorkload(block, 'baseline', items[i].key).measured) / + median(findWorkload(block, 'baseline', items[i - 1].key).measured) + ); + cand.push( + median(findWorkload(block, 'candidate', items[i].key).measured) / + median(findWorkload(block, 'candidate', items[i - 1].key).measured) + ); + } + logs.push(cand.map(Math.log)); + results.push({ + group, + from: items[i - 1].size, + to: items[i].size, + baselineMedian: median(base), + candidateMedian: median(cand), + }); + } + } + return { results, claimRows: logs }; +} + +function addGrowthIntervals(growthData, bootstrap, offset, blocks, config) { + return growthData.results.map((result, index) => { + const intervals = bootstrap.intervals[offset + index]; + const standardError = bootstrap.standardErrors[offset + index]; + result.candidateLowerRatio = Math.exp(intervals.familyLower); + result.candidateUpperRatio = Math.exp(intervals.familyUpper); + result.candidateMedian = Math.exp(bootstrap.observed[offset + index]); + result.familyAdjusted95 = { + lower: intervals.familyLower, + upper: intervals.familyUpper, + 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.precision = precisionSummary( + variationMetrics(growthData.claimRows[index]).sd, + standardError, + config, + intervals.familyLower, + intervals.familyUpper, + bootstrap.observed[offset + index], + Math.log(config.precisionMargin), + blocks.length + ); + return result; + }); +} + +function parseKey(key) { + const parts = key.split(':'); + const size = Number(parts.at(-1)); + if (parts.length === 3) return { key, shape: parts[0], mode: parts[1], size }; + return { key, shape: 'nested-fallbacks', mode: parts[0], size }; +} +function findWorkload(block, revision, key) { + return block.revisions + .find((item) => item.revision === revision) + .workloads.find((item) => item.key === key); +} + +export function runParserBenchmark({ + root = process.cwd(), + benchmark = 'arithmetic-chains', + baseline = 'HEAD', + blocks = MIN_VALID_BLOCKS, + maxAttempts = Math.max(30, blocks), + seed = 0x51f15eed, + output, +} = {}) { + if ( + !Number.isInteger(blocks) || + blocks < MIN_VALID_BLOCKS || + blocks % 2 !== 0 + ) + throw new TypeError( + `--blocks must be an even integer of at least ${MIN_VALID_BLOCKS}` + ); + if (!Number.isInteger(maxAttempts) || maxAttempts < blocks) + throw new TypeError('--max-attempts must be an integer at least --blocks'); + const normalizedSeed = normalizeSeed(seed); + const workloads = parserWorkloads(benchmark); + const workloadKeys = workloads.map((item) => item.key); + const config = { + decisionConfigVersion: DECISION_CONFIG_VERSION, + benchmark, + baseline, + requestedBlocks: blocks, + minimumBlocks: MIN_VALID_BLOCKS, + maxAttempts, + targetBatchMs: 25, + warmupMinimum: 5, + warmupMaximum: MAX_WARMUPS, + measuredBatchCount: MEASURED_BATCHES, + driftThreshold: DRIFT_THRESHOLD, + bootstrapResamples: 100_000, + confidence: 0.95, + runtimeNonRegressionMargin: NON_REGRESSION_MARGIN, + equivalenceMargin: 1.1, + precisionMargin: 1.1, + precisionMethod: PRECISION_METHOD, + growthThreshold: GROWTH_THRESHOLD, + orderInteractionThreshold: Math.log(1.1), + intervalMethod: DECISION_INTERVAL_METHOD, + }; + const environment = collectBenchmarkProvenance(root, { + baselineRef: baseline, + benchmark: `parser-${benchmark}`, + command: process.argv.join(' '), + }); + const materialized = materializeBaseline(root, baseline); + try { + const attempts = []; + const validBlocks = []; + const processSchedule = seededShuffle( + Array.from({ length: blocks }, (_, index) => + index < blocks / 2 ? 'baseline-first' : 'candidate-first' + ), + normalizedSeed + ); + let correctnessFailure = null; + let attempt = 0; + while (validBlocks.length < blocks && attempt < maxAttempts) { + const blockSeed = + (normalizedSeed + Math.imul(attempt + 1, 0x9e3779b9)) >>> 0; + const order = seededShuffle(workloads, blockSeed); + // Rejected attempts retry the same acceptance slot. This preserves the + // randomized, balanced process-order schedule among retained blocks. + const processOrder = processSchedule[validBlocks.length]; + const revisions = []; + const sources = + processOrder === 'baseline-first' + ? [ + ['baseline', materialized.sourceRoot], + ['candidate', join(root, 'src')], + ] + : [ + ['candidate', join(root, 'src')], + ['baseline', materialized.sourceRoot], + ]; + for (const [revision, sourceRoot] of sources) { + const child = runChild( + WORKER, + { + sourceRoot, + revision, + processOrder, + workloads: order, + targetBatchMs: config.targetBatchMs, + warmupMinimum: config.warmupMinimum, + warmupMaximum: config.warmupMaximum, + measuredBatchCount: config.measuredBatchCount, + }, + root + ); + revisions.push(child); + } + const drift = revisions.map((revision) => + Math.abs( + controlMedian(revision.controlAfter) / + controlMedian(revision.controlBefore) - + 1 + ) + ); + const structural = new Map( + revisions.flatMap((revision) => + revision.workloads.map((workload) => [ + `${revision.revision}:${workload.key}`, + workload.structural, + ]) + ) + ); + const mismatches = workloadKeys.filter( + (key) => + structural.get(`baseline:${key}`) !== + structural.get(`candidate:${key}`) + ); + const rejected = + drift.some((value) => value > config.driftThreshold) || + mismatches.length > 0; + const rejectionReasons = [ + ...(drift.some((value) => value > config.driftThreshold) + ? ['drift'] + : []), + ...(mismatches.length > 0 ? ['structural-mismatch'] : []), + ]; + const record = { + index: attempt, + seed: blockSeed, + processOrder, + workloadOrder: order.map(endpointKey), + rejected, + rejectionReasons, + rejectionReason: rejectionReasons.join('+') || null, + drift, + structuralMismatches: mismatches, + revisions, + }; + attempts.push(record); + if (mismatches.length > 0) { + correctnessFailure = record; + break; + } + if (!rejected) validBlocks.push(record); + attempt++; + } + const artifact = { + schema: 2, + benchmark: `parser-${benchmark}`, + seed: normalizedSeed, + config, + environment, + workloadKeys, + workloads: workloads.map((workload) => + Object.fromEntries( + Object.entries(workload).filter(([key]) => key !== 'source') + ) + ), + attempts, + blocks: validBlocks, + }; + if (correctnessFailure) { + artifact.analysis = { + status: 'correctness-failure', + validBlocks: validBlocks.length, + reason: 'baseline and candidate parser structures differ', + attempt: correctnessFailure.index, + structuralMismatches: correctnessFailure.structuralMismatches, + }; + } else if (validBlocks.length >= MIN_VALID_BLOCKS) { + artifact.analysis = analyzeParser(artifact); + } else { + artifact.analysis = { + status: 'inconclusive', + validBlocks: validBlocks.length, + reason: 'fewer than twenty valid blocks', + }; + } + validateSchemaV2Artifact(artifact); + const path = output + ? resolve(root, output) + : join( + root, + 'reports/benchmarks', + `${benchmark}-${Date.now()}-${normalizedSeed}.json` + ); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(artifact, null, 2)}\n`); + if (correctnessFailure) + throw new Error( + `parser structural mismatch in attempt ${correctnessFailure.index}: ${correctnessFailure.structuralMismatches.join(', ')}` + ); + return { artifact, path }; + } finally { + materialized.cleanup(); + } +} + +function controlMedian(control) { + return typeof control === 'number' ? control : control.medianMs; +} + +export { analyzeParser, SIZES, DEPTHS }; diff --git a/scripts/parser-benchmark-worker.js b/scripts/parser-benchmark-worker.js new file mode 100644 index 0000000..783c219 --- /dev/null +++ b/scripts/parser-benchmark-worker.js @@ -0,0 +1,186 @@ +// One fresh parser process. The parent supplies the source tree and the +// already-derived workload order; this file deliberately has no benchmark +// state that can leak between blocks. +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { tokenize } from '@csstools/css-tokenizer'; + +const payload = JSON.parse( + process.argv[2] ?? (readFileSync(0, 'utf8') || '{}') +); +const sourceRoot = payload.sourceRoot; +if (typeof sourceRoot !== 'string') + throw new Error('missing parser source root'); + +const parser = await import(pathToFileURL(`${sourceRoot}/lib/parser.js`).href); +let blockIndex; +try { + blockIndex = await import( + pathToFileURL(`${sourceRoot}/lib/block-index.js`).href + ); +} catch (error) { + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ERR_MODULE_NOT_FOUND' + ) { + throw error; + } + blockIndex = undefined; +} +if (typeof parser.parse !== 'function') + throw new Error('incompatible baseline parser API'); + +const TARGET_MS = payload.targetBatchMs ?? 25; +const MIN_WARMUPS = payload.warmupMinimum ?? 5; +const MAX_WARMUPS = payload.warmupMaximum ?? 10; +const MEASURED_BATCHES = payload.measuredBatchCount ?? 6; + +function digest(value) { + const hash = createHash('sha256'); + const stack = [value]; + while (stack.length) { + const node = stack.pop(); + if (node === null || typeof node !== 'object') { + hash.update(`${typeof node}:${String(node)};`); + continue; + } + hash.update(`{${node.type ?? 'object'};`); + for (const key of Object.keys(node).sort().reverse()) { + hash.update(`${key}:`); + stack.push(node[key]); + } + hash.update('}'); + } + return hash.digest('hex'); +} + +function consume(value) { + // A tiny iterative walk prevents the JIT from eliminating the final parse + // of each timed batch, without putting structural hashing in the timer. + let count = 0; + const stack = [value]; + while (stack.length) { + const node = stack.pop(); + count += typeof node === 'object' && node !== null ? 1 : 0; + if (node && typeof node === 'object') { + for (const child of Object.values(node)) { + if (child && typeof child === 'object') stack.push(child); + } + } + } + return count; +} + +function makeRun(workload) { + const tokens = tokenize({ css: workload.source }); + const index = + blockIndex && workload.mode === 'hot-shared-index' + ? blockIndex.indexBlocks(tokens) + : undefined; + return () => { + if (!blockIndex) return parser.parse(tokens, 0, tokens.length); + const currentIndex = index ?? blockIndex.indexBlocks(tokens); + return parser.parse(tokens, 0, tokens.length, currentIndex); + }; +} + +function timedBatch(run, repetitions) { + const start = performance.now(); + let last; + for (let i = 0; i < repetitions; i++) last = run(); + const elapsed = performance.now() - start; + return { elapsed, perRun: elapsed / repetitions, consumed: consume(last) }; +} + +function calibrate(run) { + let repetitions = 1; + const samples = []; + let batch = timedBatch(run, repetitions); + samples.push(batch.elapsed); + let sample = batch.elapsed; + while (sample < TARGET_MS * 0.6 && repetitions < 1_000_000) { + repetitions *= 2; + batch = timedBatch(run, repetitions); + samples.push(batch.elapsed); + sample = batch.elapsed; + } + return { repetitions, samples }; +} + +function stable(warmups) { + if (warmups.length < 3) return false; + const last = warmups.slice(-3); + const sorted = [...last].sort((a, b) => a - b); + const med = sorted[1]; + return med > 0 && (sorted[2] - sorted[0]) / med <= 0.1; +} + +function runWorkload(workload) { + const run = makeRun(workload); + const structural = digest(run()); + const calibration = calibrate(run); + const repetitions = calibration.repetitions; + const warmups = []; + const warmupElapsedMs = []; + for (let i = 0; i < MAX_WARMUPS; i++) { + const batch = timedBatch(run, repetitions); + warmups.push(batch.perRun); + warmupElapsedMs.push(batch.elapsed); + if (i + 1 >= MIN_WARMUPS && stable(warmups)) break; + } + const measured = []; + const measuredElapsedMs = []; + let consumed = 0; + for (let i = 0; i < MEASURED_BATCHES; i++) { + const batch = timedBatch(run, repetitions); + measured.push(batch.perRun); + measuredElapsedMs.push(batch.elapsed); + consumed += batch.consumed; + } + return { + key: workload.key, + repetitions, + calibrationSamplesMs: calibration.samples, + warmups, + warmupElapsedMs, + measured, + measuredElapsedMs, + structural, + checksum: structural, + consumed, + }; +} + +function control() { + const workload = { + source: Array(5_000).fill('1').join(' + '), + mode: 'hot-shared-index', + }; + const run = makeRun(workload); + const repetitions = 50; + for (let i = 0; i < 5; i++) timedBatch(run, repetitions); + const samples = []; + const elapsedMs = []; + for (let i = 0; i < 3; i++) { + const batch = timedBatch(run, repetitions); + samples.push(batch.perRun); + elapsedMs.push(batch.elapsed); + } + samples.sort((a, b) => a - b); + return { medianMs: samples[1], samplesMs: elapsedMs }; +} + +const before = control(); +const workloads = (payload.workloads ?? []).map(runWorkload); +const after = control(); +process.stdout.write( + JSON.stringify({ + revision: payload.revision, + processOrder: payload.processOrder, + controlBefore: before, + controlAfter: after, + workloads, + }) +); diff --git a/scripts/randomizer.mjs b/scripts/randomizer.js similarity index 95% rename from scripts/randomizer.mjs rename to scripts/randomizer.js index d4e88e6..9f5c9d8 100644 --- a/scripts/randomizer.mjs +++ b/scripts/randomizer.js @@ -10,7 +10,8 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import fc from 'fast-check'; import { calc as csstoolsCalc } from '@csstools/css-calc'; -import { tokenize } from '../src/lib/tokenizer.js'; +import { tokenize } from '@csstools/css-tokenizer'; +import { indexBlocks } from '../src/lib/block-index.js'; import { parse } from '../src/lib/parser.js'; import { simplify } from '../src/lib/simplify.js'; import { serialize } from '../src/lib/serialize.js'; @@ -18,7 +19,7 @@ import { astArb, astArbWithDegenerate, astToCalc, -} from '../test/helpers/arbitraries.mjs'; +} from '../test/helpers/arbitraries.js'; import { mkSum, mkProduct } from '../src/lib/node.js'; const ROOT = dirname(fileURLToPath(import.meta.url)); const REPORT_DEFAULT = join(ROOT, '..', 'reports', 'randomizer-finds.jsonl'); @@ -30,9 +31,11 @@ const MODE = process.env.RANDOMIZER_MODE ?? 'complex'; const COMPARE_PRECISION = 9; function ourOut(input) { try { - return serialize(simplify(parse(tokenize(input))), { - precision: COMPARE_PRECISION, - }); + const tokens = tokenize({ css: input }); + return serialize( + simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))), + { precision: COMPARE_PRECISION } + ); } catch { return null; } @@ -72,7 +75,7 @@ function bucketOf(tokenCount) { function countTokens(input) { try { // -1 drops the trailing 'eof' token. - return Math.max(0, tokenize(input).length - 1); + return Math.max(0, tokenize({ css: input }).length - 1); } catch { return 0; } diff --git a/scripts/show-divergences.mjs b/scripts/show-divergences.js similarity index 85% rename from scripts/show-divergences.mjs rename to scripts/show-divergences.js index c9be2e8..b95dee0 100644 --- a/scripts/show-divergences.mjs +++ b/scripts/show-divergences.js @@ -1,14 +1,19 @@ // Bucket github-pure corpus divergences against @csstools/css-calc. -import { tokenize } from '../src/lib/tokenizer.js'; +import { tokenize } from '@csstools/css-tokenizer'; +import { indexBlocks } from '../src/lib/block-index.js'; import { parse } from '../src/lib/parser.js'; import { simplify } from '../src/lib/simplify.js'; import { serialize } from '../src/lib/serialize.js'; import { calc as csstoolsCalc } from '@csstools/css-calc'; -import { loadCorpus } from './lib/corpus.mjs'; +import { loadCorpus } from './lib/corpus.js'; const lines = loadCorpus(); const ours = (s) => { try { - return serialize(simplify(parse(tokenize(s))), { precision: 10 }); + const tokens = tokenize({ css: s }); + return serialize( + simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))), + { precision: 10 } + ); } catch { return null; } diff --git a/scripts/split-corpus.mjs b/scripts/split-corpus.js similarity index 87% rename from scripts/split-corpus.mjs rename to scripts/split-corpus.js index eaa6093..f419285 100644 --- a/scripts/split-corpus.mjs +++ b/scripts/split-corpus.js @@ -5,7 +5,8 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { tokenize } from '../src/lib/tokenizer.js'; +import { tokenize } from '@csstools/css-tokenizer'; +import { indexBlocks } from '../src/lib/block-index.js'; import { parse } from '../src/lib/parser.js'; import { simplify } from '../src/lib/simplify.js'; import { serialize } from '../src/lib/serialize.js'; @@ -18,7 +19,10 @@ const INVALID = join(ROOT, 'test/corpus/github/invalid.txt'); const PREPROC_RE = /#\{|\$[A-Za-z_]|@[A-Za-z_]|~["']/; function ourParserAccepts(s) { try { - serialize(simplify(parse(tokenize(s))), { precision: 10 }); + const tokens = tokenize({ css: s }); + serialize(simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))), { + precision: 10, + }); return true; } catch { return false; diff --git a/scripts/tokenizer-compat.mjs b/scripts/tokenizer-compat.mjs deleted file mode 100644 index b8ae12c..0000000 --- a/scripts/tokenizer-compat.mjs +++ /dev/null @@ -1,130 +0,0 @@ -// Projects our tokens and csstools-format tokens (library tuples or the -// @rmenke/css-tokenizer-tests JSON) onto one shape and diffs the streams. -// Compares decoded values, normalizing the two designed differences: -// function-token ≡ ident + `(`, and signed numeric ≡ punct sign + numeric. -const PUNCT_DELIMS = new Set(['+', '-', '*', '/']); -export class OutOfSubsetError extends Error { - tokenType; - raw; - constructor(tokenType, raw) { - super(`out of subset: ${tokenType} ${JSON.stringify(raw)}`); - this.tokenType = tokenType; - this.raw = raw; - } -} -export function fromCsstools(tokens) { - const out = []; - let ws = true; - const push = (t) => { - out.push({ ...t, ws }); - ws = false; - }; - for (const t of tokens) { - switch (t.type) { - case 'whitespace-token': - case 'comment': - ws = true; - break; - case 'EOF-token': - break; - case 'number-token': - push({ type: 'number', num: t.structured?.value, raw: t.raw }); - break; - case 'dimension-token': - push({ - type: 'dimension', - num: t.structured?.value, - unit: t.structured?.unit, - raw: t.raw, - }); - break; - case 'percentage-token': - push({ - type: 'dimension', - num: t.structured?.value, - unit: '%', - raw: t.raw, - }); - break; - case 'ident-token': - push({ type: 'ident', name: t.structured?.value, raw: t.raw }); - break; - case 'function-token': - push({ type: 'ident', name: t.structured?.value, raw: t.raw }); - push({ type: 'punct', name: '(', raw: '(' }); - break; - case '(-token': - push({ type: 'punct', name: '(', raw: t.raw }); - break; - case ')-token': - push({ type: 'punct', name: ')', raw: t.raw }); - break; - case 'comma-token': - push({ type: 'punct', name: ',', raw: t.raw }); - break; - case 'delim-token': { - const ch = t.structured?.value; - if (!PUNCT_DELIMS.has(ch)) throw new OutOfSubsetError(t.type, t.raw); - push({ type: 'punct', name: ch, raw: t.raw }); - break; - } - default: - throw new OutOfSubsetError(t.type, t.raw); - } - } - return out; -} -export function fromOurs(tokens) { - const out = []; - for (const t of tokens) { - if (t.type === 'eof') continue; - if (t.type === 'number' || t.type === 'dimension') { - out.push({ - type: t.type, - num: Number.parseFloat(t.value), - unit: t.unit, - raw: `${t.value}${t.unit ?? ''}`, - ws: t.ws, - }); - } else { - out.push({ type: t.type, name: t.value, raw: t.value, ws: t.ws }); - } - } - return out; -} -const isNumeric = (t) => t.type === 'number' || t.type === 'dimension'; -const tokenEq = (a, b) => - a.type === b.type && - a.ws === b.ws && - a.name === b.name && - a.num === b.num && - a.unit === b.unit; -export function compareStreams(ours, theirs) { - let i = 0; - let j = 0; - while (i < ours.length || j < theirs.length) { - const a = ours[i] ?? null; - const b = theirs[j] ?? null; - if (!a || !b) return { index: j, ours: a, theirs: b }; - const next = ours[i + 1]; - if ( - a.type === 'punct' && - (a.name === '+' || a.name === '-') && - next !== undefined && - isNumeric(next) && - !next.ws && - isNumeric(b) && - b.ws === a.ws && - b.unit === next.unit && - b.num === (a.name === '-' ? -next.num : next.num) - ) { - i += 2; - j += 1; - continue; - } - if (!tokenEq(a, b)) return { index: j, ours: a, theirs: b }; - i++; - j++; - } - return null; -} diff --git a/scripts/tokenizer-suite.mjs b/scripts/tokenizer-suite.mjs deleted file mode 100644 index 9800110..0000000 --- a/scripts/tokenizer-suite.mjs +++ /dev/null @@ -1,86 +0,0 @@ -// Runs the @rmenke/css-tokenizer-tests corpus against our tokenizer. -import { testCorpus } from '@rmenke/css-tokenizer-tests'; -import { tokenize as ourTokenize } from '../src/lib/tokenizer.js'; -import { - fromCsstools, - fromOurs, - compareStreams, - OutOfSubsetError, -} from './tokenizer-compat.mjs'; -const buckets = { - pass: [], - fail: [], - 'out-of-scope': [], -}; -const fmt = (t) => { - if (!t) return ''; - const text = t.name ?? `${t.num}${t.unit ?? ''}`; - const wsFlag = t.ws ? ', ws' : ''; - return `${t.type}(${JSON.stringify(text)}${wsFlag})`; -}; -for (const [name, testCase] of Object.entries(testCorpus)) { - let expected; - try { - expected = fromCsstools(testCase.tokens); - } catch (e) { - if (e instanceof OutOfSubsetError) { - buckets['out-of-scope'].push({ name, why: e.message }); - continue; - } - throw e; - } - let ours; - try { - ours = fromOurs(ourTokenize(testCase.css)); - } catch (e) { - buckets.fail.push({ - name, - css: testCase.css, - detail: `threw: ${e.message}`, - }); - continue; - } - const diff = compareStreams(ours, expected); - if (!diff) { - buckets.pass.push(name); - } else { - buckets.fail.push({ - name, - css: testCase.css, - detail: `token #${diff.index}: ours ${fmt(diff.ours)} vs expected ${fmt(diff.theirs)}`, - }); - } -} -const total = Object.values(buckets).reduce((n, b) => n + b.length, 0); -console.log(`css-tokenizer-tests: ${total} cases`); -console.log(` pass: ${buckets.pass.length}`); -console.log(` fail: ${buckets.fail.length}`); -console.log( - ` out-of-scope: ${buckets['out-of-scope'].length} (token types outside the calc subset)` -); -if (buckets.fail.length) { - console.log('\n=== FAILURES (in-subset divergence — real bugs) ==='); - for (const f of buckets.fail) { - console.log(`CASE: ${f.name}`); - console.log(`CSS: ${JSON.stringify(f.css)}`); - console.log(`DETAIL: ${f.detail}`); - } -} -const byCategory = new Map(); -const bump = (name, key) => { - const cat = name.split('/')[1]; - const e = byCategory.get(cat) ?? { inScope: 0, outOfScope: 0 }; - e[key]++; - byCategory.set(cat, e); -}; -for (const name of buckets.pass) bump(name, 'inScope'); -for (const f of buckets.fail) bump(f.name, 'inScope'); -for (const o of buckets['out-of-scope']) bump(o.name, 'outOfScope'); -console.log('\nPer-category (ran / skipped-out-of-scope):'); -for (const [cat, c] of [...byCategory.entries()].sort((a, b) => - a[0].localeCompare(b[0]) -)) { - console.log( - ` ${cat.padEnd(20)} ${String(c.inScope).padStart(3)} / ${c.outOfScope}` - ); -} diff --git a/src/index.js b/src/index.js index 39d161a..d8be55c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,12 +1,13 @@ // PostCSS adapter over the standalone component-value reducer. -import reduceCalc, { hasPotentialMathFunction } from './reduce.js'; - +import reduceCalc from './reduce.js'; +import { hasPotentialMathFunction } from './lib/functions.js'; /** * @typedef {object} PostCssCalcOptions * @property {number | false} [precision] * @property {boolean} [warnWhenCannotResolve] * @property {boolean} [mediaQueries] * @property {boolean} [selectors] + * @property {boolean} [unwrapSingleValue] Serialize fully resolved finite scalar results without calculation syntax. Defaults to `false`. * @property {(error: Error, input: string) => void} [onParseError] Invoked when parse/simplify throws. Replaces the default `result.warn`. */ @@ -24,7 +25,7 @@ import reduceCalc, { hasPotentialMathFunction } from './reduce.js'; * @param {(target: import('postcss').ChildNode, value: string) => void} setProp * @param {ResolvedOptions} options * @param {import('postcss').Result} result - * @param {boolean} unwrapSingleNegativeNumber + * @param {boolean} selectorContext * @return {void} */ function applyTransform( @@ -33,7 +34,7 @@ function applyTransform( setProp, options, result, - unwrapSingleNegativeNumber + selectorContext ) { if (!hasPotentialMathFunction(current)) { return; @@ -49,7 +50,8 @@ function applyTransform( onWarn: (message) => { result.warn(message, { plugin: 'postcss-calc', node }); }, - unwrapSingleNegativeNumber, + // Selectors cannot contain calc(), so they always unwrap resolved values. + unwrapSingleValue: selectorContext || options.unwrapSingleValue, }); if (transformed !== current) { setProp(node, transformed); @@ -67,6 +69,7 @@ function pluginCreator(opts) { warnWhenCannotResolve: false, mediaQueries: false, selectors: false, + unwrapSingleValue: false, ...opts, }; diff --git a/src/lib/analyze.js b/src/lib/analyze.js new file mode 100644 index 0000000..2ec3e0a --- /dev/null +++ b/src/lib/analyze.js @@ -0,0 +1,166 @@ +import { baseOf } from './convertUnits.js'; +import { addTypes, isFailure, mathFunctions } from './functions.js'; +import { assertDepth } from './limits.js'; + +/** @typedef {import('./node.js').Node} Node */ +/** @typedef {import('./functions.js').CalculationType} CalculationType */ + +/** @typedef {'number' | 'unknown' | {dimension: string | null}} AnalysisType */ +/** @typedef {{type: AnalysisType, valid: boolean, unresolved: boolean}} Analysis */ + +/** @type {CalculationType} */ const numberType = { kind: 'number' }; +/** @type {CalculationType} */ const unknownType = { kind: 'unknown' }; +/** @type {CalculationType} */ const failureType = { kind: 'failure' }; + +/** @param {Node} node @return {Analysis} */ +function analyze(node) { + const result = analyzeType(node); + return { + type: publicType(result.type), + valid: result.valid, + unresolved: result.unresolved, + }; +} + +/** @param {Node} node @param {number} [depth] @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */ +function analyzeType(node, depth = 0) { + assertDepth(depth); + switch (node.type) { + case 'Num': + return resolved(numberType); + case 'Dim': + // Percentages are contextual. Unknown units are opaque, while known + // families can still reject px + seconds. + return node.unit === '%' + ? finish(unknownType, true, true) + : resolved({ kind: 'dimension', base: baseOf(node.unit) }); + case 'Ident': + return markUnresolved(unknownType); + case 'Sum': + return analyzeSum(node, depth); + case 'Product': + return analyzeProduct(node, depth); + case 'Call': + return analyzeCall(node, depth); + case 'OpaqueCall': + return finish(unknownType, true, true); + } +} + +/** @param {Extract} node @param {number} depth @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */ +function analyzeSum(node, depth) { + let type = numberType; + let valid = true; + let hasTerm = false; + let hasUnresolved = false; + for (const term of node.terms) { + const child = analyzeType(term.node, depth + 1); + type = hasTerm ? addTypes(type, child.type) : child.type; + hasTerm = true; + valid = valid && child.valid; + hasUnresolved = hasUnresolved || child.unresolved; + } + return finish(type, valid, hasUnresolved); +} + +/** @param {Extract} node @param {number} depth @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */ +function analyzeProduct(node, depth) { + let numerator = null; + let denominator = null; + let valid = true; + let structurallyValid = true; + let hasUnknown = false; + let hasUnresolved = false; + for (const factor of node.factors) { + const child = analyzeType(factor.node, depth + 1); + valid = valid && child.valid; + hasUnresolved = hasUnresolved || child.unresolved; + if (isFailure(child.type)) { + continue; + } + if (child.type.kind === 'unknown') { + hasUnknown = true; + continue; + } + if (child.type.kind !== 'dimension') continue; + if (factor.exponent === 1) { + if (numerator !== null) structurallyValid = false; + else numerator = child.type; + } else { + if (denominator !== null) structurallyValid = false; + else denominator = child.type; + } + } + if (!valid) return finish(failureType, false, hasUnresolved); + // Opaque factors can supply missing type information, but they cannot make + // an already-invalid combination of known dimensions valid. + if (!structurallyValid) return finish(failureType, false, hasUnresolved); + if ( + numerator !== null && + denominator !== null && + numerator.base !== denominator.base + ) { + return finish(failureType, false, hasUnresolved); + } + // An opaque factor may supply type information that changes how the known + // dimensions combine once the known factors are structurally valid. + if (hasUnknown) return finish(unknownType, true, hasUnresolved); + if (numerator !== null && denominator !== null) { + return finish( + numerator.base === denominator.base ? numberType : failureType, + valid && numerator.base === denominator.base, + hasUnresolved + ); + } + if (denominator !== null) return finish(failureType, false, hasUnresolved); + return finish(numerator ?? numberType, valid, hasUnresolved); +} + +/** @param {Extract} node @param {number} depth @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */ +function analyzeCall(node, depth) { + const name = node.name.toLowerCase(); + const childResults = node.args.map((arg) => analyzeType(arg, depth + 1)); + const childTypes = childResults.map((child) => child.type); + const valid = childResults.every((child) => child.valid); + const definition = mathFunctions.get(name); + const unresolvedArgs = childResults.some( + (child, index) => + !definition?.isKeyword?.(node.args[index], index) && child.unresolved + ); + if (!definition) return finish(unknownType, valid, true); + const type = definition.analyze(childTypes, node.args); + const unresolvedType = type.kind === 'unknown'; + return finish( + type, + valid && !isFailure(type), + unresolvedArgs || unresolvedType + ); +} + +/** @param {CalculationType} type @param {boolean} valid @param {boolean} unresolved @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */ +function finish(type, valid, unresolved) { + return { + type, + valid: valid && !isFailure(type), + unresolved, + }; +} + +/** @param {CalculationType} type @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */ +function resolved(type) { + return finish(type, true, false); +} + +/** @param {CalculationType} type @return {{type: CalculationType, valid: boolean, unresolved: boolean}} */ +function markUnresolved(type) { + return finish(type, true, true); +} + +/** @param {CalculationType} type @return {AnalysisType} */ +function publicType(type) { + if (type.kind === 'number') return 'number'; + if (type.kind === 'unknown' || type.kind === 'failure') return 'unknown'; + return { dimension: type.base }; +} + +export { analyze }; diff --git a/src/lib/block-index.js b/src/lib/block-index.js new file mode 100644 index 0000000..68d6024 --- /dev/null +++ b/src/lib/block-index.js @@ -0,0 +1,118 @@ +import { TokenType as CssType } from '@csstools/css-tokenizer'; + +/** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ + +const BLOCK_CLOSE = new Map([ + [CssType.Function, CssType.CloseParen], + [CssType.OpenParen, CssType.CloseParen], + [CssType.OpenSquare, CssType.CloseSquare], + [CssType.OpenCurly, CssType.CloseCurly], +]); + +/** + * Read-only delimiter navigation for one native token stream range. + */ +class BlockIndex { + /** @type {CSSToken[]} */ + #tokens; + /** @type {number} */ + #rangeStart; + /** @type {number} */ + #rangeEnd; + /** @type {Map} */ + #closes = new Map(); + /** @type {number} */ + #maxDepth = 0; + + /** + * Build the delimiter index once for a token stream range. Matching remains + * LIFO: a mismatched closer is ignored and does not disturb the open stack. + * + * @param {CSSToken[]} tokens + * @param {number} start + * @param {number} end + */ + constructor(tokens, start, end) { + this.#tokens = tokens; + this.#rangeStart = Math.max(0, start); + this.#rangeEnd = Math.min(tokens.length, Math.max(this.#rangeStart, end)); + /** @type {{index: number, close: import('@csstools/css-tokenizer').TokenType}[]} */ + const stack = []; + + for (let i = this.#rangeStart; i < this.#rangeEnd; i++) { + const type = tokens[i][0]; + const close = BLOCK_CLOSE.get(type); + if (close !== undefined) { + stack.push({ index: i, close }); + this.#maxDepth = Math.max(this.#maxDepth, stack.length); + continue; + } + + if ( + type === CssType.CloseParen || + type === CssType.CloseSquare || + type === CssType.CloseCurly + ) { + const open = stack.at(-1); + if (open === undefined || open.close !== type) { + continue; + } + stack.pop(); + this.#closes.set(open.index, i); + } + } + } + + /** @return {number} */ + get maxDepth() { + return this.#maxDepth; + } + + /** @param {number | undefined} end @return {number} */ + #boundedEnd(end) { + return Math.min( + this.#rangeEnd, + Math.max(this.#rangeStart, end ?? this.#rangeEnd) + ); + } + + /** @param {number} openPosition @param {number} [endPosition] @return {number} */ + closeOf(openPosition, endPosition) { + const close = this.#closes.get(openPosition) ?? -1; + const bound = this.#boundedEnd(endPosition); + return close >= this.#rangeStart && close < bound ? close : -1; + } + + /** @param {number} position @param {number} endPosition @return {number} */ + nextComponent(position, endPosition) { + const bound = this.#boundedEnd(endPosition); + if (position < this.#rangeStart || position >= bound) return bound; + const close = this.closeOf(position, bound); + return close === -1 ? position + 1 : close + 1; + } + + /** @param {number} startPosition @param {number} endPosition @return {number} */ + firstTopLevelComma(startPosition, endPosition) { + const bound = this.#boundedEnd(endPosition); + for (let i = Math.max(this.#rangeStart, startPosition); i < bound;) { + if (this.#tokens[i][0] === CssType.Comma) return i; + i = this.nextComponent(i, bound); + } + return -1; + } +} + +/** + * Build the delimiter index once for a token stream range. Matching remains + * LIFO: a mismatched closer is ignored and does not disturb the open stack. + * + * @param {CSSToken[]} tokens + * @param {number} [start] + * @param {number} [end] + * @return {BlockIndex} + */ +function indexBlocks(tokens, start = 0, end = tokens.length) { + return new BlockIndex(tokens, start, end); +} + +export { indexBlocks }; diff --git a/src/lib/calculation-type.js b/src/lib/calculation-type.js new file mode 100644 index 0000000..6d6092e --- /dev/null +++ b/src/lib/calculation-type.js @@ -0,0 +1,25 @@ +// Compatibility facade for the former calculation-type module. New code uses +// analyze() for the complete result and limits.js for depth policy. + +import { analyze } from './analyze.js'; +import { MAX_CALCULATION_DEPTH, checkCalculationDepth } from './limits.js'; + +/** @typedef {import('./node.js').Node} Node */ + +/** @typedef {{kind: 'number'} | {kind: 'dimension', base: string | null} | {kind: 'unknown'} | {kind: 'failure'}} CalculationType */ + +/** @param {Node} node @return {CalculationType} */ +function checkCalculationType(node) { + const result = analyze(node); + if (!result.valid) return { kind: 'failure' }; + if (result.type === 'number') return { kind: 'number' }; + if (result.type === 'unknown') return { kind: 'unknown' }; + return { kind: 'dimension', base: result.type.dimension }; +} + +export { + MAX_CALCULATION_DEPTH, + checkCalculationDepth, + checkCalculationType, + analyze, +}; diff --git a/src/lib/compile.js b/src/lib/compile.js new file mode 100644 index 0000000..757f7d5 --- /dev/null +++ b/src/lib/compile.js @@ -0,0 +1,78 @@ +import { parse } from './parser.js'; +import { simplify } from './simplify.js'; +import { analyze } from './analyze.js'; + +/** @typedef {import('./scan.js').Candidate} Candidate */ +/** @typedef {import('../reduce.js').ResolvedReduceCalcOptions} ResolvedReduceCalcOptions */ +/** @typedef {import('../reduce.js').Replacement} Replacement */ +/** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ +/** @typedef {ReturnType} BlockIndex */ +/** @typedef {{options: ResolvedReduceCalcOptions, value: string, tokens: CSSToken[], index: BlockIndex}} CompileContext */ + +/** + * Parse, analyze, and simplify one candidate. + * + * @param {Candidate} candidate + * @param {CompileContext} ctx + * @return {Replacement} + */ +function compileCandidate(candidate, ctx) { + if (!candidate.closed) { + throw new Error( + `Unclosed ${candidate.name}( at position ${candidate.start}` + ); + } + const parsed = parse( + ctx.tokens, + candidate.sliceStart, + candidate.sliceEnd, + ctx.index + ); + const analysis = analyze(parsed); + if (!analysis.valid) { + throw new Error('Invalid CSS calculation type'); + } + const tree = simplify(parsed); + const original = + analysis.unresolved && !candidate.calculation + ? ctx.value.slice(candidate.start, candidate.end) + : undefined; + return { + start: candidate.start, + end: candidate.end, + result: { + tree, + status: analysis.unresolved ? 'unresolved' : 'resolved', + rootName: candidate.normalizedName, + rootSpelling: candidate.rootSpelling, + calculation: candidate.calculation, + original, + }, + }; +} + +/** + * Compile candidates independently so one malformed calculation is preserved + * without preventing unrelated candidates from being reduced. + * + * @param {Candidate[]} candidates + * @param {CompileContext} ctx + * @return {Replacement[]} + */ +function compileCandidates(candidates, ctx) { + /** @type {Replacement[]} */ + const replacements = []; + for (const candidate of candidates) { + try { + replacements.push(compileCandidate(candidate, ctx)); + } catch (error) { + ctx.options.onParseError?.( + error instanceof Error ? error : new Error('Error', { cause: error }), + ctx.value.slice(candidate.start, candidate.end) + ); + } + } + return replacements; +} + +export { compileCandidate, compileCandidates }; diff --git a/src/lib/functions.js b/src/lib/functions.js new file mode 100644 index 0000000..cf2d050 --- /dev/null +++ b/src/lib/functions.js @@ -0,0 +1,342 @@ +import { simplifyMinMax } from './simplify/min-max.js'; +import { simplifyClamp } from './simplify/clamp.js'; +import { simplifyAbs } from './simplify/abs.js'; +import { simplifySign } from './simplify/sign.js'; +import { simplifyModRem } from './simplify/mod-rem.js'; +import { ROUND_STRATEGIES, simplifyRound } from './simplify/round.js'; +import { simplifyTrig } from './simplify/trig.js'; +import { simplifyInverseTrig } from './simplify/inverse-trig.js'; +import { simplifyAtan2 } from './simplify/atan2.js'; +import { simplifyPow } from './simplify/pow.js'; +import { simplifySqrt } from './simplify/sqrt.js'; +import { simplifyExp } from './simplify/exp.js'; +import { simplifyLog } from './simplify/log.js'; +import { simplifyHypot } from './simplify/hypot.js'; + +/** @typedef {import('./node.js').Node} Node */ +/** @typedef {{kind: 'number'} | {kind: 'dimension', base: string | null} | {kind: 'unknown'} | {kind: 'failure'}} CalculationType */ +/** @typedef {(name: string, args: Node[]) => Node} MathSimplifier */ +/** @typedef {(args: CalculationType[], nodes: Node[]) => CalculationType} TypeAnalyzer */ +/** @typedef {{analyze: TypeAnalyzer, simplify?: MathSimplifier, isKeyword?: (node: Node, index: number) => boolean, calculation?: boolean}} MathFunction */ + +/** @type {CalculationType} */ const numberType = { kind: 'number' }; +/** @type {CalculationType} */ const unknownType = { kind: 'unknown' }; +/** @type {CalculationType} */ const failureType = { kind: 'failure' }; + +/** @param {CalculationType} type @return {boolean} */ +function isFailure(type) { + return type.kind === 'failure'; +} + +/** @param {CalculationType} a @param {CalculationType} b @return {CalculationType} */ +function addTypes(a, b) { + if (isFailure(a) || isFailure(b)) return failureType; + if (a.kind === 'unknown' || b.kind === 'unknown') return unknownType; + if (a.kind === 'number' && b.kind === 'number') return numberType; + if (a.kind === 'dimension' && b.kind === 'dimension') { + if (a.base === null || b.base === null) return unknownType; + return a.base === b.base ? a : failureType; + } + return failureType; +} + +/** + * Check an all-number function without assuming anything about an unresolved + * operand. A concrete dimension can never become a number, so it is still a + * definite error when another argument is opaque. + * @param {CalculationType[]} args + * @param {number} min + * @param {number} max + * @return {CalculationType} + */ +function numberArguments(args, min, max) { + if (args.length < min || args.length > max) return failureType; + if (args.some((arg) => arg.kind === 'dimension')) return failureType; + return args.some((arg) => arg.kind === 'unknown') ? unknownType : numberType; +} + +/** + * Check values that must share a calculation type. Unknown operands remain + * unknown: they might resolve to the concrete type required by their peers. + * @param {CalculationType[]} args + * @param {number} min + * @param {number} max + * @return {CalculationType} + */ +function matchingArguments(args, min, max) { + if (args.length < min || args.length > max) return failureType; + let result = args[0]; + for (const arg of args.slice(1)) { + result = addTypes(result, arg); + if (isFailure(result)) return failureType; + } + return result; +} + +/** @param {CalculationType[]} args @return {CalculationType} */ +function analyzeTrig(args) { + return args.length === 1 && + (args[0].kind === 'unknown' || + args[0].kind === 'number' || + (args[0].kind === 'dimension' && args[0].base === 'angle')) + ? numberType + : failureType; +} + +/** @param {CalculationType[]} args @return {CalculationType} */ +function analyzeInverseTrig(args) { + if (args.length !== 1 || args[0].kind === 'dimension') { + return failureType; + } + return args[0].kind === 'unknown' + ? unknownType + : { kind: 'dimension', base: 'angle' }; +} + +/** @param {CalculationType[]} args @return {CalculationType} */ +function analyzeIdentity(args) { + return args.length === 1 ? args[0] : failureType; +} + +/** @param {CalculationType[]} args @param {Node[]} nodes @return {CalculationType} */ +function analyzeRound(args, nodes) { + const strategy = nodes[0]; + const hasStrategy = + strategy?.type === 'Ident' && + ROUND_STRATEGIES.has(strategy.name.toLowerCase()); + const values = args.slice(hasStrategy ? 1 : 0); + if (values.length === 1) return numberArguments(values, 1, 1); + return matchingArguments(values, 2, 2); +} + +/** @param {CalculationType[]} args @return {CalculationType} */ +function analyzeAtan2(args) { + const type = matchingArguments(args, 2, 2); + return isFailure(type) || type.kind === 'unknown' + ? type + : { kind: 'dimension', base: 'angle' }; +} + +/** @param {CalculationType[]} args @return {CalculationType} */ +function analyzeCalc(args) { + return args.length === 1 ? args[0] : failureType; +} + +/** @param {Node} node @return {boolean} */ +function isRoundStrategy(node) { + return node.type === 'Ident' && ROUND_STRATEGIES.has(node.name.toLowerCase()); +} + +/** @param {Node} node @param {number} index @return {boolean} */ +function isClampKeyword(node, index) { + return ( + (index === 0 || index === 2) && + node.type === 'Ident' && + node.name.toLowerCase() === 'none' + ); +} + +/** @param {CalculationType[]} args @param {Node[]} nodes @return {CalculationType} */ +function analyzeClamp(args, nodes) { + if (args.length !== 3) return failureType; + const values = args.filter( + (_, index) => !isClampKeyword(nodes[index], index) + ); + return matchingArguments(values, 1, 3); +} + +const mathFunctions = new Map( + /** @type {[string, MathFunction][]} */ ([ + ['calc', { analyze: analyzeCalc, calculation: true }], + ['-webkit-calc', { analyze: analyzeCalc, calculation: true }], + ['-moz-calc', { analyze: analyzeCalc, calculation: true }], + [ + 'min', + { + analyze: (args) => matchingArguments(args, 1, Infinity), + simplify: simplifyMinMax, + }, + ], + [ + 'max', + { + analyze: (args) => matchingArguments(args, 1, Infinity), + simplify: simplifyMinMax, + }, + ], + [ + 'clamp', + { + analyze: analyzeClamp, + simplify: (_name, args) => simplifyClamp(args), + isKeyword: isClampKeyword, + }, + ], + [ + 'abs', + { + analyze: analyzeIdentity, + simplify: (_name, args) => simplifyAbs(args), + }, + ], + [ + 'sign', + { + analyze: analyzeIdentity, + simplify: (_name, args) => simplifySign(args), + }, + ], + [ + 'mod', + { + analyze: (args) => matchingArguments(args, 2, 2), + simplify: (_name, args) => simplifyModRem('mod', args), + }, + ], + [ + 'rem', + { + analyze: (args) => matchingArguments(args, 2, 2), + simplify: (_name, args) => simplifyModRem('rem', args), + }, + ], + [ + 'round', + { + analyze: analyzeRound, + simplify: (_name, args) => simplifyRound(args), + isKeyword: (node, index) => index === 0 && isRoundStrategy(node), + }, + ], + [ + 'sin', + { + analyze: analyzeTrig, + simplify: (_name, args) => simplifyTrig('sin', args), + }, + ], + [ + 'cos', + { + analyze: analyzeTrig, + simplify: (_name, args) => simplifyTrig('cos', args), + }, + ], + [ + 'tan', + { + analyze: analyzeTrig, + simplify: (_name, args) => simplifyTrig('tan', args), + }, + ], + [ + 'asin', + { + analyze: analyzeInverseTrig, + simplify: (_name, args) => simplifyInverseTrig('asin', args), + }, + ], + [ + 'acos', + { + analyze: analyzeInverseTrig, + simplify: (_name, args) => simplifyInverseTrig('acos', args), + }, + ], + [ + 'atan', + { + analyze: analyzeInverseTrig, + simplify: (_name, args) => simplifyInverseTrig('atan', args), + }, + ], + [ + 'atan2', + { + analyze: analyzeAtan2, + simplify: (_name, args) => simplifyAtan2(args), + }, + ], + [ + 'pow', + { + analyze: (args) => numberArguments(args, 2, 2), + simplify: (_name, args) => simplifyPow(args), + }, + ], + [ + 'sqrt', + { + analyze: (args) => numberArguments(args, 1, 1), + simplify: (_name, args) => simplifySqrt(args), + }, + ], + [ + 'hypot', + { + analyze: (args) => matchingArguments(args, 1, Infinity), + simplify: (_name, args) => simplifyHypot(args), + }, + ], + [ + 'log', + { + analyze: (args) => numberArguments(args, 1, 2), + simplify: (_name, args) => simplifyLog(args), + }, + ], + [ + 'exp', + { + analyze: (args) => numberArguments(args, 1, 1), + simplify: (_name, args) => simplifyExp(args), + }, + ], + ]) +); + +/** + * @param {string} name + * @return {{normalizedName: string, definition: MathFunction} | undefined} + */ +function lookupMathFunction(name) { + const normalizedName = name.toLowerCase(); + const definition = mathFunctions.get(normalizedName); + return definition === undefined ? undefined : { normalizedName, definition }; +} + +const mathFunctionNames = [...mathFunctions.keys()]; +const QUICK_MATH_TEST = new RegExp( + `(?:${mathFunctionNames.join('|')})\\(`, + 'i' +); + +/** @param {string} name @return {boolean} */ +function isCalculationFunction(name) { + return mathFunctions.get(name.toLowerCase())?.calculation === true; +} + +/** @param {string} name @return {boolean} */ +function isSupportedMathFunction(name) { + const normalizedName = name.toLowerCase(); + const definition = mathFunctions.get(normalizedName); + return definition !== undefined && definition.calculation !== true; +} + +/** @param {string} value @return {boolean} */ +function hasPotentialMathFunction(value) { + return ( + value.includes('(') && (QUICK_MATH_TEST.test(value) || value.includes('\\')) + ); +} + +export { + addTypes, + mathFunctions, + lookupMathFunction, + QUICK_MATH_TEST, + isFailure, + isCalculationFunction, + isSupportedMathFunction, + hasPotentialMathFunction, +}; diff --git a/src/lib/limits.js b/src/lib/limits.js new file mode 100644 index 0000000..19d1f36 --- /dev/null +++ b/src/lib/limits.js @@ -0,0 +1,49 @@ +/** @typedef {import('./node.js').Node} Node */ + +const MAX_CALCULATION_DEPTH = 1024; + +/** @param {number} depth @return {void} */ +function assertDepth(depth) { + if (depth > MAX_CALCULATION_DEPTH) { + throw new Error( + `Calculation nesting exceeds the limit of ${MAX_CALCULATION_DEPTH}` + ); + } +} + +/** @param {unknown} part @param {number} depth @return {void} */ +function checkOpaquePartDepth(part, depth) { + assertDepth(depth); + if (typeof part === 'string') return; + if (Array.isArray(part)) { + for (const child of part) checkOpaquePartDepth(child, depth + 1); + return; + } + checkCalculationDepth(/** @type {Node} */ (part), depth + 1); +} + +/** @param {Node} node @param {number} [depth] @return {void} */ +function checkCalculationDepth(node, depth = 0) { + assertDepth(depth); + switch (node.type) { + case 'Sum': + for (const term of node.terms) { + checkCalculationDepth(term.node, depth + 1); + } + return; + case 'Product': + for (const factor of node.factors) { + checkCalculationDepth(factor.node, depth + 1); + } + return; + case 'Call': + for (const child of node.args) checkCalculationDepth(child, depth + 1); + return; + case 'OpaqueCall': + for (const part of node.components) { + checkOpaquePartDepth(part, depth + 1); + } + } +} + +export { MAX_CALCULATION_DEPTH, assertDepth, checkCalculationDepth }; diff --git a/src/lib/node.js b/src/lib/node.js index 3582495..2078ba1 100644 --- a/src/lib/node.js +++ b/src/lib/node.js @@ -11,7 +11,10 @@ // - No Product directly contains another Product (flattened). // - A Sum/Product with one positive element collapses to that element. // - A Sum/Product with no elements collapses to Num(0) / Num(1). -// - Zero-valued Nums are dropped from sums (they contribute nothing). +// - Positive zero-valued Nums are dropped from sums (they contribute +// nothing). Negative zero is retained until calculation evaluation has +// finished, because it is an IEEE-754 value with observable math-function +// behavior. // Zero-valued Dims are kept — the unit carries type info. /** @@ -19,11 +22,13 @@ * @typedef {{type: 'Dim', value: number, unit: string, rawUnit?: string}} Dim * @typedef {{type: 'Ident', name: string, rawName?: string}} Ident * @typedef {{type: 'Call', name: string, args: Node[], rawName?: string}} Call + * @typedef {string | Node | OpaqueComponent[]} OpaqueComponent + * @typedef {{type: 'OpaqueCall', name: string, components: OpaqueComponent[], rawName?: string}} OpaqueCall * @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 {Num | Dim | Ident | Call | Sum | Product} Node + * @typedef {Num | Dim | Ident | Call | OpaqueCall | Sum | Product} Node */ /** @@ -69,6 +74,18 @@ function call(name, args, rawName) { : { type: 'Call', name, args, rawName }; } +/** + * @param {string} name + * @param {OpaqueComponent[]} components + * @param {string} [rawName] + * @return {OpaqueCall} + */ +function opaqueCall(name, components, rawName) { + return rawName === undefined + ? { type: 'OpaqueCall', name, components } + : { type: 'OpaqueCall', name, components, rawName }; +} + /** * @param {SumTerm[]} rawTerms * @return {Node} @@ -76,13 +93,26 @@ function call(name, args, rawName) { function mkSum(rawTerms) { /** @type {SumTerm[]} */ const flat = []; + let hasNegativeZero = false; for (const t of rawTerms) { - pushSumTerm(flat, t); + if (pushSumTerm(flat, t)) hasNegativeZero = true; } - if (flat.length === 0) { + // `+0 + -0` evaluates to +0. Keep positive zero terms when the sum also + // contains -0 so simplification can perform that IEEE-754 operation before + // the canonical zero-elision below. + let length = 0; + for (let i = 0; i < flat.length; i++) { + const term = flat[i]; + if (!hasNegativeZero && term.node.type === 'Num' && term.node.value === 0) { + continue; + } + flat[length++] = term; + } + flat.length = length; + if (length === 0) { return num(0); } - if (flat.length === 1 && flat[0].sign === 1) { + if (length === 1 && flat[0].sign === 1) { return flat[0].node; } return { type: 'Sum', terms: flat }; @@ -91,19 +121,24 @@ function mkSum(rawTerms) { /** * @param {SumTerm[]} out * @param {SumTerm} term - * @return {void} + * @return {boolean} Whether the appended terms contain negative zero. */ function pushSumTerm(out, term) { let { sign, node } = term; if (node.type === 'Sum' && !node.grouped) { + let hasNegativeZero = false; for (const inner of node.terms) { - pushSumTerm(out, { - sign: /** @type {1 | -1} */ (sign * inner.sign), - node: inner.node, - }); + if ( + pushSumTerm(out, { + sign: /** @type {1 | -1} */ (sign * inner.sign), + node: inner.node, + }) + ) { + hasNegativeZero = true; + } } - return; + return hasNegativeZero; } // sign=-1 around a Num/Dim leaf collapses into the value's sign — the @@ -113,12 +148,8 @@ function pushSumTerm(out, term) { sign = 1; } - // Drop zero-valued Nums. Dims with value 0 stay — the unit carries type. - if (node.type === 'Num' && node.value === 0) { - return; - } - out.push({ sign, node }); + return node.type === 'Num' && Object.is(node.value, -0); } /** @@ -176,19 +207,23 @@ function negate(node) { return dim(-node.value, node.unit, node.rawUnit); } if (node.type === 'Sum') { + // A grouped sum may contain opaque terms whose meaning depends on the + // surrounding context. Keep the group intact so `-(a + b)` cannot turn + // into `-a - b` while it is still unresolved. + if (node.grouped) { + return mkSum([{ sign: -1, node }]); + } const result = mkSum( node.terms.map((t) => ({ sign: /** @type {1 | -1} */ (-t.sign), node: t.node, })) ); - return node.grouped && result.type === 'Sum' - ? { ...result, grouped: true } - : result; + return result; } - // Opaque (Ident, Call, Product): wrap as a single negative-sign term — + // Opaque (Ident, Call, OpaqueCall, Product): wrap as a single negative-sign term — // the only case where sign=-1 remains on a SumTerm. return mkSum([{ sign: -1, node }]); } -export { num, dim, ident, call, mkSum, mkProduct, negate }; +export { num, dim, ident, call, opaqueCall, mkSum, mkProduct, negate }; diff --git a/src/lib/opaque.js b/src/lib/opaque.js index c15f57a..cca92ff 100644 --- a/src/lib/opaque.js +++ b/src/lib/opaque.js @@ -1,40 +1,20 @@ -// Private metadata for opaque function contents (including var() fallbacks). -// Keeping it in a WeakMap means the public calculation AST remains unchanged. /** @typedef {import('./node.js').Node} Node */ -/** @typedef {string | Node | Component[]} Component */ -/** @type {WeakMap, Component[]>} */ -const components = new WeakMap(); -/** @param {Extract} node @param {Component[]} tree */ -function setComponents(node, tree) { - components.set(node, tree); - return node; -} -/** @param {Extract} node */ -function getComponents(node) { - return components.get(node); -} -/** @param {Component[]} tree @param {(node: Node) => Node} simplify @return {Component[]} */ -function simplifyComponents(tree, simplify) { - return tree.map((part) => { +/** @typedef {import('./node.js').OpaqueComponent} OpaqueComponent */ + +/** @param {OpaqueComponent[]} components @param {(node: Node) => Node} simplify @return {OpaqueComponent[]} */ +function simplifyComponents(components, simplify) { + return components.map((part) => { if (typeof part === 'string') return part; if (Array.isArray(part)) return simplifyComponents(part, simplify); return simplify(part); }); } -/** @param {Component[]} tree @param {(node: Node) => string} serialize @return {string} */ -function serializeComponents(tree, serialize) { - let result = ''; - for (const part of tree) { - if (typeof part === 'string') result += part; - else if (Array.isArray(part)) - result += serializeComponents(part, serialize); - else result += serialize(part); +/** @param {OpaqueComponent[]} components @param {string[]} buffer @param {(node: Node, buffer: string[]) => void} serialize @return {void} */ +function serializeComponents(components, buffer, serialize) { + for (const part of components) { + if (typeof part === 'string') buffer.push(part); + else if (Array.isArray(part)) serializeComponents(part, buffer, serialize); + else serialize(part, buffer); } - return result; } -export { - getComponents, - setComponents, - simplifyComponents, - serializeComponents, -}; +export { simplifyComponents, serializeComponents }; diff --git a/src/lib/parser.js b/src/lib/parser.js index 824bd76..09db2ca 100644 --- a/src/lib/parser.js +++ b/src/lib/parser.js @@ -1,227 +1,138 @@ // Pratt parser over native @csstools/css-tokenizer tokens. import { TokenType as CssType } from '@csstools/css-tokenizer'; import { baseOf } from './convertUnits.js'; -import { mkSum, mkProduct, negate, num, dim, ident, call } from './node.js'; -import { setComponents } from './opaque.js'; -import { isSupportedMathFunction } from './simplify/call.js'; +import { + call, + dim, + ident, + mkProduct, + mkSum, + negate, + num, + opaqueCall, +} from './node.js'; +import { isCalculationFunction, isSupportedMathFunction } from './functions.js'; +import { assertDepth } from './limits.js'; +import { CSS_NUMBER_PREFIX } from './regex.js'; /** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */ /** @typedef {import('./node.js').Node} Node */ -/** @typedef {string | Node | Component[]} Component */ +/** @typedef {import('./node.js').OpaqueComponent} OpaqueComponent */ +/** @typedef {ReturnType} BlockIndex */ +/** @typedef {{raw: string, pos: number, ws: boolean, index: number}} TokenBase */ +/** @typedef {TokenBase & {type: 'number', value: number, signCharacter?: '+' | '-'}} NumberToken */ +/** @typedef {TokenBase & {type: 'dimension', value: number, unit: string, rawUnit: string, signCharacter?: '+' | '-'}} DimensionToken */ +/** @typedef {TokenBase & {type: 'ident', value: string}} IdentToken */ +/** @typedef {TokenBase & {type: 'function', value: string}} FunctionToken */ +/** @typedef {'(' | ')' | ',' | '+' | '-' | '*' | '/'} Punctuator */ +/** @typedef {TokenBase & {type: 'punct', value: Punctuator}} PunctToken */ +/** @typedef {TokenBase & {type: 'eof', value: '', raw: ''}} EofToken */ +/** @typedef {NumberToken | DimensionToken | IdentToken | FunctionToken | PunctToken | EofToken} Token */ /** - * @typedef {object} Token - * @property {'number' | 'dimension' | 'ident' | 'function' | 'punct' | 'eof'} type - * @property {string | number} value - * @property {string} raw - * @property {string} [unit] - * @property {string} [rawUnit] - * @property {'+' | '-'} [signCharacter] - * @property {number} pos - * @property {boolean} ws + * Immutable bounds and shared block index for one parse range. + * @typedef {Readonly<{tokens: CSSToken[], end: number, index: BlockIndex}>} ParseInput */ -/** @typedef {(p: Parser, token: Token) => Node} PrefixParselet */ -const NUMERIC_RAW = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?/; -const PUNCT_DELIMS = new Set(['+', '-', '*', '/']); -const BLOCK_CLOSE = new Map([ - [CssType.Function, CssType.CloseParen], - [CssType.OpenParen, CssType.CloseParen], - [CssType.OpenSquare, CssType.CloseSquare], - [CssType.OpenCurly, CssType.CloseCurly], -]); +/** @param {string} value @return {value is '+' | '-' | '*' | '/'} */ +function isOperator(value) { + return value === '+' || value === '-' || value === '*' || value === '/'; +} + +/** + * CSS numeric tokens do not retain a signed-zero distinction. Keep that + * normalization at the source boundary so a later IEEE-754 `-0` can only + * have been introduced by calculation evaluation. + * + * @param {number} value + * @return {number} + */ +function normalizeSourceZero(value) { + return value === 0 ? 0 : value; +} + /** @param {string} raw @param {string} decoded */ function sourceSpelling(raw, decoded) { return raw === decoded ? undefined : raw; } -/** Bounded cursor that skips trivia but records whether it preceded a token. */ -class Parser { - /** @type {CSSToken[]} */ - #tokens; - /** @type {number} */ - #end; - /** @type {Map} */ - #ends; - /** @type {number} */ - #i; - /** @type {boolean} */ - #precededByWhitespace = true; - /** @type {Token | null} */ - #lookahead = null; - - /** - * @param {CSSToken[]} tokens - * @param {number} start - * @param {number} end - * @param {Map} [ends] - */ - constructor(tokens, start, end, ends = blockEnds(tokens, start, end)) { - this.#tokens = tokens; - this.#end = end; - this.#ends = ends; - this.#i = start; - this.#precededByWhitespace = true; - this.#lookahead = null; +/** + * Mutable navigation state only. `index` is always the next native token + * position; trivia is intentionally left visible to `scanToken`. + */ +class Cursor { + /** @param {number} start */ + constructor(start) { + /** @type {number} */ + this.index = start; + /** @type {boolean} */ + this.firstToken = true; + /** @type {Token | null} */ + this.lookahead = null; + /** @type {number} */ + this.lookaheadNextIndex = start; } - /** @return {Map} */ - get ends() { - return this.#ends; + /** @param {number} index @return {void} */ + skipTo(index) { + this.index = index; + this.lookaheadNextIndex = index; + this.firstToken = false; + this.lookahead = null; } +} - /** @return {number} */ - eofPosition() { - if (this.#i < this.#end && this.#tokens[this.#i][0] !== CssType.EOF) { - return this.#tokens[this.#i][2]; - } - if ( - this.#end < this.#tokens.length && - this.#tokens[this.#end][0] !== CssType.EOF - ) { - return this.#tokens[this.#end][2]; - } - for (let i = Math.min(this.#end, this.#tokens.length) - 1; i >= 0; i--) { - const token = this.#tokens[i]; - if (token[0] !== CssType.EOF) return token[3] + 1; - } - return 0; - } +/** @param {OpaqueComponent[]} target @param {OpaqueComponent} part */ +function pushComponent(target, part) { + if (typeof part === 'string' && typeof target.at(-1) === 'string') + target[target.length - 1] += part; + else target.push(part); +} - /** @return {Token} */ - read() { - while (this.#i < this.#end) { - const native = this.#tokens[this.#i++]; - if (native[0] === CssType.Whitespace || native[0] === CssType.Comment) { - this.#precededByWhitespace = true; - continue; - } - if (native[0] === CssType.EOF) break; - const ws = this.#precededByWhitespace; - this.#precededByWhitespace = false; - return normalizeToken(native, ws); +/** + * Scan one token without consuming it. `firstToken` supplies the virtual + * leading trivia at a bounded parse boundary; all later whitespace state is + * derived from the native tokens encountered in this scan. + * + * @param {ParseInput} input + * @param {Cursor} cursor + * @return {Token} + */ +function scanToken(input, cursor) { + let i = cursor.index; + let ws = cursor.firstToken; + while (i < input.end) { + const native = input.tokens[i]; + if (native[0] === CssType.Whitespace || native[0] === CssType.Comment) { + ws = true; + i++; + continue; } - return { - type: 'eof', - value: '', - raw: '', - pos: this.eofPosition(), - ws: this.#precededByWhitespace, - }; - } - - /** @return {Token} */ - peek() { - if (this.#lookahead === null) this.#lookahead = this.read(); - return this.#lookahead; - } - - /** @return {Token} */ - next() { - const token = this.peek(); - this.#lookahead = null; + if (native[0] === CssType.EOF) break; + const token = normalizeToken(native, i, ws); + cursor.lookahead = token; + cursor.lookaheadNextIndex = i + 1; return token; } - - /** @return {{start: number, close: number, tokens: CSSToken[], ends: Map}} */ - functionRange() { - return { - start: this.#i, - close: this.#ends.get(this.#i - 1) ?? -1, - tokens: this.#tokens, - ends: this.#ends, - }; - } - - /** @param {number} index */ - consumeThrough(index) { - this.#i = index; - this.#lookahead = null; - } - - /** @param {string} value @param {string} [value2] @return {boolean} */ - isPunct(value, value2) { - const t = this.peek(); - return ( - t.type === 'punct' && - (t.value === value || (value2 !== undefined && t.value === value2)) - ); - } - - /** @param {string} value @return {boolean} */ - matchPunct(value) { - if (!this.isPunct(value)) return false; - this.next(); - return true; - } - - /** @param {string} value @return {Token} */ - expectPunct(value) { - const t = this.next(); - if (t.type !== 'punct' || t.value !== value) { - throw new Error( - `Expected ${value} at position ${t.pos}, got "${t.value}"` - ); - } - return t; - } - - /** @param {number} [minBp] @return {Node} */ - parseExpr(minBp = 0) { - const t = this.next(); - const key = t.type === 'punct' ? String(t.value) : t.type; - const prefix = PREFIX[key]; - if (!prefix) - throw new Error(`Unexpected token "${t.raw}" at position ${t.pos}`); - let left = prefix(this, t); - - while (true) { - const nxt = this.peek(); - if ( - (nxt.type === 'number' || nxt.type === 'dimension') && - nxt.signCharacter !== undefined - ) { - throw new Error( - `"${nxt.signCharacter}" must be surrounded by whitespace at position ${nxt.pos}` - ); - } - const infixKey = nxt.type === 'punct' ? String(nxt.value) : nxt.type; - const rule = INFIX[infixKey]; - if (!rule || rule.lbp < minBp) break; - if (infixKey === '+' || infixKey === '-') { - /** @type {import('./node.js').SumTerm[]} */ - const terms = [{ sign: /** @type {1} */ (1), node: left }]; - do { - const token = this.next(); - requireSurroundingWs(this, token); - terms.push({ - sign: /** @type {1 | -1} */ (token.value === '+' ? 1 : -1), - node: this.parseExpr(ADD_BP + 1), - }); - } while (this.isPunct('+', '-')); - left = mkSum(terms); - continue; - } - if (infixKey === '*' || infixKey === '/') { - /** @type {import('./node.js').ProductFactor[]} */ - const factors = [{ exponent: /** @type {1} */ (1), node: left }]; - do { - const token = this.next(); - factors.push({ - exponent: /** @type {1 | -1} */ (token.value === '*' ? 1 : -1), - node: this.parseExpr(MUL_BP + 1), - }); - } while (this.isPunct('*', '/')); - left = mkProduct(factors); - continue; - } - break; - } - return left; - } + /** @type {EofToken} */ + const token = { + type: 'eof', + value: '', + raw: '', + pos: eofPositionAt(input, i), + ws, + index: i, + }; + cursor.lookahead = token; + // Native EOF is a real token and is consumed past its array index. When + // the bounded range ends before native EOF, this is a virtual EOF and must + // remain at the range boundary. + cursor.lookaheadNextIndex = + i < input.end && input.tokens[i][0] === CssType.EOF ? i + 1 : input.end; + return token; } -/** @param {CSSToken} t @param {boolean} ws @return {Token} */ -function normalizeToken(t, ws) { +/** @param {CSSToken} t @param {number} index @param {boolean} ws @return {Token} */ +function normalizeToken(t, index, ws) { const [type, raw, pos, , detail] = t; switch (type) { case CssType.Number: @@ -231,16 +142,18 @@ function normalizeToken(t, ws) { raw, pos, ws, + index, signCharacter: detail.signCharacter, }; case CssType.Dimension: { - const match = NUMERIC_RAW.exec(raw); + const match = CSS_NUMBER_PREFIX.exec(raw); return { type: 'dimension', value: detail.value, raw, pos, ws, + index, unit: detail.unit, rawUnit: match ? raw.slice(match[0].length) : detail.unit, signCharacter: detail.signCharacter, @@ -253,6 +166,7 @@ function normalizeToken(t, ws) { raw, pos, ws, + index, unit: '%', rawUnit: '%', signCharacter: detail.signCharacter, @@ -265,18 +179,180 @@ function normalizeToken(t, ws) { raw, pos, ws, + index, }; case CssType.OpenParen: + return { type: 'punct', value: '(', raw, pos, ws, index }; case CssType.CloseParen: + return { type: 'punct', value: ')', raw, pos, ws, index }; case CssType.Comma: - return { type: 'punct', value: raw, raw, pos, ws }; + return { type: 'punct', value: ',', raw, pos, ws, index }; case CssType.Delim: - if (PUNCT_DELIMS.has(detail.value)) - return { type: 'punct', value: detail.value, raw, pos, ws }; + if (isOperator(detail.value)) + return { + type: 'punct', + value: detail.value, + raw, + pos, + ws, + index, + }; } throw new Error(`Unexpected character "${raw[0] ?? ''}" at position ${pos}`); } +/** @param {ParseInput} input @param {number} index @return {number} */ +function eofPositionAt(input, index) { + if (index < input.end && input.tokens[index][0] !== CssType.EOF) { + return input.tokens[index][2]; + } + if ( + input.end < input.tokens.length && + input.tokens[input.end][0] !== CssType.EOF + ) { + return input.tokens[input.end][2]; + } + for (let i = Math.min(input.end, input.tokens.length) - 1; i >= 0; i--) { + const token = input.tokens[i]; + if (token[0] !== CssType.EOF) return token[3] + 1; + } + return 0; +} + +/** @param {ParseInput} input @param {Cursor} cursor @return {Token} */ +function peekToken(input, cursor) { + return cursor.lookahead ?? scanToken(input, cursor); +} + +/** + * Consume the cached token and advance to its native next index. This is one + * of the only two operations allowed to advance `cursor.index`. + * @param {ParseInput} input + * @param {Cursor} cursor + * @return {Token} + */ +function takeToken(input, cursor) { + const token = peekToken(input, cursor); + cursor.index = cursor.lookaheadNextIndex; + cursor.firstToken = false; + cursor.lookahead = null; + return token; +} + +/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @param {Punctuator} [value2] @return {boolean} */ +function isPunct(input, cursor, value, value2) { + const t = peekToken(input, cursor); + return ( + t.type === 'punct' && + (t.value === value || (value2 !== undefined && t.value === value2)) + ); +} + +/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @return {boolean} */ +function matchPunct(input, cursor, value) { + if (!isPunct(input, cursor, value)) return false; + takeToken(input, cursor); + return true; +} + +/** @param {ParseInput} input @param {Cursor} cursor @param {Punctuator} value @return {PunctToken} */ +function expectPunct(input, cursor, value) { + const t = takeToken(input, cursor); + if (t.type !== 'punct' || t.value !== value) { + throw new Error(`Expected ${value} at position ${t.pos}, got "${t.value}"`); + } + return t; +} + +/** @param {ParseInput} input @param {Cursor} cursor @param {Token} token @param {number} depth @return {Node} */ +function parsePrefix(input, cursor, token, depth) { + switch (token.type) { + case 'number': + return num(normalizeSourceZero(token.value)); + case 'dimension': { + const unit = token.unit.toLowerCase(); + return dim( + normalizeSourceZero(token.value), + unit, + baseOf(unit) || token.rawUnit === unit ? undefined : token.rawUnit + ); + } + case 'ident': + return ( + foldCalcKeyword(token.value) ?? + ident(token.value, sourceSpelling(token.raw, token.value)) + ); + case 'function': + return parseCall(input, cursor, token, depth); + case 'punct': + switch (token.value) { + case '(': { + const expression = parseExpr(input, cursor, 0, depth + 1); + expectPunct(input, cursor, ')'); + return expression.type === 'Sum' + ? { ...expression, grouped: true } + : expression; + } + case '-': + return negate(parseExpr(input, cursor, 7, depth + 1)); + case '+': + return parseExpr(input, cursor, 7, depth + 1); + } + } + throw new Error(`Unexpected token "${token.raw}" at position ${token.pos}`); +} + +/** @param {ParseInput} input @param {Cursor} cursor @param {number} minBp @param {number} depth @return {Node} */ +function parseExpr(input, cursor, minBp = 0, depth = 0) { + assertDepth(depth); + const t = takeToken(input, cursor); + let left = parsePrefix(input, cursor, t, depth); + + while (true) { + const nxt = peekToken(input, cursor); + if ( + (nxt.type === 'number' || nxt.type === 'dimension') && + nxt.signCharacter !== undefined + ) { + throw new Error( + `"${nxt.signCharacter}" must be surrounded by whitespace at position ${nxt.pos}` + ); + } + const infixKey = nxt.type === 'punct' ? String(nxt.value) : nxt.type; + const rule = INFIX[infixKey]; + if (!rule || rule.lbp < minBp) break; + if (infixKey === '+' || infixKey === '-') { + /** @type {import('./node.js').SumTerm[]} */ + const terms = [{ sign: /** @type {1} */ (1), node: left }]; + do { + const token = takeToken(input, cursor); + requireSurroundingWs(input, cursor, token); + terms.push({ + sign: /** @type {1 | -1} */ (token.value === '+' ? 1 : -1), + node: parseExpr(input, cursor, ADD_BP + 1, depth), + }); + } while (isPunct(input, cursor, '+', '-')); + left = mkSum(terms); + continue; + } + if (infixKey === '*' || infixKey === '/') { + /** @type {import('./node.js').ProductFactor[]} */ + const factors = [{ exponent: /** @type {1} */ (1), node: left }]; + do { + const token = takeToken(input, cursor); + factors.push({ + exponent: /** @type {1 | -1} */ (token.value === '*' ? 1 : -1), + node: parseExpr(input, cursor, MUL_BP + 1, depth), + }); + } while (isPunct(input, cursor, '*', '/')); + left = mkProduct(factors); + continue; + } + break; + } + return left; +} + /** §10.9 — case-insensitive except for NaN. @param {string} name @return {Node | null} */ function foldCalcKeyword(name) { if (name === 'NaN' || name === '-NaN') return num(Number.NaN); @@ -295,41 +371,45 @@ function foldCalcKeyword(name) { const ADD_BP = 1; const MUL_BP = 3; -const MATCH_CALC = /^(?:-(?:moz|webkit)-)?calc$/i; - -/** @param {Parser} p @param {string} name @param {string} rawName @return {Node} */ -function parseOpaqueCall(p, name, rawName) { - const { start, close, tokens, ends } = p.functionRange(); +/** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} token @param {string} name @param {string} rawName @return {Node} */ +function parseOpaqueCall(input, cursor, token, name, rawName) { + const start = token.index + 1; + const close = input.index.closeOf(token.index, input.end); if (close === -1) - throw new Error(`Unclosed ${name}( at position ${p.eofPosition()}`); - p.consumeThrough(close + 1); - return setComponents( - call(name, [], sourceSpelling(rawName, name)), - componentTree(tokens, start, close, ends) + throw new Error( + `Unclosed ${name}( at position ${eofPositionAt(input, cursor.index)}` + ); + cursor.skipTo(close + 1); + return opaqueCall( + name, + componentTree(input, start, close), + sourceSpelling(rawName, name) ); } -/** @param {Parser} p @param {Token} token */ -function requireSurroundingWs(p, token) { - if (!token.ws || !p.peek().ws) +/** @param {ParseInput} input @param {Cursor} cursor @param {Token} token */ +function requireSurroundingWs(input, cursor, token) { + if (!token.ws || !peekToken(input, cursor).ws) throw new Error( `"${token.value}" must be surrounded by whitespace at position ${token.pos}` ); } -/** @param {Parser} p @param {Token} t @return {Node} */ -function parseCall(p, t) { - const name = String(t.value); +/** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} t @param {number} depth @return {Node} */ +function parseCall(input, cursor, t, depth) { + const name = t.value; const rawName = t.raw.slice(0, -1); - if (name.toLowerCase() === 'var') return parseVar(p, name, rawName); - if (!MATCH_CALC.test(name) && !isSupportedMathFunction(name)) - return parseOpaqueCall(p, name, rawName); + if (name.toLowerCase() === 'var') + return parseVar(input, cursor, t, name, rawName); + if (!isCalculationFunction(name) && !isSupportedMathFunction(name)) + return parseOpaqueCall(input, cursor, t, name, rawName); /** @type {Node[]} */ const args = []; - if (!p.isPunct(')')) { - args.push(p.parseExpr(0)); - while (p.matchPunct(',')) args.push(p.parseExpr(0)); + if (!isPunct(input, cursor, ')')) { + args.push(parseExpr(input, cursor, 0, depth + 1)); + while (matchPunct(input, cursor, ',')) + args.push(parseExpr(input, cursor, 0, depth + 1)); } - p.expectPunct(')'); + expectPunct(input, cursor, ')'); return call(name, args, sourceSpelling(rawName, name)); } @@ -339,37 +419,6 @@ function rawTokens(tokens, start, end) { for (let i = start; i < end; i++) raw += tokens[i][1]; return raw; } -/** @param {CSSToken[]} tokens @param {number} start @param {number} end @param {Map} ends @return {number} */ -function firstComma(tokens, start, end, ends) { - for (let i = start; i < end; i++) { - const close = ends.get(i); - if (close !== undefined) { - i = close; - continue; - } - if (tokens[i][0] === CssType.Comma) return i; - } - return -1; -} -/** @param {CSSToken[]} tokens @param {number} start @param {number} end */ -function blockEnds(tokens, start, end) { - /** @type {{index: number, close: import('@csstools/css-tokenizer').TokenType}[]} */ - const stack = []; - /** @type {Map} */ const ends = new Map(); - for (let i = start; i < end; i++) { - const close = BLOCK_CLOSE.get(tokens[i][0]); - if (close !== undefined) { - stack.push({ index: i, close }); - continue; - } - const open = stack.at(-1); - if (open !== undefined && tokens[i][0] === open.close) { - stack.pop(); - ends.set(open.index, i); - } - } - return ends; -} /** @param {CSSToken[]} tokens @param {number} start @param {number} end */ function customProperty(tokens, start, end) { /** @type {CSSToken | null} */ @@ -388,88 +437,88 @@ function customProperty(tokens, start, end) { ? { decoded, raw: found[1], index: foundIndex } : null; } -/** @param {CSSToken[]} tokens @param {number} start @param {number} end @param {Map} ends @return {Component[]} */ -function componentTree(tokens, start, end, ends) { - /** @type {Component[]} */ const tree = []; - /** @param {Component} part */ - const push = (part) => { - if (typeof part === 'string' && typeof tree.at(-1) === 'string') - tree[tree.length - 1] += part; - else tree.push(part); - }; - for (let i = start; i < end; i++) { +/** @param {ParseInput} input @param {number} start @param {number} end @param {number} [depth] @return {OpaqueComponent[]} */ +function componentTree(input, start, end, depth = 0) { + assertDepth(depth); + /** @type {OpaqueComponent[]} */ const root = []; + /** @type {OpaqueComponent[]} */ let tree = root; + /** @type {{parent: OpaqueComponent[], tree: OpaqueComponent[], close: number, end: number}[]} */ + const frames = []; + const { tokens } = input; + let i = start; + while (true) { + if (i >= end) { + if (frames.length === 0) break; + const frame = + /** @type {{parent: OpaqueComponent[], tree: OpaqueComponent[], close: number, end: number}} */ ( + frames.pop() + ); + tree = frame.parent; + pushComponent(tree, frame.tree); + pushComponent(tree, tokens[frame.close][1]); + end = frame.end; + i = frame.close + 1; + continue; + } + const token = tokens[i]; - const close = ends.get(i) ?? -1; + const close = input.index.closeOf(i, end); if (close === -1) { - push(token[1]); + pushComponent(tree, token[1]); + i++; continue; } + const isMathFunction = token[0] === CssType.Function && - (MATCH_CALC.test(token[4].value) || + (isCalculationFunction(token[4].value) || isSupportedMathFunction(token[4].value)); if (isMathFunction) { try { - push(parseRange(tokens, i, close + 1, ends)); + pushComponent(tree, parseRange(input, i, close + 1)); } catch { - push(rawTokens(tokens, i, close + 1)); + pushComponent(tree, rawTokens(tokens, i, close + 1)); } - } else { - push(token[1]); - push(componentTree(tokens, i + 1, close, ends)); - push(tokens[close][1]); + i = close + 1; + continue; } - i = close; + + pushComponent(tree, token[1]); + assertDepth(depth + frames.length + 1); + /** @type {OpaqueComponent[]} */ + const child = []; + frames.push({ parent: tree, tree: child, close, end }); + tree = child; + end = close; + i++; } - return tree; + return root; } -/** @param {Parser} p @param {string} name @param {string} rawName @return {Node} */ -function parseVar(p, name, rawName) { - const { start, close, tokens, ends } = p.functionRange(); +/** @param {ParseInput} input @param {Cursor} cursor @param {FunctionToken} token @param {string} name @param {string} rawName @return {Node} */ +function parseVar(input, cursor, token, name, rawName) { + const { tokens } = input; + const start = token.index + 1; + const close = input.index.closeOf(token.index, input.end); if (close === -1) - throw new Error(`Unclosed ${name}( at position ${p.eofPosition()}`); - const comma = firstComma(tokens, start, close, ends); + throw new Error( + `Unclosed ${name}( at position ${eofPositionAt(input, cursor.index)}` + ); + const comma = input.index.firstTopLevelComma(start, close); const property = customProperty(tokens, start, comma === -1 ? close : comma); if (!property) throw new Error( - `Invalid custom property in ${name}() at position ${tokens[start]?.[2] ?? p.eofPosition()}` + `Invalid custom property in ${name}() at position ${tokens[start]?.[2] ?? eofPositionAt(input, cursor.index)}` ); - p.consumeThrough(close + 1); - const node = call( - name, - [ident(property.decoded, sourceSpelling(property.raw, property.decoded))], - sourceSpelling(rawName, name) - ); + cursor.skipTo(close + 1); + /** @type {OpaqueComponent[]} */ + const components = [ + ident(property.decoded, sourceSpelling(property.raw, property.decoded)), + ]; if (comma !== -1) - setComponents(node, componentTree(tokens, property.index + 1, close, ends)); - return node; + components.push(...componentTree(input, property.index + 1, close)); + return opaqueCall(name, components, sourceSpelling(rawName, name)); } -/** @type {Record} */ -const PREFIX = { - number: (_p, t) => num(/** @type {number} */ (t.value)), - dimension: (_p, t) => { - const unit = /** @type {string} */ (t.unit).toLowerCase(); - return dim( - /** @type {number} */ (t.value), - unit, - baseOf(unit) || t.rawUnit === unit ? undefined : t.rawUnit - ); - }, - ident: (_p, t) => { - const name = String(t.value); - return foldCalcKeyword(name) ?? ident(name, sourceSpelling(t.raw, name)); - }, - function: parseCall, - '(': (p) => { - const e = p.parseExpr(0); - p.expectPunct(')'); - return e.type === 'Sum' ? { ...e, grouped: true } : e; - }, - '-': (p) => negate(p.parseExpr(7)), - '+': (p) => p.parseExpr(7), -}; - /** @type {Record} */ const INFIX = { '+': { lbp: ADD_BP }, @@ -479,16 +528,23 @@ const INFIX = { }; /** - * @param {CSSToken[]} tokens + * @param {ParseInput} input * @param {number} start * @param {number} end - * @param {Map} ends * @return {Node} */ -function parseRange(tokens, start, end, ends) { - const p = new Parser(tokens, start, end, ends); - const ast = p.parseExpr(0); - const trailing = p.peek(); +function parseRange(input, start, end) { + const bounded = + input.end === end + ? input + : /** @type {ParseInput} */ ({ + tokens: input.tokens, + end, + index: input.index, + }); + const cursor = new Cursor(start); + const ast = parseExpr(bounded, cursor, 0, 0); + const trailing = peekToken(bounded, cursor); if (trailing.type !== 'eof') throw new Error( `Unexpected token "${trailing.raw}" at position ${trailing.pos}` @@ -496,10 +552,9 @@ function parseRange(tokens, start, end, ends) { return ast; } -/** @param {CSSToken[]} tokens @param {number} [start] @param {number} [end] @return {Node} */ -function parse(tokens, start = 0, end = tokens.length) { - const ends = blockEnds(tokens, start, end); - return parseRange(tokens, start, end, ends); +/** @param {CSSToken[]} tokens @param {number} start @param {number} end @param {BlockIndex} index @return {Node} */ +function parse(tokens, start, end, index) { + return parseRange({ tokens, end, index }, start, end); } export { parse }; diff --git a/src/lib/print.js b/src/lib/print.js new file mode 100644 index 0000000..b96a18b --- /dev/null +++ b/src/lib/print.js @@ -0,0 +1,75 @@ +import { serializeResult } from './serialize.js'; + +/** @typedef {import('../reduce.js').ResolvedReduceCalcOptions} ResolvedReduceCalcOptions */ +/** @typedef {import('../reduce.js').Replacement} Replacement */ +/** @typedef {import('./serialize.js').SerializeOptions} SerializeOptions */ + +/** + * @param {string} value + * @param {Replacement} replacement + * @param {ResolvedReduceCalcOptions} options + * @param {SerializeOptions} serializeOptions + * @return {string} + */ +function serializeReplacement(value, replacement, options, serializeOptions) { + let text; + try { + text = serializeResult(replacement.result, serializeOptions); + } catch (error) { + const err = error instanceof Error ? error : new Error('Error'); + const original = + replacement.result.original ?? + value.slice(replacement.start, replacement.end); + options.onParseError?.(err, original); + text = original; + } + if ( + options.warnWhenCannotResolve && + replacement.result.status === 'unresolved' + ) { + options.onWarn?.('Could not reduce expression: ' + value); + } + return text; +} + +/** + * Serialize compiled candidates and splice the resulting text into the + * original source. Replacements are already non-overlapping because the + * finder treats a supported outer function as one range. + * + * @param {string} value + * @param {Replacement[]} replacements + * @param {ResolvedReduceCalcOptions} options + * @param {SerializeOptions} serializeOptions + * @return {string} + */ +function applyReplacements(value, replacements, options, serializeOptions) { + if (replacements.length === 1) { + const replacement = replacements[0]; + const text = serializeReplacement( + value, + replacement, + options, + serializeOptions + ); + return ( + value.slice(0, replacement.start) + text + value.slice(replacement.end) + ); + } + + let output = ''; + let lastIndex = 0; + for (const replacement of replacements) { + const text = serializeReplacement( + value, + replacement, + options, + serializeOptions + ); + output += value.slice(lastIndex, replacement.start) + text; + lastIndex = replacement.end; + } + return output + value.slice(lastIndex); +} + +export { applyReplacements }; diff --git a/src/lib/regex.js b/src/lib/regex.js new file mode 100644 index 0000000..3c11f89 --- /dev/null +++ b/src/lib/regex.js @@ -0,0 +1,4 @@ +// CSS numeric token prefix, including an optional sign and exponent. +const CSS_NUMBER_PREFIX = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?/; + +export { CSS_NUMBER_PREFIX }; diff --git a/src/lib/scan.js b/src/lib/scan.js new file mode 100644 index 0000000..0980da1 --- /dev/null +++ b/src/lib/scan.js @@ -0,0 +1,61 @@ +import { TokenType as CssType } from '@csstools/css-tokenizer'; +import { lookupMathFunction } from './functions.js'; + +/** + * @typedef {object} Candidate + * @property {string} name + * @property {string} normalizedName + * @property {number} start + * @property {number} end + * @property {string} rootSpelling + * @property {boolean} calculation + * @property {number} sliceStart + * @property {number} sliceEnd + * @property {boolean} closed + */ + +/** + * Find complete supported math-function ranges. A supported function is + * treated as one candidate even when parsing it later fails, so nested + * calculations cannot produce partial output for an invalid outer function. + * + * @param {string} value + * @param {import('@csstools/css-tokenizer').CSSToken[]} tokens + * @param {ReturnType} index + * @return {Candidate[]} + */ +function findCalculations(value, tokens, index) { + /** @type {Candidate[]} */ + const candidates = []; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (token[0] !== CssType.Function) continue; + + const name = token[4].value; + const lookup = lookupMathFunction(name); + if (!lookup) continue; + const isCalc = lookup.definition.calculation === true; + + const close = index.closeOf(i, tokens.length); + const closed = close !== -1; + const end = closed ? tokens[close][3] + 1 : value.length; + const sliceStart = isCalc ? i + 1 : i; + const sliceEnd = closed ? close + (isCalc ? 0 : 1) : tokens.length - 1; + candidates.push({ + name, + normalizedName: lookup.normalizedName, + start: token[2], + end, + rootSpelling: value.slice(token[2], token[3]), + calculation: isCalc, + sliceStart, + sliceEnd, + closed, + }); + if (!closed) break; + i = close; + } + return candidates; +} + +export { findCalculations }; diff --git a/src/lib/serialize.js b/src/lib/serialize.js index a22b741..ff6e1b6 100644 --- a/src/lib/serialize.js +++ b/src/lib/serialize.js @@ -1,9 +1,10 @@ // Spec: https://www.w3.org/TR/css-values-4/#serialize-a-calculation-tree // Outer calc() is added when the top-level result contains an arithmetic -// operator, or when a finite scalar is negative. +// operator, or when a finite scalar needs context-sensitive CSS semantics. -import { num, dim } from './node.js'; -import { getComponents, serializeComponents } from './opaque.js'; +import { serializeComponents } from './opaque.js'; +import { checkCalculationDepth } from './limits.js'; +import { isCalculationFunction } from './functions.js'; /** * @typedef {import('./node.js').Node} Node @@ -13,267 +14,324 @@ import { getComponents, serializeComponents } from './opaque.js'; * @typedef {object} SerializeOptions * @property {number | false} [precision] Decimal places for numbers. `false` disables rounding. Default 5. * @property {string} [calcName] Wrapper name to use when `calc()` is needed. Default `'calc'`. - * @property {boolean} [unwrapSingleNegativeNumber] Serialize finite negative scalars without a wrapper. Internal selector-only mode. + * @property {boolean} [unwrapSingleNegativeNumber] Deprecated alias for `unwrapSingleValue`. + * @property {boolean} [unwrapSingleValue] Serialize fully resolved finite scalar results without calculation syntax. */ -// Below this is float noise, not a value: `0.1 + 0.2 - 0.3` is 5.5e-17. +// The AST is canonical: sums and products are flat, so these precedence +// levels cover every binary expression +const SUM_PRECEDENCE = 1; +const PRODUCT_PRECEDENCE = 2; +const ATOMIC_PRECEDENCE = 3; +// Unary minus binds more tightly than a sum but has the same atomic boundary +// for deciding whether `-x` needs parentheses. +const UNARY_PRECEDENCE = ATOMIC_PRECEDENCE; const NOISE_FLOOR = 1e-12; -/** - * Rounding to `prec` decimal places turns `calc(1/1000000)` into `0`, and a - * `0` in CSS is often a switch, not a small number (`flex-grow: 0` never - * grows). So when a value is too small for `prec`, keep its significant digits - * instead: `1/1000000` -> `0.000001`, `1/3000000` -> `3.3333e-7`. - * - * @param {number} v - * @param {number | false} prec - * @return {number} - */ +/** @param {number} v @param {number | false} prec @return {number} */ function round(v, prec) { - if (prec === false) { - return v; - } + if (prec === false) return v; const m = Math.pow(10, prec); const rounded = Math.round(v * m) / m; if (rounded === 0 && Math.abs(v) > NOISE_FLOOR) { - // toPrecision needs at least one significant digit; `prec` may be 0. return Number(v.toPrecision(Math.max(prec, 1))); } return rounded; } // §10.13 / §10.7.2: Infinity/NaN serialize as canonical keywords. -/** - * @param {number} v - * @return {boolean} - */ +/** @param {number} v @return {boolean} */ function isDegenerate(v) { return !Number.isFinite(v) || Number.isNaN(v); } -/** - * @param {number} v - * @return {string} - */ +/** @param {number} v @return {string} */ function degenerateKeyword(v) { - if (Number.isNaN(v)) { - return 'NaN'; - } + if (Number.isNaN(v)) return 'NaN'; return v > 0 ? 'infinity' : '-infinity'; } -/** - * Serialize a finite CSS number. CSS numbers may omit the zero before a - * fractional value between -1 and 1 (`.5`, `-.5`). Scientific notation is - * left untouched because it already has no leading zero to remove. - * - * @param {number} v - * @return {string} - */ +/** @param {number} v @return {string} */ function serializeNumber(v) { + if (Object.is(v, -0)) return '0'; const text = String(v); - if (text.startsWith('0.')) { - return text.slice(1); - } - if (text.startsWith('-0.')) { - return `-${text.slice(2)}`; - } + if (text.startsWith('0.')) return text.slice(1); + if (text.startsWith('-0.')) return `-${text.slice(2)}`; return text; } +/** @param {SerializeOptions} opts @return {'standard' | 'unwrap-all'} */ +function normalizeScalarPolicy(opts) { + return opts.unwrapSingleValue || opts.unwrapSingleNegativeNumber + ? 'unwrap-all' + : 'standard'; +} + /** - * Round and serialize a finite scalar once so callers can use the same value - * to decide its syntactic context and render its text. - * * @param {import('./node.js').Num | import('./node.js').Dim} node - * @param {number | false} prec - * @return {{value: number, text: string}} + * @param {number | false} precision + * @param {number} [value] + * @return {number} */ -function serializeScalar(node, prec) { - const value = round(node.value, prec); - const text = `${serializeNumber(value)}${node.type === 'Dim' ? (node.rawUnit ?? node.unit) : ''}`; - return { value, text }; +function roundedScalarValue(node, precision, value) { + return round(value ?? node.value, precision); } /** - * @param {Node} node - * @param {SerializeOptions} [opts] - * @return {string} + * @param {import('./node.js').Num | import('./node.js').Dim} node + * @param {string[]} buffer + * @param {number} value + * @return {void} */ -function serialize(node, opts = {}) { - const prec = opts.precision ?? 5; - const calcName = opts.calcName ?? 'calc'; - - // §10.13: top-level Infinity/NaN wrap in calc(); dim degenerates carry - // the unit as ` * 1` so the result keeps its type. - if (node.type === 'Num' && isDegenerate(node.value)) { - return `${calcName}(${degenerateKeyword(node.value)})`; - } - if (node.type === 'Dim' && isDegenerate(node.value)) { - return `${calcName}(${degenerateKeyword(node.value)} * 1${node.rawUnit ?? node.unit})`; +function emitRoundedScalar(node, buffer, value) { + buffer.push(serializeNumber(value)); + if (node.type === 'Dim') { + buffer.push(node.rawUnit ?? node.unit); } +} - if (node.type === 'Num' || node.type === 'Dim') { - const scalar = serializeScalar(node, prec); - - // A finite negative scalar must stay inside calc() so CSS parses it as a - // calculation result (and can apply range clamping) rather than as an - // invalid bare value. Base this on the serialized value so tiny negative - // floating-point noise that rounds to zero does not get wrapped. - if (scalar.value < 0) { - return opts.unwrapSingleNegativeNumber - ? scalar.text - : `${calcName}(${scalar.text})`; +/** + * @param {import('./node.js').Num | import('./node.js').Dim} node + * @param {ReturnType} session + * @param {number} [value] + * @return {number} + */ +function emitFiniteScalar(node, session, value) { + const rounded = roundedScalarValue(node, session.precision, value); + emitRoundedScalar(node, session.buffer, rounded); + return rounded; +} + +/** + * @param {import('./node.js').Num | import('./node.js').Dim} node + * @param {ReturnType} session + * @param {number} [value] + * @return {void} + */ +function emitScalar(node, session, value) { + const buffer = session.buffer; + if (Object.is(node.value, -0)) emitSignedZero(buffer, node); + else if (isDegenerate(node.value)) { + if (node.type === 'Dim') { + buffer.push( + 'calc(', + degenerateKeyword(node.value), + ' * 1', + node.rawUnit ?? node.unit, + ')' + ); + } else { + buffer.push(degenerateKeyword(node.value)); } + } else emitFiniteScalar(node, session, value); +} - return scalar.text; - } +/** + * @param {string[]} buffer + * @param {import('./node.js').Num | import('./node.js').Dim} node + * @return {void} + */ +function emitSignedZero(buffer, node) { + const unit = node.type === 'Dim' ? (node.rawUnit ?? node.unit) : ''; + buffer.push('calc(-1 * 0', unit, ')'); +} - // A grouped sum with a leading negative term is the canonical result of - // negating a parenthesized expression. Re-invert its terms for the body so - // the grouping survives as `-(...)` instead of becoming `-a - b`. - if ( - node.type === 'Sum' && - node.grouped && - node.terms.length > 1 && - displaySign(node.terms[0]).sign === -1 - ) { - const invertedTerms = node.terms.map((t) => ({ - sign: /** @type {1 | -1} */ (-t.sign), - node: t.node, - })); - return `${calcName}(-(${serializeSumTerms(invertedTerms, prec)}))`; - } +/** @param {Node} node @return {node is import('./node.js').Num | import('./node.js').Dim} */ +function isScalar(node) { + return node.type === 'Num' || node.type === 'Dim'; +} - if (node.type === 'Ident' || node.type === 'Call') { - return serializeExpr(node, prec); - } +/** @param {Node} node @return {node is import('./node.js').Num | import('./node.js').Dim} */ +function isSignedZero(node) { + return isScalar(node) ? Object.is(node.value, -0) : false; +} - // Single-term Sum is the canonical form for `-var(--x)` / `-(a*b)` — - // sign=-1 around an opaque node. Signed leaves live in Num/Dim directly. - if (node.type === 'Sum' && node.terms.length === 1) { - return `${calcName}(${serializeLeadingNeg(node.terms[0].node, prec)})`; - } +/** @param {Node} node @return {number} */ +function precedence(node) { + if (node.type === 'Sum') return SUM_PRECEDENCE; + if (node.type === 'Product') return PRODUCT_PRECEDENCE; + return ATOMIC_PRECEDENCE; +} - return `${calcName}(${serializeExpr(node, prec)})`; +/** + * @param {Node} node + * @param {number} parentPrecedence + * @param {boolean} groupedRequired + * @return {boolean} + */ +function needsParentheses(node, parentPrecedence, groupedRequired) { + return ( + precedence(node) < parentPrecedence || + (node.type === 'Sum' && node.grouped === true && groupedRequired === true) + ); } -// --- Inside calc() expression -------------------------------------------- +/** + * @param {Node} node + * @param {ReturnType} session + * @param {number} [parentPrecedence] + * @param {boolean} [groupedRequired] + * @param {number} [scalarValueOverride] + * @return {void} + */ +function emitNode( + node, + session, + parentPrecedence = 0, + groupedRequired = false, + scalarValueOverride +) { + const parenthesized = needsParentheses( + node, + parentPrecedence, + groupedRequired + ); + if (parenthesized) session.buffer.push('('); + emitNodeBody(node, session, scalarValueOverride); + if (parenthesized) session.buffer.push(')'); +} /** * @param {Node} node - * @param {number | false} prec - * @return {string} + * @param {ReturnType} session + * @param {number} [scalarValueOverride] + * @return {void} */ -function serializeExpr(node, prec) { +function emitNodeBody(node, session, scalarValueOverride) { + const buffer = session.buffer; switch (node.type) { case 'Num': - if (isDegenerate(node.value)) { - return degenerateKeyword(node.value); - } - return serializeScalar(node, prec).text; case 'Dim': - if (isDegenerate(node.value)) { - // Nested degenerate Dim wraps in calc() so the ` * 1` form - // parses back as one Dim factor. The bare form round-trips wrong - // inside a Product — `0 * Dim(Infinity, px)` would re-fold as NaN. - return `calc(${degenerateKeyword(node.value)} * 1${node.rawUnit ?? node.unit})`; - } - return serializeScalar(node, prec).text; + emitScalar(node, session, scalarValueOverride); + return; case 'Ident': - return node.rawName ?? node.name; - case 'Call': { - const components = getComponents(node); - if (components) { - const args = node.args - .map((arg) => serializeExpr(arg, prec)) - .join(', '); - return `${node.rawName ?? node.name}(${args}${serializeComponents(components, (child) => serialize(child, { precision: prec }))})`; - } - const args = node.args.map((a) => serializeExpr(a, prec)).join(', '); - return `${node.rawName ?? node.name}(${args})`; - } + buffer.push(node.rawName ?? node.name); + return; + case 'Call': + emitCall(node, session); + return; + case 'OpaqueCall': + emitOpaqueCall(node, session); + return; case 'Sum': - return serializeSum(node, prec); + emitSum(node, session); + return; case 'Product': - return serializeProduct(node, prec); + emitProduct(node, session); + return; } } /** - * Combine the term's sign with a negative Num/Dim value's sign so - * `{sign:+1, Num(-5)}` renders as `-5`, not `+ -5`. Skip degenerate - * (Infinity/NaN) values — the `degenerateKeyword` path emits `-infinity` - * inline, and a leading minus on `calc(infinity*1)` would now - * tokenize as a `-calc` function. - * @param {{sign: 1 | -1, node: Node}} term - * @return {{sign: 1 | -1, magnitude: Node}} + * @param {import('./node.js').Call} node + * @param {ReturnType} session + * @param {string} [callNameOverride] + * @return {void} */ -function displaySign(term) { - const { sign, node } = term; - if (node.type === 'Num' && Number.isFinite(node.value) && node.value < 0) { - return { - sign: /** @type {1 | -1} */ (-sign), - magnitude: num(-node.value), - }; +function emitCall(node, session, callNameOverride) { + const buffer = session.buffer; + buffer.push(callNameOverride ?? node.rawName ?? node.name, '('); + for (let i = 0; i < node.args.length; i++) { + if (i > 0) buffer.push(', '); + emitNode(node.args[i], session); } - if (node.type === 'Dim' && Number.isFinite(node.value) && node.value < 0) { - return { - sign: /** @type {1 | -1} */ (-sign), - magnitude: dim(-node.value, node.unit, node.rawUnit), - }; + buffer.push(')'); +} + +/** + * @param {import('./node.js').OpaqueCall} node + * @param {ReturnType} session + * @param {string} [callNameOverride] + * @return {void} + */ +function emitOpaqueCall(node, session, callNameOverride) { + const buffer = session.buffer; + buffer.push(callNameOverride ?? node.rawName ?? node.name, '('); + serializeComponents(node.components, buffer, (child, childBuffer) => { + emitNestedMathResult(child, session, childBuffer); + }); + buffer.push(')'); +} + +/** + * @param {import('./node.js').SumTerm} term + * @param {1 | -1} multiplier + * @return {1 | -1} + * */ +function termSign(term, multiplier) { + let sign = /** @type {1 | -1} */ (term.sign * multiplier); + if ( + isScalar(term.node) && + Number.isFinite(term.node.value) && + term.node.value < 0 + ) { + sign = /** @type {1 | -1} */ (-sign); + } + return sign; +} + +/** + * @param {import('./node.js').SumTerm} term + * @param {ReturnType} session + * @param {1 | -1} sign + * @param {number | undefined} scalarValueOverride + * @return {void} + */ +function emitSumTerm(term, session, sign, scalarValueOverride) { + if (sign === 1) { + emitNode(term.node, session, SUM_PRECEDENCE, true, scalarValueOverride); + } else { + emitLeadingNeg(term.node, session, scalarValueOverride); } - return { sign, magnitude: node }; } /** * @param {import('./node.js').SumTerm[]} terms - * @param {number | false} prec - * @return {string} + * @param {ReturnType} session + * @param {1 | -1} [multiplier] + * @return {void} */ -function serializeSumTerms(terms, prec) { - let out = ''; +function emitSumTerms(terms, session, multiplier = 1) { + const buffer = session.buffer; for (let i = 0; i < terms.length; i++) { - const { sign, magnitude } = displaySign(terms[i]); + const term = terms[i]; + const termNode = term.node; + const scalar = isScalar(termNode); + const negativeScalar = + scalar && Number.isFinite(termNode.value) && termNode.value < 0; + let sign = /** @type {1 | -1} */ (term.sign * multiplier); + if (negativeScalar) sign = /** @type {1 | -1} */ (-sign); + const scalarValueOverride = negativeScalar ? -termNode.value : undefined; if (i === 0) { - if (magnitude.type === 'Sum' && magnitude.grouped) { - const body = `(${serializeExpr(magnitude, prec)})`; - out = sign === 1 ? body : `-${body}`; - continue; + if (scalar) { + if (sign === -1) buffer.push('-'); + emitScalar(termNode, session, scalarValueOverride); + } else { + emitSumTerm(term, session, sign, scalarValueOverride); } - out = - sign === 1 - ? serializeExpr(magnitude, prec) - : serializeLeadingNeg(magnitude, prec); + continue; + } + buffer.push(sign === 1 ? ' + ' : ' - '); + if (scalar) { + emitScalar(termNode, session, scalarValueOverride); } else { - // `-` binds looser than `*`/`/` so the right side never needs parens. - let body = serializeExpr(magnitude, prec); - if (magnitude.type === 'Sum' && magnitude.grouped) { - body = `(${body})`; - } - out += sign === 1 ? ` + ${body}` : ` - ${body}`; + emitNode(termNode, session, SUM_PRECEDENCE, true); } } - return out; } -/** - * @param {Sum} sum - * @param {number | false} prec - * @return {string} - */ -function serializeSum(sum, prec) { - return serializeSumTerms(sum.terms, prec); +/** @param {Sum} sum @param {ReturnType} session @return {void} */ +function emitSum(sum, session) { + emitSumTerms(sum.terms, session); } /** - * Fold a leading negation into a finite leading Num if there is one - * (`-(0.5 * x)` → `-0.5 * x`); else use `-(…)` for Sum/Product or `-x`. * @param {Node} node - * @param {number | false} prec - * @return {string} + * @param {ReturnType} session + * @param {number} [scalarValueOverride] + * @return {void} */ -function serializeLeadingNeg(node, prec) { +function emitLeadingNeg(node, session, scalarValueOverride) { if ( node.type === 'Product' && node.factors.length > 0 && @@ -283,54 +341,302 @@ function serializeLeadingNeg(node, prec) { node.factors[0].node.value !== 0 ) { const head = node.factors[0].node; - const negatedValue = -head.value; - const rest = node.factors.slice(1); - // A coefficient of 1 is a no-op factor, matching mkProduct. - /** @type {ProductFactor[]} */ - const negatedFactors = - negatedValue === 1 - ? rest - : [{ exponent: 1, node: num(negatedValue) }, ...rest]; - return serializeFactors(negatedFactors, prec); + emitProductFactors(node.factors, session, 1, -head.value, head); + return; } - const body = serializeExpr(node, prec); - return node.type === 'Sum' || node.type === 'Product' - ? `-(${body})` - : `-${body}`; + session.buffer.push('-'); + emitNode( + node, + session, + UNARY_PRECEDENCE, + false, + isScalar(node) ? scalarValueOverride : undefined + ); } /** * @param {ProductFactor[]} factors - * @param {number | false} prec + * @param {ReturnType} session + * @param {number} [start] + * @param {number} [coefficientValue] + * @param {import('./node.js').Num} [coefficientNode] + * @return {void} + */ +function emitProductFactors( + factors, + session, + start = 0, + coefficientValue, + coefficientNode +) { + const buffer = session.buffer; + let first = true; + if (coefficientValue !== undefined && coefficientValue !== 1) { + emitScalar( + /** @type {import('./node.js').Num} */ (coefficientNode), + session, + coefficientValue + ); + first = false; + } + for (let i = start; i < factors.length; i++) { + const factor = factors[i]; + const factorNode = factor.node; + if (first) { + if (factor.exponent === -1) buffer.push('1 / '); + if (isScalar(factorNode)) emitScalar(factorNode, session); + else emitNode(factorNode, session, PRODUCT_PRECEDENCE); + first = false; + } else { + buffer.push(factor.exponent === 1 ? ' * ' : ' / '); + if (isScalar(factorNode)) emitScalar(factorNode, session); + else emitNode(factorNode, session, PRODUCT_PRECEDENCE); + } + } +} + +/** @param {Product} product @param {ReturnType} session @return {void} */ +function emitProduct(product, session) { + emitProductFactors(product.factors, session); +} + +/** @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) === -1 + ) { + session.buffer.push('-('); + emitSumTerms(node.terms, session, -1); + session.buffer.push(')'); + return; + } + if (node.type === 'Sum' && node.terms.length === 1) { + emitLeadingNeg(node.terms[0].node, session); + return; + } + emitNode(node, session); +} + +/** + * @param {Node} node + * @param {ReturnType} session + * @param {string} wrapper + * @return {void} + */ +function emitMathResult(node, session, wrapper) { + if (isScalar(node)) { + const scalarValue = roundedScalarValue(node, session.precision); + const buffer = session.buffer; + if (isDegenerate(scalarValue)) { + buffer.push(wrapper, '(', degenerateKeyword(scalarValue)); + if (node.type === 'Dim') buffer.push(' * 1', node.rawUnit ?? node.unit); + buffer.push(')'); + } else if (session.scalarPolicy === 'standard') { + buffer.push(wrapper, '('); + emitRoundedScalar(node, buffer, scalarValue); + buffer.push(')'); + } else { + emitRoundedScalar(node, buffer, scalarValue); + } + return; + } + if ( + node.type === 'Sum' && + node.grouped && + node.terms.length > 1 && + termSign(node.terms[0], 1) === -1 + ) { + session.buffer.push(wrapper, '(-('); + emitSumTerms(node.terms, session, -1); + session.buffer.push('))'); + return; + } + if ( + node.type === 'Ident' || + node.type === 'Call' || + node.type === 'OpaqueCall' + ) { + emitNode(node, session); + return; + } + if (node.type === 'Sum' && node.terms.length === 1) { + session.buffer.push(wrapper, '('); + emitLeadingNeg(node.terms[0].node, session); + session.buffer.push(')'); + return; + } + session.buffer.push(wrapper, '('); + emitNode(node, session); + session.buffer.push(')'); +} + +/** + * Serialize a scalar result without allocating a render context or buffer. + * @param {import('./node.js').Num | import('./node.js').Dim} node + * @param {number | false} precision + * @param {'standard' | 'unwrap-all'} scalarPolicy + * @param {string} wrapper * @return {string} */ -function serializeFactors(factors, prec) { - let out = ''; - for (let i = 0; i < factors.length; i++) { - const f = factors[i]; - let body = serializeExpr(f.node, prec); - // A Sum factor needs parens: `a * (b + c)`. Flat canonical form means - // this is the only place parens are required. - if (f.node.type === 'Sum') { - body = `(${body})`; +function serializeScalarResult(node, precision, scalarPolicy, wrapper) { + const value = roundedScalarValue(node, precision); + const unit = node.type === 'Dim' ? (node.rawUnit ?? node.unit) : ''; + if (isDegenerate(value)) { + return `${wrapper}(${degenerateKeyword(value)}${unit ? ` * 1${unit}` : ''})`; + } + const scalar = serializeNumber(value) + unit; + return scalarPolicy === 'standard' ? `${wrapper}(${scalar})` : scalar; +} + +/** + * @param {Node} node + * @param {ReturnType} session + * @param {string[]} [buffer] + * @return {void} + */ +function emitNestedMathResult(node, session, buffer = session.buffer) { + if (isSignedZero(node)) { + emitSignedZero(buffer, node); + return; + } + emitMathResult(node, session, 'calc'); +} + +/** + * @param {SerializeOptions} opts + * @return {{buffer: string[], precision: number | false, scalarPolicy: 'standard' | 'unwrap-all'}} + */ +function makeContext(opts) { + return { + buffer: [], + precision: opts.precision ?? 5, + scalarPolicy: normalizeScalarPolicy(opts), + }; +} + +/** + * @param {Node} node + * @param {SerializeOptions} opts + * @return {{kind: 'math', node: Node, session: ReturnType, wrapper: string}} + */ +function planSerialize(node, opts) { + return { + kind: 'math', + node, + session: makeContext(opts), + wrapper: opts.calcName ?? 'calc', + }; +} + +/** + * @param {{tree: Node, status: 'resolved' | 'unresolved', rootName: string, rootSpelling: string, calculation?: boolean, original?: string}} result + * @param {SerializeOptions} opts + * @return {{kind: 'original', text: string} | {kind: 'root-call', node: Node, session: ReturnType, callNameOverride: string} | {kind: 'wrapped-expr', node: Node, session: ReturnType, wrapper: string} | {kind: 'math', node: Node, session: ReturnType, wrapper: string}} + */ +function planSerializeResult(result, opts) { + const isCalc = result.calculation ?? isCalculationFunction(result.rootName); + const normalizedRootName = + result.calculation === undefined + ? result.rootName.toLowerCase() + : result.rootName; + const wrapper = isCalc + ? result.rootSpelling || opts.calcName || 'calc' + : 'calc'; + const session = makeContext(opts); + + if (!isCalc && result.status === 'unresolved') { + if ( + (result.tree.type === 'Call' || result.tree.type === 'OpaqueCall') && + result.tree.name.toLowerCase() === normalizedRootName + ) { + return { + kind: 'root-call', + node: result.tree, + session, + callNameOverride: result.rootSpelling, + }; } - if (i === 0) { - // Leading denominator: implicit 1 so we emit `1 / 2px`, not `/ 2px`. - out = f.exponent === 1 ? body : `1 / ${body}`; + return { kind: 'original', text: result.original ?? '' }; + } + + if (session.scalarPolicy === 'standard') { + if (isScalar(result.tree)) + return { kind: 'math', node: result.tree, session, wrapper }; + return { kind: 'wrapped-expr', node: result.tree, session, wrapper }; + } + return { kind: 'math', node: result.tree, session, wrapper }; +} + +/** + * @param {ReturnType | ReturnType} renderSpec + * @return {string} + * */ +function emitOutput(renderSpec) { + if (renderSpec.kind === 'original') return renderSpec.text; + const { session } = renderSpec; + if (renderSpec.kind === 'root-call') { + if (renderSpec.node.type === 'Call') { + emitCall(renderSpec.node, session, renderSpec.callNameOverride); } else { - out += f.exponent === 1 ? ` * ${body}` : ` / ${body}`; + emitOpaqueCall( + /** @type {import('./node.js').OpaqueCall} */ (renderSpec.node), + session, + renderSpec.callNameOverride + ); } + } else if (renderSpec.kind === 'wrapped-expr') { + session.buffer.push(renderSpec.wrapper, '('); + emitRootExpr(renderSpec.node, session); + session.buffer.push(')'); + } else { + emitMathResult(renderSpec.node, session, renderSpec.wrapper); } - return out; + return session.buffer.join(''); } /** - * @param {Product} product - * @param {number | false} prec + * @param {Node} node + * @param {SerializeOptions} [opts] + * @return {string} + */ +function serialize(node, opts = {}) { + if (isScalar(node)) { + return serializeScalarResult( + node, + opts.precision ?? 5, + normalizeScalarPolicy(opts), + opts.calcName ?? 'calc' + ); + } + checkCalculationDepth(node); + return emitOutput(planSerialize(node, opts)); +} + +/** + * @param {{tree: Node, status: 'resolved' | 'unresolved', rootName: string, rootSpelling: string, calculation?: boolean, original?: string}} result + * @param {SerializeOptions} [opts] * @return {string} */ -function serializeProduct(product, prec) { - return serializeFactors(product.factors, prec); +function serializeResult(result, opts = {}) { + const node = result.tree; + if (isScalar(node)) { + const isCalc = result.calculation ?? isCalculationFunction(result.rootName); + if (!isCalc && result.status === 'unresolved') return result.original ?? ''; + const wrapper = isCalc + ? result.rootSpelling || opts.calcName || 'calc' + : 'calc'; + return serializeScalarResult( + node, + opts.precision ?? 5, + normalizeScalarPolicy(opts), + wrapper + ); + } + checkCalculationDepth(result.tree); + return emitOutput(planSerializeResult(result, opts)); } -export { serialize }; +export { serialize, serializeResult }; diff --git a/src/lib/simplify.js b/src/lib/simplify.js index 2bda68b..375917d 100644 --- a/src/lib/simplify.js +++ b/src/lib/simplify.js @@ -5,31 +5,44 @@ 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 { assertDepth } from './limits.js'; /** * @typedef {import('./node.js').Node} Node * - * Recursive simplifier reference, threaded into Sum/Product/Call. Lets + * 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 */ /** * @param {Node} node + * @param {number} [depth] * @return {Node} */ -function simplify(node) { +function simplify(node, depth = 0) { + assertDepth(depth); + /** @param {Node} value */ + const child = (value) => simplify(value, depth + 1); switch (node.type) { case 'Num': case 'Dim': case 'Ident': return node; case 'Call': - return simplifyCall(node, simplify); + return simplifyCall(node, child); + case 'OpaqueCall': + return opaqueCall( + node.name, + simplifyComponents(node.components, child), + node.rawName + ); case 'Sum': - return simplifySum(node, simplify); + return simplifySum(node, child); case 'Product': - return simplifyProduct(node, simplify); + return simplifyProduct(node, child); } } diff --git a/src/lib/simplify/call.js b/src/lib/simplify/call.js index 0bd687b..1414397 100644 --- a/src/lib/simplify/call.js +++ b/src/lib/simplify/call.js @@ -1,90 +1,13 @@ // Pre-simplify args once, route by name. Leaf folds receive simplified // args so they don't need to recurse into `simplify` themselves. -import { simplifyMinMax } from './min-max.js'; -import { simplifyClamp } from './clamp.js'; -import { simplifyAbs } from './abs.js'; -import { simplifySign } from './sign.js'; -import { simplifyModRem } from './mod-rem.js'; -import { simplifyRound } from './round.js'; -import { simplifyTrig } from './trig.js'; -import { simplifyInverseTrig } from './inverse-trig.js'; -import { simplifyAtan2 } from './atan2.js'; -import { simplifyPow } from './pow.js'; -import { simplifySqrt } from './sqrt.js'; -import { simplifyExp } from './exp.js'; -import { simplifyLog } from './log.js'; -import { simplifyHypot } from './hypot.js'; - import { call } from '../node.js'; -import { getComponents, setComponents, simplifyComponents } from '../opaque.js'; +import { mathFunctions, isCalculationFunction } from '../functions.js'; /** @typedef {import('../node.js').Node} Node */ /** @typedef {import('../simplify.js').SimplifyFn} SimplifyFn */ - /** @typedef {(name: string, args: Node[]) => Node} MathSimplifier */ -// Bare CSS math functions with implemented simplification semantics, keyed -// by lowercase name. calc() and its vendor-prefixed forms are handled -// separately as wrappers in simplifyCall. This map is the single source of -// truth for dispatch, `isSupportedMathFunction`, and `QUICK_MATH_TEST`. -/** @type {Map} */ -const MATH_SIMPLIFIERS = new Map([ - ['min', simplifyMinMax], - ['max', simplifyMinMax], - ['clamp', (_name, args) => simplifyClamp(args)], - ['abs', (_name, args) => simplifyAbs(args)], - ['sign', (_name, args) => simplifySign(args)], - ['mod', (_name, args) => simplifyModRem('mod', args)], - ['rem', (_name, args) => simplifyModRem('rem', args)], - ['round', (_name, args) => simplifyRound(args)], - ['sin', (_name, args) => simplifyTrig('sin', args)], - ['cos', (_name, args) => simplifyTrig('cos', args)], - ['tan', (_name, args) => simplifyTrig('tan', args)], - ['asin', (_name, args) => simplifyInverseTrig('asin', args)], - ['acos', (_name, args) => simplifyInverseTrig('acos', args)], - ['atan', (_name, args) => simplifyInverseTrig('atan', args)], - ['atan2', (_name, args) => simplifyAtan2(args)], - ['pow', (_name, args) => simplifyPow(args)], - ['sqrt', (_name, args) => simplifySqrt(args)], - ['hypot', (_name, args) => simplifyHypot(args)], - ['log', (_name, args) => simplifyLog(args)], - ['exp', (_name, args) => simplifyExp(args)], -]); - -const mathFnNames = [...MATH_SIMPLIFIERS.keys()].sort( - (a, b) => b.length - a.length -); - -const QUICK_MATH_TEST = new RegExp( - `(?:-(?:webkit|moz)-)?(?:calc|${mathFnNames.join('|')})\\(`, - 'i' -); - -/** - * Fast check to determine whether a CSS component value could contain - * a supported calculation or math function call (or an escape sequence - * that could decode to one). - * - * @param {string} value - * @return {boolean} - */ -function hasPotentialMathFunction(value) { - return ( - value.includes('(') && (QUICK_MATH_TEST.test(value) || value.includes('\\')) - ); -} - -/** - * Whether a bare CSS math function has an implemented simplifier. - * - * @param {string} name - * @return {boolean} - */ -function isSupportedMathFunction(name) { - return MATH_SIMPLIFIERS.has(name.toLowerCase()); -} - /** * @param {Extract} node * @param {SimplifyFn} simplify @@ -93,7 +16,7 @@ function isSupportedMathFunction(name) { function simplifyCall(node, simplify) { const name = node.name.toLowerCase(); - if (name === 'calc' || name === '-webkit-calc' || name === '-moz-calc') { + if (isCalculationFunction(name)) { if (node.args.length !== 1) { throw new Error(`${node.name}() takes exactly one argument`); } @@ -102,28 +25,12 @@ function simplifyCall(node, simplify) { const args = node.args.map((a) => simplify(a)); - const components = getComponents(node); - if (components) { - const result = call(node.name, args, node.rawName); - return setComponents(result, simplifyComponents(components, simplify)); - } - - const simplifier = MATH_SIMPLIFIERS.get(name); + const simplifier = mathFunctions.get(name)?.simplify; if (simplifier) { - // min/max preserve the call's original casing in their opaque-args - // fallback; the rest normalize to lowercase internally. - return simplifier( - name === 'min' || name === 'max' ? node.name : name, - args - ); + return simplifier(name, args); } return call(node.name, args, node.rawName); } -export { - isSupportedMathFunction, - simplifyCall, - hasPotentialMathFunction, - QUICK_MATH_TEST, -}; +export { simplifyCall }; diff --git a/src/lib/simplify/round.js b/src/lib/simplify/round.js index 22b7176..0df9814 100644 --- a/src/lib/simplify/round.js +++ b/src/lib/simplify/round.js @@ -116,4 +116,4 @@ function applyRound(strategy, a, b) { } } -export { simplifyRound }; +export { ROUND_STRATEGIES, simplifyRound }; diff --git a/src/lib/simplify/sum.js b/src/lib/simplify/sum.js index 28d0f37..15b35fc 100644 --- a/src/lib/simplify/sum.js +++ b/src/lib/simplify/sum.js @@ -34,6 +34,7 @@ function simplifySum(sum, simplify) { // encountered unit. `100vh - 5rem - 10rem - 100px` → `-15rem` in phase 1, // then vh/rem/px stay separate in phase 2 (none convert to each other). let numTotal = 0; + let hasNum = false; let numScale = 0; /** @type {Map} */ const byUnit = new Map(); @@ -64,7 +65,11 @@ function simplifySum(sum, simplify) { return; } if (n.type === 'Num') { - numTotal += sign * n.value; + // Do not add an artificial +0 before the first value: `+0 + -0` + // becomes +0 in IEEE-754 and would discard a parsed/simplified -0. + const value = sign * n.value; + numTotal = hasNum ? numTotal + value : value; + hasNum = true; numScale = Math.max(numScale, Math.abs(n.value)); return; } @@ -92,9 +97,10 @@ function simplifySum(sum, simplify) { processTerm(t.sign, simplify(t.node)); } - // mkSum drops zero-valued Nums, so pushing the numeric total - // unconditionally is harmless. Zero-valued unit buckets are kept for - // type info (WPT calc-serialization-002). + // mkSum drops positive zero-valued Nums, so pushing the numeric total + // unconditionally is harmless; a negative zero is deliberately retained. + // Zero-valued unit buckets are kept for type info + // (WPT calc-serialization-002). /** @type {SumTerm[]} */ const terms = [{ sign: 1, node: num(denoise(numTotal, numScale)) }]; for (const bucket of mergeConvertibleBuckets([...byUnit.values()])) { diff --git a/src/lib/tokenizer.js b/src/lib/tokenizer.js deleted file mode 100644 index aa18291..0000000 --- a/src/lib/tokenizer.js +++ /dev/null @@ -1,10 +0,0 @@ -// Thin project-local entry point for the CSS Syntax tokenizer. The parser -// consumes native tokens directly so decoded values and source spelling remain available. -import { tokenize as tokenizeCss } from '@csstools/css-tokenizer'; - -/** @param {string} input @return {import('@csstools/css-tokenizer').CSSToken[]} */ -function tokenize(input) { - return tokenizeCss({ css: input }); -} - -export { tokenize }; diff --git a/src/reduce.js b/src/reduce.js index 4e9e357..e68cae2 100644 --- a/src/reduce.js +++ b/src/reduce.js @@ -1,32 +1,20 @@ // CSS component-value reducer. This module deliberately has no PostCSS // dependency so it can also be used for individual declaration values, // at-rule parameters, or selector text. -import { - tokenize as cssTokenize, - TokenType as CssType, -} from '@csstools/css-tokenizer'; -import { parse } from './lib/parser.js'; -import { simplify } from './lib/simplify.js'; -import { - isSupportedMathFunction, - hasPotentialMathFunction, - QUICK_MATH_TEST, -} from './lib/simplify/call.js'; -import { serialize } from './lib/serialize.js'; - -const MATCH_CALC = /^(?:-(?:moz|webkit)-)?calc$/i; - -const BLOCK_CLOSE = new Map([ - [CssType.OpenParen, CssType.CloseParen], - [CssType.OpenSquare, CssType.CloseSquare], - [CssType.OpenCurly, CssType.CloseCurly], -]); +import { tokenize as cssTokenize } from '@csstools/css-tokenizer'; +import { indexBlocks } from './lib/block-index.js'; +import { hasPotentialMathFunction } from './lib/functions.js'; +import { assertDepth } from './lib/limits.js'; +import { findCalculations } from './lib/scan.js'; +import { compileCandidates } from './lib/compile.js'; +import { applyReplacements } from './lib/print.js'; /** * @typedef {object} ReduceCalcOptions * @property {number | false} [precision] * @property {boolean} [warnWhenCannotResolve] - * @property {boolean} [unwrapSingleNegativeNumber] Serialize finite negative results without a `calc()` wrapper. Defaults to `false`. + * @property {boolean} [unwrapSingleNegativeNumber] Deprecated alias for `unwrapSingleValue`. + * @property {boolean} [unwrapSingleValue] Serialize fully resolved finite scalar results without calculation syntax. Defaults to `false`. * @property {(error: Error, input: string) => void} [onParseError] Invoked when parse/simplify throws. * @property {(message: string) => void} [onWarn] Invoked when `warnWhenCannotResolve` is set and an expression cannot be reduced to a single value. */ @@ -40,87 +28,23 @@ const BLOCK_CLOSE = new Map([ * @property {ResolvedReduceCalcOptions} options * @property {string} value * @property {import('@csstools/css-tokenizer').CSSToken[]} tokens - * @property {Replacement[]} replacements */ /** * @typedef {object} Replacement * @property {number} start * @property {number} end - * @property {import('./lib/node.js').Node} node - * @property {string} calcName + * @property {CalculationResult} result */ - /** - * Walk one component-value level. Unsupported functions and simple blocks are - * traversed, while a supported function is treated as one opaque calculation - * even when parsing it fails. A missing closer consumes through EOF, matching - * CSS component-value parsing's error recovery. - * - * @param {number} start - * @param {import('@csstools/css-tokenizer').TokenType | undefined} expectedClose - * @param {TransformContext} ctx - * @param {boolean} transform - * @return {number} Index of the matching closer, or the EOF token. + * @typedef {object} CalculationResult + * @property {import('./lib/node.js').Node} tree + * @property {'resolved' | 'unresolved'} status + * @property {string} rootName + * @property {string} rootSpelling + * @property {boolean} calculation + * @property {string | undefined} original */ -function walkTokens(start, expectedClose, ctx, transform) { - for (let i = start; i < ctx.tokens.length; i++) { - const token = ctx.tokens[i]; - if (token[0] === CssType.EOF || token[0] === expectedClose) return i; - - const blockClose = BLOCK_CLOSE.get(token[0]); - if (blockClose) { - i = walkTokens(i + 1, blockClose, ctx, transform); - continue; - } - - if (token[0] !== CssType.Function) continue; - - const name = token[4].value; - const isCalc = MATCH_CALC.test(name); - const isMath = !isCalc && isSupportedMathFunction(name); - if (!transform || (!isCalc && !isMath)) { - i = walkTokens(i + 1, CssType.CloseParen, ctx, transform); - continue; - } - - // Locate the complete outer function without transforming its children. - const close = walkTokens(i + 1, CssType.CloseParen, ctx, false); - const closed = ctx.tokens[close][0] === CssType.CloseParen; - const end = closed ? ctx.tokens[close][3] + 1 : ctx.value.length; - const sliceStart = isCalc ? i + 1 : i; - const sliceEnd = closed ? close + (isCalc ? 0 : 1) : close; - const inputStart = isCalc ? token[3] + 1 : token[2]; - const inputEnd = closed && isCalc ? ctx.tokens[close][2] : end; - const contents = ctx.value.slice(inputStart, inputEnd); - try { - const node = simplify(parse(ctx.tokens, sliceStart, sliceEnd)); - ctx.replacements.push({ - start: token[2], - end, - node, - calcName: isCalc ? name : 'calc', - }); - } catch (error) { - const err = error instanceof Error ? error : new Error('Error'); - ctx.options.onParseError?.(err, contents); - } - i = close; - } - - return ctx.tokens.length - 1; -} - -/** - * @param {import('./lib/node.js').Node} node - * @return {boolean} - */ -function isUnresolvedResult(node) { - if (node.type === 'Sum' || node.type === 'Product') { - return true; - } - return node.type === 'Call' && isSupportedMathFunction(node.name); -} /** * Simplify every supported CSS math function in a component-value string. @@ -140,34 +64,41 @@ function reduceCalc(value, opts) { precision: 5, warnWhenCannotResolve: false, unwrapSingleNegativeNumber: false, + unwrapSingleValue: false, ...opts, }; - const tokens = cssTokenize({ css: value }); - /** @type {Replacement[]} */ - const replacements = []; - walkTokens(0, undefined, { options, value, tokens, replacements }, true); - - if (replacements.length === 0) { + /** @type {import('@csstools/css-tokenizer').CSSToken[]} */ + let tokens; + /** @type {ReturnType} */ + let index; + try { + tokens = cssTokenize({ css: value }); + index = indexBlocks(tokens); + assertDepth(index.maxDepth); + } catch (error) { + options.onParseError?.( + error instanceof Error ? error : new Error('Error', { cause: error }), + value + ); return value; } - let output = ''; - let lastIndex = 0; - for (const replacement of replacements) { - const text = serialize(replacement.node, { - precision: options.precision, - calcName: replacement.calcName, - unwrapSingleNegativeNumber: options.unwrapSingleNegativeNumber, - }); - if (options.warnWhenCannotResolve && isUnresolvedResult(replacement.node)) { - options.onWarn?.('Could not reduce expression: ' + value); - } - output += value.slice(lastIndex, replacement.start) + text; - lastIndex = replacement.end; + const candidates = findCalculations(value, tokens, index); + const replacements = compileCandidates(candidates, { + options, + value, + tokens, + index, + }); + if (replacements.length === 0) { + return value; } - output += value.slice(lastIndex); - return output; + const serializationOptions = { + precision: options.precision, + unwrapSingleNegativeNumber: options.unwrapSingleNegativeNumber, + unwrapSingleValue: options.unwrapSingleValue, + }; + return applyReplacements(value, replacements, options, serializationOptions); } -export { QUICK_MATH_TEST, hasPotentialMathFunction }; export default reduceCalc; diff --git a/test/benchmark/statistical-simulation.js b/test/benchmark/statistical-simulation.js new file mode 100644 index 0000000..a2b2061 --- /dev/null +++ b/test/benchmark/statistical-simulation.js @@ -0,0 +1,182 @@ +/* A small, fixed-seed calibration smoke test. Long-running calibration can + * increase the replicate count without changing the ordinary test suite. */ +import assert from 'node:assert/strict'; +import { + bootstrapStratifiedMaxT, + seededRandom, +} from '../../scripts/lib/benchmark.js'; +import { analyzeParser } from '../../scripts/lib/parser-benchmark.js'; +import { syntheticParserArtifact } from '../helpers/benchmark-artifact.js'; + +const FULL = process.env.POSTCSS_CALC_FULL_CALIBRATION === '1'; +const RUNS = FULL ? 2_000 : 200; +const ROWS = 30; +const RESAMPLES = FULL ? 10_000 : 1_000; +let covered = 0; + +for (let run = 0; run < RUNS; run++) { + const random = seededRandom(0x510e + run); + const strata = Array.from({ length: ROWS }, (_, index) => + index % 2 ? 'candidate-first' : 'baseline-first' + ); + const rows = Array.from({ length: ROWS }, () => { + const common = (random() - 0.5) * 0.2; + const low = common + (random() - 0.5) * 0.1; + const high = common + (random() - 0.5) * 0.8; + return [low, high]; + }); + const intervals = bootstrapStratifiedMaxT({ + rows, + strata, + seed: 0xabc000 + run, + resamples: RESAMPLES, + }); + assert.equal(intervals.method, 'stratified-max-t-studentized-bootstrap'); + assert.ok( + intervals.intervals[0].familyUpper - intervals.intervals[0].familyLower < + intervals.intervals[1].familyUpper - intervals.intervals[1].familyLower + ); + if ( + intervals.intervals.every( + (interval) => interval.familyLower <= 0 && interval.familyUpper >= 0 + ) + ) + covered++; +} + +const coverage = covered / RUNS; +const minimumCoverage = FULL ? 0.925 : 0.9; +const maximumCoverage = FULL ? 0.975 : 0.99; +assert.ok( + coverage >= minimumCoverage && coverage <= maximumCoverage, + `simultaneous coverage was ${coverage}, expected ${minimumCoverage}..${maximumCoverage}` +); + +const parserArtifact = syntheticParserArtifact({ + seed: 0x510e, + rows: Array.from({ length: 20 }, (_, index) => ({ + baseline: [1], + candidate: [1.01], + processOrder: index % 2 ? 'candidate-first' : 'baseline-first', + })), + config: { bootstrapResamples: RESAMPLES }, +}); +const parserAnalysis = analyzeParser(parserArtifact); +assert.equal(parserAnalysis.endpoints.length, 1); +assert.equal(parserAnalysis.endpoints[0].bootstrap95.familyCount, 1); + +function noise(random, scale) { + return ( + (random() + random() + random() + random() + random() + random() - 3) * + scale + ); +} + +const scenarioTruth = { + boundary: [Math.log(1.1), Math.log(1.1)], + beyondMargin: [Math.log(1.15), 0], + skewed: [0.01, 0.01], + differingVariance: [0, 0], + temporalDrift: [0, 0], + orderPenalty: [0, 0], + outlier: [0, 0], +}; + +function scenarioRows(name, run) { + const random = seededRandom(0x7200 + run * 17 + name.length); + const effects = scenarioTruth[name]; + return Array.from({ length: ROWS }, (_, index) => { + const processOrder = index % 2 ? 'candidate-first' : 'baseline-first'; + const common = noise(random, name === 'differingVariance' ? 0.02 : 0.08); + const drift = name === 'temporalDrift' ? (index - ROWS / 2) * 0.004 : 0; + let penalty = 0; + if (name === 'orderPenalty') + penalty = processOrder === 'candidate-first' ? 0.18 : -0.18; + const skew = name === 'skewed' ? (random() ** 2 - 1 / 3) * 0.3 : 0; + const outlier = name === 'outlier' && index === 3 ? 0.8 : 0; + const baseline = [1, 1].map((value) => value * Math.exp(common + drift)); + const candidate = effects.map((effect, endpoint) => { + const endpointNoise = + name === 'differingVariance' && endpoint === 1 + ? noise(random, 0.35) + : noise(random, 0.08); + return ( + baseline[endpoint] * + Math.exp(effect + endpointNoise + penalty + skew + outlier) + ); + }); + return { baseline, candidate, processOrder }; + }); +} + +function classify(intervals) { + const threshold = Math.log(1.1); + if (intervals.some((item) => item.familyLower > threshold)) + return 'regression'; + if (intervals.every((item) => item.familyUpper <= threshold)) return 'pass'; + return 'inconclusive'; +} + +const scenarioNames = [ + 'boundary', + 'beyondMargin', + 'skewed', + 'differingVariance', + 'temporalDrift', + 'orderPenalty', + 'outlier', +]; +const scenarioRuns = FULL ? 500 : 100; +const scenarioResamples = FULL ? 5_000 : 500; +const scenarioResults = Object.fromEntries( + scenarioNames.map((name) => { + const counts = { pass: 0, regression: 0, inconclusive: 0 }; + let scenarioCovered = 0; + for (let run = 0; run < scenarioRuns; run++) { + const rows = scenarioRows(name, run); + const intervals = bootstrapStratifiedMaxT({ + rows: rows.map(({ baseline, candidate }) => + candidate.map((value, endpoint) => + Math.log(value / baseline[endpoint]) + ) + ), + strata: rows.map((row) => row.processOrder), + seed: 0x910000 + run, + resamples: scenarioResamples, + }).intervals; + counts[classify(intervals)]++; + if ( + intervals.every( + (interval, endpoint) => + interval.familyLower <= scenarioTruth[name][endpoint] && + interval.familyUpper >= scenarioTruth[name][endpoint] + ) + ) + scenarioCovered++; + } + return [ + name, + { + runs: scenarioRuns, + falseRegressionRate: + name === 'beyondMargin' ? null : counts.regression / scenarioRuns, + falsePassRate: + name === 'beyondMargin' ? counts.pass / scenarioRuns : null, + inconclusiveRate: counts.inconclusive / scenarioRuns, + simultaneousCoverage: scenarioCovered / scenarioRuns, + }, + ]; + }) +); +console.log( + JSON.stringify({ + seed: 0x510e, + runs: RUNS, + rows: ROWS, + resamples: RESAMPLES, + simultaneousCoverage: coverage, + expectedCoverageRange: FULL ? [0.925, 0.975] : [0.9, 0.99], + mode: FULL ? 'full' : 'smoke', + scenarios: scenarioResults, + }) +); diff --git a/test/conformance/corpus.test.mjs b/test/conformance/corpus.test.js similarity index 60% rename from test/conformance/corpus.test.mjs rename to test/conformance/corpus.test.js index b9c08ae..a35925e 100644 --- a/test/conformance/corpus.test.mjs +++ b/test/conformance/corpus.test.js @@ -14,96 +14,46 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { calc as csstoolsCalc } from '@csstools/css-calc'; -import { out } from '../helpers/out.mjs'; +import { + classifyCorpusExpression, + NEUTRAL_CORPUS_CATEGORIES, + referenceOutput, +} from '../../scripts/lib/corpus-policy.js'; import { ROUTINE_CORPUS_TARGET, selectCorpusExpressions, stableHash, -} from '../helpers/corpus-selection.mjs'; +} from '../helpers/corpus-selection.js'; const CORPUS_DIR = fileURLToPath(new URL('../corpus/', import.meta.url)); -const COMPARE_PRECISION = 10; -function ourOut(input) { - try { - return out(input, { precision: COMPARE_PRECISION }); - } catch { - return null; - } -} -function theirOut(input) { - try { - const r = csstoolsCalc(input); - return typeof r === 'string' ? r : null; - } catch { - return null; - } -} -/** - * Documented divergences from csstools that we accept. Each entry is an - * INPUT string; the comment explains the chosen behavior. Adding a case - * here means the design choice is deliberate — not a workaround. - */ -const KNOWN_DIVERGENCES = new Set([ - // Mixed-unit angle sum: when an inverse trig function output (radians) - // is summed with degrees, we fold to a single deg-unit constant - // (`atan(.5) + 90deg` → `116.5650511771deg`); csstools keeps the rad+deg - // sum un-folded. Both outputs represent the same angle. Our choice - // matches the rest of our angle-serialization (degrees), and once the - // numeric folding is done the sum can't be expressed without a unit - // choice anyway. - 'calc(atan(.5) + 90deg - (var(--dir)*90deg))', - // Emoji/math-symbol custom properties: the current CSS Syntax draft - // excludes these code points from idents, so `--➕` splits and we warn + - // preserve; css-calc silently passes through. Same output either way. - 'calc(1 / var(--√𝟤))', - 'calc(var(--➕) * -1)', - 'calc(var(--➕) * var(--✖️))', - 'calc(var(--➖) * var(--✖️))', -]); function runLibrary(lib, calcs) { const result = { lib, total: calcs.length, agree: 0, bothFailed: 0, + referenceRejected: 0, divergences: [], }; for (const input of calcs) { - const ours = ourOut(input); - const theirs = theirOut(input); - if (ours === null && theirs === null) { + const comparison = classifyCorpusExpression(input); + if (comparison.category === 'both-failed') { result.bothFailed++; continue; } - if (ours === null || theirs === null) { - if (!KNOWN_DIVERGENCES.has(input)) { - result.divergences.push({ - input, - ours: ours ?? '', - theirs: theirs ?? '', - }); - } - continue; - } - if (ours === theirs) { + if (comparison.category === 'accepted') { result.agree++; continue; } - const canonicalTheirs = ourOut(theirs); - if (canonicalTheirs === null) { - // csstools produced something our parser couldn't read — rare. - if (!KNOWN_DIVERGENCES.has(input)) { - result.divergences.push({ input, ours, theirs }); - } + if (NEUTRAL_CORPUS_CATEGORIES.has(comparison.category)) { + if (comparison.category === 'reference-rejected') + result.referenceRejected++; continue; } - if (ours === canonicalTheirs) { - result.agree++; - continue; - } - if (!KNOWN_DIVERGENCES.has(input)) { - result.divergences.push({ input, ours, theirs }); - } + result.divergences.push({ + input, + ours: comparison.ours ?? '', + theirs: comparison.theirs ?? '', + }); } return result; } @@ -131,11 +81,11 @@ const inputs = fullCorpus ? selection.allInputs : selection.routineInputs; const result = runLibrary(fullCorpus ? 'full' : 'sample', inputs); const parserRejectedHash = stableHash(selection.parserRejected.join('\n')); const parserRejectedAcceptedByCsstools = selection.parserRejected.filter( - (input) => theirOut(input) !== null + (input) => referenceOutput(input) !== null ); -// These are Sass/preprocessor and malformed inputs harvested by the GitHub -// pool. They are checked separately because css-calc passes them through, +// These are Sass/preprocessor and malformed inputs harvested from GitHub. +// They are checked separately because css-calc passes them through, // while this package intentionally rejects them as non-CSS expressions. const EXPECTED_PARSER_REJECTED_COUNT = 2282; const EXPECTED_PARSER_REJECTED_HASH = 895423645; diff --git a/test/conformance/csstools-core.test.js b/test/conformance/csstools-core.test.js new file mode 100644 index 0000000..cb6637c --- /dev/null +++ b/test/conformance/csstools-core.test.js @@ -0,0 +1,268 @@ +// Cribbed from @csstools/css-calc test corpus: +// https://github.com/csstools/postcss-plugins/tree/main/packages/css-calc/test +// +// Each test cites its source file. Cases selected where our pipeline +// produces the same output as csstools. Deliberately excluded: +// - csstools `globals` option (variable substitution) — not in our scope +// - relative-color math (`rgb(from ...)`) — out of scope +// - exponential family (pow/sqrt/hypot/log/exp) — not yet implemented +// - cases where floating-point serialization precision differs (we use +// `precision: false` to emit full-float, but csstools occasionally +// rounds at ~15 significant figures in its own way) +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { out as pipeline } from '../helpers/out.js'; + +/** Full-precision output, matching csstools' default. */ +const out = (input) => pipeline(input, { precision: false }); + +// --- basic/test.js ------------------------------------------------------- +// One representative keeps arithmetic precedence and explicit grouping here; +// focused parser/simplifier and grammar properties cover the overlapping +// plain add/subtract/multiply examples. +test('csstools basic: precedence and parenthesized division', () => { + assert.equal(out('calc(15 / (5 / 3))'), 'calc(9)'); + assert.equal(out('calc(2 * 3 + 7 * 5)'), 'calc(41)'); +}); + +// --- wpt/calc-unit-analysis.js ------------------------------------------ +describe('csstools unit-analysis', () => { + test('csstools unit-analysis: calc(0) → 0', () => { + assert.equal(out('calc(0)'), 'calc(0)'); + }); + + test('csstools unit-analysis: calc(0px) → 0px', () => { + assert.equal(out('calc(0px)'), 'calc(0px)'); + }); + + // DIVERGE: csstools preserves source term order; we emit resolvables + // (numbers/same-unit dims) first. Both are valid per §10.12 (which actually + // specifies a third order: numbers → percentages → dims-ASCII-sorted). + test('csstools unit-analysis: length + number preserved as a sum', () => { + // csstools: `calc(1px + 2)`. Ours reorders. + assert.equal(out('calc(1px + 2)'), 'calc(2 + 1px)'); + }); + + test('csstools unit-analysis: number + length preserved as a sum', () => { + assert.equal(out('calc(2 + 1px)'), 'calc(2 + 1px)'); + }); + + test('csstools unit-analysis: length - number preserved as a sum', () => { + // csstools: `calc(1px - 2)`. Ours: `calc(-2 + 1px)` (reorder pushes the + // negative number to the front). + assert.equal(out('calc(1px - 2)'), 'calc(-2 + 1px)'); + }); + + test('csstools unit-analysis: number - length preserved as a sum', () => { + assert.equal(out('calc(2 - 1px)'), 'calc(2 - 1px)'); + }); + + test('csstools unit-analysis: length * number folds', () => { + assert.equal(out('calc(2px * 2)'), 'calc(4px)'); + }); + + test('csstools unit-analysis: number * length folds', () => { + assert.equal(out('calc(2 * 2px)'), 'calc(4px)'); + }); + + test('csstools unit-analysis: length * length preserved (unit^2 not expressible)', () => { + assert.equal(out('calc(2px * 1px)'), 'calc(2px * 1px)'); + }); +}); +// --- wpt/calc-time-values.js -------------------------------------------- +test('csstools time: compatible units divide to a number', () => { + assert.equal(out('calc(8s / 2s)'), 'calc(4)'); +}); + +// --- wpt/calc-angle-values.js ------------------------------------------- +test('csstools angle: compatible angle sum', () => { + assert.equal(out('calc(0.5turn + 0.5turn)'), 'calc(1turn)'); +}); + +// --- wpt/minmax-percentage-computed.js ---------------------------------- +// csstools preserves percent inside min/max/clamp unconditionally. +describe('csstools min/max percentages', () => { + test('csstools minmax-%: single-arg min kept', () => { + assert.equal(out('min(1%)'), 'min(1%)'); + }); + + test('csstools minmax-%: single-arg max kept', () => { + assert.equal(out('max(1%)'), 'max(1%)'); + }); + + test('csstools minmax-%: nested min/max with percent kept', () => { + assert.equal(out('min(20%, max(10%, 15%))'), 'min(20%, max(10%, 15%))'); + }); + + test('csstools minmax-%: sum around min/max percent kept intact', () => { + // DIVERGE (order): csstools `calc(min(10%, 20%) + 5%)` → same. + // Ours emits resolvable `5%` first. + assert.equal(out('calc(min(10%, 20%) + 5%)'), 'calc(5% + min(10%, 20%))'); + }); +}); +// --- wpt/minmax-integer-computed.js (number-typed min/max) -------------- +describe('csstools min/max numbers', () => { + test('csstools minmax-int: min of integers folds', () => { + assert.equal(out('min(1, 2, 3)'), 'calc(1)'); + }); + + test('csstools minmax-int: max of integers folds', () => { + assert.equal(out('max(1, 2, 3)'), 'calc(3)'); + }); + + test('csstools minmax-int: single-arg min of number folds', () => { + assert.equal(out('min(1)'), 'calc(1)'); + }); +}); + +// --- wpt/minmax-time-computed.js (same-unit cases) ---------------------- +test('csstools minmax-time: min of seconds', () => { + assert.equal(out('min(1s, 2s, 3s)'), 'calc(1s)'); +}); + +test('csstools minmax-time: max of seconds', () => { + assert.equal(out('max(1s, 2s, 3s)'), 'calc(3s)'); +}); + +// --- wpt/max-20-arguments.js -------------------------------------------- +test('csstools max-20: max with many numeric args folds', () => { + assert.equal( + out( + 'max(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20)' + ), + 'calc(20)' + ); +}); + +// --- wpt/calc-in-calc.js ------------------------------------------------ +test('csstools calc-in-calc: nested calculation flattens', () => { + assert.equal(out('calc(calc(1px + 2px))'), 'calc(3px)'); +}); + +// --- wpt/clamp-length-computed.js (same-unit, fully-resolvable) --------- +test('csstools clamp: middle value selected', () => { + assert.equal(out('clamp(1px, 2px, 3px)'), 'calc(2px)'); +}); + +describe('csstools clamp', () => { + test('csstools clamp: min cap applied', () => { + assert.equal(out('clamp(5px, 2px, 10px)'), 'calc(5px)'); + }); + + test('csstools clamp: max cap applied', () => { + assert.equal(out('clamp(1px, 10px, 5px)'), 'calc(5px)'); + }); +}); + +// --- basic/none-in-clamp.js (subset) ------------------------------------ +// clamp(none, ...) uses keyword `none` as unbounded per §10.5.3. +test('csstools none-in-clamp: none as lower bound folds via min()', () => { + assert.equal(out('clamp(none, 10px, 20px)'), 'calc(10px)'); +}); + +test('csstools none-in-clamp: none as upper bound folds via max()', () => { + assert.equal(out('clamp(1px, 10px, none)'), 'calc(10px)'); +}); + +// --- wpt/invalid.js (subset our tokenizer/parser rejects) --------------- +test('csstools invalid: empty calc throws', () => { + assert.throws(() => out('calc()'), /takes exactly one argument/); +}); + +describe('csstools invalid syntax', () => { + test('csstools invalid: trailing operator throws', () => { + // §10.1: `+`/`-` must be surrounded by whitespace. The trailing `+` + // is followed by `)` without a space, which now throws at the + // strict-whitespace check. + assert.throws( + () => out('calc(1 +)'), + /must be surrounded by whitespace|Unexpected token/ + ); + }); + + test('csstools invalid: lonely binary op throws', () => { + assert.throws(() => out('calc(/)'), /Unexpected token/); + }); +}); + +// --- @csstools/css-calc round/mod/rem/abs/sign fixtures ------------------ +// Cribbed from packages/css-calc/test for the stepped/sign-related suite. +describe('csstools round/mod/rem/abs/sign', () => { + test('csstools round: default strategy (nearest)', () => { + assert.equal(out('round(15, 10)'), 'calc(20)'); + assert.equal(out('round(14, 10)'), 'calc(10)'); + }); + + test('csstools round: dim A and B in same family', () => { + assert.equal(out('round(15px, 10px)'), 'calc(20px)'); + }); + + test('csstools round: each strategy', () => { + assert.equal(out('round(up, 1.1, 1)'), 'calc(2)'); + assert.equal(out('round(down, 1.9, 1)'), 'calc(1)'); + assert.equal(out('round(to-zero, -1.9, 1)'), 'calc(-1)'); + assert.equal(out('round(nearest, 1.5, 1)'), 'calc(2)'); + }); + + test('csstools round: B omitted for A', () => { + assert.equal(out('round(3.7)'), 'calc(4)'); + }); + + test('csstools round: opaque var() preserved', () => { + assert.equal(out('round(var(--x), 10)'), 'round(var(--x), 10)'); + }); + + test('csstools mod: spec examples', () => { + assert.equal(out('mod(18, 5)'), 'calc(3)'); + assert.equal(out('mod(-18, 5)'), 'calc(2)'); + assert.equal(out('mod(18, -5)'), 'calc(-2)'); + }); + + test('csstools rem: spec examples', () => { + assert.equal(out('rem(18, 5)'), 'calc(3)'); + assert.equal(out('rem(-18, 5)'), 'calc(-3)'); + assert.equal(out('rem(18, -5)'), 'calc(3)'); + }); + + test('csstools mod/rem: dim args fold', () => { + assert.equal(out('mod(18px, 5px)'), 'calc(3px)'); + assert.equal(out('rem(18px, 5px)'), 'calc(3px)'); + }); + + test('csstools abs: number and dim', () => { + assert.equal(out('abs(-5)'), 'calc(5)'); + assert.equal(out('abs(-5px)'), 'calc(5px)'); + assert.equal(out('abs(5em)'), 'calc(5em)'); + }); + + test('csstools abs: opaque preserves', () => { + assert.equal(out('abs(var(--x))'), 'abs(var(--x))'); + }); + + test('csstools sign: number, dim, opaque', () => { + assert.equal(out('sign(-5)'), 'calc(-1)'); + assert.equal(out('sign(5)'), 'calc(1)'); + assert.equal(out('sign(0)'), 'calc(0)'); + assert.equal(out('sign(-5px)'), 'calc(-1)'); + assert.equal(out('sign(var(--x))'), 'sign(var(--x))'); + }); + + test('csstools round: type mismatch → opaque', () => { + assert.equal(out('round(1px, 1deg)'), 'round(1px, 1deg)'); + }); + + test('csstools mod/rem: type mismatch → opaque', () => { + assert.equal(out('mod(1px, 1deg)'), 'mod(1px, 1deg)'); + assert.equal(out('rem(1px, 1deg)'), 'rem(1px, 1deg)'); + }); + + test('csstools round: cross-family conversion (in/px)', () => { + // 1in = 96px exactly; round(96px, 24px) = 96px = 1in (first unit wins). + assert.equal(out('round(1in, 24px)'), 'calc(1in)'); + }); + + test('csstools mod: cross-family time (1s, 100ms)', () => { + // 1s = 1000ms; mod(1000ms, 100ms) = 0ms; result in first unit (s) → 0s. + assert.equal(out('mod(1s, 100ms)'), 'calc(0s)'); + }); +}); diff --git a/test/conformance/csstools-exponential.test.js b/test/conformance/csstools-exponential.test.js new file mode 100644 index 0000000..a0cc273 --- /dev/null +++ b/test/conformance/csstools-exponential.test.js @@ -0,0 +1,83 @@ +// Cribbed from @csstools/css-calc test corpus: +// https://github.com/csstools/postcss-plugins/tree/main/packages/css-calc/test +// +// Each test cites its source file. Cases selected where our pipeline +// produces the same output as csstools. Deliberately excluded: +// - csstools `globals` option (variable substitution) — not in our scope +// - relative-color math (`rgb(from ...)`) — out of scope +// - exponential family (pow/sqrt/hypot/log/exp) — not yet implemented +// - cases where floating-point serialization precision differs (we use +// `precision: false` to emit full-float, but csstools occasionally +// rounds at ~15 significant figures in its own way) +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { out as pipeline } from '../helpers/out.js'; + +/** Full-precision output, matching csstools' default. */ +const out = (input) => pipeline(input, { precision: false }); + +// --- §10.5 exponential family fixtures ------------------------------- +describe('csstools exponential functions', () => { + test('csstools pow: pow(2, 3) → 8', () => { + assert.equal(out('pow(2, 3)'), 'calc(8)'); + }); + + test('csstools pow: pow(8, 1 / 3) ≈ 2', () => { + // csstools agrees on the cube-root identity within FP precision. + const got = Number.parseFloat( + out('pow(8, 1 / 3)').replace(/^calc\(|\)$/g, '') + ); + assert.ok(Math.abs(got - 2) < 1e-9, `got ${got}`); + }); + + test('csstools sqrt: sqrt(16) → 4', () => { + assert.equal(out('sqrt(16)'), 'calc(4)'); + }); + + test('csstools sqrt: sqrt(0) → 0', () => { + assert.equal(out('sqrt(0)'), 'calc(0)'); + }); + + test('csstools exp: exp(0) → 1', () => { + assert.equal(out('exp(0)'), 'calc(1)'); + }); + + test('csstools log: log(8, 2) → 3', () => { + assert.equal(out('log(8, 2)'), 'calc(3)'); + }); + + test('csstools log: natural log of e → 1', () => { + assert.equal(out('log(e)'), 'calc(1)'); + }); + + test('csstools hypot: hypot(3, 4) → 5', () => { + assert.equal(out('hypot(3, 4)'), 'calc(5)'); + }); + + test('csstools hypot: hypot(3px, 4px) → 5px', () => { + assert.equal(out('hypot(3px, 4px)'), 'calc(5px)'); + }); + + test('csstools hypot: single arg passes through as abs', () => { + assert.equal(out('hypot(-2em)'), 'calc(2em)'); + }); +}); + +// --- §10.13 degenerate-number fixtures ------------------------------- +describe('csstools degenerate', () => { + test('csstools degenerate: calc(infinity) round-trips', () => { + assert.equal(out('calc(infinity)'), 'calc(infinity)'); + }); + + test('csstools degenerate: division by zero produces calc(infinity * 1px)', () => { + assert.equal(out('calc(1px / 0)'), 'calc(infinity * 1px)'); + }); + + test('csstools degenerate: NaN canonical casing on output', () => { + assert.equal(out('calc(NaN)'), 'calc(NaN)'); + }); + + test('csstools degenerate: subtracting infinities → NaN', () => { + assert.equal(out('calc(infinity - infinity)'), 'calc(NaN)'); + }); +}); diff --git a/test/conformance/csstools-trigonometry.test.js b/test/conformance/csstools-trigonometry.test.js new file mode 100644 index 0000000..706517f --- /dev/null +++ b/test/conformance/csstools-trigonometry.test.js @@ -0,0 +1,136 @@ +// Cribbed from @csstools/css-calc test corpus: +// https://github.com/csstools/postcss-plugins/tree/main/packages/css-calc/test +// +// Each test cites its source file. Cases selected where our pipeline +// produces the same output as csstools. Deliberately excluded: +// - csstools `globals` option (variable substitution) — not in our scope +// - relative-color math (`rgb(from ...)`) — out of scope +// - exponential family (pow/sqrt/hypot/log/exp) — not yet implemented +// - cases where floating-point serialization precision differs (we use +// `precision: false` to emit full-float, but csstools occasionally +// rounds at ~15 significant figures in its own way) +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { out as pipeline } from '../helpers/out.js'; + +/** Full-precision output, matching csstools' default. */ +const out = (input) => pipeline(input, { precision: false }); + +// --- trig/test.js (§10.4) ----------------------------------------------- +// +// `out` here uses precision: false, so floating-point artifacts that the +// default-precision unit suite swallows show through here as the literal +// JS strings (e.g. cos(60deg) = 0.5000000000000001). +describe('csstools trigonometric functions', () => { + test('csstools trig: sin(0) → 0', () => { + assert.equal(out('sin(0)'), 'calc(0)'); + }); + + test('csstools trig: cos(0) → 1', () => { + assert.equal(out('cos(0)'), 'calc(1)'); + }); + + test('csstools trig: tan(0) → 0', () => { + assert.equal(out('tan(0)'), 'calc(0)'); + }); + + test('csstools trig: sin(90deg) → 1', () => { + assert.equal(out('sin(90deg)'), 'calc(1)'); + }); + + test('csstools trig: cos(180deg) → -1', () => { + assert.equal(out('cos(180deg)'), 'calc(-1)'); + }); + + test('csstools trig: cos(60deg) → 0.5000000000000001 (full precision)', () => { + assert.equal(out('cos(60deg)'), 'calc(.5000000000000001)'); + }); + + test('csstools trig: tan(45deg) → 0.9999999999999999 (full precision)', () => { + assert.equal(out('tan(45deg)'), 'calc(.9999999999999999)'); + }); + + test('csstools trig: sin(pi) → 1.2246467991473532e-16 (full precision)', () => { + assert.equal(out('sin(pi)'), 'calc(1.2246467991473532e-16)'); + }); + + test('csstools trig: sin(0.5turn) → 1.2246467991473532e-16', () => { + assert.equal(out('sin(0.5turn)'), 'calc(1.2246467991473532e-16)'); + }); + + test('csstools trig: bare-number arg is radians — sin(pi / 2) → 1', () => { + assert.equal(out('sin(pi / 2)'), 'calc(1)'); + }); + + test('csstools trig: var() arg → opaque', () => { + assert.equal(out('sin(var(--x))'), 'sin(var(--x))'); + }); + + test('csstools trig: length arg → opaque (must be number or angle)', () => { + assert.equal(out('sin(10px)'), 'sin(10px)'); + }); + + test('csstools inverse-trig: asin(0) → 0deg', () => { + assert.equal(out('asin(0)'), 'calc(0deg)'); + }); + + test('csstools inverse-trig: asin(1) → 90deg', () => { + assert.equal(out('asin(1)'), 'calc(90deg)'); + }); + + test('csstools inverse-trig: asin(-1) → -90deg', () => { + assert.equal(out('asin(-1)'), 'calc(-90deg)'); + }); + + test('csstools inverse-trig: asin(0.5) → 30.000000000000004deg', () => { + assert.equal(out('asin(0.5)'), 'calc(30.000000000000004deg)'); + }); + + test('csstools inverse-trig: acos(1) → 0deg (zero-valued angle keeps unit)', () => { + assert.equal(out('acos(1)'), 'calc(0deg)'); + }); + + test('csstools inverse-trig: acos(-1) → 180deg', () => { + assert.equal(out('acos(-1)'), 'calc(180deg)'); + }); + + test('csstools inverse-trig: atan(1) → 45deg (exact in JS)', () => { + assert.equal(out('atan(1)'), 'calc(45deg)'); + }); + + test('csstools inverse-trig: atan(infinity) → 90deg', () => { + assert.equal(out('atan(infinity)'), 'calc(90deg)'); + }); + + test('csstools inverse-trig: dim arg → opaque (asin/acos/atan need )', () => { + assert.equal(out('asin(45deg)'), 'asin(45deg)'); + }); + + test('csstools atan2: (0, 1) → 0deg', () => { + assert.equal(out('atan2(0, 1)'), 'calc(0deg)'); + }); + + test('csstools atan2: (1, 0) → 90deg', () => { + assert.equal(out('atan2(1, 0)'), 'calc(90deg)'); + }); + + test('csstools atan2: (1, 1) → 45deg', () => { + assert.equal(out('atan2(1, 1)'), 'calc(45deg)'); + }); + + test('csstools atan2: (-1, -1) → -135deg', () => { + assert.equal(out('atan2(-1, -1)'), 'calc(-135deg)'); + }); + + test('csstools atan2: cross-unit-same-base (1in, 96px) → 45deg', () => { + assert.equal(out('atan2(1in, 96px)'), 'calc(45deg)'); + }); + + test('csstools atan2: type mismatch → opaque', () => { + assert.equal(out('atan2(1px, 1deg)'), 'atan2(1px, 1deg)'); + }); + + test('csstools atan2: percentages → opaque', () => { + assert.equal(out('atan2(50%, 50%)'), 'atan2(50%, 50%)'); + }); +}); diff --git a/test/conformance/csstools.test.mjs b/test/conformance/csstools.test.mjs deleted file mode 100644 index 4f9a24f..0000000 --- a/test/conformance/csstools.test.mjs +++ /dev/null @@ -1,451 +0,0 @@ -// Cribbed from @csstools/css-calc test corpus: -// https://github.com/csstools/postcss-plugins/tree/main/packages/css-calc/test -// -// Each test cites its source file. Cases selected where our pipeline -// produces the same output as csstools. Deliberately excluded: -// - csstools `globals` option (variable substitution) — not in our scope -// - relative-color math (`rgb(from ...)`) — out of scope -// - exponential family (pow/sqrt/hypot/log/exp) — not yet implemented -// - cases where floating-point serialization precision differs (we use -// `precision: false` to emit full-float, but csstools occasionally -// rounds at ~15 significant figures in its own way) -import { describe, test } from 'node:test'; -import assert from 'node:assert/strict'; -import { out as pipeline } from '../helpers/out.mjs'; - -/** Full-precision output, matching csstools' default. */ -const out = (input) => pipeline(input, { precision: false }); - -// --- basic/test.mjs ------------------------------------------------------- -// One representative keeps arithmetic precedence and explicit grouping here; -// focused parser/simplifier and grammar properties cover the overlapping -// plain add/subtract/multiply examples. -test('csstools basic: precedence and parenthesized division', () => { - assert.equal(out('calc(15 / (5 / 3))'), '9'); - assert.equal(out('calc(2 * 3 + 7 * 5)'), '41'); -}); - -// --- wpt/calc-unit-analysis.mjs ------------------------------------------ -describe('csstools unit-analysis', () => { - test('csstools unit-analysis: calc(0) → 0', () => { - assert.equal(out('calc(0)'), '0'); - }); - - test('csstools unit-analysis: calc(0px) → 0px', () => { - assert.equal(out('calc(0px)'), '0px'); - }); - - // DIVERGE: csstools preserves source term order; we emit resolvables - // (numbers/same-unit dims) first. Both are valid per §10.12 (which actually - // specifies a third order: numbers → percentages → dims-ASCII-sorted). - test('csstools unit-analysis: length + number preserved as a sum', () => { - // csstools: `calc(1px + 2)`. Ours reorders. - assert.equal(out('calc(1px + 2)'), 'calc(2 + 1px)'); - }); - - test('csstools unit-analysis: number + length preserved as a sum', () => { - assert.equal(out('calc(2 + 1px)'), 'calc(2 + 1px)'); - }); - - test('csstools unit-analysis: length - number preserved as a sum', () => { - // csstools: `calc(1px - 2)`. Ours: `calc(-2 + 1px)` (reorder pushes the - // negative number to the front). - assert.equal(out('calc(1px - 2)'), 'calc(-2 + 1px)'); - }); - - test('csstools unit-analysis: number - length preserved as a sum', () => { - assert.equal(out('calc(2 - 1px)'), 'calc(2 - 1px)'); - }); - - test('csstools unit-analysis: length * number folds', () => { - assert.equal(out('calc(2px * 2)'), '4px'); - }); - - test('csstools unit-analysis: number * length folds', () => { - assert.equal(out('calc(2 * 2px)'), '4px'); - }); - - test('csstools unit-analysis: length * length preserved (unit^2 not expressible)', () => { - assert.equal(out('calc(2px * 1px)'), 'calc(2px * 1px)'); - }); -}); -// --- wpt/calc-time-values.mjs -------------------------------------------- -test('csstools time: compatible units divide to a number', () => { - assert.equal(out('calc(8s / 2s)'), '4'); -}); - -// --- wpt/calc-angle-values.mjs ------------------------------------------- -test('csstools angle: compatible angle sum', () => { - assert.equal(out('calc(0.5turn + 0.5turn)'), '1turn'); -}); - -// --- wpt/minmax-percentage-computed.mjs ---------------------------------- -// csstools preserves percent inside min/max/clamp unconditionally. -describe('csstools minmax-%:', () => { - test('csstools minmax-%: single-arg min kept', () => { - assert.equal(out('min(1%)'), 'min(1%)'); - }); - - test('csstools minmax-%: single-arg max kept', () => { - assert.equal(out('max(1%)'), 'max(1%)'); - }); - - test('csstools minmax-%: nested min/max with percent kept', () => { - assert.equal(out('min(20%, max(10%, 15%))'), 'min(20%, max(10%, 15%))'); - }); - - test('csstools minmax-%: sum around min/max percent kept intact', () => { - // DIVERGE (order): csstools `calc(min(10%, 20%) + 5%)` → same. - // Ours emits resolvable `5%` first. - assert.equal(out('calc(min(10%, 20%) + 5%)'), 'calc(5% + min(10%, 20%))'); - }); -}); -// --- wpt/minmax-integer-computed.mjs (number-typed min/max) -------------- -describe('csstools minmax-int: Max Of', () => { - test('csstools minmax-int: min of integers folds', () => { - assert.equal(out('min(1, 2, 3)'), '1'); - }); - - test('csstools minmax-int: max of integers folds', () => { - assert.equal(out('max(1, 2, 3)'), '3'); - }); - - test('csstools minmax-int: single-arg min of number folds', () => { - assert.equal(out('min(1)'), '1'); - }); -}); - -// --- wpt/minmax-time-computed.mjs (same-unit cases) ---------------------- -test('csstools minmax-time: min of seconds', () => { - assert.equal(out('min(1s, 2s, 3s)'), '1s'); -}); - -test('csstools minmax-time: max of seconds', () => { - assert.equal(out('max(1s, 2s, 3s)'), '3s'); -}); - -// --- wpt/max-20-arguments.mjs -------------------------------------------- -test('csstools max-20: max with many numeric args folds', () => { - assert.equal( - out( - 'max(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20)' - ), - '20' - ); -}); - -// --- wpt/calc-in-calc.mjs ------------------------------------------------ -test('csstools calc-in-calc: nested calculation flattens', () => { - assert.equal(out('calc(calc(1px + 2px))'), '3px'); -}); - -// --- wpt/clamp-length-computed.mjs (same-unit, fully-resolvable) --------- -test('csstools clamp: middle value selected', () => { - assert.equal(out('clamp(1px, 2px, 3px)'), '2px'); -}); - -describe('csstools clamp', () => { - test('csstools clamp: min cap applied', () => { - assert.equal(out('clamp(5px, 2px, 10px)'), '5px'); - }); - - test('csstools clamp: max cap applied', () => { - assert.equal(out('clamp(1px, 10px, 5px)'), '5px'); - }); -}); - -// --- basic/none-in-clamp.mjs (subset) ------------------------------------ -// clamp(none, ...) uses keyword `none` as unbounded per §10.5.3. -test('csstools none-in-clamp: none as lower bound folds via min()', () => { - assert.equal(out('clamp(none, 10px, 20px)'), '10px'); -}); - -test('csstools none-in-clamp: none as upper bound folds via max()', () => { - assert.equal(out('clamp(1px, 10px, none)'), '10px'); -}); - -// --- wpt/invalid.mjs (subset our tokenizer/parser rejects) --------------- -test('csstools invalid: empty calc throws', () => { - assert.throws(() => out('calc()'), /takes exactly one argument/); -}); - -describe('csstools invalid: Trailing Operator', () => { - test('csstools invalid: trailing operator throws', () => { - // §10.1: `+`/`-` must be surrounded by whitespace. The trailing `+` - // is followed by `)` without a space, which now throws at the - // strict-whitespace check. - assert.throws( - () => out('calc(1 +)'), - /must be surrounded by whitespace|Unexpected token/ - ); - }); - - test('csstools invalid: lonely binary op throws', () => { - assert.throws(() => out('calc(/)'), /Unexpected token/); - }); -}); - -// --- @csstools/css-calc round/mod/rem/abs/sign fixtures ------------------ -// Cribbed from packages/css-calc/test for the stepped/sign-related suite. -describe('csstools round/mod/rem/abs/sign', () => { - test('csstools round: default strategy (nearest)', () => { - assert.equal(out('round(15, 10)'), '20'); - assert.equal(out('round(14, 10)'), '10'); - }); - - test('csstools round: dim A and B in same family', () => { - assert.equal(out('round(15px, 10px)'), '20px'); - }); - - test('csstools round: each strategy', () => { - assert.equal(out('round(up, 1.1, 1)'), '2'); - assert.equal(out('round(down, 1.9, 1)'), '1'); - assert.equal(out('round(to-zero, -1.9, 1)'), 'calc(-1)'); - assert.equal(out('round(nearest, 1.5, 1)'), '2'); - }); - - test('csstools round: B omitted for A', () => { - assert.equal(out('round(3.7)'), '4'); - }); - - test('csstools round: opaque var() preserved', () => { - assert.equal(out('round(var(--x), 10)'), 'round(var(--x), 10)'); - }); - - test('csstools mod: spec examples', () => { - assert.equal(out('mod(18, 5)'), '3'); - assert.equal(out('mod(-18, 5)'), '2'); - assert.equal(out('mod(18, -5)'), 'calc(-2)'); - }); - - test('csstools rem: spec examples', () => { - assert.equal(out('rem(18, 5)'), '3'); - assert.equal(out('rem(-18, 5)'), 'calc(-3)'); - assert.equal(out('rem(18, -5)'), '3'); - }); - - test('csstools mod/rem: dim args fold', () => { - assert.equal(out('mod(18px, 5px)'), '3px'); - assert.equal(out('rem(18px, 5px)'), '3px'); - }); - - test('csstools abs: number and dim', () => { - assert.equal(out('abs(-5)'), '5'); - assert.equal(out('abs(-5px)'), '5px'); - assert.equal(out('abs(5em)'), '5em'); - }); - - test('csstools abs: opaque preserves', () => { - assert.equal(out('abs(var(--x))'), 'abs(var(--x))'); - }); - - test('csstools sign: number, dim, opaque', () => { - assert.equal(out('sign(-5)'), 'calc(-1)'); - assert.equal(out('sign(5)'), '1'); - assert.equal(out('sign(0)'), '0'); - assert.equal(out('sign(-5px)'), 'calc(-1)'); - assert.equal(out('sign(var(--x))'), 'sign(var(--x))'); - }); - - test('csstools round: type mismatch → opaque', () => { - assert.equal(out('round(1px, 1deg)'), 'round(1px, 1deg)'); - }); - - test('csstools mod/rem: type mismatch → opaque', () => { - assert.equal(out('mod(1px, 1deg)'), 'mod(1px, 1deg)'); - assert.equal(out('rem(1px, 1deg)'), 'rem(1px, 1deg)'); - }); - - test('csstools round: cross-family conversion (in/px)', () => { - // 1in = 96px exactly; round(96px, 24px) = 96px = 1in (first unit wins). - assert.equal(out('round(1in, 24px)'), '1in'); - }); - - test('csstools mod: cross-family time (1s, 100ms)', () => { - // 1s = 1000ms; mod(1000ms, 100ms) = 0ms; result in first unit (s) → 0s. - assert.equal(out('mod(1s, 100ms)'), '0s'); - }); -}); - -// --- trig/test.mjs (§10.4) ----------------------------------------------- -// -// `out` here uses precision: false, so floating-point artifacts that the -// default-precision unit suite swallows show through here as the literal -// JS strings (e.g. cos(60deg) = 0.5000000000000001). -describe('csstools trig:', () => { - test('csstools trig: sin(0) → 0', () => { - assert.equal(out('sin(0)'), '0'); - }); - - test('csstools trig: cos(0) → 1', () => { - assert.equal(out('cos(0)'), '1'); - }); - - test('csstools trig: tan(0) → 0', () => { - assert.equal(out('tan(0)'), '0'); - }); - - test('csstools trig: sin(90deg) → 1', () => { - assert.equal(out('sin(90deg)'), '1'); - }); - - test('csstools trig: cos(180deg) → -1', () => { - assert.equal(out('cos(180deg)'), 'calc(-1)'); - }); - - test('csstools trig: cos(60deg) → 0.5000000000000001 (full precision)', () => { - assert.equal(out('cos(60deg)'), '.5000000000000001'); - }); - - test('csstools trig: tan(45deg) → 0.9999999999999999 (full precision)', () => { - assert.equal(out('tan(45deg)'), '.9999999999999999'); - }); - - test('csstools trig: sin(pi) → 1.2246467991473532e-16 (full precision)', () => { - assert.equal(out('sin(pi)'), '1.2246467991473532e-16'); - }); - - test('csstools trig: sin(0.5turn) → 1.2246467991473532e-16', () => { - assert.equal(out('sin(0.5turn)'), '1.2246467991473532e-16'); - }); - - test('csstools trig: bare-number arg is radians — sin(pi / 2) → 1', () => { - assert.equal(out('sin(pi / 2)'), '1'); - }); - - test('csstools trig: var() arg → opaque', () => { - assert.equal(out('sin(var(--x))'), 'sin(var(--x))'); - }); - - test('csstools trig: length arg → opaque (must be number or angle)', () => { - assert.equal(out('sin(10px)'), 'sin(10px)'); - }); - - test('csstools inverse-trig: asin(0) → 0deg', () => { - assert.equal(out('asin(0)'), '0deg'); - }); - - test('csstools inverse-trig: asin(1) → 90deg', () => { - assert.equal(out('asin(1)'), '90deg'); - }); - - test('csstools inverse-trig: asin(-1) → -90deg', () => { - assert.equal(out('asin(-1)'), 'calc(-90deg)'); - }); - - test('csstools inverse-trig: asin(0.5) → 30.000000000000004deg', () => { - assert.equal(out('asin(0.5)'), '30.000000000000004deg'); - }); - - test('csstools inverse-trig: acos(1) → 0deg (zero-valued angle keeps unit)', () => { - assert.equal(out('acos(1)'), '0deg'); - }); - - test('csstools inverse-trig: acos(-1) → 180deg', () => { - assert.equal(out('acos(-1)'), '180deg'); - }); - - test('csstools inverse-trig: atan(1) → 45deg (exact in JS)', () => { - assert.equal(out('atan(1)'), '45deg'); - }); - - test('csstools inverse-trig: atan(infinity) → 90deg', () => { - assert.equal(out('atan(infinity)'), '90deg'); - }); - - test('csstools inverse-trig: dim arg → opaque (asin/acos/atan need )', () => { - assert.equal(out('asin(45deg)'), 'asin(45deg)'); - }); - - test('csstools atan2: (0, 1) → 0deg', () => { - assert.equal(out('atan2(0, 1)'), '0deg'); - }); - - test('csstools atan2: (1, 0) → 90deg', () => { - assert.equal(out('atan2(1, 0)'), '90deg'); - }); - - test('csstools atan2: (1, 1) → 45deg', () => { - assert.equal(out('atan2(1, 1)'), '45deg'); - }); - - test('csstools atan2: (-1, -1) → -135deg', () => { - assert.equal(out('atan2(-1, -1)'), 'calc(-135deg)'); - }); - - test('csstools atan2: cross-unit-same-base (1in, 96px) → 45deg', () => { - assert.equal(out('atan2(1in, 96px)'), '45deg'); - }); - - test('csstools atan2: type mismatch → opaque', () => { - assert.equal(out('atan2(1px, 1deg)'), 'atan2(1px, 1deg)'); - }); - - test('csstools atan2: percentages → opaque', () => { - assert.equal(out('atan2(50%, 50%)'), 'atan2(50%, 50%)'); - }); -}); - -// --- §10.5 exponential family fixtures ------------------------------- -describe('csstools pow:', () => { - test('csstools pow: pow(2, 3) → 8', () => { - assert.equal(out('pow(2, 3)'), '8'); - }); - - test('csstools pow: pow(8, 1 / 3) ≈ 2', () => { - // csstools agrees on the cube-root identity within FP precision. - const got = Number.parseFloat(out('pow(8, 1 / 3)')); - assert.ok(Math.abs(got - 2) < 1e-9, `got ${got}`); - }); - - test('csstools sqrt: sqrt(16) → 4', () => { - assert.equal(out('sqrt(16)'), '4'); - }); - - test('csstools sqrt: sqrt(0) → 0', () => { - assert.equal(out('sqrt(0)'), '0'); - }); - - test('csstools exp: exp(0) → 1', () => { - assert.equal(out('exp(0)'), '1'); - }); - - test('csstools log: log(8, 2) → 3', () => { - assert.equal(out('log(8, 2)'), '3'); - }); - - test('csstools log: natural log of e → 1', () => { - assert.equal(out('log(e)'), '1'); - }); - - test('csstools hypot: hypot(3, 4) → 5', () => { - assert.equal(out('hypot(3, 4)'), '5'); - }); - - test('csstools hypot: hypot(3px, 4px) → 5px', () => { - assert.equal(out('hypot(3px, 4px)'), '5px'); - }); - - test('csstools hypot: single arg passes through as abs', () => { - assert.equal(out('hypot(-2em)'), '2em'); - }); -}); - -// --- §10.13 degenerate-number fixtures ------------------------------- -describe('csstools degenerate', () => { - test('csstools degenerate: calc(infinity) round-trips', () => { - assert.equal(out('calc(infinity)'), 'calc(infinity)'); - }); - - test('csstools degenerate: division by zero produces calc(infinity * 1px)', () => { - assert.equal(out('calc(1px / 0)'), 'calc(infinity * 1px)'); - }); - - test('csstools degenerate: NaN canonical casing on output', () => { - assert.equal(out('calc(NaN)'), 'calc(NaN)'); - }); - - test('csstools degenerate: subtracting infinities → NaN', () => { - assert.equal(out('calc(infinity - infinity)'), 'calc(NaN)'); - }); -}); diff --git a/test/conformance/invalid-corpus.test.mjs b/test/conformance/invalid-corpus.test.js similarity index 99% rename from test/conformance/invalid-corpus.test.mjs rename to test/conformance/invalid-corpus.test.js index f109bd6..435a825 100644 --- a/test/conformance/invalid-corpus.test.mjs +++ b/test/conformance/invalid-corpus.test.js @@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { runCorpus, assertResilience } from '../helpers/resilience.mjs'; +import { runCorpus, assertResilience } from '../helpers/resilience.js'; const r = runCorpus( fileURLToPath(new URL('../corpus/github/invalid.txt', import.meta.url)) ); diff --git a/test/conformance/preprocessor-corpus.test.mjs b/test/conformance/preprocessor-corpus.test.js similarity index 98% rename from test/conformance/preprocessor-corpus.test.mjs rename to test/conformance/preprocessor-corpus.test.js index 1accfda..ab2c639 100644 --- a/test/conformance/preprocessor-corpus.test.mjs +++ b/test/conformance/preprocessor-corpus.test.js @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { runCorpus, assertResilience } from '../helpers/resilience.mjs'; +import { runCorpus, assertResilience } from '../helpers/resilience.js'; const r = runCorpus( fileURLToPath(new URL('../corpus/github/preprocessor.txt', import.meta.url)) ); diff --git a/test/conformance/wpt-core.test.js b/test/conformance/wpt-core.test.js new file mode 100644 index 0000000..559d898 --- /dev/null +++ b/test/conformance/wpt-core.test.js @@ -0,0 +1,282 @@ +// WPT (web-platform-tests) subset cribbed from: +// https://github.com/web-platform-tests/wpt/tree/master/css/css-values +// +// Each test cites its source file. Cases selected where our output +// matches the spec-defined simplified form without requiring: +// - Chrome/Firefox's canonical reordering of sum terms (§10.12 step 4), +// - eager normalization of absolute length units to px (a browser +// serialization choice, not a spec requirement for calc()), +// - infinity / NaN serialization (covered when full IEEE-754 fold lands). +// +// Trig (§10.4: sin/cos/tan/asin/acos/atan/atan2) is covered below; the +// exponential family (pow/sqrt/hypot/log/exp) is a planned follow-up. +// +// Divergences are documented with `DIVERGE:` comments. +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { out } from '../helpers/out.js'; + +// --- calc-serialization.html --------------------------------------------- +// https://github.com/web-platform-tests/wpt/blob/master/css/css-values/calc-serialization.html +test('WPT calc-serialization: single negative length preserved', () => { + assert.equal(out('calc(-10px)'), 'calc(-10px)'); +}); + +test('WPT calc-serialization: resolvable + opaque kept as a sum', () => { + assert.equal(out('calc(10px + 1vmin)'), 'calc(10px + 1vmin)'); +}); + +// --- minmax-length-serialize.html ---------------------------------------- +// https://github.com/web-platform-tests/wpt/blob/master/css/css-values/minmax-length-serialize.html +describe('WPT min/max lengths', () => { + test('WPT minmax-length: single-arg min folds', () => { + // WPT specified: `calc(1px)`. + assert.equal(out('min(1px)'), 'calc(1px)'); + }); + + test('WPT minmax-length: single-arg max folds', () => { + assert.equal(out('max(1px)'), 'calc(1px)'); + }); + + test('WPT minmax-length: unit case normalized to lowercase', () => { + // Spec §10.12: `1Q` serializes as `1q`, `1PX` as `1px`. + assert.equal(out('min(1PX)'), 'calc(1px)'); + }); + + test('WPT minmax-length: min() preserved when arg types mix', () => { + // WPT: `min(1px, 1em)` stays `min(1px, 1em)` (em is relative). + assert.equal(out('min(1px, 1em)'), 'min(1px, 1em)'); + }); + + test('WPT minmax-length: max folds when all args share a unit', () => { + // WPT (same unit): `max(1px, 2px, 3px)` → `3px`. + assert.equal(out('max(1px, 2px, 3px)'), 'calc(3px)'); + }); +}); + +// calc-in-calc flattening is represented once in csstools.test.js; the +// source grammar property also generates nested calc() wrappers. +// --- calc-catch-divide-by-0.html (now §10.9.1 IEEE-754 form) ------------ +// https://github.com/web-platform-tests/wpt/blob/master/css/css-values/calc-catch-divide-by-0.html +test('WPT divide-by-zero: 100px / 0 → calc(infinity * 1px)', () => { + assert.equal(out('calc(100px / 0)'), 'calc(infinity * 1px)'); +}); + +test('WPT divide-by-zero: 100px / (2 - 2) → calc(infinity * 1px)', () => { + assert.equal(out('calc(100px / (2 - 2))'), 'calc(infinity * 1px)'); +}); + +// --- calc-typed-arithmetic-parsing (implied from spec §10.2) ------------- +describe('WPT typed arithmetic', () => { + test('WPT typed-arith: / ', () => { + assert.equal(out('calc(10px / 2px)'), 'calc(5)'); + }); + + test('WPT typed-arith: