diff --git a/.oxlintrc.json b/.oxlintrc.json index 5e07bbd..e8aed12 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -7,12 +7,24 @@ "rules": { "array-callback-return": "error", "complexity": "error", - "eqeqeq": ["error", "always", { "null": "ignore" }], + "eqeqeq": [ + "error", + "always", + { + "null": "ignore" + } + ], "guard-for-in": "error", + "import/no-cycle": "error", "import/no-duplicates": "error", "import/no-self-import": "error", "max-depth": "error", - "max-lines": ["error", { "max": 400 }], + "max-lines": [ + "error", + { + "max": 400 + } + ], "no-bitwise": "error", "no-case-declarations": "error", "no-duplicate-imports": "error", @@ -20,6 +32,7 @@ "no-fallthrough": "error", "no-labels": "error", "no-nested-ternary": "error", + "no-param-reassign": "error", "no-promise-executor-return": "error", "no-prototype-builtins": "error", "no-redeclare": "error", @@ -28,15 +41,32 @@ "no-shadow": "error", "no-template-curly-in-string": "error", "no-throw-literal": "error", - "no-underscore-dangle": ["error", { "allow": ["_default"] }], + "no-underscore-dangle": [ + "error", + { + "allow": ["_default"] + } + ], "no-unmodified-loop-condition": "error", "no-unnecessary-await": "error", "no-unreachable-loop": "error", "no-useless-assignment": "error", + "no-var": "error", "no-void": "error", + "object-shorthand": "error", "only-throw-error": "error", "oxc/no-accumulating-spread": "error", - "prefer-const": ["error", { "destructuring": "all" }], + "prefer-arrow-callback": "error", + "prefer-const": [ + "error", + { + "destructuring": "all" + } + ], + "prefer-numeric-literals": "error", + "prefer-object-has-own": "error", + "prefer-object-spread": "error", + "prefer-rest-params": "error", "preserve-caught-error": "error", "promise/always-return": "error", "promise/no-nesting": "error", @@ -45,22 +75,34 @@ "unicorn/consistent-function-scoping": "error", "unicorn/no-array-for-each": "error", "unicorn/no-array-reduce": "error", + "unicorn/no-array-reverse": "error", "unicorn/no-instanceof-array": "error", "unicorn/no-unnecessary-slice-end": "error", "unicorn/no-useless-fallback-in-spread": "error", "unicorn/no-useless-spread": "error", "unicorn/prefer-array-find": "error", "unicorn/prefer-array-flat": "error", + "unicorn/prefer-array-flat-map": "error", + "unicorn/prefer-array-index-of": "error", "unicorn/prefer-array-some": "error", + "unicorn/prefer-date-now": "error", + "unicorn/prefer-default-parameters": "error", + "unicorn/prefer-global-this": "error", "unicorn/prefer-includes": "error", + "unicorn/prefer-math-min-max": "error", "unicorn/prefer-math-trunc": "error", "unicorn/prefer-modern-math-apis": "error", "unicorn/prefer-negative-index": "error", "unicorn/prefer-node-protocol": "error", "unicorn/prefer-number-properties": "error", + "unicorn/prefer-object-from-entries": "error", + "unicorn/prefer-optional-catch-binding": "error", "unicorn/prefer-regexp-test": "error", "unicorn/prefer-set-has": "error", "unicorn/prefer-set-size": "error", + "unicorn/prefer-string-replace-all": "error", + "unicorn/prefer-string-starts-ends-with": "error", + "unicorn/prefer-string-trim-start-end": "error", "unicorn/prefer-type-error": "error" }, "env": { diff --git a/README.md b/README.md index 21f59a3..5d65725 100755 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ [PostCSS Calc] lets you reduce `calc()` references whenever it's possible. When an expression mixes units that cannot be combined exactly (such as `px` and `em`), or contains values only known at runtime (such as `var()`), the -unresolved part is left for the browser's [W3C calc() implementation]. +unresolved part is left for the browser's [CSS Values 4][css-values-4] +implementation. ## Installation @@ -69,9 +70,8 @@ reduceCalc('min(50px, calc(2 * 40px))'); // => 'calc(50px)' ``` -It accepts `precision`, `unwrapSingleValue`, the deprecated -`unwrapSingleNegativeNumber` alias, -`warnWhenCannotResolve`, `onParseError`, and `onWarn`: +It accepts `precision`, `unwrapSingleValue`, `warnWhenCannotResolve`, +`onParseError`, and `onWarn`: ```js const result = reduceCalc('calc(100% + var(--gap))', { @@ -191,20 +191,20 @@ Example: With `mediaQueries: true`, this becomes: ```css -@media (min-width: 200px) { +@media (min-width: calc(200px)) { div { width: 100px; } } ``` +Add `unwrapSingleValue: true` to get the bare value, `(min-width: 200px)`. + #### `selectors` (default: `false`) Reduces `calc()` functions found in selectors. Selectors do not accept -`calc()` functions, so the plugin replaces them with their reduced values. -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. +`calc()` functions, so the plugin replaces them with their reduced values, as +if `unwrapSingleValue` were enabled. ```js var out = postcss() @@ -224,8 +224,8 @@ With `selectors: true`, this becomes `div:nth-child(3)`. #### `onParseError` -Callback invoked when a `calc()` body fails to parse or simplify. Matches -[`@csstools/css-calc`][csstools-css-calc]'s shape: +Callback invoked with the error and the input value when a `calc()` body fails +to parse or simplify: ```js postcss().use( @@ -250,13 +250,13 @@ canonical-form decisions: - **Strict whitespace (§10.1).** `calc(2px+3px)` is invalid CSS (binary `+` / `-` require surrounding whitespace) and is preserved with a warning instead of reduced. -- **Canonical operand order.** Commutative operands serialize - numeric-first, matching [`@csstools/css-calc`][csstools-css-calc]: +- **Canonical operand order.** Sums serialize numbers first, then + dimensions in first-seen order, then unresolved terms: `calc(var(--foo) + 10px)` → `calc(10px + var(--foo))`. - **Zero buckets are kept.** `calc(100px - (100px - 100%))` → `calc(0px + 100%)`, not `100%` — [WPT calc-serialization-002][wpt-calc-serialization] requires the zero term because it carries the length-percentage type. -- **Constant folding.** `calc(43 + pi)` now folds to `46.14159` (§10.7.1). +- **Constant folding.** `calc(43 + pi)` now folds to `calc(46.14159)` (§10.7.1). Previously `pi` / `e` stayed symbolic. - **Distributive multiplication.** `calc(0.5 * (100vw - 10px))` becomes `calc(50vw - 5px)`. @@ -270,7 +270,6 @@ canonical-form decisions: if you want validation behavior. [css-values-4]: https://www.w3.org/TR/css-values-4/ -[csstools-css-calc]: https://www.npmjs.com/package/@csstools/css-calc [wpt-calc-serialization]: https://github.com/web-platform-tests/wpt/blob/master/css/css-values/calc-serialization-002.html [jison]: https://github.com/zaach/jison @@ -288,6 +287,7 @@ for the full guidelines. The project uses [pnpm](https://pnpm.io/). ```bash git clone git@github.com:postcss/postcss-calc.git +cd postcss-calc git checkout -b patch-1 pnpm install pnpm test @@ -302,23 +302,8 @@ when changing parsing/simplification behavior: pnpm test:corpus:full ``` -Performance changes to the parser, analyzer, simplifier, or serializer should be -checked with the benchmarks. `pnpm benchmark:arithmetic-chains` and -`pnpm benchmark:nested-fallbacks` compare the working tree against `HEAD` using -20 fresh-process paired blocks by default and write git-ignored schema-v2 -reports under `reports/benchmarks/`. Re-check a saved report with -`pnpm benchmark:reanalyze `. `pnpm benchmark:corpus` compares the whole -pipeline with `@csstools/css-calc` on real-world expressions and is -report-only. - -These benchmarks time only the parser (or, for the corpus, the whole reducer) -on one machine, and a `pass` means "no regression detected at the declared -margin", not "no change". Read [BENCHMARKS.md](BENCHMARKS.md) before -interpreting results. - -The PostCSS benchmark awaits `postcss().process(...)`, and that await already -triggers result stringification. It therefore does not add a redundant -`result.css` access. +Check performance changes with the benchmarks described in +[BENCHMARKS.md](BENCHMARKS.md), and read it before interpreting results. ## [Changelog](CHANGELOG.md) @@ -332,4 +317,3 @@ triggers result stringification. It therefore does not add a redundant [PostCSS Calc]: https://github.com/postcss/postcss-calc [PostCSS Custom Properties]: https://github.com/postcss/postcss-custom-properties [tests]: test/ -[W3C calc() implementation]: https://www.w3.org/TR/css3-values/#calc-notation diff --git a/scripts/README.md b/scripts/README.md index f32a2a1..32d4608 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -29,9 +29,23 @@ and software architecture, see [BENCHMARKS.md](../BENCHMARKS.md). from an observed effect during a run. - **`benchmark/compare-parser-benchmarks.js`** — reanalyzes one schema-v2 parser artifact and applies the uncertainty-aware runtime, slope, and growth gates. + The analysis is split across `benchmark/parser-analysis.js` (entry point and + sensitivity analysis), `parser-analysis-endpoints.js` (per-endpoint + intervals, process-order summaries), and `parser-analysis-verdicts.js` + (runtime, slope, and growth verdicts). +- **`benchmark/corpus-analysis.js`** — recomputes corpus decisions from raw + observations and checks stored summaries against them. Its helpers are + `corpus-group-results.js` (per-group paired ratios and bootstrap intervals) + and `corpus-decisions.js` (statistical and practical verdicts). +- **`benchmark/validate-corpus.js`** — validates corpus artifact structure. Its + helpers are `validate-corpus-metadata.js` (correctness and group metadata) + and `validate-corpus-measurements.js` (per-batch and per-measurement checks). - **`benchmark/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`. + public GitHub into `test/corpus/github/expressions.txt`. Its helpers live in + `harvest/`: `queries.js` (search phrases and languages), `github.js` (paced + `gh` search and fetch with rate-limit backoff), and `extract.js` (comment- and + string-aware `calc()` extraction). - **`split-corpus.js`** — splits that file into `github-pure.txt` (feeds `benchmark/benchmark-corpus.js`/`show-divergences.js` below), `preprocessor.txt`, and `invalid.txt` (the latter two are used by real CI resilience tests). diff --git a/scripts/benchmark/corpus-analysis.js b/scripts/benchmark/corpus-analysis.js index 5ccc7ad..426807a 100644 --- a/scripts/benchmark/corpus-analysis.js +++ b/scripts/benchmark/corpus-analysis.js @@ -1,248 +1,10 @@ -/* oxlint-disable no-bitwise */ -import { stableHash } from '../lib/corpus-policy.js'; import { - BOOTSTRAP_RESAMPLES, - bootstrapPairedReplicateInterval, - CORPUS_INTERVAL_METHOD, DECISION_CONFIG_VERSION, decisionConfigForArtifact, - median, - ordinaryInterval, validateSchemaV2Artifact, - variationMetrics, } from './benchmark.js'; - -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; -} - -export 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, - }; -} - -export 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, - }, - }; -} +import { analyzeCorpusObservations } from './corpus-decisions.js'; +import { groupResults } from './corpus-group-results.js'; /** Recompute corpus decisions from retained raw benchmark observations. */ export function analyzeCorpus(artifact) { @@ -311,26 +73,3 @@ function assertStoredValue(left, right, path) { throw new TypeError(`${path} does not match recomputed observations`); } } - -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/benchmark/corpus-benchmark.js b/scripts/benchmark/corpus-benchmark.js index dd897f8..0127269 100644 --- a/scripts/benchmark/corpus-benchmark.js +++ b/scripts/benchmark/corpus-benchmark.js @@ -1,5 +1,6 @@ export { CORPUS_EQUIVALENCE_MARGIN } from './config.js'; -export { groupResults, analyzeCorpus } from './corpus-analysis.js'; +export { groupResults } from './corpus-group-results.js'; +export { analyzeCorpus } from './corpus-analysis.js'; export { CORPUS_ESTIMAND, parseArgs, diff --git a/scripts/benchmark/corpus-decisions.js b/scripts/benchmark/corpus-decisions.js new file mode 100644 index 0000000..a1181ec --- /dev/null +++ b/scripts/benchmark/corpus-decisions.js @@ -0,0 +1,92 @@ +import { variationMetrics } from './benchmark.js'; + +export 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, + }; +} + +export 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, + }, + }; +} diff --git a/scripts/benchmark/corpus-group-results.js b/scripts/benchmark/corpus-group-results.js new file mode 100644 index 0000000..b66f8b6 --- /dev/null +++ b/scripts/benchmark/corpus-group-results.js @@ -0,0 +1,174 @@ +/* oxlint-disable no-bitwise */ +import { stableHash } from '../lib/corpus-policy.js'; +import { + BOOTSTRAP_RESAMPLES, + bootstrapPairedReplicateInterval, + CORPUS_INTERVAL_METHOD, + median, + ordinaryInterval, + variationMetrics, +} from './benchmark.js'; + +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 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/benchmark/corpus-runner.js b/scripts/benchmark/corpus-runner.js index f46ce97..f058bb6 100644 --- a/scripts/benchmark/corpus-runner.js +++ b/scripts/benchmark/corpus-runner.js @@ -22,7 +22,8 @@ import { seededShuffle, validateSchemaV2Artifact, } from './benchmark.js'; -import { analyzeCorpusObservations, groupResults } from './corpus-analysis.js'; +import { analyzeCorpusObservations } from './corpus-decisions.js'; +import { groupResults } from './corpus-group-results.js'; export const CORPUS_ESTIMAND = 'Relative total runtime over the fixed set of unique harvested expressions accepted equivalently by both implementations.'; diff --git a/scripts/benchmark/parser-analysis-endpoints.js b/scripts/benchmark/parser-analysis-endpoints.js new file mode 100644 index 0000000..b62a0fc --- /dev/null +++ b/scripts/benchmark/parser-analysis-endpoints.js @@ -0,0 +1,193 @@ +import { + logRatio, + median, + ordinaryInterval, + variationMetrics, +} from './benchmark.js'; +import { bootstrapRatioSummary, precisionSummary } from './parser-scaling.js'; + +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; +} + +export function pairedLogRatios(blocks, keys) { + const baseline = revisionResults(blocks, 'baseline'); + const candidate = revisionResults(blocks, 'candidate'); + return 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])); + }); +} + +export function initialEndpoint(blocks, key, logs) { + 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), + }; + endpoint.observedLogRatioSd = variationMetrics(logs).sd; + return endpoint; +} + +export function applyFamilyBootstrap( + endpoints, + familyBootstrap, + config, + blockCount +) { + 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, + blockCount, + index + ); + } +} + +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; +} + +export 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 = bootstrapRatioSummary(intervals, bootstrap); + 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 }; +} diff --git a/scripts/benchmark/parser-analysis-verdicts.js b/scripts/benchmark/parser-analysis-verdicts.js new file mode 100644 index 0000000..fe62cdc --- /dev/null +++ b/scripts/benchmark/parser-analysis-verdicts.js @@ -0,0 +1,42 @@ +export function growthVerdict(growth, threshold) { + let status = 'pass'; + if (growth.some((item) => item.candidateLowerRatio > threshold)) + status = 'regression'; + else if (growth.some((item) => item.candidateUpperRatio > threshold)) + status = 'inconclusive'; + return applyPrecision(status, growth); +} + +export function combineStatuses(runtimeStatus, slopeStatus, growthStatus) { + const statuses = [runtimeStatus, slopeStatus, growthStatus]; + if (statuses.includes('regression')) return 'regression'; + if (statuses.every((item) => item === 'pass')) return 'pass'; + return 'inconclusive'; +} +export function applyPrecision(status, endpoints) { + if (endpoints.length === 0) return status; + const precise = endpoints.every((endpoint) => endpoint.precision?.targetMet); + if (precise) return status; + return 'inconclusive'; +} + +export 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'; +} diff --git a/scripts/benchmark/parser-analysis.js b/scripts/benchmark/parser-analysis.js index adddf05..6ac2c4a 100644 --- a/scripts/benchmark/parser-analysis.js +++ b/scripts/benchmark/parser-analysis.js @@ -3,11 +3,7 @@ import { bootstrapStratifiedMaxT, decisionConfigForArtifact, DECISION_INTERVAL_METHOD, - logRatio, - median, MIN_VALID_BLOCKS, - ordinaryInterval, - variationMetrics, validateSchemaV2Artifact, } from './benchmark.js'; import { @@ -15,104 +11,20 @@ import { addSlopeIntervals, analyzeGrowth, addGrowthIntervals, - bootstrapRatioSummary, largestSizeKeys, - precisionSummary, } from './parser-scaling.js'; - -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 pairedLogRatios(blocks, keys) { - const baseline = revisionResults(blocks, 'baseline'); - const candidate = revisionResults(blocks, 'candidate'); - return 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])); - }); -} - -function initialEndpoint(blocks, key, logs) { - 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), - }; - endpoint.observedLogRatioSd = variationMetrics(logs).sd; - return endpoint; -} - -function applyFamilyBootstrap(endpoints, familyBootstrap, config, blockCount) { - 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, - blockCount, - index - ); - } -} - -function growthVerdict(growth, threshold) { - let status = 'pass'; - if (growth.some((item) => item.candidateLowerRatio > threshold)) - status = 'regression'; - else if (growth.some((item) => item.candidateUpperRatio > threshold)) - status = 'inconclusive'; - return applyPrecision(status, growth); -} - -function combineStatuses(runtimeStatus, slopeStatus, growthStatus) { - const statuses = [runtimeStatus, slopeStatus, growthStatus]; - if (statuses.includes('regression')) return 'regression'; - if (statuses.every((item) => item === 'pass')) return 'pass'; - return 'inconclusive'; -} +import { + applyFamilyBootstrap, + initialEndpoint, + pairedLogRatios, + processOrderEffect, +} from './parser-analysis-endpoints.js'; +import { + applyPrecision, + combineStatuses, + growthVerdict, + verdict, +} from './parser-analysis-verdicts.js'; function addSensitivity(result, artifact, blocks) { const structurallyValid = Array.isArray(artifact.attempts) @@ -252,134 +164,3 @@ function rejectionSummary(attempts) { 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 = bootstrapRatioSummary(intervals, bootstrap); - 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 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'; -} diff --git a/scripts/benchmark/parser-benchmark-worker.js b/scripts/benchmark/parser-benchmark-worker.js index 06d97de..b1e59db 100644 --- a/scripts/benchmark/parser-benchmark-worker.js +++ b/scripts/benchmark/parser-benchmark-worker.js @@ -47,7 +47,7 @@ function digest(value) { continue; } hash.update(`{${node.type ?? 'object'};`); - for (const key of Object.keys(node).sort().reverse()) { + for (const key of Object.keys(node).sort().toReversed()) { hash.update(`${key}:`); stack.push(node[key]); } diff --git a/scripts/benchmark/validate-corpus-measurements.js b/scripts/benchmark/validate-corpus-measurements.js new file mode 100644 index 0000000..8dbf5cf --- /dev/null +++ b/scripts/benchmark/validate-corpus-measurements.js @@ -0,0 +1,130 @@ +import { positiveFinite } from './validate-parser.js'; + +export function validateBatchMeasurements( + batch, + batchIndex, + replicate, + index, + state +) { + const { expectedGroups } = state; + 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); + validateCorpusMeasurement( + measurement, + replicate, + index, + state.config, + state.repetitionsByGroup, + state.checksumsByGroup, + state.allChecksumsByGroup + ); + } + if (seenGroups.size !== expectedGroups.length) + throw new TypeError( + `corpus replicate ${index} batch ${batchIndex} has missing groups` + ); +} + +function validateCorpusMeasurement( + measurement, + replicate, + index, + config, + repetitionsByGroup, + checksumsByGroup, + allChecksumsByGroup +) { + validateMeasurementRepetitions( + measurement, + replicate, + index, + repetitionsByGroup + ); + validateCalibrationSamples(measurement, index); + 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); + } +} + +function validateMeasurementRepetitions( + measurement, + replicate, + index, + repetitionsByGroup +) { + 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); +} + +function validateCalibrationSamples(measurement, index) { + 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` + ); +} diff --git a/scripts/benchmark/validate-corpus-metadata.js b/scripts/benchmark/validate-corpus-metadata.js new file mode 100644 index 0000000..845d115 --- /dev/null +++ b/scripts/benchmark/validate-corpus-metadata.js @@ -0,0 +1,70 @@ +import { + CORPUS_CATEGORIES, + NEUTRAL_CORPUS_CATEGORIES, +} from '../lib/corpus-policy.js'; + +export 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'); +} + +export 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; +} + +export 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 + ); +} diff --git a/scripts/benchmark/validate-corpus.js b/scripts/benchmark/validate-corpus.js index 9fb2154..4cc548d 100644 --- a/scripts/benchmark/validate-corpus.js +++ b/scripts/benchmark/validate-corpus.js @@ -1,14 +1,15 @@ -import { - CORPUS_CATEGORIES, - NEUTRAL_CORPUS_CATEGORIES, -} from '../lib/corpus-policy.js'; import { DECISION_CONFIG_VERSION, MIN_VALID_BLOCKS, migrateLegacyDecisionConfig, validateDecisionConfig, } from './config.js'; -import { positiveFinite } from './validate-parser.js'; +import { validateBatchMeasurements } from './validate-corpus-measurements.js'; +import { + corpusGroups, + isPermutation, + validateCorpusCorrectness, +} from './validate-corpus-metadata.js'; import { validateCorpusGroupSummaries } from './validate-corpus-summaries.js'; const ORDERS = ['ours-first', 'reference-first']; @@ -118,192 +119,3 @@ function validateReplicateBatches(replicate, index, state) { `corpus replicate ${index} has unbalanced process orders` ); } - -function validateBatchMeasurements(batch, batchIndex, replicate, index, state) { - const { expectedGroups } = state; - 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); - validateCorpusMeasurement( - measurement, - replicate, - index, - state.config, - state.repetitionsByGroup, - state.checksumsByGroup, - state.allChecksumsByGroup - ); - } - if (seenGroups.size !== expectedGroups.length) - throw new TypeError( - `corpus replicate ${index} batch ${batchIndex} has missing groups` - ); -} - -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 validateCorpusMeasurement( - measurement, - replicate, - index, - config, - repetitionsByGroup, - checksumsByGroup, - allChecksumsByGroup -) { - validateMeasurementRepetitions( - measurement, - replicate, - index, - repetitionsByGroup - ); - validateCalibrationSamples(measurement, index); - 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); - } -} - -function validateMeasurementRepetitions( - measurement, - replicate, - index, - repetitionsByGroup -) { - 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); -} - -function validateCalibrationSamples(measurement, index) { - 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` - ); -} diff --git a/scripts/harvest-github.js b/scripts/harvest-github.js index 8372e58..73f811c 100644 --- a/scripts/harvest-github.js +++ b/scripts/harvest-github.js @@ -2,11 +2,12 @@ // Phase 1: discover paths via diversifying queries (bypasses GH's // 1000-result-per-query cap). Phase 2: fetch raw content and extract // paren-balanced calc() bodies. -import { execFileSync } from 'node:child_process'; import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { setTimeout as delay } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; +import { extractCalcs } from './harvest/extract.js'; +import { fetchRaw, searchCode } from './harvest/github.js'; +import { FULL_QUERIES, LANGUAGES, PILOT_QUERIES } from './harvest/queries.js'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..'); const OUT_DIR = join(ROOT, 'test/corpus/github'); @@ -16,79 +17,6 @@ const STATE_FILE = join(OUT_DIR, '.harvest-state.json'); const args = new Set(process.argv.slice(2)); const PILOT = args.has('--pilot'); const SKIP_FETCH = args.has('--skip-fetch'); -const PILOT_QUERIES = [ - 'calc(100%', - 'calc(var(', - 'calc(100vh', - 'calc(min(', - 'calc(clamp(', -]; -const FULL_QUERIES = [ - 'calc(100%', - 'calc(50%', - 'calc(100vh', - 'calc(100vw', - 'calc(1px', - 'calc(2px', - 'calc(1em', - 'calc(1rem', - 'calc(0.5rem', - 'calc(1.5rem', - 'calc(1deg', - 'calc(1turn', - 'calc(1s', - 'calc(1fr', - 'calc(1ch', - 'calc(var(', - 'calc( var(', - 'calc(100% -', - 'calc(100% +', - 'calc(100% /', - 'calc(100% *', - 'calc(min(', - 'calc(max(', - 'calc(clamp(', - 'calc(round(', - 'calc(mod(', - 'calc(abs(', - 'calc(sin(', - 'calc(cos(', - 'calc(pow(', - 'calc(sqrt(', - 'calc(hypot(', - 'margin: calc(', - 'padding: calc(', - 'width: calc(', - 'height: calc(', - 'top: calc(', - 'left: calc(', - 'transform: translate(calc(', - 'grid-template-columns: calc(', - 'calc((', - 'calc(((', - 'calc( (', - 'calc( ((', - 'calc(calc(', - 'calc( calc(', - 'calc(0', - 'calc(1', - 'calc(2', - 'calc(3', - 'calc(4', - 'calc(5', - 'calc(6', - 'calc(7', - 'calc(8', - 'calc(9', - 'calc(.5', - 'calc(.25', - 'calc(-.', - 'calc(-1', - 'calc(-2', - 'calc(env(', - 'calc(attr(', -]; -const LANGUAGES = ['css', 'scss', 'less']; const QUERIES = PILOT ? PILOT_QUERIES : FULL_QUERIES; mkdirSync(FILES_DIR, { recursive: true }); function loadState() { @@ -105,196 +33,10 @@ function saveState(s) { writeFileSync(STATE_FILE, JSON.stringify(s, null, 2)); } const state = loadState(); -function gh(argv) { - // gh CLI is intentionally PATH-resolved — this is a CI/dev tool script. - // eslint-disable-next-line sonarjs/no-os-command-from-path - return execFileSync('gh', argv, { - encoding: 'utf8', - maxBuffer: 64 * 1024 * 1024, - stdio: ['ignore', 'pipe', 'pipe'], - }); -} -// GH code_search: 10 req/min. Pace at 7s; on 403 sleep until reset and retry. -const MIN_SEARCH_GAP_MS = 7000; -let lastSearchAt = 0; -async function paceSearch() { - const elapsed = Date.now() - lastSearchAt; - if (elapsed < MIN_SEARCH_GAP_MS) await delay(MIN_SEARCH_GAP_MS - elapsed); - lastSearchAt = Date.now(); -} -async function waitForSearchReset() { - try { - const j = JSON.parse(gh(['api', '/rate_limit'])); - const reset = j.resources?.code_search?.reset; - if (reset) { - const waitMs = Math.max(0, reset * 1000 - Date.now()) + 2000; - process.stderr.write( - ` rate-limit hit; sleeping ${Math.round(waitMs / 1000)}s until reset\n` - ); - await delay(waitMs); - } else { - await delay(60_000); - } - } catch { - await delay(60_000); - } -} -async function searchCode(query, language, limit = 100) { - // Wrap multi-word queries in literal quotes so GH treats them as a phrase. - const phrase = /[\s+\-/*:]/.test(query) ? `"${query}"` : query; - const argv = [ - 'search', - 'code', - phrase, - '--language', - language, - '--limit', - String(limit), - '--json', - 'repository,path', - ]; - for (let attempt = 0; attempt < 2; attempt++) { - await paceSearch(); - try { - return JSON.parse(gh(argv)); - } catch (err) { - const msg = err.message ?? ''; - if (/rate limit|HTTP 403|HTTP 429/i.test(msg)) { - await waitForSearchReset(); - continue; - } - process.stderr.write( - ` ! search failed for ${JSON.stringify(query)} lang=${language}: ${msg.split('\n')[0]}\n` - ); - return []; - } - } - return []; -} -// Pace fetches under 5000/hr core-API limit. 900ms ≈ 4000/hr with headroom. -const MIN_FETCH_GAP_MS = 900; -let lastFetchAt = 0; -async function paceFetch() { - const elapsed = Date.now() - lastFetchAt; - if (elapsed < MIN_FETCH_GAP_MS) await delay(MIN_FETCH_GAP_MS - elapsed); - lastFetchAt = Date.now(); -} -async function waitForCoreReset() { - try { - const j = JSON.parse(gh(['api', '/rate_limit'])); - const reset = j.resources?.core?.reset; - if (reset) { - const waitMs = Math.max(0, reset * 1000 - Date.now()) + 5000; - process.stderr.write( - ` core rate-limit hit; sleeping ${Math.round(waitMs / 1000)}s until reset\n` - ); - await delay(waitMs); - } else { - await delay(60_000); - } - } catch { - await delay(60_000); - } -} -async function fetchRaw(owner, repo, path) { - const argv = [ - 'api', - '-H', - 'Accept: application/vnd.github.raw', - `/repos/${owner}/${repo}/contents/${encodeURI(path)}`, - ]; - for (let attempt = 0; attempt < 3; attempt++) { - await paceFetch(); - try { - return gh(argv); - } catch (err) { - const msg = err.message ?? ''; - if (/rate limit|HTTP 403|HTTP 429/i.test(msg)) { - await waitForCoreReset(); - continue; - } - process.stderr.write( - ` ! fetch failed ${owner}/${repo}:${path}: ${msg.split('\n')[0]}\n` - ); - return null; - } - } - return null; -} -// Strip /* */ comments and quoted strings (replace with spaces to keep offsets). -function sanitize(src) { - let out = ''; - let i = 0; - while (i < src.length) { - const c = src[i]; - const n = src[i + 1]; - if (c === '/' && n === '*') { - const end = src.indexOf('*/', i + 2); - if (end === -1) { - out += ' '.repeat(src.length - i); - break; - } - out += ' '.repeat(end + 2 - i); - i = end + 2; - continue; - } - if (c === '"' || c === "'") { - const quote = c; - out += ' '; - i++; - while (i < src.length && src[i] !== quote) { - if (src[i] === '\\' && i + 1 < src.length) { - out += ' '; - i += 2; - continue; - } - out += ' '; - i++; - } - if (i < src.length) { - out += ' '; - i++; - } - continue; - } - out += c; - i++; - } - return out; -} -const CALC_RE = /(?:^|[^\w-])(?:-(?:webkit|moz|ms|o)-)?calc\(/gi; -function extractCalcs(src) { - const sanitized = sanitize(src); - const results = []; - let m; - CALC_RE.lastIndex = 0; - while ((m = CALC_RE.exec(sanitized)) !== null) { - const matchEnd = m.index + m[0].length; - const openParen = matchEnd - 1; - const before = sanitized.slice(0, openParen).toLowerCase(); - const calcStart = before.lastIndexOf('calc'); - if (calcStart === -1) continue; - let depth = 1; - let j = openParen + 1; - while (j < sanitized.length && depth > 0) { - const ch = sanitized[j]; - if (ch === '(') depth++; - else if (ch === ')') depth--; - j++; - } - if (depth !== 0) continue; - const expr = src.slice(calcStart, j); - const flat = expr.replace(/\s+/g, ' ').trim(); - if (flat.length > 2 && flat.length < 4096) results.push(flat); - // Continue scanning after the open paren so nested calc()s also match. - CALC_RE.lastIndex = matchEnd; - } - return results; -} function safeName(owner, repo, path) { return `${owner}__${repo}__${path}` - .replace(/[/\\]/g, '_') - .replace(/[^\w.-]/g, '_'); + .replaceAll(/[/\\]/g, '_') + .replaceAll(/[^\w.-]/g, '_'); } // Phase 1: discover paths. const discovered = new Map(); diff --git a/scripts/harvest/extract.js b/scripts/harvest/extract.js new file mode 100644 index 0000000..0601cd1 --- /dev/null +++ b/scripts/harvest/extract.js @@ -0,0 +1,70 @@ +// Strip /* */ comments and quoted strings (replace with spaces to keep offsets). +function sanitize(src) { + let out = ''; + let i = 0; + while (i < src.length) { + const c = src[i]; + const n = src[i + 1]; + if (c === '/' && n === '*') { + const end = src.indexOf('*/', i + 2); + if (end === -1) { + out += ' '.repeat(src.length - i); + break; + } + out += ' '.repeat(end + 2 - i); + i = end + 2; + continue; + } + if (c === '"' || c === "'") { + const quote = c; + out += ' '; + i++; + while (i < src.length && src[i] !== quote) { + if (src[i] === '\\' && i + 1 < src.length) { + out += ' '; + i += 2; + continue; + } + out += ' '; + i++; + } + if (i < src.length) { + out += ' '; + i++; + } + continue; + } + out += c; + i++; + } + return out; +} +const CALC_RE = /(?:^|[^\w-])(?:-(?:webkit|moz|ms|o)-)?calc\(/gi; +export function extractCalcs(src) { + const sanitized = sanitize(src); + const results = []; + let m; + CALC_RE.lastIndex = 0; + while ((m = CALC_RE.exec(sanitized)) !== null) { + const matchEnd = m.index + m[0].length; + const openParen = matchEnd - 1; + const before = sanitized.slice(0, openParen).toLowerCase(); + const calcStart = before.lastIndexOf('calc'); + if (calcStart === -1) continue; + let depth = 1; + let j = openParen + 1; + while (j < sanitized.length && depth > 0) { + const ch = sanitized[j]; + if (ch === '(') depth++; + else if (ch === ')') depth--; + j++; + } + if (depth !== 0) continue; + const expr = src.slice(calcStart, j); + const flat = expr.replaceAll(/\s+/g, ' ').trim(); + if (flat.length > 2 && flat.length < 4096) results.push(flat); + // Continue scanning after the open paren so nested calc()s also match. + CALC_RE.lastIndex = matchEnd; + } + return results; +} diff --git a/scripts/harvest/github.js b/scripts/harvest/github.js new file mode 100644 index 0000000..a891409 --- /dev/null +++ b/scripts/harvest/github.js @@ -0,0 +1,119 @@ +import { execFileSync } from 'node:child_process'; +import { setTimeout as delay } from 'node:timers/promises'; + +function gh(argv) { + // gh CLI is intentionally PATH-resolved — this is a CI/dev tool script. + // eslint-disable-next-line sonarjs/no-os-command-from-path + return execFileSync('gh', argv, { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} +// GH code_search: 10 req/min. Pace at 7s; on 403 sleep until reset and retry. +const MIN_SEARCH_GAP_MS = 7000; +let lastSearchAt = 0; +async function paceSearch() { + const elapsed = Date.now() - lastSearchAt; + if (elapsed < MIN_SEARCH_GAP_MS) await delay(MIN_SEARCH_GAP_MS - elapsed); + lastSearchAt = Date.now(); +} +async function waitForSearchReset() { + try { + const j = JSON.parse(gh(['api', '/rate_limit'])); + const reset = j.resources?.code_search?.reset; + if (reset) { + const waitMs = Math.max(0, reset * 1000 - Date.now()) + 2000; + process.stderr.write( + ` rate-limit hit; sleeping ${Math.round(waitMs / 1000)}s until reset\n` + ); + await delay(waitMs); + } else { + await delay(60_000); + } + } catch { + await delay(60_000); + } +} +export async function searchCode(query, language, limit = 100) { + // Wrap multi-word queries in literal quotes so GH treats them as a phrase. + const phrase = /[\s+\-/*:]/.test(query) ? `"${query}"` : query; + const argv = [ + 'search', + 'code', + phrase, + '--language', + language, + '--limit', + String(limit), + '--json', + 'repository,path', + ]; + for (let attempt = 0; attempt < 2; attempt++) { + await paceSearch(); + try { + return JSON.parse(gh(argv)); + } catch (err) { + const msg = err.message ?? ''; + if (/rate limit|HTTP 403|HTTP 429/i.test(msg)) { + await waitForSearchReset(); + continue; + } + process.stderr.write( + ` ! search failed for ${JSON.stringify(query)} lang=${language}: ${msg.split('\n')[0]}\n` + ); + return []; + } + } + return []; +} +// Pace fetches under 5000/hr core-API limit. 900ms ≈ 4000/hr with headroom. +const MIN_FETCH_GAP_MS = 900; +let lastFetchAt = 0; +async function paceFetch() { + const elapsed = Date.now() - lastFetchAt; + if (elapsed < MIN_FETCH_GAP_MS) await delay(MIN_FETCH_GAP_MS - elapsed); + lastFetchAt = Date.now(); +} +async function waitForCoreReset() { + try { + const j = JSON.parse(gh(['api', '/rate_limit'])); + const reset = j.resources?.core?.reset; + if (reset) { + const waitMs = Math.max(0, reset * 1000 - Date.now()) + 5000; + process.stderr.write( + ` core rate-limit hit; sleeping ${Math.round(waitMs / 1000)}s until reset\n` + ); + await delay(waitMs); + } else { + await delay(60_000); + } + } catch { + await delay(60_000); + } +} +export async function fetchRaw(owner, repo, path) { + const argv = [ + 'api', + '-H', + 'Accept: application/vnd.github.raw', + `/repos/${owner}/${repo}/contents/${encodeURI(path)}`, + ]; + for (let attempt = 0; attempt < 3; attempt++) { + await paceFetch(); + try { + return gh(argv); + } catch (err) { + const msg = err.message ?? ''; + if (/rate limit|HTTP 403|HTTP 429/i.test(msg)) { + await waitForCoreReset(); + continue; + } + process.stderr.write( + ` ! fetch failed ${owner}/${repo}:${path}: ${msg.split('\n')[0]}\n` + ); + return null; + } + } + return null; +} diff --git a/scripts/harvest/queries.js b/scripts/harvest/queries.js new file mode 100644 index 0000000..18248f8 --- /dev/null +++ b/scripts/harvest/queries.js @@ -0,0 +1,73 @@ +export const PILOT_QUERIES = [ + 'calc(100%', + 'calc(var(', + 'calc(100vh', + 'calc(min(', + 'calc(clamp(', +]; +export const FULL_QUERIES = [ + 'calc(100%', + 'calc(50%', + 'calc(100vh', + 'calc(100vw', + 'calc(1px', + 'calc(2px', + 'calc(1em', + 'calc(1rem', + 'calc(0.5rem', + 'calc(1.5rem', + 'calc(1deg', + 'calc(1turn', + 'calc(1s', + 'calc(1fr', + 'calc(1ch', + 'calc(var(', + 'calc( var(', + 'calc(100% -', + 'calc(100% +', + 'calc(100% /', + 'calc(100% *', + 'calc(min(', + 'calc(max(', + 'calc(clamp(', + 'calc(round(', + 'calc(mod(', + 'calc(abs(', + 'calc(sin(', + 'calc(cos(', + 'calc(pow(', + 'calc(sqrt(', + 'calc(hypot(', + 'margin: calc(', + 'padding: calc(', + 'width: calc(', + 'height: calc(', + 'top: calc(', + 'left: calc(', + 'transform: translate(calc(', + 'grid-template-columns: calc(', + 'calc((', + 'calc(((', + 'calc( (', + 'calc( ((', + 'calc(calc(', + 'calc( calc(', + 'calc(0', + 'calc(1', + 'calc(2', + 'calc(3', + 'calc(4', + 'calc(5', + 'calc(6', + 'calc(7', + 'calc(8', + 'calc(9', + 'calc(.5', + 'calc(.25', + 'calc(-.', + 'calc(-1', + 'calc(-2', + 'calc(env(', + 'calc(attr(', +]; +export const LANGUAGES = ['css', 'scss', 'less']; diff --git a/scripts/randomizer.js b/scripts/randomizer.js index 9f5c9d8..25e6869 100644 --- a/scripts/randomizer.js +++ b/scripts/randomizer.js @@ -52,7 +52,7 @@ function compare(input) { const ours = ourOut(input); const theirs = theirOut(input); if (ours === null && theirs === null) return { kind: 'both-threw' }; - if (ours === null) return { kind: 'we-threw', theirs: theirs }; + if (ours === null) return { kind: 'we-threw', theirs }; if (theirs === null) return { kind: 'they-threw', ours }; if (ours === theirs) return { kind: 'agree' }; // Re-feed csstools' output through our pipeline to absorb cosmetic noise. diff --git a/src/lib/function-analyzers.js b/src/lib/function-analyzers.js new file mode 100644 index 0000000..338618d --- /dev/null +++ b/src/lib/function-analyzers.js @@ -0,0 +1,119 @@ +import { ROUND_STRATEGIES } from './simplify/round.js'; +import { + addTypes, + failureType, + isFailure, + matchingArguments, + numberType, + unknownType, +} from './types.js'; + +/** + * @typedef {import('./node.js').Node} Node + * @typedef {import('./types.js').CalculationType} CalculationType + */ + +/** @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 @return {CalculationType} */ +function analyzeSign(args) { + return args.length === 1 ? numberType : failureType; +} + +/** @param {CalculationType[]} args @param {Node[]} nodes @return {CalculationType} */ +function analyzeRound(args, nodes) { + const strategy = nodes[0]; + const hasStrategy = + strategy?.type === 'Ident' && + ROUND_STRATEGIES.has(strategy.name.toLowerCase()); + const start = hasStrategy ? 1 : 0; + const valueCount = args.length - start; + if (valueCount === 1) { + const value = args[start]; + if (value.kind === 'dimension') return failureType; + if (value.kind === 'unknown') return unknownType; + return numberType; + } + if (valueCount !== 2) return failureType; + const type = addTypes(args[start], args[start + 1]); + return isFailure(type) ? failureType : type; +} + +/** @param {CalculationType[]} args @return {CalculationType} */ +function analyzeAtan2(args) { + const type = matchingArguments(args, 2, 2); + if (isFailure(type)) return type; + // The type table gives atan2() «["angle" → 1]»; an unresolved result is + // plain unknown, never the percentage of its arguments. + if (type.kind === 'unknown') return unknownType; + return { kind: 'dimension', base: 'angle' }; +} + +/** @param {CalculationType[]} args @return {CalculationType} */ +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; + let valueCount = 0; + /** @type {CalculationType} */ let result = failureType; + for (let index = 0; index < args.length; index++) { + if (isClampKeyword(nodes[index], index)) continue; + result = valueCount === 0 ? args[index] : addTypes(result, args[index]); + valueCount++; + } + if (valueCount < 1 || isFailure(result)) return failureType; + return result; +} + +export { + analyzeAtan2, + analyzeCalc, + analyzeClamp, + analyzeIdentity, + analyzeInverseTrig, + analyzeRound, + analyzeSign, + analyzeTrig, + isClampKeyword, + isRoundStrategy, +}; diff --git a/src/lib/functions.js b/src/lib/functions.js index 9440eb8..df39268 100644 --- a/src/lib/functions.js +++ b/src/lib/functions.js @@ -3,7 +3,7 @@ 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 { simplifyRound } from './simplify/round.js'; import { simplifyTrig } from './simplify/trig.js'; import { simplifyInverseTrig } from './simplify/inverse-trig.js'; import { simplifyAtan2 } from './simplify/atan2.js'; @@ -12,186 +12,36 @@ import { simplifySqrt } from './simplify/sqrt.js'; import { simplifyExp } from './simplify/exp.js'; import { simplifyLog } from './simplify/log.js'; import { simplifyHypot } from './simplify/hypot.js'; +import { + analyzeAtan2, + analyzeCalc, + analyzeClamp, + analyzeIdentity, + analyzeInverseTrig, + analyzeRound, + analyzeSign, + analyzeTrig, + isClampKeyword, + isRoundStrategy, +} from './function-analyzers.js'; +import { + addTypes, + failureType, + isFailure, + isPercentage, + matchingArguments, + numberArguments, + numberType, + percentageType, + unknownType, +} from './types.js'; /** @typedef {import('./node.js').Node} Node */ -/** - * `percent` marks a value that resolves in the same percentage context as its - * peers (a pure percentage). It is only produced at leaves, by abs(), and by - * homogeneous sums and calls — never by a product, where an unpaired - * percentage could no longer cancel against anything. - * @typedef {{kind: 'number'} | {kind: 'dimension', base: string | null} | {kind: 'unknown', percent?: true} | {kind: 'failure'}} CalculationType - */ +/** @typedef {import('./types.js').CalculationType} CalculationType */ /** @typedef {(name: string, args: Node[]) => Node} MathSimplifier */ /** @typedef {(args: CalculationType[], nodes: Node[]) => CalculationType} TypeAnalyzer */ /** @typedef {{analyze: TypeAnalyzer, simplify?: MathSimplifier, isKeyword?: (node: Node, index: number) => boolean, calculation?: boolean}} MathFunction */ -/** @type {CalculationType} */ const numberType = { kind: 'number' }; -/** @type {CalculationType} */ const unknownType = { kind: 'unknown' }; -/** @type {CalculationType} */ const percentageType = { - kind: 'unknown', - percent: true, -}; -/** @type {CalculationType} */ const failureType = { kind: 'failure' }; - -/** @param {CalculationType} type @return {boolean} */ -function isFailure(type) { - return type.kind === 'failure'; -} - -/** @param {CalculationType} type @return {boolean} */ -function isPercentage(type) { - return type.kind === 'unknown' && type.percent === true; -} - -/** @param {CalculationType} a @param {CalculationType} b @return {CalculationType} */ -function addTypes(a, b) { - if (isFailure(a) || isFailure(b)) return failureType; - if (a.kind === 'unknown' || b.kind === 'unknown') { - // A pure percentage keeps its contextual type so a surrounding product - // can cancel `% / %`; mixing it with any other opaque operand loses the - // guarantee that it resolves in the same context as its peers. A known - // number + percentage sum is likewise kept unknown and valid: the spec - // resolves the percentage against its surrounding context, which this - // coarse type model does not track. - return isPercentage(a) && isPercentage(b) ? percentageType : unknownType; - } - if (a.kind === 'number' && b.kind === 'number') return numberType; - if (a.kind === 'dimension' && b.kind === 'dimension') { - if (a.base === null || b.base === null) return unknownType; - 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; - let hasUnknown = false; - for (const arg of args) { - if (arg.kind === 'dimension') return failureType; - hasUnknown = hasUnknown || arg.kind === 'unknown'; - } - return hasUnknown ? 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 (let index = 1; index < args.length; index++) { - result = addTypes(result, args[index]); - 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 @return {CalculationType} */ -function analyzeSign(args) { - return args.length === 1 ? numberType : failureType; -} - -/** @param {CalculationType[]} args @param {Node[]} nodes @return {CalculationType} */ -function analyzeRound(args, nodes) { - const strategy = nodes[0]; - const hasStrategy = - strategy?.type === 'Ident' && - ROUND_STRATEGIES.has(strategy.name.toLowerCase()); - const start = hasStrategy ? 1 : 0; - const valueCount = args.length - start; - if (valueCount === 1) { - const value = args[start]; - if (value.kind === 'dimension') return failureType; - if (value.kind === 'unknown') return unknownType; - return numberType; - } - if (valueCount !== 2) return failureType; - const type = addTypes(args[start], args[start + 1]); - return isFailure(type) ? failureType : type; -} - -/** @param {CalculationType[]} args @return {CalculationType} */ -function analyzeAtan2(args) { - const type = matchingArguments(args, 2, 2); - if (isFailure(type)) return type; - // The type table gives atan2() «["angle" → 1]»; an unresolved result is - // plain unknown, never the percentage of its arguments. - if (type.kind === 'unknown') return unknownType; - return { kind: 'dimension', base: 'angle' }; -} - -/** @param {CalculationType[]} args @return {CalculationType} */ -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; - let valueCount = 0; - /** @type {CalculationType} */ let result = failureType; - for (let index = 0; index < args.length; index++) { - if (isClampKeyword(nodes[index], index)) continue; - result = valueCount === 0 ? args[index] : addTypes(result, args[index]); - valueCount++; - } - if (valueCount < 1 || isFailure(result)) return failureType; - return result; -} - const mathFunctions = new Map( /** @type {[string, MathFunction][]} */ ([ ['calc', { analyze: analyzeCalc, calculation: true }], diff --git a/src/lib/parser/opaque.js b/src/lib/parser/opaque.js index 1d53222..3a75f2c 100644 --- a/src/lib/parser/opaque.js +++ b/src/lib/parser/opaque.js @@ -71,10 +71,11 @@ function customProperty(tokens, start, end) { /** * Append the component tree of `[start, end)` to `root` and return it. * Callers seed `root` so leading components need no copy or spread. - * @param {ParseInput} input @param {number} start @param {number} end @param {ParseRange} parseRange @param {OpaqueComponent[]} root @return {OpaqueComponent[]} + * @param {ParseInput} input @param {number} start @param {number} rangeEnd @param {ParseRange} parseRange @param {OpaqueComponent[]} root @return {OpaqueComponent[]} */ -function componentTree(input, start, end, parseRange, root) { +function componentTree(input, start, rangeEnd, parseRange, root) { /** @type {OpaqueComponent[]} */ let tree = root; + let end = rangeEnd; /** @type {{parent: OpaqueComponent[], tree: OpaqueComponent[], close: number, end: number}[]} */ const frames = []; const { tokens } = input; diff --git a/src/lib/serialize.js b/src/lib/serialize.js index 15b8169..96665ea 100644 --- a/src/lib/serialize.js +++ b/src/lib/serialize.js @@ -13,10 +13,10 @@ import { } from './serialize/precision.js'; import { emitCall, + emitLeadingNeg, emitOpaqueCall, emitMathResult, emitNode, - emitLeadingNeg, } from './serialize/expression.js'; /** diff --git a/src/lib/serialize/expression.js b/src/lib/serialize/expression.js index 324dd2c..e4f567d 100644 --- a/src/lib/serialize/expression.js +++ b/src/lib/serialize/expression.js @@ -1,62 +1,37 @@ // Spec: https://www.w3.org/TR/css-values-4/#serialize-a-calculation-tree -// Canonical AST expression emission: operator precedence, parenthesization, -// sum terms, product factors, and function calls. +// Canonical AST expression emission: node dispatch, sums, products, function +// calls, and math results. These emitters recurse into each other, so they +// share one module; precision and precedence rules live in sibling modules. -import { serializeComponents } from '../opaque.js'; import { num } from '../node.js'; +import { serializeComponents } from '../opaque.js'; import { - round, - isDegenerate, degenerateKeyword, - roundedScalarValue, + emitBareSignedZero, emitRoundedScalar, emitScalar, emitSignedZero, + isDegenerate, isScalar, isSignedZero, - isEffectivelyNegative, + round, + roundedScalarValue, } from './precision.js'; +import { + needsParentheses, + PRODUCT_PRECEDENCE, + SUM_PRECEDENCE, + UNARY_PRECEDENCE, +} from './precedence.js'; /** * @typedef {import('../node.js').Node} Node * @typedef {import('../node.js').Sum} Sum - * @typedef {import('../node.js').Product} Product + * @typedef {import('../node.js').SumTerm} SumTerm * @typedef {import('../node.js').ProductFactor} ProductFactor * @typedef {import('./precision.js').SerializeSession} SerializeSession */ -// The AST is canonical: sums and products are flat, except grouped nodes that -// keep their parentheses, so these precedence levels cover every binary expression -const SUM_PRECEDENCE = 1; -const PRODUCT_PRECEDENCE = 2; -const ATOMIC_PRECEDENCE = 3; -// Negation (-1 * ...) binds more tightly than a sum but has the same atomic boundary -// for deciding whether the operand needs parentheses. -const UNARY_PRECEDENCE = ATOMIC_PRECEDENCE; - -/** @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; -} - -/** - * @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) || - (node.type === 'Product' && node.grouped === true && parentPrecedence > 0) - ); -} - /** * @param {Node} node * @param {SerializeSession} session @@ -105,129 +80,88 @@ function emitNodeBody(node, session) { emitSum(node, session); return; case 'Product': - emitProduct(node, session); + emitProductFactors(node.factors, session); return; } } -/** - * @param {import('../node.js').Call} node - * @param {SerializeSession} session - * @param {string} [callNameOverride] - * @return {void} - */ -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); - } - buffer.push(')'); -} - -/** - * @param {import('../node.js').OpaqueCall} node - * @param {SerializeSession} 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 - * @param {number | false} precision - * @return {1 | -1} - */ -function termSign(term, multiplier, precision) { - let sign = /** @type {1 | -1} */ (term.sign * multiplier); - if (isEffectivelyNegative(term.node, precision)) { - sign = /** @type {1 | -1} */ (-sign); - } - return sign; -} - -/** - * @param {import('../node.js').SumTerm} term - * @param {SerializeSession} session - * @param {1 | -1} sign - * @return {void} - */ -function emitSumTerm(term, session, sign) { - if (sign === 1) { - emitNode(term.node, session, SUM_PRECEDENCE, true); - } else { - emitLeadingNeg(term.node, session); - } -} - /** * @param {import('../node.js').Num | import('../node.js').Dim} termNode * @param {1 | -1} sign - * @param {number} i + * @param {boolean} leading * @param {SerializeSession} session * @return {void} */ -function emitScalarSumTerm(termNode, sign, i, session) { +function emitScalarSumTerm(termNode, sign, leading, session) { const buffer = session.buffer; const effectiveVal = sign * termNode.value; if (Object.is(effectiveVal, -0)) { - if (i > 0) buffer.push(' + '); - emitSignedZero(buffer, termNode); + // `x - 0` reparses as x + -(0), so subtraction round-trips negative zero. + // A leading term starts a , so `-1 * 0` needs no calc(). + if (leading) { + emitBareSignedZero(buffer, termNode); + } else { + buffer.push(' - '); + emitRoundedScalar(termNode, buffer, 0); + } return; } if (isDegenerate(effectiveVal)) { - if (i === 0 && sign === -1) buffer.push('-'); - else if (i > 0) buffer.push(sign === 1 ? ' + ' : ' - '); + if (!leading) buffer.push(sign === 1 ? ' + ' : ' - '); + else if (sign === -1) buffer.push('-'); emitScalar(termNode, session); return; } const rounded = round(effectiveVal, session.precision); if (rounded < 0) { - buffer.push(i === 0 ? '-' : ' - '); + buffer.push(leading ? '-' : ' - '); emitRoundedScalar(termNode, buffer, -rounded); } else { - if (i > 0) buffer.push(' + '); + if (!leading) buffer.push(' + '); emitRoundedScalar(termNode, buffer, rounded); } } /** - * @param {import('../node.js').SumTerm[]} terms + * @param {SumTerm} term + * @param {boolean} leading * @param {SerializeSession} session - * @param {1 | -1} [multiplier] * @return {void} */ -function emitSumTerms(terms, session, multiplier = 1) { - const buffer = session.buffer; - for (let i = 0; i < terms.length; i++) { - const term = terms[i]; - const termNode = term.node; - const sign = /** @type {1 | -1} */ (term.sign * multiplier); - if (isScalar(termNode)) { - emitScalarSumTerm(termNode, sign, i, session); - continue; - } - if (i === 0) { - emitSumTerm(term, session, sign); - } else { - buffer.push(sign === 1 ? ' + ' : ' - '); - emitNode(termNode, session, SUM_PRECEDENCE, true); - } +function emitSumTerm(term, leading, session) { + const termNode = term.node; + if (isScalar(termNode)) { + emitScalarSumTerm(termNode, term.sign, leading, session); + } else if (leading && term.sign === -1) { + emitLeadingNeg(termNode, session); + } else { + if (!leading) session.buffer.push(term.sign === 1 ? ' + ' : ' - '); + emitNode(termNode, session, SUM_PRECEDENCE, true); } } -/** @param {Sum} sum @param {SerializeSession} session @return {void} */ +/** @param {SumTerm} term @return {boolean} */ +function isNegativeZeroTerm(term) { + return isScalar(term.node) && Object.is(term.sign * term.node.value, -0); +} + +/** + * @param {Sum} sum + * @param {SerializeSession} session + * @return {void} + */ function emitSum(sum, session) { - emitSumTerms(sum.terms, session); + const terms = sum.terms; + // x + -0 is exactly x for every x, so leading negative zeros can move + // behind the first other term and use the shorter subtraction form. When + // every term is negative zero, keep the order so reducing again is stable. + let lead = 0; + while (lead < terms.length && isNegativeZeroTerm(terms[lead])) lead++; + if (lead === terms.length) lead = 0; + emitSumTerm(terms[lead], true, session); + for (let i = 0; i < terms.length; i++) { + if (i !== lead) emitSumTerm(terms[i], false, session); + } } /** @@ -269,34 +203,64 @@ function emitProductFactors( coefficientNode ) { const buffer = session.buffer; - let first = true; + // Only the product's first multiplied scalar may print negative zero as a + // bare `-1 * 0`: reparsing folds the `-1` back into that same coefficient. + // After any operator, the `-1` would bind to the preceding operand instead. + let leading = true; if (coefficientValue !== undefined && coefficientValue !== 1) { - emitScalar( - /** @type {import('../node.js').Num} */ (coefficientNode), - session, - coefficientValue + const coefficient = /** @type {import('../node.js').Num} */ ( + coefficientNode ); - first = false; + emitScalar(coefficient, session, coefficientValue, true); + leading = 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; + const multiplied = factor.exponent === 1; + if (!leading) buffer.push(multiplied ? ' * ' : ' / '); + else if (!multiplied) buffer.push('1 / '); + if (isScalar(factorNode)) { + emitScalar(factorNode, session, undefined, leading && multiplied); } else { - buffer.push(factor.exponent === 1 ? ' * ' : ' / '); - if (isScalar(factorNode)) emitScalar(factorNode, session); - else emitNode(factorNode, session, PRODUCT_PRECEDENCE); + emitNode(factorNode, session, PRODUCT_PRECEDENCE); } + leading = false; + } +} + +/** + * @param {import('../node.js').Call} node + * @param {SerializeSession} session + * @param {string} [callNameOverride] + * @return {void} + */ +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(', '); + const arg = node.args[i]; + // Every math-function argument is a , so it needs no calc() + if (isSignedZero(arg)) emitBareSignedZero(buffer, arg); + else emitNode(arg, session); } + buffer.push(')'); } -/** @param {Product} product @param {SerializeSession} session @return {void} */ -function emitProduct(product, session) { - emitProductFactors(product.factors, session); +/** + * @param {import('../node.js').OpaqueCall} node + * @param {SerializeSession} 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(')'); } /** @@ -356,21 +320,10 @@ function emitNestedMathResult(node, session, buffer = session.buffer) { } export { - SUM_PRECEDENCE, - PRODUCT_PRECEDENCE, - ATOMIC_PRECEDENCE, - UNARY_PRECEDENCE, - precedence, - needsParentheses, emitNode, + emitLeadingNeg, emitCall, emitOpaqueCall, - termSign, - emitSumTerms, - emitSum, - emitLeadingNeg, - emitProductFactors, - emitProduct, emitMathResult, emitNestedMathResult, }; diff --git a/src/lib/serialize/precedence.js b/src/lib/serialize/precedence.js new file mode 100644 index 0000000..779a143 --- /dev/null +++ b/src/lib/serialize/precedence.js @@ -0,0 +1,47 @@ +// Spec: https://www.w3.org/TR/css-values-4/#serialize-a-calculation-tree +// Operator precedence and parenthesization rules for canonical AST emission. + +/** + * @typedef {import('../node.js').Node} Node + */ + +// The AST is canonical: sums and products are flat, except grouped nodes that +// keep their parentheses, so these precedence levels cover every binary expression +const SUM_PRECEDENCE = 1; +const PRODUCT_PRECEDENCE = 2; +const ATOMIC_PRECEDENCE = 3; +// Negation (-1 * ...) binds more tightly than a sum but has the same atomic boundary +// for deciding whether the operand needs parentheses. +const UNARY_PRECEDENCE = ATOMIC_PRECEDENCE; + +/** @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; +} + +/** + * @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) || + (node.type === 'Product' && node.grouped === true && parentPrecedence > 0) + ); +} + +export { + SUM_PRECEDENCE, + PRODUCT_PRECEDENCE, + ATOMIC_PRECEDENCE, + UNARY_PRECEDENCE, + precedence, + needsParentheses, +}; diff --git a/src/lib/serialize/precision.js b/src/lib/serialize/precision.js index e3ce967..4924093 100644 --- a/src/lib/serialize/precision.js +++ b/src/lib/serialize/precision.js @@ -168,13 +168,16 @@ function emitFiniteScalar(node, session, value) { * @param {import('../node.js').Num | import('../node.js').Dim} node * @param {SerializeSession} session * @param {number} [value] + * @param {boolean} [bare] emit negative zero without its calc() wrapper * @return {void} */ -function emitScalar(node, session, value) { +function emitScalar(node, session, value, bare = false) { const buffer = session.buffer; const effective = value ?? node.value; - if (Object.is(effective, -0)) emitSignedZero(buffer, node); - else if (isDegenerate(effective)) { + if (Object.is(effective, -0)) { + if (bare) emitBareSignedZero(buffer, node); + else emitSignedZero(buffer, node); + } else if (isDegenerate(effective)) { if (node.type === 'Dim') { buffer.push( 'calc(', @@ -195,8 +198,21 @@ function emitScalar(node, session, value) { * @return {void} */ function emitSignedZero(buffer, node) { + buffer.push('calc('); + emitBareSignedZero(buffer, node); + buffer.push(')'); +} + +/** + * Negative zero as an unwrapped product, valid only where a `` + * may appear without changing how the surrounding expression parses. + * @param {string[]} buffer + * @param {import('../node.js').Num | import('../node.js').Dim} node + * @return {void} + */ +function emitBareSignedZero(buffer, node) { const unit = node.type === 'Dim' ? (node.rawUnit ?? node.unit) : ''; - buffer.push('calc(-1 * 0', unit, ')'); + buffer.push('-1 * 0', unit); } /** @param {Node} node @return {node is import('../node.js').Num | import('../node.js').Dim} */ @@ -209,22 +225,6 @@ function isSignedZero(node) { return isScalar(node) ? Object.is(node.value, -0) : false; } -/** - * Whether a scalar node is strictly negative after precision rounding - * (excluding signed zero and sub-precision values that round to zero). - * @param {Node} node - * @param {number | false} precision - * @return {node is import('../node.js').Num | import('../node.js').Dim} - */ -function isEffectivelyNegative(node, precision) { - return ( - isScalar(node) && - !Object.is(node.value, -0) && - Number.isFinite(node.value) && - round(node.value, precision) < 0 - ); -} - export { NOISE_FLOOR, divideByPowerOfTen, @@ -238,7 +238,7 @@ export { emitFiniteScalar, emitScalar, emitSignedZero, + emitBareSignedZero, isScalar, isSignedZero, - isEffectivelyNegative, }; diff --git a/src/lib/simplify/product-barrier.js b/src/lib/simplify/product-barrier.js new file mode 100644 index 0000000..225efe5 --- /dev/null +++ b/src/lib/simplify/product-barrier.js @@ -0,0 +1,55 @@ +import { num } from '../node.js'; + +/** + * @typedef {import('../node.js').Node} Node + * @typedef {import('../node.js').ProductFactor} ProductFactor + */ + +/** + * A substitution function (`var()`, `env()`, `attr()`, ...) is replaced by + * tokens, not by a value, so factors cannot move or cancel across it. Any + * function the parser does not recognise (`anchor()`, `foo()`, ...) is also an + * OpaqueCall and is treated the same, conservatively. A grouped Product is a + * parenthesized substitution and is equally opaque. + * @param {Node} node + * @return {boolean} + */ +function isBarrier(node) { + return ( + node.type === 'OpaqueCall' || + (node.type === 'Product' && node.grouped === true) + ); +} + +/** + * Flatten a simplified factor through nested ungrouped Products and the + * canonical negation form, so barriers are visible at the top level. + * @param {ProductFactor[]} out + * @param {1 | -1} exponent + * @param {Node} node + * @return {boolean} Whether a barrier was pushed. + */ +function flattenFactor(out, exponent, node) { + if (node.type === 'Product' && !node.grouped) { + let barrier = false; + for (const inner of node.factors) { + if ( + flattenFactor( + out, + /** @type {1 | -1} */ (exponent * inner.exponent), + inner.node + ) + ) + barrier = true; + } + return barrier; + } + if (node.type === 'Sum' && node.terms.length === 1) { + out.push({ exponent, node: num(-1) }); + return flattenFactor(out, exponent, node.terms[0].node); + } + out.push({ exponent, node }); + return isBarrier(node); +} + +export { flattenFactor, isBarrier }; diff --git a/src/lib/simplify/product-quotient.js b/src/lib/simplify/product-quotient.js new file mode 100644 index 0000000..def3d77 --- /dev/null +++ b/src/lib/simplify/product-quotient.js @@ -0,0 +1,141 @@ +import { mkProduct, num, dim } from '../node.js'; +import { isExact } from './exact.js'; + +/** + * @typedef {import('../node.js').Node} Node + * @typedef {import('../node.js').ProductFactor} ProductFactor + */ + +/** + * @param {number} a non-negative + * @param {number} b non-negative + * @return {number} + */ +function gcd(a, b) { + let x = a; + let y = b; + while (y !== 0) { + [x, y] = [y, x % y]; + } + return x || 1; +} + +/** + * @param {{exponent: 1 | -1, value: number}[]} chain + * @return {number} + */ +function chainValue(chain) { + let value = 1; + for (const f of chain) { + value = f.exponent === 1 ? value * f.value : value / f.value; + } + return value; +} + +/** + * Whether the opaque factors are a single Sum of only Num/Dim terms. + * @param {ProductFactor[]} opaque + * @return {boolean} + */ +function isScalarSum(opaque) { + return ( + opaque.length === 1 && + opaque[0].exponent === 1 && + opaque[0].node.type === 'Sum' && + opaque[0].node.terms.every( + (t) => t.node.type === 'Num' || t.node.type === 'Dim' + ) + ); +} + +/** + * Whether a folded quotient prints exactly. A quotient distributed over a + * scalar Sum is judged term by term, so `(3px + 6em) / 3` still folds. + * @param {number} value + * @param {ProductFactor[]} opaque + * @param {boolean} distributable + * @param {number | false} precision + * @return {boolean} + */ +function foldsExactly(value, opaque, distributable, precision) { + if (!Number.isFinite(value) || isExact(value, precision)) { + return true; + } + if (!distributable) { + return false; + } + const sum = /** @type {import('../node.js').Sum} */ (opaque[0].node); + return sum.terms.every((t) => + isExact( + value * + /** @type {import('../node.js').Num | import('../node.js').Dim} */ ( + t.node + ).value, + precision + ) + ); +} + +/** + * Emit an inexact quotient as `numerator * dims * opaque / denominator`, with + * integer parts reduced by their gcd. A lone Dim absorbs the numerator. + * @param {number} numerator + * @param {number} denominator + * @param {{value: number, unit: string, rawUnit?: string} | null} loneDim + * @param {{exponent: 1 | -1, value: number, unit: string, rawUnit?: string}[]} dims + * @param {ProductFactor[]} opaque + * @param {number | false} precision + * @return {Node | null} null when a part would be rounded on output + */ +function rationalProduct( + numerator, + denominator, + loneDim, + dims, + opaque, + precision +) { + // Drop float noise (`1.9999999999999993 * 100`) so the parts print as + // written and the gcd below can reduce them. + let n = Number( + (loneDim === null ? numerator : numerator * loneDim.value).toPrecision(15) + ); + let d = Number(denominator.toPrecision(15)); + if (!isExact(n, precision) || !isExact(d, precision)) { + return null; + } + if (Number.isSafeInteger(n) && Number.isSafeInteger(d)) { + const g = gcd(Math.abs(n), Math.abs(d)); + n /= g; + d /= g; + } + if (d < 0) { + n = -n; + d = -d; + } + /** @type {ProductFactor[]} */ + const factors = []; + if (loneDim !== null) { + factors.push({ + exponent: 1, + node: dim(n, loneDim.unit, loneDim.rawUnit), + }); + } else { + if (n !== 1) { + factors.push({ exponent: 1, node: num(n) }); + } + for (const dm of dims) { + factors.push({ + exponent: dm.exponent, + node: dim(dm.value, dm.unit, dm.rawUnit), + }); + } + } + factors.push(...opaque); + if (d !== 1) { + factors.push({ exponent: -1, node: num(d) }); + } + return mkProduct(factors); +} + +export { chainValue, foldsExactly, isScalarSum, rationalProduct }; diff --git a/src/lib/simplify/product.js b/src/lib/simplify/product.js index dd2455a..dd84c7f 100644 --- a/src/lib/simplify/product.js +++ b/src/lib/simplify/product.js @@ -1,6 +1,12 @@ import { mkSum, mkProduct, mkGroup, num, dim } from '../node.js'; import { tryCancelPair } from './cancel.js'; -import { isExact } from './exact.js'; +import { flattenFactor, isBarrier } from './product-barrier.js'; +import { + chainValue, + foldsExactly, + isScalarSum, + rationalProduct, +} from './product-quotient.js'; /** * @typedef {import('../node.js').Node} Node @@ -9,136 +15,6 @@ import { isExact } from './exact.js'; * @typedef {import('../simplify.js').SimplifyFn} SimplifyFn */ -/** - * @param {number} a non-negative - * @param {number} b non-negative - * @return {number} - */ -function gcd(a, b) { - while (b !== 0) { - [a, b] = [b, a % b]; - } - return a || 1; -} - -/** - * @param {{exponent: 1 | -1, value: number}[]} chain - * @return {number} - */ -function chainValue(chain) { - let value = 1; - for (const f of chain) { - value = f.exponent === 1 ? value * f.value : value / f.value; - } - return value; -} - -/** - * Whether the opaque factors are a single Sum of only Num/Dim terms. - * @param {ProductFactor[]} opaque - * @return {boolean} - */ -function isScalarSum(opaque) { - return ( - opaque.length === 1 && - opaque[0].exponent === 1 && - opaque[0].node.type === 'Sum' && - opaque[0].node.terms.every( - (t) => t.node.type === 'Num' || t.node.type === 'Dim' - ) - ); -} - -/** - * Whether a folded quotient prints exactly. A quotient distributed over a - * scalar Sum is judged term by term, so `(3px + 6em) / 3` still folds. - * @param {number} value - * @param {ProductFactor[]} opaque - * @param {boolean} distributable - * @param {number | false} precision - * @return {boolean} - */ -function foldsExactly(value, opaque, distributable, precision) { - if (!Number.isFinite(value) || isExact(value, precision)) { - return true; - } - if (!distributable) { - return false; - } - const sum = /** @type {import('../node.js').Sum} */ (opaque[0].node); - return sum.terms.every((t) => - isExact( - value * - /** @type {import('../node.js').Num | import('../node.js').Dim} */ ( - t.node - ).value, - precision - ) - ); -} - -/** - * Emit an inexact quotient as `numerator * dims * opaque / denominator`, with - * integer parts reduced by their gcd. A lone Dim absorbs the numerator. - * @param {number} numerator - * @param {number} denominator - * @param {{value: number, unit: string, rawUnit?: string} | null} loneDim - * @param {{exponent: 1 | -1, value: number, unit: string, rawUnit?: string}[]} dims - * @param {ProductFactor[]} opaque - * @param {number | false} precision - * @return {Node | null} null when a part would be rounded on output - */ -function rationalProduct( - numerator, - denominator, - loneDim, - dims, - opaque, - precision -) { - // Drop float noise (`1.9999999999999993 * 100`) so the parts print as - // written and the gcd below can reduce them. - let n = Number( - (loneDim === null ? numerator : numerator * loneDim.value).toPrecision(15) - ); - let d = Number(denominator.toPrecision(15)); - if (!isExact(n, precision) || !isExact(d, precision)) { - return null; - } - if (Number.isSafeInteger(n) && Number.isSafeInteger(d)) { - const g = gcd(Math.abs(n), Math.abs(d)); - n /= g; - d /= g; - } - if (d < 0) { - n = -n; - d = -d; - } - /** @type {ProductFactor[]} */ - const factors = []; - if (loneDim !== null) { - factors.push({ - exponent: 1, - node: dim(n, loneDim.unit, loneDim.rawUnit), - }); - } else { - if (n !== 1) { - factors.push({ exponent: 1, node: num(n) }); - } - for (const dm of dims) { - factors.push({ - exponent: dm.exponent, - node: dim(dm.value, dm.unit, dm.rawUnit), - }); - } - } - factors.push(...opaque); - if (d !== 1) { - factors.push({ exponent: -1, node: num(d) }); - } - return mkProduct(factors); -} - /** * Fold a run of factors that contains no substitution barrier. * @param {ProductFactor[]} items Simplified factors, flattened by the caller. @@ -266,53 +142,6 @@ function foldFactors(items, start, end, simplify, precision) { return mkProduct(factors); } -/** - * A substitution function (`var()`, `env()`, `attr()`, ...) is replaced by - * tokens, not by a value, so factors cannot move or cancel across it. Any - * function the parser does not recognise (`anchor()`, `foo()`, ...) is also an - * OpaqueCall and is treated the same, conservatively. A grouped Product is a - * parenthesized substitution and is equally opaque. - * @param {Node} node - * @return {boolean} - */ -function isBarrier(node) { - return ( - node.type === 'OpaqueCall' || - (node.type === 'Product' && node.grouped === true) - ); -} - -/** - * Flatten a simplified factor through nested ungrouped Products and the - * canonical negation form, so barriers are visible at the top level. - * @param {ProductFactor[]} out - * @param {1 | -1} exponent - * @param {Node} node - * @return {boolean} Whether a barrier was pushed. - */ -function flattenFactor(out, exponent, node) { - if (node.type === 'Product' && !node.grouped) { - let barrier = false; - for (const inner of node.factors) { - if ( - flattenFactor( - out, - /** @type {1 | -1} */ (exponent * inner.exponent), - inner.node - ) - ) - barrier = true; - } - return barrier; - } - if (node.type === 'Sum' && node.terms.length === 1) { - out.push({ exponent, node: num(-1) }); - return flattenFactor(out, exponent, node.terms[0].node); - } - out.push({ exponent, node }); - return isBarrier(node); -} - /** * @param {Product} product * @param {SimplifyFn} simplify diff --git a/src/lib/types.js b/src/lib/types.js new file mode 100644 index 0000000..c1aa08c --- /dev/null +++ b/src/lib/types.js @@ -0,0 +1,94 @@ +/** + * `percent` marks a value that resolves in the same percentage context as its + * peers (a pure percentage). It is only produced at leaves, by abs(), and by + * homogeneous sums and calls — never by a product, where an unpaired + * percentage could no longer cancel against anything. + * @typedef {{kind: 'number'} | {kind: 'dimension', base: string | null} | {kind: 'unknown', percent?: true} | {kind: 'failure'}} CalculationType + */ + +/** @type {CalculationType} */ const numberType = { kind: 'number' }; +/** @type {CalculationType} */ const unknownType = { kind: 'unknown' }; +/** @type {CalculationType} */ const percentageType = { + kind: 'unknown', + percent: true, +}; +/** @type {CalculationType} */ const failureType = { kind: 'failure' }; + +/** @param {CalculationType} type @return {boolean} */ +function isFailure(type) { + return type.kind === 'failure'; +} + +/** @param {CalculationType} type @return {boolean} */ +function isPercentage(type) { + return type.kind === 'unknown' && type.percent === true; +} + +/** @param {CalculationType} a @param {CalculationType} b @return {CalculationType} */ +function addTypes(a, b) { + if (isFailure(a) || isFailure(b)) return failureType; + if (a.kind === 'unknown' || b.kind === 'unknown') { + // A pure percentage keeps its contextual type so a surrounding product + // can cancel `% / %`; mixing it with any other opaque operand loses the + // guarantee that it resolves in the same context as its peers. A known + // number + percentage sum is likewise kept unknown and valid: the spec + // resolves the percentage against its surrounding context, which this + // coarse type model does not track. + return isPercentage(a) && isPercentage(b) ? percentageType : unknownType; + } + if (a.kind === 'number' && b.kind === 'number') return numberType; + if (a.kind === 'dimension' && b.kind === 'dimension') { + if (a.base === null || b.base === null) return unknownType; + 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; + let hasUnknown = false; + for (const arg of args) { + if (arg.kind === 'dimension') return failureType; + hasUnknown = hasUnknown || arg.kind === 'unknown'; + } + return hasUnknown ? 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 (let index = 1; index < args.length; index++) { + result = addTypes(result, args[index]); + if (isFailure(result)) return failureType; + } + return result; +} + +export { + addTypes, + failureType, + isFailure, + isPercentage, + matchingArguments, + numberArguments, + numberType, + percentageType, + unknownType, +}; diff --git a/test/conformance/csstools-exponential.test.js b/test/conformance/csstools-exponential.test.js index a0cc273..718c3b9 100644 --- a/test/conformance/csstools-exponential.test.js +++ b/test/conformance/csstools-exponential.test.js @@ -25,7 +25,7 @@ describe('csstools exponential functions', () => { 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, '') + out('pow(8, 1 / 3)').replaceAll(/^calc\(|\)$/g, '') ); assert.ok(Math.abs(got - 2) < 1e-9, `got ${got}`); }); diff --git a/test/helpers/testValue.js b/test/helpers/testValue.js index 9bc1c5e..3c510d7 100644 --- a/test/helpers/testValue.js +++ b/test/helpers/testValue.js @@ -8,15 +8,12 @@ const reduceCalc = (opts = {}) => require('../../src/index.js')(opts); const postcssOpts = { from: undefined }; export function testValue(fixture, expected, opts = {}) { - fixture = `foo{bar:${fixture}}`; - expected = `foo{bar:${expected}}`; + const source = `foo{bar:${fixture}}`; + const expectedCss = `foo{bar:${expected}}`; return async () => { - const result = await postcss(reduceCalc(opts)).process( - fixture, - postcssOpts - ); - assert.strictEqual(result.css, expected); + const result = await postcss(reduceCalc(opts)).process(source, postcssOpts); + assert.strictEqual(result.css, expectedCss); }; } diff --git a/test/integration/css-units.test.js b/test/integration/css-units.test.js index 2ca27da..1231f95 100644 --- a/test/integration/css-units.test.js +++ b/test/integration/css-units.test.js @@ -156,7 +156,7 @@ describe('Unknown units', () => { describe('Mixed units', () => { test( 'should keep zero with for mixed units for the type info (cssnano#211)', - testValue('calc(99.99% * 1/1 - 0rem)', 'calc(99.99% + calc(-1 * 0rem))') + testValue('calc(99.99% * 1/1 - 0rem)', 'calc(99.99% - 0rem)') ); test( diff --git a/test/integration/math-constants-precision.test.js b/test/integration/math-constants-precision.test.js new file mode 100644 index 0000000..3d49979 --- /dev/null +++ b/test/integration/math-constants-precision.test.js @@ -0,0 +1,130 @@ +import { describe, test } from 'node:test'; +import { testValue } from '../helpers/testValue.js'; + +describe('Math edge cases', () => { + test( + 'should not throw an exception when attempting to divide by zero', + // `/0` → `infinity` (§10.13); previously threw. + testValue('calc(500px/0)', 'calc(infinity * 1px)') + ); + + test( + 'should not throw an exception when attempting to divide by unit (#1)', + // dim / dim → unitless number; previously threw. + testValue('calc(500px/2px)', 'calc(250)') + ); +}); + +describe('Math constants', () => { + test( + 'should calculate e', + // fold `e` (§10.7.1). + testValue('calc(e)', 'calc(2.71828)') + ); + + test( + 'should ignore multiplication with infinity', + // spec-style spaces around `*`. + testValue('calc(infinity * 1px)', 'calc(infinity * 1px)') + ); + + test( + 'should ignore addition with infinity', + testValue('calc(infinity + 1px)', 'calc(infinity + 1px)') + ); + + test( + 'should perform multiplication with pi (§10.7.1)', + testValue('calc(1px * pi)', 'calc(3.14159px)') + ); + + test( + 'should perform addition with pi (§10.7.1)', + testValue('calc(43 + pi)', 'calc(46.14159)') + ); +}); + +describe('Precision', () => { + test( + 'should handle precision correctly (1)', + testValue('calc(1/100)', 'calc(.01)') + ); + + test( + 'should handle precision correctly (2)', + testValue('calc(5/1000000)', 'calc(.000005)') + ); + + test( + 'should handle precision correctly (3)', + testValue('calc(5/1000000)', 'calc(.000005)', { precision: 6 }) + ); + + test( + 'should keep a value smaller than the precision instead of rounding it to zero', + testValue('calc(1/1000000)', 'calc(.000001)') + ); + + test( + 'should keep a dimension smaller than the precision', + testValue('calc(1px/1000000)', 'calc(.000001px)') + ); + + test( + 'should keep a negative value smaller than the precision', + testValue('calc(-1/1000000)', 'calc(-.000001)') + ); + + test( + 'should keep the ratio between two values smaller than the precision', + testValue('calc(2/1000000)', 'calc(.000002)') + ); + + test( + 'should limit a value smaller than the precision to that many significant digits', + testValue('calc(.00000033333333 + 0)', 'calc(3.3333e-7)') + ); + + test( + 'should still round float noise down to zero', + testValue('calc(0.1px + 0.2px - 0.3px)', 'calc(0px)') + ); + + test( + 'should fold exact cancellation with large operands to zero, not a phantom', + testValue('calc(0.07px * 1e7 - 700000px)', 'calc(0px)') + ); + test('precision for calc', testValue('calc(100% / 3 * 3)', 'calc(100%)')); + + test( + 'precision for nested calc', + testValue('calc(calc(100% / 3) * 3)', 'calc(100%)') + ); + + test( + 'accurate midpoint rounding for dimensions', + testValue('calc(1.005px)', 'calc(1.01px)', { precision: 2 }) + ); + + test( + 'accurate midpoint rounding for negative dimensions', + testValue('calc(-1.005px)', 'calc(-1.01px)', { precision: 2 }) + ); + + test( + 'precision false retains lossless float for division', + testValue('calc(1/3)', 'calc(.3333333333333333)', { precision: false }) + ); + + test( + 'division of an opaque term is kept at default precision', + testValue('calc(var(--x) / 3)', 'calc(var(--x) / 3)') + ); + + test( + 'division of an opaque term is kept at precision false', + testValue('calc(var(--x) / 3)', 'calc(var(--x) / 3)', { + precision: false, + }) + ); +}); diff --git a/test/integration/math-operations.test.js b/test/integration/math-operations.test.js index ffb8025..ec2fb72 100644 --- a/test/integration/math-operations.test.js +++ b/test/integration/math-operations.test.js @@ -202,131 +202,3 @@ describe('Minus', () => { test('minus sign (#4)', testValue('calc(200px / -1)', 'calc(-200px)')); }); - -describe('Math edge cases', () => { - test( - 'should not throw an exception when attempting to divide by zero', - // `/0` → `infinity` (§10.13); previously threw. - testValue('calc(500px/0)', 'calc(infinity * 1px)') - ); - - test( - 'should not throw an exception when attempting to divide by unit (#1)', - // dim / dim → unitless number; previously threw. - testValue('calc(500px/2px)', 'calc(250)') - ); -}); - -describe('Math constants', () => { - test( - 'should calculate e', - // fold `e` (§10.7.1). - testValue('calc(e)', 'calc(2.71828)') - ); - - test( - 'should ignore multiplication with infinity', - // spec-style spaces around `*`. - testValue('calc(infinity * 1px)', 'calc(infinity * 1px)') - ); - - test( - 'should ignore addition with infinity', - testValue('calc(infinity + 1px)', 'calc(infinity + 1px)') - ); - - test( - 'should perform multiplication with pi (§10.7.1)', - testValue('calc(1px * pi)', 'calc(3.14159px)') - ); - - test( - 'should perform addition with pi (§10.7.1)', - testValue('calc(43 + pi)', 'calc(46.14159)') - ); -}); - -describe('Precision', () => { - test( - 'should handle precision correctly (1)', - testValue('calc(1/100)', 'calc(.01)') - ); - - test( - 'should handle precision correctly (2)', - testValue('calc(5/1000000)', 'calc(.000005)') - ); - - test( - 'should handle precision correctly (3)', - testValue('calc(5/1000000)', 'calc(.000005)', { precision: 6 }) - ); - - test( - 'should keep a value smaller than the precision instead of rounding it to zero', - testValue('calc(1/1000000)', 'calc(.000001)') - ); - - test( - 'should keep a dimension smaller than the precision', - testValue('calc(1px/1000000)', 'calc(.000001px)') - ); - - test( - 'should keep a negative value smaller than the precision', - testValue('calc(-1/1000000)', 'calc(-.000001)') - ); - - test( - 'should keep the ratio between two values smaller than the precision', - testValue('calc(2/1000000)', 'calc(.000002)') - ); - - test( - 'should limit a value smaller than the precision to that many significant digits', - testValue('calc(.00000033333333 + 0)', 'calc(3.3333e-7)') - ); - - test( - 'should still round float noise down to zero', - testValue('calc(0.1px + 0.2px - 0.3px)', 'calc(0px)') - ); - - test( - 'should fold exact cancellation with large operands to zero, not a phantom', - testValue('calc(0.07px * 1e7 - 700000px)', 'calc(0px)') - ); - test('precision for calc', testValue('calc(100% / 3 * 3)', 'calc(100%)')); - - test( - 'precision for nested calc', - testValue('calc(calc(100% / 3) * 3)', 'calc(100%)') - ); - - test( - 'accurate midpoint rounding for dimensions', - testValue('calc(1.005px)', 'calc(1.01px)', { precision: 2 }) - ); - - test( - 'accurate midpoint rounding for negative dimensions', - testValue('calc(-1.005px)', 'calc(-1.01px)', { precision: 2 }) - ); - - test( - 'precision false retains lossless float for division', - testValue('calc(1/3)', 'calc(.3333333333333333)', { precision: false }) - ); - - test( - 'division of an opaque term is kept at default precision', - testValue('calc(var(--x) / 3)', 'calc(var(--x) / 3)') - ); - - test( - 'division of an opaque term is kept at precision false', - testValue('calc(var(--x) / 3)', 'calc(var(--x) / 3)', { - precision: false, - }) - ); -}); diff --git a/test/property/algebraic-round.test.js b/test/property/algebraic-round.test.js index 3c45411..cfdb3fb 100644 --- a/test/property/algebraic-round.test.js +++ b/test/property/algebraic-round.test.js @@ -17,24 +17,21 @@ import { finiteNum, positiveNum } from '../helpers/arbitraries.js'; const evalRound = (strategy, x, b) => numeric(out(call('round', [ident(strategy), x, b]))); +const strategyArb = fc.constantFrom('nearest', 'up', 'down', 'to-zero'); + +const assertForEachStrategy = (check) => + fc.assert(fc.property(strategyArb, finiteNum, positiveNum, check), { + numRuns: NUM_RUNS, + }); + // --- round laws ---------------------------------------------------------- test('law: round is idempotent on the same step — round(round(x, B), B) ≡ round(x, B)', () => { - fc.assert( - fc.property( - fc.constantFrom('nearest', 'up', 'down', 'to-zero'), - finiteNum, - positiveNum, - (strategy, x, b) => { - const inner = call('round', [ident(strategy), x, b]); - const once = out(inner); - const twice = out( - call('round', [ident(strategy), num(numeric(once)), b]) - ); - return once === twice; - } - ), - { numRuns: NUM_RUNS } - ); + assertForEachStrategy((strategy, x, b) => { + const inner = call('round', [ident(strategy), x, b]); + const once = out(inner); + const twice = out(call('round', [ident(strategy), num(numeric(once)), b])); + return once === twice; + }); }); describe('round() laws', () => { @@ -66,35 +63,19 @@ describe('round() laws', () => { }); test('law: round result is on the B-grid — (result / B) is integer', () => { - fc.assert( - fc.property( - fc.constantFrom('nearest', 'up', 'down', 'to-zero'), - finiteNum, - positiveNum, - (strategy, x, b) => { - const r = evalRound(strategy, x, b); - const q = r / b.value; - // Allow tiny FP drift: integer means q ≡ round(q) within EPSILON. - return Math.abs(q - Math.round(q)) < 1e-9; - } - ), - { numRuns: NUM_RUNS } - ); + assertForEachStrategy((strategy, x, b) => { + const r = evalRound(strategy, x, b); + const q = r / b.value; + // Allow tiny FP drift: integer means q ≡ round(q) within EPSILON. + return Math.abs(q - Math.round(q)) < 1e-9; + }); }); test('law: round result is within B of A — |round(x, B) − x| ≤ B', () => { - fc.assert( - fc.property( - fc.constantFrom('nearest', 'up', 'down', 'to-zero'), - finiteNum, - positiveNum, - (strategy, x, b) => { - const r = evalRound(strategy, x, b); - return Math.abs(r - x.value) <= b.value + 1e-9; - } - ), - { numRuns: NUM_RUNS } - ); + assertForEachStrategy((strategy, x, b) => { + const r = evalRound(strategy, x, b); + return Math.abs(r - x.value) <= b.value + 1e-9; + }); }); test('law: nearest minimizes |result − x| (with tie → upper)', () => { diff --git a/test/property/fuzz.test.js b/test/property/fuzz.test.js index 097eaf1..7c28d1f 100644 --- a/test/property/fuzz.test.js +++ b/test/property/fuzz.test.js @@ -101,7 +101,7 @@ function mutateString(s, op, pos, replChar) { return s.slice(0, i) + s[i + 1] + s[i] + s.slice(i + 2); } default: - return s.replace(/[()]/g, ''); // strip all parens + return s.replaceAll(/[()]/g, ''); // strip all parens } } const mutatedCorpus = fc diff --git a/test/property/properties.test.js b/test/property/properties.test.js index a0b677f..41c4ad3 100644 --- a/test/property/properties.test.js +++ b/test/property/properties.test.js @@ -18,6 +18,7 @@ import { astArbWithDegenerate, numericAstArb, } from '../helpers/arbitraries.js'; +import reduceCalc from 'postcss-calc/reduce'; import { parseSource } from '../helpers/parse-source.js'; import { num, mkProduct, mkSum, negate } from '../../src/lib/node.js'; const NUM_RUNS = 500; @@ -52,6 +53,17 @@ for (const [label, arb] of [ ); }); } +// --- Reducer idempotence ------------------------------------------------- +// Reducing the reducer's own output must not change it. +test('property: reduceCalc(reduceCalc(x)) === reduceCalc(x)', () => { + fc.assert( + fc.property(astArb(4), (ast) => { + const once = reduceCalc(`calc(${str(ast)})`); + return reduceCalc(once) === once; + }), + { numRuns: NUM_RUNS } + ); +}); // --- Analysis/simplification contract ----------------------------------- // Analysis summarizes the original tree, while simplification may refine // coarse unknown types. It must not invalidate a valid tree, change a known diff --git a/test/property/substitution.test.js b/test/property/substitution.test.js index 168a1b1..ba31a4a 100644 --- a/test/property/substitution.test.js +++ b/test/property/substitution.test.js @@ -44,8 +44,8 @@ const { expr } = fc.letrec((tie) => ({ */ function evaluate(value, tokens) { const expression = value - .replace(/calc\(/g, '(') - .replace(/var\(--([abc])\)/g, (_, name) => tokens[name]); + .replaceAll('calc(', '(') + .replaceAll(/var\(--([abc])\)/g, (_, name) => tokens[name]); return Function(`"use strict"; return ${expression};`)(); } @@ -57,7 +57,7 @@ test('property: reduced expressions evaluate like their token substitution', () const expected = evaluate(source, tokens); if ( !Number.isFinite(expected) || - /[a-z]/.test(output.replace(/calc\(|var\(--[abc]\)/g, '')) + /[a-z]/.test(output.replaceAll(/calc\(|var\(--[abc]\)/g, '')) ) { return; // degenerate result, serialized as a keyword } diff --git a/test/unit/corpus-selection.test.js b/test/unit/corpus-selection.test.js index 1284402..3df142b 100644 --- a/test/unit/corpus-selection.test.js +++ b/test/unit/corpus-selection.test.js @@ -20,7 +20,7 @@ const FIXTURE = [ describe('corpus selection:', () => { test('corpus selection: is deterministic and retains grouped subtraction shapes', () => { const first = selectCorpusExpressions(FIXTURE, 20); - const second = selectCorpusExpressions([...FIXTURE].reverse(), 20); + const second = selectCorpusExpressions([...FIXTURE].toReversed(), 20); assert.deepEqual(first, second); assert.equal(first.total, FIXTURE.length); assert.equal(first.eligible, FIXTURE.length - 2); diff --git a/test/unit/node-product-negate.test.js b/test/unit/node-product-negate.test.js new file mode 100644 index 0000000..4531a24 --- /dev/null +++ b/test/unit/node-product-negate.test.js @@ -0,0 +1,123 @@ +// Unit tests for node.ts — AST constructors and canonical-form invariants. +// Every invariant the rest of the pipeline relies on is asserted here +// directly, not through the parser or simplifier. +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + num, + dim, + ident, + mkSum, + mkProduct, + negate, +} from '../../src/lib/node.js'; + +describe('mkProduct:', () => { + test('mkProduct: zero factors collapses to Num(1)', () => { + assert.deepEqual(mkProduct([]), { type: 'Num', value: 1 }); + }); + + test('mkProduct: single positive-exponent factor unwraps', () => { + const result = mkProduct([{ exponent: 1, node: dim(2, 'px') }]); + assert.deepEqual(result, { type: 'Dim', value: 2, unit: 'px' }); + }); + + test('mkProduct: single negative-exponent factor stays as Product', () => { + const result = mkProduct([{ exponent: -1, node: dim(2, 'px') }]); + assert.equal(result.type, 'Product'); + }); + + test('mkProduct: factor of Num(1) is dropped', () => { + const result = mkProduct([ + { exponent: 1, node: num(1) }, + { exponent: 1, node: ident('x') }, + ]); + assert.deepEqual(result, { type: 'Ident', name: 'x' }); + }); + + test('mkProduct: nested Product flattens with exponent composition', () => { + const inner = mkProduct([ + { exponent: 1, node: ident('a') }, + { exponent: -1, node: ident('b') }, + ]); + const outer = mkProduct([ + { exponent: -1, node: inner }, + { exponent: 1, node: ident('c') }, + ]); + // outer_exp=-1 multiplied with inner exponents: a becomes -1, b becomes +1. + assert.deepEqual(outer, { + type: 'Product', + factors: [ + { exponent: -1, node: { type: 'Ident', name: 'a' } }, + { exponent: 1, node: { type: 'Ident', name: 'b' } }, + { exponent: 1, node: { type: 'Ident', name: 'c' } }, + ], + }); + }); +}); + +describe('negate', () => { + test('negate: Num flips value sign', () => { + assert.deepEqual(negate(num(5)), { type: 'Num', value: -5 }); + }); + + test('negate: Dim flips value sign', () => { + assert.deepEqual(negate(dim(5, 'px')), { + type: 'Dim', + value: -5, + unit: 'px', + }); + }); + + test('negate: double-negation is an identity transform', () => { + assert.deepEqual(negate(negate(num(5))), { type: 'Num', value: 5 }); + assert.deepEqual(negate(negate(ident('x'))), { type: 'Ident', name: 'x' }); + }); + + test('negate: ident wraps in single-term negative Sum', () => { + assert.deepEqual(negate(ident('x')), { + type: 'Sum', + terms: [{ sign: -1, node: { type: 'Ident', name: 'x' } }], + }); + }); + + test('negate: multi-term Sum flips every term`s sign (and re-normalizes leaves)', () => { + const s = mkSum([ + { sign: 1, node: num(5) }, + { sign: 1, node: ident('x') }, + ]); + assert.deepEqual(negate(s), { + type: 'Sum', + terms: [ + { sign: 1, node: { type: 'Num', value: -5 } }, + { sign: -1, node: { type: 'Ident', name: 'x' } }, + ], + }); + }); + + test('negate: grouped multi-term Sum preserves the group', () => { + const s = /** @type {import('../../src/lib/node.js').Sum} */ ({ + ...mkSum([ + { sign: 1, node: ident('a') }, + { sign: -1, node: ident('b') }, + ]), + grouped: true, + }); + assert.deepEqual(negate(s), { + type: 'Sum', + terms: [{ sign: -1, node: s }], + }); + }); + + test('negate: Product wraps in single-term negative Sum', () => { + const p = mkProduct([ + { exponent: 1, node: ident('a') }, + { exponent: 1, node: ident('b') }, + ]); + const result = negate(p); + assert.equal(result.type, 'Sum'); + const terms = result.terms; + assert.equal(terms.length, 1); + assert.equal(terms[0].sign, -1); + }); +}); diff --git a/test/unit/node.test.js b/test/unit/node.test.js index 93fed97..397764e 100644 --- a/test/unit/node.test.js +++ b/test/unit/node.test.js @@ -10,8 +10,6 @@ import { call, opaqueCall, mkSum, - mkProduct, - negate, } from '../../src/lib/node.js'; test('opaqueCall: stores the component tree on the canonical node', () => { @@ -201,114 +199,3 @@ describe('mkSum:', () => { assert.deepEqual(result, { type: 'Num', value: 5 }); }); }); - -// --- mkProduct canonical form -------------------------------------------- -describe('mkProduct:', () => { - test('mkProduct: zero factors collapses to Num(1)', () => { - assert.deepEqual(mkProduct([]), { type: 'Num', value: 1 }); - }); - - test('mkProduct: single positive-exponent factor unwraps', () => { - const result = mkProduct([{ exponent: 1, node: dim(2, 'px') }]); - assert.deepEqual(result, { type: 'Dim', value: 2, unit: 'px' }); - }); - - test('mkProduct: single negative-exponent factor stays as Product', () => { - const result = mkProduct([{ exponent: -1, node: dim(2, 'px') }]); - assert.equal(result.type, 'Product'); - }); - - test('mkProduct: factor of Num(1) is dropped', () => { - const result = mkProduct([ - { exponent: 1, node: num(1) }, - { exponent: 1, node: ident('x') }, - ]); - assert.deepEqual(result, { type: 'Ident', name: 'x' }); - }); - - test('mkProduct: nested Product flattens with exponent composition', () => { - const inner = mkProduct([ - { exponent: 1, node: ident('a') }, - { exponent: -1, node: ident('b') }, - ]); - const outer = mkProduct([ - { exponent: -1, node: inner }, - { exponent: 1, node: ident('c') }, - ]); - // outer_exp=-1 multiplied with inner exponents: a becomes -1, b becomes +1. - assert.deepEqual(outer, { - type: 'Product', - factors: [ - { exponent: -1, node: { type: 'Ident', name: 'a' } }, - { exponent: 1, node: { type: 'Ident', name: 'b' } }, - { exponent: 1, node: { type: 'Ident', name: 'c' } }, - ], - }); - }); -}); - -describe('negate', () => { - test('negate: Num flips value sign', () => { - assert.deepEqual(negate(num(5)), { type: 'Num', value: -5 }); - }); - - test('negate: Dim flips value sign', () => { - assert.deepEqual(negate(dim(5, 'px')), { - type: 'Dim', - value: -5, - unit: 'px', - }); - }); - - test('negate: double-negation is an identity transform', () => { - assert.deepEqual(negate(negate(num(5))), { type: 'Num', value: 5 }); - assert.deepEqual(negate(negate(ident('x'))), { type: 'Ident', name: 'x' }); - }); - - test('negate: ident wraps in single-term negative Sum', () => { - assert.deepEqual(negate(ident('x')), { - type: 'Sum', - terms: [{ sign: -1, node: { type: 'Ident', name: 'x' } }], - }); - }); - - test('negate: multi-term Sum flips every term`s sign (and re-normalizes leaves)', () => { - const s = mkSum([ - { sign: 1, node: num(5) }, - { sign: 1, node: ident('x') }, - ]); - assert.deepEqual(negate(s), { - type: 'Sum', - terms: [ - { sign: 1, node: { type: 'Num', value: -5 } }, - { sign: -1, node: { type: 'Ident', name: 'x' } }, - ], - }); - }); - - test('negate: grouped multi-term Sum preserves the group', () => { - const s = /** @type {import('../../src/lib/node.js').Sum} */ ({ - ...mkSum([ - { sign: 1, node: ident('a') }, - { sign: -1, node: ident('b') }, - ]), - grouped: true, - }); - assert.deepEqual(negate(s), { - type: 'Sum', - terms: [{ sign: -1, node: s }], - }); - }); - - test('negate: Product wraps in single-term negative Sum', () => { - const p = mkProduct([ - { exponent: 1, node: ident('a') }, - { exponent: 1, node: ident('b') }, - ]); - const result = negate(p); - assert.equal(result.type, 'Sum'); - const terms = result.terms; - assert.equal(terms.length, 1); - assert.equal(terms[0].sign, -1); - }); -}); diff --git a/test/unit/parser-core.test.js b/test/unit/parser-core.test.js index 2916e55..5d0f963 100644 --- a/test/unit/parser-core.test.js +++ b/test/unit/parser-core.test.js @@ -10,6 +10,13 @@ import { serialize } from '../../src/lib/serialize.js'; import { sexpr } from '../helpers/sexpr.js'; import { parseSource, parseSexpr as ast } from '../helpers/parse-source.js'; +const boundedRange = (css) => { + const tokens = tokenize({ css }); + const start = tokens.findIndex((token) => token[1] === '1'); + const end = tokens.findIndex((token) => token[1] === ')'); + return { tokens, start, end }; +}; + test('parser: accepts a bounded range of a shared native token stream', () => { const tokens = tokenize({ css: 'prefix calc(/* gap */-2px + 3px) suffix' }); const start = tokens.findIndex((token) => token[1] === '/* gap */'); @@ -22,9 +29,7 @@ test('parser: accepts a bounded range of a shared native token stream', () => { }); test('parser: bounded virtual EOF keeps its source-relative position', () => { - const tokens = tokenize({ css: 'prefix calc(1 *) suffix' }); - const start = tokens.findIndex((token) => token[1] === '1'); - const end = tokens.findIndex((token) => token[1] === ')'); + const { tokens, start, end } = boundedRange('prefix calc(1 *) suffix'); assert.throws( () => parse(tokens, start, end, indexBlocks(tokens)), @@ -33,9 +38,7 @@ test('parser: bounded virtual EOF keeps its source-relative position', () => { }); test('parser: bounded virtual EOF scans trailing trivia before reporting its position', () => { - const tokens = tokenize({ css: 'prefix calc(1 + ) suffix' }); - const start = tokens.findIndex((token) => token[1] === '1'); - const end = tokens.findIndex((token) => token[1] === ')'); + const { tokens, start, end } = boundedRange('prefix calc(1 + ) suffix'); assert.throws(() => parse(tokens, start, end, indexBlocks(tokens)), { message: `Unexpected token "" at position ${tokens[end][2]}`, diff --git a/test/unit/parser-keywords-errors.test.js b/test/unit/parser-keywords-errors.test.js new file mode 100644 index 0000000..a639b3b --- /dev/null +++ b/test/unit/parser-keywords-errors.test.js @@ -0,0 +1,168 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { tokenize } from '@csstools/css-tokenizer'; +import { indexBlocks } from '../../src/lib/block-index.js'; +import { parse } from '../../src/lib/parser.js'; +import { parseSource, parseSexpr as ast } from '../helpers/parse-source.js'; + +test('parser: `pi` folds to Math.PI', () => { + assert.equal(ast('pi'), String(Math.PI)); +}); +test('parser: `e` folds to Math.E', () => { + assert.equal(ast('e'), String(Math.E)); +}); +test('parser: `infinity` folds to Infinity', () => { + assert.equal(ast('infinity'), 'Infinity'); +}); +test('parser: calc-keywords are case-insensitive except NaN', () => { + assert.equal(ast('PI'), String(Math.PI)); + assert.equal(ast('Infinity'), 'Infinity'); +}); +test('parser: lowercase `nan` is NOT the keyword (case-sensitive)', () => { + // Spec: `NaN` is the only case-sensitive calc keyword. `nan` as + // lowercase is just an opaque identifier. + assert.equal(ast('nan'), 'nan'); +}); +// --- Strict whitespace around +/- ---------------------------------------- +test('parser: `1px + 2px` is valid', () => { + assert.doesNotThrow(() => parseSource('1px + 2px')); +}); +test('parser: a signed token after whitespace still fails at its position', () => { + assert.throws(() => parseSource('1 +2'), { + message: '"+" must be surrounded by whitespace at position 2', + }); +}); +test('parser: a unary plus after multiplication has the canonical AST', () => { + assert.deepEqual(parseSource('1 * +2'), { + type: 'Num', + value: 2, + }); +}); +// §10.1: `+` and `-` must be surrounded by whitespace. All three asymmetric +// cases (no/before-only/after-only) must throw the same way. +for (const input of ['1px+2px', '1px +2px', '1px+ 2px']) { + test(`parser: \`${input}\` throws (asymmetric whitespace around +)`, () => { + assert.throws(() => parseSource(input), /must be surrounded by whitespace/); + }); +} +test('parser: asymmetric additive whitespace reports the operator position', () => { + for (const [input, position] of [ + ['1px+2px', 3], + ['1px +2px', 4], + ['1px+ 2px', 3], + ]) { + assert.throws(() => parseSource(input), { + message: `"+" must be surrounded by whitespace at position ${position}`, + }); + } +}); +test('parser: * / do not require whitespace (spec allows both)', () => { + const productTokens = tokenize({ css: '1px*2' }); + assert.doesNotThrow(() => + parse(productTokens, 0, productTokens.length, indexBlocks(productTokens)) + ); + const quotientTokens = tokenize({ css: '1px/2' }); + assert.doesNotThrow(() => + parse(quotientTokens, 0, quotientTokens.length, indexBlocks(quotientTokens)) + ); +}); +test('parser: tab / newline satisfy the §10.1 whitespace rule', () => { + assert.equal(ast('1\t+\n2'), '(+ 1 2)'); + assert.equal(ast('1\n-\t2'), '(+ 1 -2)'); +}); +test('parser: comments satisfy the §10.1 whitespace rule', () => { + assert.equal(ast('1px/* gap */+/* gap */2px'), '(+ 1px 2px)'); + assert.equal(ast('/* gap */1px + 2px'), '(+ 1px 2px)'); +}); +test('parser: lookahead preserves comment-derived whitespace when consumed', () => { + assert.equal(ast('1 /* before */ + /* after */ 2'), '(+ 1 2)'); + assert.equal(ast('1 /* before */ - /* after */ 2'), '(+ 1 -2)'); +}); + +describe('Error positions', () => { + test('parser: trailing operator throws (whitespace-before-EOF fails)', () => { + // `1 +` has space before `+` but nothing after — EOF has ws=false, so + // the strict-whitespace check fires before the unexpected-token path. + assert.throws( + () => parseSource('1 +'), + /must be surrounded by whitespace|Unexpected token/ + ); + }); + test('parser: unclosed paren expects )', () => { + assert.throws(() => parseSource('(1 + 2'), /Expected/); + }); + test('parser: stacked operators throw', () => { + assert.throws(() => parseSource('1 * * 2'), /Unexpected token/); + }); +}); + +// --- expect() failures (unclosed groups) ----------------------------------- +test('parser: unclosed paren throws with expected-token message', () => { + assert.throws(() => parseSource('(1 + 2'), /Expected \)/); +}); + +test('parser: unclosed call throws with expected-token message', () => { + assert.throws(() => parseSource('min(1, 2'), /Expected \)/); +}); + +test('parser: unclosed var throws with unclosed message and position', () => { + const tokens = tokenize({ css: 'var(--foo' }); + assert.throws( + () => parse(tokens, 0, tokens.length, indexBlocks(tokens)), + /Unclosed var\( at position 4/ + ); +}); + +describe('Trailing tokens', () => { + // --- Trailing tokens ------------------------------------------------------ + test('parse: rejects input with trailing tokens after a complete expression', () => { + assert.throws(() => parseSource('1 2'), /Unexpected token/); + }); + test('parse: empty input throws', () => { + assert.throws(() => parseSource(''), /Unexpected token/); + }); + + test('parser: punctuation helper methods match and expect punctuation tokens', () => { + assert.equal(ast('1 + 2 + 3 + 4'), '(+ 1 2 3 4)'); + assert.equal(ast('2 * 3 * 4 * 5'), '(* 2 3 4 5)'); + assert.equal(ast('min(1, 2, 3)'), '(min 1 2 3)'); + }); + + test('parser: var() and opaque calls resume outer parsing after block skipping', () => { + assert.equal( + ast('calc(var(--x, 1px) + 2px)'), + '(calc (+ OpaqueCall(var [--x ", 1px"]) 2px))' + ); + assert.equal( + ast('calc(var(--x, 1px) - 2px)'), + '(calc (+ OpaqueCall(var [--x ", 1px"]) -2px))' + ); + assert.equal( + ast('calc(unknown(1px + 2px) + 3px)'), + '(calc (+ OpaqueCall(unknown ["1px + 2px"]) 3px))' + ); + assert.equal( + ast('calc(unknown(1px + 2px) - 3px)'), + '(calc (+ OpaqueCall(unknown ["1px + 2px"]) -3px))' + ); + }); + + test('parser: skipped blocks do not hide trailing tokens', () => { + const varTokens = tokenize({ css: 'var(--x) 1px' }); + assert.throws( + () => parse(varTokens, 0, varTokens.length, indexBlocks(varTokens)), + /Unexpected token/ + ); + const unknownTokens = tokenize({ css: 'unknown(1px + 2px) 3px' }); + assert.throws( + () => + parse( + unknownTokens, + 0, + unknownTokens.length, + indexBlocks(unknownTokens) + ), + /Unexpected token/ + ); + }); +}); diff --git a/test/unit/parser-opaque.test.js b/test/unit/parser-opaque.test.js index 1f4521d..fb530cd 100644 --- a/test/unit/parser-opaque.test.js +++ b/test/unit/parser-opaque.test.js @@ -4,7 +4,7 @@ import { tokenize } from '@csstools/css-tokenizer'; import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import { serialize } from '../../src/lib/serialize.js'; -import { parseSource, parseSexpr as ast } from '../helpers/parse-source.js'; +import { parseSource } from '../helpers/parse-source.js'; // --- Opaque non-math functions ------------------------------------------- // @@ -146,165 +146,3 @@ describe('parser: opaque expressions and invalid syntax', () => { assert.throws(() => parseSource('anchor(--foo top'), /Unclosed anchor\(/); }); }); -// --- Calc-keyword folding ------------------------------------------------- -test('parser: `pi` folds to Math.PI', () => { - assert.equal(ast('pi'), String(Math.PI)); -}); -test('parser: `e` folds to Math.E', () => { - assert.equal(ast('e'), String(Math.E)); -}); -test('parser: `infinity` folds to Infinity', () => { - assert.equal(ast('infinity'), 'Infinity'); -}); -test('parser: calc-keywords are case-insensitive except NaN', () => { - assert.equal(ast('PI'), String(Math.PI)); - assert.equal(ast('Infinity'), 'Infinity'); -}); -test('parser: lowercase `nan` is NOT the keyword (case-sensitive)', () => { - // Spec: `NaN` is the only case-sensitive calc keyword. `nan` as - // lowercase is just an opaque identifier. - assert.equal(ast('nan'), 'nan'); -}); -// --- Strict whitespace around +/- ---------------------------------------- -test('parser: `1px + 2px` is valid', () => { - assert.doesNotThrow(() => parseSource('1px + 2px')); -}); -test('parser: a signed token after whitespace still fails at its position', () => { - assert.throws(() => parseSource('1 +2'), { - message: '"+" must be surrounded by whitespace at position 2', - }); -}); -test('parser: a unary plus after multiplication has the canonical AST', () => { - assert.deepEqual(parseSource('1 * +2'), { - type: 'Num', - value: 2, - }); -}); -// §10.1: `+` and `-` must be surrounded by whitespace. All three asymmetric -// cases (no/before-only/after-only) must throw the same way. -for (const input of ['1px+2px', '1px +2px', '1px+ 2px']) { - test(`parser: \`${input}\` throws (asymmetric whitespace around +)`, () => { - assert.throws(() => parseSource(input), /must be surrounded by whitespace/); - }); -} -test('parser: asymmetric additive whitespace reports the operator position', () => { - for (const [input, position] of [ - ['1px+2px', 3], - ['1px +2px', 4], - ['1px+ 2px', 3], - ]) { - assert.throws(() => parseSource(input), { - message: `"+" must be surrounded by whitespace at position ${position}`, - }); - } -}); -test('parser: * / do not require whitespace (spec allows both)', () => { - const productTokens = tokenize({ css: '1px*2' }); - assert.doesNotThrow(() => - parse(productTokens, 0, productTokens.length, indexBlocks(productTokens)) - ); - const quotientTokens = tokenize({ css: '1px/2' }); - assert.doesNotThrow(() => - parse(quotientTokens, 0, quotientTokens.length, indexBlocks(quotientTokens)) - ); -}); -test('parser: tab / newline satisfy the §10.1 whitespace rule', () => { - assert.equal(ast('1\t+\n2'), '(+ 1 2)'); - assert.equal(ast('1\n-\t2'), '(+ 1 -2)'); -}); -test('parser: comments satisfy the §10.1 whitespace rule', () => { - assert.equal(ast('1px/* gap */+/* gap */2px'), '(+ 1px 2px)'); - assert.equal(ast('/* gap */1px + 2px'), '(+ 1px 2px)'); -}); -test('parser: lookahead preserves comment-derived whitespace when consumed', () => { - assert.equal(ast('1 /* before */ + /* after */ 2'), '(+ 1 2)'); - assert.equal(ast('1 /* before */ - /* after */ 2'), '(+ 1 -2)'); -}); - -describe('Error positions', () => { - test('parser: trailing operator throws (whitespace-before-EOF fails)', () => { - // `1 +` has space before `+` but nothing after — EOF has ws=false, so - // the strict-whitespace check fires before the unexpected-token path. - assert.throws( - () => parseSource('1 +'), - /must be surrounded by whitespace|Unexpected token/ - ); - }); - test('parser: unclosed paren expects )', () => { - assert.throws(() => parseSource('(1 + 2'), /Expected/); - }); - test('parser: stacked operators throw', () => { - assert.throws(() => parseSource('1 * * 2'), /Unexpected token/); - }); -}); - -// --- expect() failures (unclosed groups) ----------------------------------- -test('parser: unclosed paren throws with expected-token message', () => { - assert.throws(() => parseSource('(1 + 2'), /Expected \)/); -}); - -test('parser: unclosed call throws with expected-token message', () => { - assert.throws(() => parseSource('min(1, 2'), /Expected \)/); -}); - -test('parser: unclosed var throws with unclosed message and position', () => { - const tokens = tokenize({ css: 'var(--foo' }); - assert.throws( - () => parse(tokens, 0, tokens.length, indexBlocks(tokens)), - /Unclosed var\( at position 4/ - ); -}); - -describe('Trailing tokens', () => { - // --- Trailing tokens ------------------------------------------------------ - test('parse: rejects input with trailing tokens after a complete expression', () => { - assert.throws(() => parseSource('1 2'), /Unexpected token/); - }); - test('parse: empty input throws', () => { - assert.throws(() => parseSource(''), /Unexpected token/); - }); - - test('parser: punctuation helper methods match and expect punctuation tokens', () => { - assert.equal(ast('1 + 2 + 3 + 4'), '(+ 1 2 3 4)'); - assert.equal(ast('2 * 3 * 4 * 5'), '(* 2 3 4 5)'); - assert.equal(ast('min(1, 2, 3)'), '(min 1 2 3)'); - }); - - test('parser: var() and opaque calls resume outer parsing after block skipping', () => { - assert.equal( - ast('calc(var(--x, 1px) + 2px)'), - '(calc (+ OpaqueCall(var [--x ", 1px"]) 2px))' - ); - assert.equal( - ast('calc(var(--x, 1px) - 2px)'), - '(calc (+ OpaqueCall(var [--x ", 1px"]) -2px))' - ); - assert.equal( - ast('calc(unknown(1px + 2px) + 3px)'), - '(calc (+ OpaqueCall(unknown ["1px + 2px"]) 3px))' - ); - assert.equal( - ast('calc(unknown(1px + 2px) - 3px)'), - '(calc (+ OpaqueCall(unknown ["1px + 2px"]) -3px))' - ); - }); - - test('parser: skipped blocks do not hide trailing tokens', () => { - const varTokens = tokenize({ css: 'var(--x) 1px' }); - assert.throws( - () => parse(varTokens, 0, varTokens.length, indexBlocks(varTokens)), - /Unexpected token/ - ); - const unknownTokens = tokenize({ css: 'unknown(1px + 2px) 3px' }); - assert.throws( - () => - parse( - unknownTokens, - 0, - unknownTokens.length, - indexBlocks(unknownTokens) - ), - /Unexpected token/ - ); - }); -}); diff --git a/test/unit/reduceCalc-core.test.js b/test/unit/reduceCalc-core.test.js index ba30636..06a5463 100644 --- a/test/unit/reduceCalc-core.test.js +++ b/test/unit/reduceCalc-core.test.js @@ -134,126 +134,11 @@ describe('reduceCalc: basic pipeline', () => { test('reduceCalc: preserves arithmetic signed zero inside unresolved calculations', () => { assert.equal( reduceCalc('calc(0 / -1 * var(--x))', { precision: false }), - 'calc(calc(-1 * 0) * var(--x))' + 'calc(-1 * 0 * var(--x))' ); assert.equal( reduceCalc('min(0px / -1, var(--x))', { precision: false }), - 'min(calc(-1 * 0px), var(--x))' + 'min(-1 * 0px, var(--x))' ); }); - - describe('reduceCalc: avoids negative zero serialization in sums and grouped sums', () => { - test('sub-precision negative number term serializes as 0 in unresolved sum', () => { - assert.equal(reduceCalc('calc(var(--x) - 1e-20)'), 'calc(0 + var(--x))'); - }); - - test('sub-precision negative dimension term serializes as 0 in unresolved sum', () => { - assert.equal( - reduceCalc('calc(var(--x) - 1e-20px)'), - 'calc(0px + var(--x))' - ); - }); - - test('sub-precision negative dimension term preserves unit alongside resolvable term', () => { - assert.equal(reduceCalc('calc(1em - 1e-20px)'), 'calc(1em + 0px)'); - }); - - test('leading sub-precision negative number term serializes as 0', () => { - assert.equal( - reduceCalc('calc((-1e-20 + var(--x)))'), - 'calc(0 + var(--x))' - ); - }); - - test('negated grouped sum with leading sub-precision negative term serializes with positive sign', () => { - assert.equal( - reduceCalc('calc(-1 * (-1e-20 + var(--x)))'), - 'calc(-1 * (0 + var(--x)))' - ); - }); - - test('negated grouped sum with non-leading sub-precision negative term serializes with positive sign', () => { - assert.equal( - reduceCalc('calc((-10px + var(--x) - 1e-20em))'), - 'calc(-10px + 0em + var(--x))' - ); - }); - - test('negated grouped sum with non-leading sub-precision positive term serializes with positive sign', () => { - assert.equal( - reduceCalc('calc((-10px + var(--x) + 1e-20em))'), - 'calc(-10px + 0em + var(--x))' - ); - }); - - test('precision: false retains sub-precision negative number term', () => { - assert.equal( - reduceCalc('calc(var(--x) - 1e-20)', { precision: false }), - 'calc(-1e-20 + var(--x))' - ); - }); - - test('precision: false retains grouped negative sum inversion', () => { - assert.equal( - reduceCalc('calc((-1e-20 + var(--x)))', { precision: false }), - 'calc(-1e-20 + var(--x))' - ); - }); - - test('threshold survivor above noise floor retains negative sign', () => { - assert.equal( - reduceCalc('calc(var(--x) - 1e-10)'), - 'calc(-1e-10 + var(--x))' - ); - }); - - test('sufficient precision retains tiny negative term', () => { - assert.equal( - reduceCalc('calc(var(--x) - 1e-20)', { precision: 20 }), - 'calc(-1e-20 + var(--x))' - ); - }); - - test('precision: 0 preserves non-zero value above noise floor', () => { - assert.equal( - reduceCalc('calc(var(--x) - 0.4)', { precision: 0 }), - 'calc(-.4 + var(--x))' - ); - }); - - test('nested math-function call argument avoids negative zero serialization', () => { - assert.equal( - reduceCalc('min(-1e-20 + var(--x), 1)'), - 'min(0 + var(--x), 1)' - ); - }); - - test('arithmetic signed zero is preserved with default precision', () => { - assert.equal( - reduceCalc('calc(0 / -1 * var(--x))'), - 'calc(calc(-1 * 0) * var(--x))' - ); - }); - - test('arithmetic signed zero dimension is preserved under subtraction', () => { - assert.equal( - reduceCalc('calc(1em - 1px * 0)'), - 'calc(1em + calc(-1 * 0px))' - ); - }); - - test('grouped sum with a leading negative term keeps its signs and a positive zero term', () => { - assert.equal( - reduceCalc('calc((-1em + var(--x) + 0px))'), - 'calc(-1em + 0px + var(--x))' - ); - }); - - test('grouped sum with a leading negative term keeps its signs and a negative zero term', () => { - assert.equal( - reduceCalc('calc((-1em + var(--x) - 0px))'), - 'calc(-1em + calc(-1 * 0px) + var(--x))' - ); - }); - }); }); diff --git a/test/unit/reduceCalc-negative-zero.test.js b/test/unit/reduceCalc-negative-zero.test.js new file mode 100644 index 0000000..2876fa4 --- /dev/null +++ b/test/unit/reduceCalc-negative-zero.test.js @@ -0,0 +1,160 @@ +// Standalone reduceCalc tests. Mirrors test/unit/plugin.test.js for cases +// that operate on a CSS value string rather than PostCSS node walking. +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import reduceCalc from 'postcss-calc/reduce'; + +describe('reduceCalc: avoids negative zero serialization in sums and grouped sums', () => { + test('sub-precision negative number term serializes as 0 in unresolved sum', () => { + assert.equal(reduceCalc('calc(var(--x) - 1e-20)'), 'calc(0 + var(--x))'); + }); + + test('sub-precision negative dimension term serializes as 0 in unresolved sum', () => { + assert.equal( + reduceCalc('calc(var(--x) - 1e-20px)'), + 'calc(0px + var(--x))' + ); + }); + + test('sub-precision negative dimension term preserves unit alongside resolvable term', () => { + assert.equal(reduceCalc('calc(1em - 1e-20px)'), 'calc(1em + 0px)'); + }); + + test('leading sub-precision negative number term serializes as 0', () => { + assert.equal(reduceCalc('calc((-1e-20 + var(--x)))'), 'calc(0 + var(--x))'); + }); + + test('negated grouped sum with leading sub-precision negative term serializes with positive sign', () => { + assert.equal( + reduceCalc('calc(-1 * (-1e-20 + var(--x)))'), + 'calc(-1 * (0 + var(--x)))' + ); + }); + + test('negated grouped sum with non-leading sub-precision negative term serializes with positive sign', () => { + assert.equal( + reduceCalc('calc((-10px + var(--x) - 1e-20em))'), + 'calc(-10px + 0em + var(--x))' + ); + }); + + test('negated grouped sum with non-leading sub-precision positive term serializes with positive sign', () => { + assert.equal( + reduceCalc('calc((-10px + var(--x) + 1e-20em))'), + 'calc(-10px + 0em + var(--x))' + ); + }); + + test('precision: false retains sub-precision negative number term', () => { + assert.equal( + reduceCalc('calc(var(--x) - 1e-20)', { precision: false }), + 'calc(-1e-20 + var(--x))' + ); + }); + + test('precision: false retains grouped negative sum inversion', () => { + assert.equal( + reduceCalc('calc((-1e-20 + var(--x)))', { precision: false }), + 'calc(-1e-20 + var(--x))' + ); + }); + + test('threshold survivor above noise floor retains negative sign', () => { + assert.equal( + reduceCalc('calc(var(--x) - 1e-10)'), + 'calc(-1e-10 + var(--x))' + ); + }); + + test('sufficient precision retains tiny negative term', () => { + assert.equal( + reduceCalc('calc(var(--x) - 1e-20)', { precision: 20 }), + 'calc(-1e-20 + var(--x))' + ); + }); + + test('precision: 0 preserves non-zero value above noise floor', () => { + assert.equal( + reduceCalc('calc(var(--x) - 0.4)', { precision: 0 }), + 'calc(-.4 + var(--x))' + ); + }); + + test('nested math-function call argument avoids negative zero serialization', () => { + assert.equal( + reduceCalc('min(-1e-20 + var(--x), 1)'), + 'min(0 + var(--x), 1)' + ); + }); + + test('arithmetic signed zero is preserved with default precision', () => { + assert.equal( + reduceCalc('calc(0 / -1 * var(--x))'), + 'calc(-1 * 0 * var(--x))' + ); + }); + + test('arithmetic signed zero dimension is preserved under subtraction', () => { + assert.equal(reduceCalc('calc(1em - 1px * 0)'), 'calc(1em - 0px)'); + }); + + test('grouped sum with a leading negative term keeps its signs and a positive zero term', () => { + assert.equal( + reduceCalc('calc((-1em + var(--x) + 0px))'), + 'calc(-1em + 0px + var(--x))' + ); + }); + + test('grouped sum with a leading negative term keeps its signs and a negative zero term', () => { + assert.equal( + reduceCalc('calc((-1em + var(--x) - 0px))'), + 'calc(-1em - 0px + var(--x))' + ); + }); + + test('negative zero beside a contextual percentage serializes as subtraction', () => { + assert.equal(reduceCalc('calc(100% - 0px)'), 'calc(100% - 0px)'); + assert.equal(reduceCalc('calc(100% - 0)'), 'calc(100% - 0)'); + }); + + test('leading negative zero term moves behind the next term', () => { + assert.equal(reduceCalc('calc(0 / -1 + var(--x))'), 'calc(var(--x) - 0)'); + assert.equal(reduceCalc('calc(0px / -1 + 1%)'), 'calc(1% - 0px)'); + }); + + test('all-negative-zero sums keep a stable term order', () => { + for (const input of [ + 'calc(-1em * 0 - 1px * 0)', + 'calc(1 / sign(calc(-1em * 0 - 1px * 0)))', + ]) { + const once = reduceCalc(input); + assert.equal(reduceCalc(once), once); + } + assert.equal( + reduceCalc('calc(-1em * 0 - 1px * 0)'), + 'calc(-1 * 0em - 0px)' + ); + }); + + test('negative zero before a non-zero term moves behind it', () => { + assert.equal(reduceCalc('calc(-1em * 0 + 1px)'), 'calc(1px - 0em)'); + }); + + test('negative zero type anchor still invalidates an incompatible sum', () => { + assert.equal(reduceCalc('calc(1deg - 0px)'), 'calc(1deg - 0px)'); + }); + + test('non-leading negative zero factor keeps its calc() wrapper', () => { + assert.equal( + reduceCalc('calc(var(--x) * (0 / -1))'), + 'calc(var(--x) * calc(-1 * 0))' + ); + }); + + test('negative zero opening a product in a non-leading sum term keeps its sign', () => { + assert.equal( + reduceCalc('calc(1px - (0 / -1) * var(--x))'), + 'calc(1px - -1 * 0 * var(--x))' + ); + }); +}); diff --git a/test/unit/reduceCalc-substitution.test.js b/test/unit/reduceCalc-substitution.test.js index 7e8a81b..1ed88ee 100644 --- a/test/unit/reduceCalc-substitution.test.js +++ b/test/unit/reduceCalc-substitution.test.js @@ -14,8 +14,8 @@ import reduceCalc from 'postcss-calc/reduce'; */ function evaluate(value) { const expression = value - .replace(/calc\(/g, '(') - .replace(/(?:var|env|attr)\([^()]*\)/g, '1 + 2'); + .replaceAll('calc(', '(') + .replaceAll(/(?:var|env|attr)\([^()]*\)/g, '1 + 2'); return Function(`"use strict"; return ${expression};`)(); } diff --git a/test/unit/serialize-core.test.js b/test/unit/serialize-core.test.js index 8f7e0c6..e82f740 100644 --- a/test/unit/serialize-core.test.js +++ b/test/unit/serialize-core.test.js @@ -11,6 +11,12 @@ import { mkProduct, } from '../../src/lib/node.js'; +const onePxPlusTwoPx = () => + mkSum([ + { sign: 1, node: dim(1, 'px') }, + { sign: 1, node: dim(2, 'px') }, + ]); + describe('serialize: core syntax and expressions', () => { test('serialize: single number uses standard calculation syntax', () => { assert.equal(serialize(num(42)), 'calc(42)'); @@ -21,10 +27,7 @@ describe('serialize: core syntax and expressions', () => { }); test('serialize: sum wrapped in calc(), spaces around +/-', () => { - const ast = mkSum([ - { sign: 1, node: dim(1, 'px') }, - { sign: 1, node: dim(2, 'px') }, - ]); + const ast = onePxPlusTwoPx(); assert.equal(serialize(ast), 'calc(1px + 2px)'); }); @@ -79,10 +82,7 @@ describe('serialize: core syntax and expressions', () => { }); test('serialize: custom calcName', () => { - const ast = mkSum([ - { sign: 1, node: dim(1, 'px') }, - { sign: 1, node: dim(2, 'px') }, - ]); + const ast = onePxPlusTwoPx(); assert.equal( serialize(ast, { calcName: '-webkit-calc' }), '-webkit-calc(1px + 2px)' @@ -224,9 +224,9 @@ describe('serialize: core syntax and expressions', () => { assert.equal(serialize(ast), 'calc(-1 / 2px)'); }); - test('serialize: negating a Product with leading zero coefficient emits calc(-1 * 0) times the rest', () => { + test('serialize: negating a Product with leading zero coefficient emits -1 * 0 times the rest', () => { // The negated coefficient is -0, which must take the signed-zero path - // (calc(-1 * 0)) instead of collapsing to plain `0` or `1`. + // (-1 * 0) instead of collapsing to plain `0` or `1`. const ast = mkSum([ { sign: -1, @@ -236,7 +236,7 @@ describe('serialize: core syntax and expressions', () => { ]), }, ]); - assert.equal(serialize(ast), 'calc(calc(-1 * 0) * var(--x))'); + assert.equal(serialize(ast), 'calc(-1 * 0 * var(--x))'); }); test('serialize: negating a Product with leading -0 coefficient emits positive zero times the rest', () => { diff --git a/test/unit/serialize-precision.test.js b/test/unit/serialize-precision.test.js index 9a2135d..ad20576 100644 --- a/test/unit/serialize-precision.test.js +++ b/test/unit/serialize-precision.test.js @@ -173,18 +173,12 @@ describe('serialize: precision and rounding', () => { test('serialize: preserves signed zero vs zero under custom precision', () => { const negNested = call('min', [num(-0), num(1)]); const posNested = call('min', [num(0), num(1)]); - assert.equal( - serialize(negNested, { precision: 2 }), - 'min(calc(-1 * 0), 1)' - ); + assert.equal(serialize(negNested, { precision: 2 }), 'min(-1 * 0, 1)'); assert.equal(serialize(posNested, { precision: 2 }), 'min(0, 1)'); const negDim = call('min', [dim(-0, 'px'), dim(1, 'px')]); const posDim = call('min', [dim(0, 'px'), dim(1, 'px')]); - assert.equal( - serialize(negDim, { precision: 2 }), - 'min(calc(-1 * 0px), 1px)' - ); + assert.equal(serialize(negDim, { precision: 2 }), 'min(-1 * 0px, 1px)'); assert.equal(serialize(posDim, { precision: 2 }), 'min(0px, 1px)'); }); @@ -198,10 +192,7 @@ describe('serialize: precision and rounding', () => { test('serialize: lowers a signed zero number inside a calculation', () => { const nested = call('min', [num(-0), num(1)]); - assert.equal( - serialize(nested, { precision: false }), - 'min(calc(-1 * 0), 1)' - ); + assert.equal(serialize(nested, { precision: false }), 'min(-1 * 0, 1)'); }); test('serialize: lowers signed zero leaves inside structural expressions', () => { @@ -211,13 +202,13 @@ describe('serialize: precision and rounding', () => { ]); assert.equal( serialize(ast, { precision: false }), - 'calc(calc(-1 * 0) * var(--x))' + 'calc(-1 * 0 * var(--x))' ); const dimensional = call('min', [dim(-0, 'px'), dim(1, 'px')]); assert.equal( serialize(dimensional, { precision: false }), - 'min(calc(-1 * 0px), 1px)' + 'min(-1 * 0px, 1px)' ); }); diff --git a/types/lib/calculation-type.d.ts b/types/lib/calculation-type.d.ts deleted file mode 100644 index d333422..0000000 --- a/types/lib/calculation-type.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { analyze } from './analyze.js'; -import { MAX_CALCULATION_DEPTH, checkCalculationDepth } from './limits.js'; -export type Node = import('./node.js').Node; -export type CalculationType = { - kind: 'number'; -} | { - kind: 'dimension'; - base: string | null; -} | { - kind: 'unknown'; -} | { - kind: 'failure'; -}; -/** @typedef {import('./node.js').Node} Node */ -/** @typedef {{kind: 'number'} | {kind: 'dimension', base: string | null} | {kind: 'unknown'} | {kind: 'failure'}} CalculationType */ -/** @param {Node} node @return {CalculationType} */ -declare function checkCalculationType(node: Node): CalculationType; -export { MAX_CALCULATION_DEPTH, checkCalculationDepth, checkCalculationType, analyze, }; diff --git a/types/lib/function-analyzers.d.ts b/types/lib/function-analyzers.d.ts new file mode 100644 index 0000000..11fbda5 --- /dev/null +++ b/types/lib/function-analyzers.d.ts @@ -0,0 +1,27 @@ +export type Node = import('./node.js').Node; +export type CalculationType = import('./types.js').CalculationType; +/** + * @typedef {import('./node.js').Node} Node + * @typedef {import('./types.js').CalculationType} CalculationType + */ +/** @param {CalculationType[]} args @return {CalculationType} */ +declare function analyzeTrig(args: CalculationType[]): CalculationType; +/** @param {CalculationType[]} args @return {CalculationType} */ +declare function analyzeInverseTrig(args: CalculationType[]): CalculationType; +/** @param {CalculationType[]} args @return {CalculationType} */ +declare function analyzeIdentity(args: CalculationType[]): CalculationType; +/** @param {CalculationType[]} args @return {CalculationType} */ +declare function analyzeSign(args: CalculationType[]): CalculationType; +/** @param {CalculationType[]} args @param {Node[]} nodes @return {CalculationType} */ +declare function analyzeRound(args: CalculationType[], nodes: Node[]): CalculationType; +/** @param {CalculationType[]} args @return {CalculationType} */ +declare function analyzeAtan2(args: CalculationType[]): CalculationType; +/** @param {CalculationType[]} args @return {CalculationType} */ +declare function analyzeCalc(args: CalculationType[]): CalculationType; +/** @param {Node} node @return {boolean} */ +declare function isRoundStrategy(node: Node): boolean; +/** @param {Node} node @param {number} index @return {boolean} */ +declare function isClampKeyword(node: Node, index: number): boolean; +/** @param {CalculationType[]} args @param {Node[]} nodes @return {CalculationType} */ +declare function analyzeClamp(args: CalculationType[], nodes: Node[]): CalculationType; +export { analyzeAtan2, analyzeCalc, analyzeClamp, analyzeIdentity, analyzeInverseTrig, analyzeRound, analyzeSign, analyzeTrig, isClampKeyword, isRoundStrategy, }; diff --git a/types/lib/functions.d.ts b/types/lib/functions.d.ts index 5ca4013..3a562ea 100644 --- a/types/lib/functions.d.ts +++ b/types/lib/functions.d.ts @@ -1,15 +1,6 @@ +import { addTypes, failureType, isFailure, isPercentage, numberType, percentageType, unknownType } from './types.js'; export type Node = import('./node.js').Node; -export type CalculationType = { - kind: 'number'; -} | { - kind: 'dimension'; - base: string | null; -} | { - kind: 'unknown'; - percent?: true; -} | { - kind: 'failure'; -}; +export type CalculationType = import('./types.js').CalculationType; export type MathSimplifier = (name: string, args: Node[]) => Node; export type TypeAnalyzer = (args: CalculationType[], nodes: Node[]) => CalculationType; export type MathFunction = { @@ -19,26 +10,10 @@ export type MathFunction = { calculation?: boolean; }; /** @typedef {import('./node.js').Node} Node */ -/** - * `percent` marks a value that resolves in the same percentage context as its - * peers (a pure percentage). It is only produced at leaves, by abs(), and by - * homogeneous sums and calls — never by a product, where an unpaired - * percentage could no longer cancel against anything. - * @typedef {{kind: 'number'} | {kind: 'dimension', base: string | null} | {kind: 'unknown', percent?: true} | {kind: 'failure'}} CalculationType - */ +/** @typedef {import('./types.js').CalculationType} CalculationType */ /** @typedef {(name: string, args: Node[]) => Node} MathSimplifier */ /** @typedef {(args: CalculationType[], nodes: Node[]) => CalculationType} TypeAnalyzer */ /** @typedef {{analyze: TypeAnalyzer, simplify?: MathSimplifier, isKeyword?: (node: Node, index: number) => boolean, calculation?: boolean}} MathFunction */ -/** @type {CalculationType} */ declare const numberType: CalculationType; -/** @type {CalculationType} */ declare const unknownType: CalculationType; -/** @type {CalculationType} */ declare const percentageType: CalculationType; -/** @type {CalculationType} */ declare const failureType: CalculationType; -/** @param {CalculationType} type @return {boolean} */ -declare function isFailure(type: CalculationType): boolean; -/** @param {CalculationType} type @return {boolean} */ -declare function isPercentage(type: CalculationType): boolean; -/** @param {CalculationType} a @param {CalculationType} b @return {CalculationType} */ -declare function addTypes(a: CalculationType, b: CalculationType): CalculationType; declare const mathFunctions: Map; /** * @param {string} name diff --git a/types/lib/serialize/expression.d.ts b/types/lib/serialize/expression.d.ts index d551225..f0f5dbc 100644 --- a/types/lib/serialize/expression.d.ts +++ b/types/lib/serialize/expression.d.ts @@ -1,28 +1,15 @@ export type Node = import('../node.js').Node; export type Sum = import('../node.js').Sum; -export type Product = import('../node.js').Product; +export type SumTerm = import('../node.js').SumTerm; export type ProductFactor = import('../node.js').ProductFactor; export type SerializeSession = import('./precision.js').SerializeSession; /** * @typedef {import('../node.js').Node} Node * @typedef {import('../node.js').Sum} Sum - * @typedef {import('../node.js').Product} Product + * @typedef {import('../node.js').SumTerm} SumTerm * @typedef {import('../node.js').ProductFactor} ProductFactor * @typedef {import('./precision.js').SerializeSession} SerializeSession */ -declare const SUM_PRECEDENCE = 1; -declare const PRODUCT_PRECEDENCE = 2; -declare const ATOMIC_PRECEDENCE = 3; -declare const UNARY_PRECEDENCE = 3; -/** @param {Node} node @return {number} */ -declare function precedence(node: Node): number; -/** - * @param {Node} node - * @param {number} parentPrecedence - * @param {boolean} groupedRequired - * @return {boolean} - */ -declare function needsParentheses(node: Node, parentPrecedence: number, groupedRequired: boolean): boolean; /** * @param {Node} node * @param {SerializeSession} session @@ -31,6 +18,12 @@ declare function needsParentheses(node: Node, parentPrecedence: number, groupedR * @return {void} */ declare function emitNode(node: Node, session: SerializeSession, parentPrecedence?: number, groupedRequired?: boolean): void; +/** + * @param {Node} node + * @param {SerializeSession} session + * @return {void} + */ +declare function emitLeadingNeg(node: Node, session: SerializeSession): void; /** * @param {import('../node.js').Call} node * @param {SerializeSession} session @@ -45,39 +38,6 @@ declare function emitCall(node: import('../node.js').Call, session: SerializeSes * @return {void} */ declare function emitOpaqueCall(node: import('../node.js').OpaqueCall, session: SerializeSession, callNameOverride?: string): void; -/** - * @param {import('../node.js').SumTerm} term - * @param {1 | -1} multiplier - * @param {number | false} precision - * @return {1 | -1} - */ -declare function termSign(term: import('../node.js').SumTerm, multiplier: 1 | -1, precision: number | false): 1 | -1; -/** - * @param {import('../node.js').SumTerm[]} terms - * @param {SerializeSession} session - * @param {1 | -1} [multiplier] - * @return {void} - */ -declare function emitSumTerms(terms: import('../node.js').SumTerm[], session: SerializeSession, multiplier?: 1 | -1): void; -/** @param {Sum} sum @param {SerializeSession} session @return {void} */ -declare function emitSum(sum: Sum, session: SerializeSession): void; -/** - * @param {Node} node - * @param {SerializeSession} session - * @return {void} - */ -declare function emitLeadingNeg(node: Node, session: SerializeSession): void; -/** - * @param {ProductFactor[]} factors - * @param {SerializeSession} session - * @param {number} [start] - * @param {number} [coefficientValue] - * @param {import('../node.js').Num} [coefficientNode] - * @return {void} - */ -declare function emitProductFactors(factors: ProductFactor[], session: SerializeSession, start?: number, coefficientValue?: number, coefficientNode?: import('../node.js').Num): void; -/** @param {Product} product @param {SerializeSession} session @return {void} */ -declare function emitProduct(product: Product, session: SerializeSession): void; /** * @param {Node} node * @param {SerializeSession} session @@ -92,4 +52,4 @@ declare function emitMathResult(node: Node, session: SerializeSession, wrapper: * @return {void} */ declare function emitNestedMathResult(node: Node, session: SerializeSession, buffer?: string[]): void; -export { SUM_PRECEDENCE, PRODUCT_PRECEDENCE, ATOMIC_PRECEDENCE, UNARY_PRECEDENCE, precedence, needsParentheses, emitNode, emitCall, emitOpaqueCall, termSign, emitSumTerms, emitSum, emitLeadingNeg, emitProductFactors, emitProduct, emitMathResult, emitNestedMathResult, }; +export { emitNode, emitLeadingNeg, emitCall, emitOpaqueCall, emitMathResult, emitNestedMathResult, }; diff --git a/types/lib/serialize/precedence.d.ts b/types/lib/serialize/precedence.d.ts new file mode 100644 index 0000000..eee9428 --- /dev/null +++ b/types/lib/serialize/precedence.d.ts @@ -0,0 +1,18 @@ +export type Node = import('../node.js').Node; +/** + * @typedef {import('../node.js').Node} Node + */ +declare const SUM_PRECEDENCE = 1; +declare const PRODUCT_PRECEDENCE = 2; +declare const ATOMIC_PRECEDENCE = 3; +declare const UNARY_PRECEDENCE = 3; +/** @param {Node} node @return {number} */ +declare function precedence(node: Node): number; +/** + * @param {Node} node + * @param {number} parentPrecedence + * @param {boolean} groupedRequired + * @return {boolean} + */ +declare function needsParentheses(node: Node, parentPrecedence: number, groupedRequired: boolean): boolean; +export { SUM_PRECEDENCE, PRODUCT_PRECEDENCE, ATOMIC_PRECEDENCE, UNARY_PRECEDENCE, precedence, needsParentheses, }; diff --git a/types/lib/serialize/precision.d.ts b/types/lib/serialize/precision.d.ts index ffe3270..a889b41 100644 --- a/types/lib/serialize/precision.d.ts +++ b/types/lib/serialize/precision.d.ts @@ -77,25 +77,26 @@ declare function emitFiniteScalar(node: import('../node.js').Num | import('../no * @param {import('../node.js').Num | import('../node.js').Dim} node * @param {SerializeSession} session * @param {number} [value] + * @param {boolean} [bare] emit negative zero without its calc() wrapper * @return {void} */ -declare function emitScalar(node: import('../node.js').Num | import('../node.js').Dim, session: SerializeSession, value?: number): void; +declare function emitScalar(node: import('../node.js').Num | import('../node.js').Dim, session: SerializeSession, value?: number, bare?: boolean): void; /** * @param {string[]} buffer * @param {import('../node.js').Num | import('../node.js').Dim} node * @return {void} */ declare function emitSignedZero(buffer: string[], node: import('../node.js').Num | import('../node.js').Dim): void; +/** + * Negative zero as an unwrapped product, valid only where a `` + * may appear without changing how the surrounding expression parses. + * @param {string[]} buffer + * @param {import('../node.js').Num | import('../node.js').Dim} node + * @return {void} + */ +declare function emitBareSignedZero(buffer: string[], node: import('../node.js').Num | import('../node.js').Dim): void; /** @param {Node} node @return {node is import('../node.js').Num | import('../node.js').Dim} */ declare function isScalar(node: Node): node is import('../node.js').Num | import('../node.js').Dim; /** @param {Node} node @return {node is import('../node.js').Num | import('../node.js').Dim} */ declare function isSignedZero(node: Node): node is import('../node.js').Num | import('../node.js').Dim; -/** - * Whether a scalar node is strictly negative after precision rounding - * (excluding signed zero and sub-precision values that round to zero). - * @param {Node} node - * @param {number | false} precision - * @return {node is import('../node.js').Num | import('../node.js').Dim} - */ -declare function isEffectivelyNegative(node: Node, precision: number | false): node is import('../node.js').Num | import('../node.js').Dim; -export { NOISE_FLOOR, divideByPowerOfTen, roundDecimal, round, isDegenerate, degenerateKeyword, serializeNumber, roundedScalarValue, emitRoundedScalar, emitFiniteScalar, emitScalar, emitSignedZero, isScalar, isSignedZero, isEffectivelyNegative, }; +export { NOISE_FLOOR, divideByPowerOfTen, roundDecimal, round, isDegenerate, degenerateKeyword, serializeNumber, roundedScalarValue, emitRoundedScalar, emitFiniteScalar, emitScalar, emitSignedZero, emitBareSignedZero, isScalar, isSignedZero, }; diff --git a/types/lib/simplify/product-barrier.d.ts b/types/lib/simplify/product-barrier.d.ts new file mode 100644 index 0000000..606fa75 --- /dev/null +++ b/types/lib/simplify/product-barrier.d.ts @@ -0,0 +1,26 @@ +export type Node = import('../node.js').Node; +export type ProductFactor = import('../node.js').ProductFactor; +/** + * @typedef {import('../node.js').Node} Node + * @typedef {import('../node.js').ProductFactor} ProductFactor + */ +/** + * A substitution function (`var()`, `env()`, `attr()`, ...) is replaced by + * tokens, not by a value, so factors cannot move or cancel across it. Any + * function the parser does not recognise (`anchor()`, `foo()`, ...) is also an + * OpaqueCall and is treated the same, conservatively. A grouped Product is a + * parenthesized substitution and is equally opaque. + * @param {Node} node + * @return {boolean} + */ +declare function isBarrier(node: Node): boolean; +/** + * Flatten a simplified factor through nested ungrouped Products and the + * canonical negation form, so barriers are visible at the top level. + * @param {ProductFactor[]} out + * @param {1 | -1} exponent + * @param {Node} node + * @return {boolean} Whether a barrier was pushed. + */ +declare function flattenFactor(out: ProductFactor[], exponent: 1 | -1, node: Node): boolean; +export { flattenFactor, isBarrier }; diff --git a/types/lib/simplify/product-quotient.d.ts b/types/lib/simplify/product-quotient.d.ts new file mode 100644 index 0000000..2826965 --- /dev/null +++ b/types/lib/simplify/product-quotient.d.ts @@ -0,0 +1,48 @@ +export type Node = import('../node.js').Node; +export type ProductFactor = import('../node.js').ProductFactor; +/** + * @param {{exponent: 1 | -1, value: number}[]} chain + * @return {number} + */ +declare function chainValue(chain: { + exponent: 1 | -1; + value: number; +}[]): number; +/** + * Whether the opaque factors are a single Sum of only Num/Dim terms. + * @param {ProductFactor[]} opaque + * @return {boolean} + */ +declare function isScalarSum(opaque: ProductFactor[]): boolean; +/** + * Whether a folded quotient prints exactly. A quotient distributed over a + * scalar Sum is judged term by term, so `(3px + 6em) / 3` still folds. + * @param {number} value + * @param {ProductFactor[]} opaque + * @param {boolean} distributable + * @param {number | false} precision + * @return {boolean} + */ +declare function foldsExactly(value: number, opaque: ProductFactor[], distributable: boolean, precision: number | false): boolean; +/** + * Emit an inexact quotient as `numerator * dims * opaque / denominator`, with + * integer parts reduced by their gcd. A lone Dim absorbs the numerator. + * @param {number} numerator + * @param {number} denominator + * @param {{value: number, unit: string, rawUnit?: string} | null} loneDim + * @param {{exponent: 1 | -1, value: number, unit: string, rawUnit?: string}[]} dims + * @param {ProductFactor[]} opaque + * @param {number | false} precision + * @return {Node | null} null when a part would be rounded on output + */ +declare function rationalProduct(numerator: number, denominator: number, loneDim: { + value: number; + unit: string; + rawUnit?: string; +} | null, dims: { + exponent: 1 | -1; + value: number; + unit: string; + rawUnit?: string; +}[], opaque: ProductFactor[], precision: number | false): Node | null; +export { chainValue, foldsExactly, isScalarSum, rationalProduct }; diff --git a/types/lib/types.d.ts b/types/lib/types.d.ts new file mode 100644 index 0000000..a889256 --- /dev/null +++ b/types/lib/types.d.ts @@ -0,0 +1,48 @@ +/** + * `percent` marks a value that resolves in the same percentage context as its + * peers (a pure percentage). It is only produced at leaves, by abs(), and by + * homogeneous sums and calls — never by a product, where an unpaired + * percentage could no longer cancel against anything. + * @typedef {{kind: 'number'} | {kind: 'dimension', base: string | null} | {kind: 'unknown', percent?: true} | {kind: 'failure'}} CalculationType + */ +export type CalculationType = { + kind: 'number'; +} | { + kind: 'dimension'; + base: string | null; +} | { + kind: 'unknown'; + percent?: true; +} | { + kind: 'failure'; +}; +/** @type {CalculationType} */ declare const numberType: CalculationType; +/** @type {CalculationType} */ declare const unknownType: CalculationType; +/** @type {CalculationType} */ declare const percentageType: CalculationType; +/** @type {CalculationType} */ declare const failureType: CalculationType; +/** @param {CalculationType} type @return {boolean} */ +declare function isFailure(type: CalculationType): boolean; +/** @param {CalculationType} type @return {boolean} */ +declare function isPercentage(type: CalculationType): boolean; +/** @param {CalculationType} a @param {CalculationType} b @return {CalculationType} */ +declare function addTypes(a: CalculationType, b: CalculationType): CalculationType; +/** + * 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} + */ +declare function numberArguments(args: CalculationType[], min: number, max: number): CalculationType; +/** + * 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} + */ +declare function matchingArguments(args: CalculationType[], min: number, max: number): CalculationType; +export { addTypes, failureType, isFailure, isPercentage, matchingArguments, numberArguments, numberType, percentageType, unknownType, };