Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
},
"rules": {
"array-callback-return": "error",
"complexity": ["error", { "max": 25 }],
"complexity": "error",
"eqeqeq": ["error", "always", { "null": "ignore" }],
"guard-for-in": "error",
"import/no-duplicates": "error",
"import/no-self-import": "error",
"max-depth": "error",
"max-lines": ["error", { "max": 400 }],
"no-bitwise": "error",
"no-case-declarations": "error",
Expand Down
465 changes: 465 additions & 0 deletions BENCHMARKS.md

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,34 @@

All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.

## Unreleased

### Bug fixes

- transform a division or unit conversion only when the result is exact at the
configured `precision`. `calc(100% / 3)` stays `calc(100% / 3)` instead of
becoming `calc(33.33333%)`, and `calc(1cm + 1px)`
is no longer converted to an approximate `1.02646cm`.
Use `precision: false` to transform every division.
- keep `precision` significant digits for values below 1, so
`calc(1px / 150000)` is no longer rounded to `.00001px`
- `min()`, `max()` and `clamp()` preserve the original unit
- keep the order of terms and factors around `var()`, `env()`, `attr()` and
other substitution functions. These are replaced by raw tokens before the
value is computed, so with `--a: 1px + 2px`, `var(--a) * 2` means
`1px + 2px * 2`. Factors are no longer reordered or cancelled:
`var(--a) * 2` is not rewritten to `2 * var(--a)`, `var(--a) / var(--a)` is
not cancelled, and parentheses are kept (`1px - (2 * var(--a))`).
Unrecognised functions such as `anchor-size()` are treated the same way, so
`2 * anchor-size(width) * .5` is no longer folded
- keep the parentheses of a nested `calc()` that contains unresolved values, so
`calc(var(--a) - calc(var(--b) - var(--c)))` is no longer rewritten to
`calc(var(--a) - var(--b) + var(--c))`
- do not flip every sign of a parenthesized sum that starts with a negative
term: `calc((var(--b) - 7 - 2))` is now `calc(-9 + var(--b))` instead of
`calc(-1 * (9 - var(--b)))`, which changed the value when `--b` expands to
several tokens

## 11.2.1 (2026-09-20)

### Bug fixes
Expand Down
31 changes: 30 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Before submitting a new issue, be sure to make a cursory search to see if the en
Before contributing any code to the project, be sure to either open a new issue in the issue tracker detailing what you intend to contribute, or comment on an existing issue if one exists.
This allows us to:

- give feedback early on before significant effort has been put into the endevour.
- give feedback early on before significant effort has been put into the endeavour.
- align your contribution with ongoing efforts.
- make sure that there's no ongoing effort into the issue already.

Expand All @@ -42,3 +42,32 @@ When submitting your pull request, make sure that you:
- summarize your contribution.
- list the issues that this contribution addresses.
- include tests for your contribution.

## Development

The project uses [pnpm](https://pnpm.io/) and ES modules. Before submitting a
pull request, run:

```sh
pnpm install
pnpm test
pnpm lint
```

`pnpm lint` runs oxlint, `tsc`, and `oxfmt --check`; `pnpm fmt` formats the
code. If your change touches parsing, analysis, or simplification, also run the
full differential corpus with `pnpm test:corpus:full`.

## Benchmarks and Performance

If your pull request touches hot parsing, analysis, simplification, or
serialization paths, run the relevant benchmarks. The paired parser benchmarks
compare your working tree against a baseline revision (`HEAD` by default), so
commit or stash unrelated changes first; there is no need to run them twice by
hand.

Benchmark results are noisy estimates, not proof. Run them on an idle machine,
report the verdict together with the intervals and your environment, and treat
`inconclusive` as "unknown", not as "fine". Do not rerun until you get a
favorable verdict. See [BENCHMARKS.md](BENCHMARKS.md) for what each benchmark
does and does not measure, the methodology, and the controlled-run checklist.
60 changes: 44 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
[![Support Chat][git-img]][git-url]

[PostCSS Calc] lets you reduce `calc()` references whenever it's possible.
When multiple units are mixed together in the same expression, the `calc()`
statement is left as is, to fallback to the [W3C calc() implementation].
When an expression mixes units that cannot be combined exactly (such as `px`
and `em`), or contains values only known at runtime (such as `var()`), the
unresolved part is left for the browser's [W3C calc() implementation].

## Installation

Expand Down Expand Up @@ -62,7 +63,7 @@ leaving all other text untouched.
import reduceCalc from 'postcss-calc/reduce';

reduceCalc('calc(1in + 10px)');
// => 'calc(1.10417in)'
// => 'calc(106px)'

reduceCalc('min(50px, calc(2 * 40px))');
// => 'calc(50px)'
Expand Down Expand Up @@ -123,6 +124,28 @@ Allows you to define the precision for decimal numbers. Set it to `false` to
disable rounding and preserve full IEEE-754 floating-point precision (emitting
the shortest round-tripping decimal representation).

Values below 1 keep `precision` significant digits (`.0123456px` becomes
`.012346px`), and larger values keep `precision` decimals. Divisions and unit
conversions are folded only when the result is exact at the precision;
otherwise they stay symbolic and everything around them is still simplified:

```css
.a {
width: calc(100% / 4);
} /* calc(25%) */
.b {
width: calc(100% / 3);
} /* calc(100% / 3), not 33.33333% */
.c {
width: calc(1cm + 1px);
} /* calc(1cm + 1px) */
.d {
width: calc(1px + 1pt);
} /* calc(1.75pt) */
```

With `precision: false` nothing is rounded, so every division is folded.

```js
var out = postcss()
.use(calc({ precision: 10 }))
Expand Down Expand Up @@ -235,15 +258,10 @@ canonical-form decisions:
requires the zero term because it carries the length-percentage type.
- **Constant folding.** `calc(43 + pi)` now folds to `46.14159` (§10.7.1).
Previously `pi` / `e` stayed symbolic.
- **Reciprocal conversion.** `calc(var(--x) / 2)` becomes
`calc(var(--x) * 0.5)`. The two are mathematically equivalent;
previously the division shape was kept.
- **Distributive multiplication.** `calc(0.5 * (100vw - 10px))` becomes
`calc(50vw - 5px)`.
- **Unit case normalization.** `2PX` becomes `2px` (CSS units are case-
insensitive; lowercase is conventional).
- **Calc unwrap (§10.6).** `calc(var(--foo))` becomes `var(--foo)` — a
`calc()` containing a single value is replaced by that value.
- **Spec-style spaced operators.** `2px*var(--x)` is serialized as
`2px * var(--x)`. The tokenizer is unaffected; only output spacing
differs.
Expand All @@ -265,13 +283,15 @@ To replace the value of CSS custom properties at build time, try [PostCSS Custom
## Contributing

Work on a branch, install dev-dependencies, respect coding style & run tests
before submitting a bug fix or a feature.
before submitting a bug fix or a feature. See [CONTRIBUTING.md](CONTRIBUTING.md)
for the full guidelines. The project uses [pnpm](https://pnpm.io/).

```bash
git clone git@github.com:postcss/postcss-calc.git
git checkout -b patch-1
npm install
npm test
pnpm install
pnpm test
pnpm lint
```

The normal test run uses a deterministic structural sample of the harvested
Expand All @@ -282,11 +302,19 @@ when changing parsing/simplification behavior:
pnpm test:corpus:full
```

Profile parser chains with `pnpm benchmark:arithmetic-chains` or
`pnpm benchmark:nested-fallbacks`; both use 20 fresh paired blocks by default
and write ignored schema-v2 reports. Compare a saved report with
`node scripts/compare-parser-benchmarks.js <report>`. Run the correctness-aware
corpus benchmark with `pnpm benchmark:corpus`.
Performance changes to the parser, analyzer, simplifier, or serializer should be
checked with the benchmarks. `pnpm benchmark:arithmetic-chains` and
`pnpm benchmark:nested-fallbacks` compare the working tree against `HEAD` using
20 fresh-process paired blocks by default and write git-ignored schema-v2
reports under `reports/benchmarks/`. Re-check a saved report with
`pnpm benchmark:reanalyze <report>`. `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
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
"scripts": {
"lint": "oxlint . && tsc && oxfmt --check",
"fmt": "oxfmt",
"benchmark:arithmetic-chains": "node scripts/benchmark/benchmark-arithmetic-chains.js",
"benchmark:nested-fallbacks": "node scripts/benchmark/benchmark-nested-fallbacks.js",
"benchmark:arithmetic-chains": "node scripts/benchmark/benchmark-parser.js arithmetic-chains",
"benchmark:nested-fallbacks": "node scripts/benchmark/benchmark-parser.js nested-fallbacks",
"benchmark:corpus": "node scripts/benchmark/benchmark-corpus.js",
"benchmark:serialization": "node scripts/benchmark/benchmark-serialization.js",
"test:benchmark": "node --test 'test/unit/benchmark-*.test.js' test/unit/compare-parser-benchmarks.test.js test/unit/corpus-benchmark.test.js",
Expand Down
8 changes: 4 additions & 4 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ reanalysis.
For a detailed explanation of the statistical methodology, experiment design,
and software architecture, see [BENCHMARKS.md](../BENCHMARKS.md).

- **`benchmark/benchmark-arithmetic-chains.js`** — runs the fresh-process, paired parser
benchmark for arithmetic shapes. `benchmark/benchmark-nested-fallbacks.js` does the
same for nested `var()` fallbacks. Both accept `--baseline`, `--blocks`,
`--max-attempts`, `--seed`, and `--output`, and write schema-v2 artifacts under
- **`benchmark/benchmark-parser.js <benchmark>`** — runs the fresh-process, paired
parser benchmark. `arithmetic-chains` covers arithmetic shapes and
`nested-fallbacks` covers nested `var()` fallbacks. It accepts `--baseline`,
`--blocks`, `--max-attempts`, `--seed`, and `--output`, and writes schema-v2 artifacts under
`reports/benchmarks/`. The default arithmetic grid uses four logarithmically
spaced sizes with uniform doubling steps (`2,000` to `16,000`) and a tuned
batch schedule so a controlled run completes under 5 minutes while preserving
Expand Down
47 changes: 0 additions & 47 deletions scripts/benchmark/benchmark-nested-fallbacks.js

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { runParserBenchmark } from './parser-benchmark.js';
// Fresh-process paired parser benchmark. Usage:
// benchmark-parser.js <arithmetic-chains|nested-fallbacks> [options]
import { PARSER_BENCHMARKS, runParserBenchmark } from './parser-benchmark.js';

try {
const [benchmark, ...args] = process.argv.slice(2);
if (!PARSER_BENCHMARKS.includes(benchmark))
throw new TypeError(
`expected benchmark name (${PARSER_BENCHMARKS.join(' or ')}), got: ${benchmark}`
);
const result = await runParserBenchmark({
benchmark: 'arithmetic-chains',
...parseOptions(process.argv.slice(2)),
benchmark,
...parseOptions(args),
});
console.log(`Parser benchmark: ${result.artifact.analysis.status}`);
console.log(`Wrote ${result.path}`);
Expand Down
13 changes: 7 additions & 6 deletions scripts/benchmark/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -255,12 +255,13 @@ export function bootstrapStratifiedMaxT({
if (sampledSE === 0) {
hasDegenerateEndpoint = true;
const deviation = effect - observed[column];
if (deviation === 0) statistic = 0;
else {
if (observedSE[column] === 0)
throw new RangeError(
'nonzero bootstrap deviation has no positive standard error'
);
if (deviation === 0) {
statistic = 0;
} else if (observedSE[column] === 0) {
throw new RangeError(
'nonzero bootstrap deviation has no positive standard error'
);
} else {
statistic = deviation / observedSE[column];
degenerateFallbacks++;
}
Expand Down
33 changes: 2 additions & 31 deletions scripts/benchmark/corpus-analysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,37 +288,8 @@ function assertStoredAnalysisMatches(stored, expected, path = 'analysis') {
const expectedKeys = Object.keys(expected).sort();
if (JSON.stringify(storedKeys) !== JSON.stringify(expectedKeys))
throw new TypeError(`${path} does not match recomputed observations`);
for (const key of expectedKeys) {
const left = stored[key];
const right = expected[key];
if (typeof right === 'number') {
if (
typeof left !== 'number' ||
!Number.isFinite(left) ||
Math.abs(left - right) > 1e-10 * Math.max(1, Math.abs(right))
)
throw new TypeError(
`${path}.${key} does not match recomputed observations`
);
} else if (Array.isArray(right)) {
if (!Array.isArray(left) || left.length !== right.length)
throw new TypeError(
`${path}.${key} does not match recomputed observations`
);
for (let index = 0; index < right.length; index++)
assertStoredValue(
left[index],
right[index],
`${path}.${key}[${index}]`
);
} else if (right && typeof right === 'object') {
assertStoredAnalysisMatches(left, right, `${path}.${key}`);
} else if (left !== right) {
throw new TypeError(
`${path}.${key} does not match recomputed observations`
);
}
}
for (const key of expectedKeys)
assertStoredValue(stored[key], expected[key], `${path}.${key}`);
}

function assertStoredValue(left, right, path) {
Expand Down
10 changes: 2 additions & 8 deletions scripts/benchmark/parser-analysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
addSlopeIntervals,
analyzeGrowth,
addGrowthIntervals,
bootstrapRatioSummary,
largestSizeKeys,
precisionSummary,
} from './parser-scaling.js';
Expand Down Expand Up @@ -316,14 +317,7 @@ function addRuntimeIntervals(
upperRatio: Math.exp(intervals.familyUpper),
};
endpoint.meaningfulImprovement = intervals.upper <= Math.log(0.9);
endpoint.bootstrap95 = {
lowerRatio: Math.exp(intervals.lower),
upperRatio: Math.exp(intervals.upper),
resamples: bootstrap.resamples,
familyCount: bootstrap.familyCount,
degenerateResamples: bootstrap.degenerateResamples,
degenerateFallbacks: bootstrap.degenerateFallbacks,
};
endpoint.bootstrap95 = bootstrapRatioSummary(intervals, bootstrap);
endpoint.precision = precisionSummary(
endpoint.observedLogRatioSd,
bootstrap.standardErrors[index],
Expand Down
7 changes: 6 additions & 1 deletion scripts/benchmark/parser-benchmark.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export { parserWorkloads, SIZES, DEPTHS } from './parser-workloads.js';
export {
parserWorkloads,
PARSER_BENCHMARKS,
SIZES,
DEPTHS,
} from './parser-workloads.js';
export { analyzeParser } from './parser-analysis.js';
export { runParserBenchmark } from './parser-runner.js';
Loading
Loading