Skip to content

Commit 16c1db0

Browse files
committed
chore: tooling scripts (benchmarks, randomizer, harvester, issue-checker) + README
1 parent 2741214 commit 16c1db0

8 files changed

Lines changed: 1207 additions & 0 deletions

File tree

README.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,115 @@ div[data-size="calc(3*3)"] {
120120
}
121121
```
122122

123+
#### `strictWhitespace` (default: `true`)
124+
125+
Reject inputs that don't follow [CSS Values 4 §10.1][css-values-4-syntax]'s
126+
whitespace rule around binary `+` / `-`. With the default, `calc(2px+3px)`
127+
is rejected (it tokenizes as `[2px, +, 3px]` but lacks whitespace around
128+
the `+`). Set to `false` to recover jison-era lenient parsing.
129+
130+
```js
131+
calc({strictWhitespace: false})
132+
```
133+
134+
#### `preserveOrder` (default: `false`)
135+
136+
Preserve input order of commutative operands rather than reordering to the
137+
canonical (numeric-first, then by-discovery, then opaque) shape that
138+
[`@csstools/css-calc`][csstools-css-calc] also uses.
139+
140+
```js
141+
calc({preserveOrder: true})
142+
```
143+
144+
| Input | Default | `preserveOrder: true` |
145+
|---|---|---|
146+
| `calc(var(--foo) + 10px)` | `calc(10px + var(--foo))` | `calc(var(--foo) + 10px)` |
147+
| `calc(1px + 1)` | `calc(1 + 1px)` | `calc(1px + 1)` |
148+
| `calc(var(--m) * 1px)` | `calc(1px * var(--m))` | `calc(var(--m) * 1px)` |
149+
150+
`preserveOrder` operates on outer-expression positions; nested-sum
151+
flattening, constant folding, and reciprocal conversion (`a / 2`
152+
`a * 0.5`) all collapse positions before assembly and can't be recovered.
153+
154+
#### `dropZeroIdentities` (default: `false`)
155+
156+
Drop `+ 0px` / `+ 0em` identities from sums when another term in the same
157+
sum already carries the type. The default preserves zero-valued buckets
158+
because [WPT calc-serialization-002][wpt-calc-serialization] and the
159+
round-trip property both require it (`calc(0px + 100%)` is a length-
160+
percentage; collapsing to `100%` loses the type signal).
161+
162+
```js
163+
calc({dropZeroIdentities: true})
164+
```
165+
166+
| Input | Default | `dropZeroIdentities: true` |
167+
|---|---|---|
168+
| `calc(100px - (100px - 100%))` | `calc(0px + 100%)` | `100%` |
169+
| `calc(99.99% * 1/1 - 0rem)` | `calc(99.99% + 0rem)` | `99.99%` |
170+
| `calc((100px - 1em) + (-50px + 1em))` | `calc(50px + 0em)` | `50px` |
171+
172+
### Migrating from `postcss-calc` 10.x
173+
174+
10.x used a [jison][jison]-generated parser; 11.x ships a hand-written
175+
Pratt parser whose simplifier follows [CSS Values 4][css-values-4]. Most
176+
inputs reduce to identical output, but some 10.x results were jison
177+
implementation choices rather than spec-required behavior. The three opt-
178+
in flags above recover the most visible differences. Setting all three
179+
matches 10.x as closely as 11.x will go:
180+
181+
```js
182+
calc({
183+
strictWhitespace: false,
184+
preserveOrder: true,
185+
dropZeroIdentities: true,
186+
})
187+
```
188+
189+
A handful of 11.x behaviors aren't flag-controlled — they're spec-aligned
190+
or canonical-form decisions:
191+
192+
- **Constant folding.** `calc(43 + pi)` now folds to `46.14159` (§10.7.1).
193+
10.x kept `pi` / `e` symbolic.
194+
- **Reciprocal conversion.** `calc(var(--x) / 2)` becomes
195+
`calc(var(--x) * 0.5)`. The two are mathematically equivalent; 10.x kept
196+
the division shape.
197+
- **Distributive multiplication.** `calc(0.5 * (100vw - 10px))` becomes
198+
`calc(50vw - 5px)`.
199+
- **Unit case normalization.** `2PX` becomes `2px` (CSS units are case-
200+
insensitive; lowercase is conventional).
201+
- **Calc unwrap (§10.6).** `calc(var(--foo))` becomes `var(--foo)` — a
202+
`calc()` containing a single value is replaced by that value.
203+
- **Spec-style spaced operators.** `2px*var(--x)` is serialized as
204+
`2px * var(--x)`. The tokenizer is unaffected; only output spacing
205+
differs.
206+
- **Division by zero / by a unit.** `calc(500px/0)` reduces to
207+
`calc(infinity * 1px)` (§10.13) instead of throwing. Use `onParseError`
208+
if you want validation behavior.
209+
210+
#### `onParseError`
211+
212+
Callback invoked when a `calc()` body fails to parse or simplify. Matches
213+
[`@csstools/css-calc`][csstools-css-calc]'s shape:
214+
215+
```js
216+
calc({
217+
onParseError: (err, input) => {
218+
throw err; // or log, route to a different channel, etc.
219+
}
220+
})
221+
```
222+
223+
When omitted, errors are reported via PostCSS `result.warn()` so the
224+
plugin never throws at the postcss level.
225+
226+
[css-values-4]: https://www.w3.org/TR/css-values-4/
227+
[css-values-4-syntax]: https://www.w3.org/TR/css-values-4/#calc-syntax
228+
[csstools-css-calc]: https://www.npmjs.com/package/@csstools/css-calc
229+
[wpt-calc-serialization]: https://github.com/web-platform-tests/wpt/blob/master/css/css-values/calc-serialization-002.html
230+
[jison]: https://github.com/zaach/jison
231+
123232
---
124233

125234
## Related PostCSS plugins

scripts/benchmark-v10.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Benchmark: v10 (legacy jison) vs pratt against the harvested corpus.
2+
3+
import { readFileSync, readdirSync } from 'node:fs';
4+
import { join, dirname } from 'node:path';
5+
import { fileURLToPath } from 'node:url';
6+
import postcss, { type AcceptedPlugin } from 'postcss';
7+
8+
interface BenchResult {
9+
name: string;
10+
perRun: number;
11+
total: number;
12+
}
13+
14+
async function main(): Promise<void> {
15+
// Dynamic imports so CJS/ESM interop works under tsx.
16+
const v10Plugin = (await import('../src/index.js')).default as () => AcceptedPlugin;
17+
const v3Plugin = (await import('../src/pratt/src/plugin/plugin.ts')).default as () => AcceptedPlugin;
18+
19+
const HERE = dirname(fileURLToPath(import.meta.url));
20+
const CORPUS_DIR = join(HERE, '..', 'src', 'pratt', 'test', 'corpus');
21+
22+
const calcs = readdirSync(CORPUS_DIR)
23+
.filter((f) => f.endsWith('.txt'))
24+
.flatMap((f) =>
25+
readFileSync(join(CORPUS_DIR, f), 'utf8')
26+
.split('\n')
27+
.filter((l) => l.trim().length > 0)
28+
);
29+
30+
const css = calcs.map((calc, i) => `.rule-${i} { prop: ${calc}; }`).join('\n');
31+
32+
const ITERATIONS = 100;
33+
const POSTCSS_OPTS = { from: undefined };
34+
35+
async function bench(name: string, plugin: () => AcceptedPlugin): Promise<BenchResult> {
36+
for (let i = 0; i < 5; i++) {
37+
await postcss(plugin()).process(css, POSTCSS_OPTS);
38+
}
39+
const start = performance.now();
40+
for (let i = 0; i < ITERATIONS; i++) {
41+
await postcss(plugin()).process(css, POSTCSS_OPTS);
42+
}
43+
const total = performance.now() - start;
44+
return { name, perRun: total / ITERATIONS, total };
45+
}
46+
47+
console.log(`\nInput: ${calcs.length} real-world calc() expressions`);
48+
console.log(`CSS size: ${css.length} bytes, ${calcs.length} declarations`);
49+
console.log(`Iterations: ${ITERATIONS} each\n`);
50+
51+
const v10 = await bench('v10 (jison)', v10Plugin);
52+
const v3 = await bench('v3 (pratt)', v3Plugin);
53+
54+
console.log(` ${v10.name}: ${v10.perRun.toFixed(2)}ms / run (${v10.total.toFixed(0)}ms total)`);
55+
console.log(` ${v3.name}: ${v3.perRun.toFixed(2)}ms / run (${v3.total.toFixed(0)}ms total)`);
56+
57+
const ratio = v3.perRun / v10.perRun;
58+
const direction = ratio < 1 ? 'faster' : 'slower';
59+
console.log(`\n v3 is ${(ratio < 1 ? 1 / ratio : ratio).toFixed(2)}x ${direction} than v10`);
60+
}
61+
62+
main();

scripts/benchmark.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Benchmark: postcss-calc (pratt) vs @csstools/css-calc on the harvested
2+
// real-world corpus.
3+
4+
import { readFileSync } from 'node:fs';
5+
import { dirname, join } from 'node:path';
6+
import { fileURLToPath } from 'node:url';
7+
8+
import { tokenize } from '../src/pratt/src/core/tokenizer.ts';
9+
import { parse } from '../src/pratt/src/core/parser.ts';
10+
import { simplify } from '../src/pratt/src/core/simplify.ts';
11+
import { serialize } from '../src/pratt/src/core/serialize.ts';
12+
import { calc as csstoolsCalc } from '@csstools/css-calc';
13+
14+
const ROOT = dirname(fileURLToPath(import.meta.url));
15+
const CORPUS = join(ROOT, '..', 'src/pratt/test/corpus/github-pure.txt');
16+
const corpus = readFileSync(CORPUS, 'utf8')
17+
.split('\n')
18+
.map((l) => l.trim())
19+
.filter(Boolean);
20+
21+
const ours = (s: string): string | null => {
22+
try {
23+
return serialize(simplify(parse(tokenize(s))), { precision: false });
24+
} catch {
25+
return null;
26+
}
27+
};
28+
const theirs = (s: string): string | null => {
29+
try {
30+
const r = csstoolsCalc(s);
31+
return typeof r === 'string' ? r : null;
32+
} catch {
33+
return null;
34+
}
35+
};
36+
37+
interface Stat {
38+
name: string;
39+
totalMs: number;
40+
okCount: number;
41+
threwCount: number;
42+
}
43+
44+
function bench(name: string, fn: (s: string) => string | null): Stat {
45+
for (let i = 0; i < 3; i++) for (const s of corpus) fn(s);
46+
47+
const ITERS = 5;
48+
let okCount = 0;
49+
let threwCount = 0;
50+
const start = performance.now();
51+
for (let it = 0; it < ITERS; it++) {
52+
okCount = 0;
53+
threwCount = 0;
54+
for (const s of corpus) {
55+
const r = fn(s);
56+
if (r === null) threwCount++;
57+
else okCount++;
58+
}
59+
}
60+
const totalMs = (performance.now() - start) / ITERS;
61+
return { name, totalMs, okCount, threwCount };
62+
}
63+
64+
console.log(`Corpus: ${corpus.length.toLocaleString()} real-world calc() expressions`);
65+
console.log('Running 3 warmup + 5 measured iterations each…\n');
66+
67+
const a = bench('postcss-calc (pratt)', ours);
68+
const b = bench('@csstools/css-calc ', theirs);
69+
70+
const fmt = (s: Stat): string => {
71+
const perCallUs = (s.totalMs * 1000) / corpus.length;
72+
return [
73+
s.name,
74+
`total ${s.totalMs.toFixed(1).padStart(6)} ms`,
75+
`${perCallUs.toFixed(2).padStart(5)} µs/expr`,
76+
`accepted ${s.okCount.toString().padStart(5)}`,
77+
`threw ${s.threwCount.toString().padStart(4)}`,
78+
].join(' ');
79+
};
80+
console.log(fmt(a));
81+
console.log(fmt(b));
82+
83+
const ratio = b.totalMs / a.totalMs;
84+
const speedLabel =
85+
ratio >= 1
86+
? `${ratio.toFixed(2)}× faster`
87+
: `${(1 / ratio).toFixed(2)}× slower`;
88+
console.log(`\nSpeed: postcss-calc is ${speedLabel} than csstools.`);
89+
console.log(
90+
`Coverage: postcss-calc accepts ${a.okCount}, csstools accepts ${b.okCount} ` +
91+
`(diff ${a.okCount - b.okCount > 0 ? '+' : ''}${a.okCount - b.okCount}).`
92+
);

0 commit comments

Comments
 (0)