Introduction

postcss-calc reduces calc() expressions at build time — calc(16px * 2) becomes 32px before the CSS ever reaches a browser. It’s a small plugin with an outsized blast radius: it sits inside cssnano, which sits inside most production PostCSS pipelines. If you’ve shipped CSS in the last decade, some of it probably passed through this parser.

That parser was jison-generated, and it had been the only implementation for about ten years. Not ten years of one design — ten years of patches on top of a design, each one a grammar rule or a special case bolted onto whatever reduce action was closest to the bug. It worked, in the sense that the fixtures were green. It didn’t know about min(), max(), clamp(), typed division (calc(100vw / 1px) should reduce to a bare number), or the calc-keywords pi, e, infinity — and every one of those was another feature request sitting on top of a system that had never been designed to hold them, only to be patched by whoever showed up next.

At some point patching a legacy system costs more than replacing it. I decided this was that point, and that CSS Values and Units 4 §10 — which gives a deterministic algorithm for exactly this — was worth implementing directly instead of reverse-engineering one more time from a failing test case. This is the story of breaking postcss-calc away from that parser: a hand-written Pratt parser instead of jison, what it took to prove the break didn’t take users down with it, and what four months of maintainer review changed about how clean a break it actually was.

What ten years of patches actually cost you

The concrete problem with the old parser wasn’t any single bug. It was that parsing and simplification lived in the same place — the grammar’s reduce actions were the math. Adding a spec feature meant understanding both the grammar shape well enough to add a production rule, and the arithmetic well enough to bolt the right logic onto that rule’s action, at the same time, in the same function. Two different kinds of correctness, entangled in one file, accreting for a decade.

You can see the shape of the cost in what was still missing after ten years: not exotic edge cases, but entire functions (min, max, clamp), an entire category of division (typed division producing a unitless number), and named constants the spec had defined for years. None of that is hard math. It’s hard to add to a system where “add a feature” means “modify the grammar and hope the reduce actions still compose correctly with everything already there.”

Breaking that meant separating the two concerns before touching either: get a real parse tree out of the input first, as a distinct step, so that “what does this expression mean” and “how do I simplify it” stop being the same question.

A precedence table instead of sixty grammar rules

jison needs one production rule per operator arrangement — a rule for expr + term, another for expr - term, another for term * factor, and so on, recursively, for every level of precedence. The calc grammar had accumulated roughly sixty of these.

A Pratt parser collapses that into one small table. Each token that can start an expression gets a prefix parselet; each token that can continue one gets an infix parselet with a binding power. Parsing is just: parse a prefix, then keep pulling in infix operators whose binding power clears the current threshold.

const ADD_BP = 1;
const MUL_BP = 3;
const UNARY_BP = 7;

const INFIX = {
  '+': {
    lbp: ADD_BP,
    parse: (p, left, token) => {
      requireSurroundingWs(p, token);
      return addTerm(left, p.parseExpr(ADD_BP + 1), 1);
    },
  },
  '-': { lbp: ADD_BP, /* … same shape, sign -1 … */ },
  '*': { lbp: MUL_BP, parse: (p, left) => mulFactor(left, p.parseExpr(MUL_BP + 1), 1) },
  '/': { lbp: MUL_BP, /* … exponent -1 … */ },
};

* and / bind tighter than + and - because MUL_BP > ADD_BP — that’s the entire precedence table. Function calls, parenthesization, and unary -/+ are three more prefix parselets. Eleven small handlers, in a file you can read top to bottom in a few minutes, replace sixty generated grammar rules — and unlike the old grammar, none of them do any arithmetic. This file only answers “what does this expression mean.” Simplification moved out into its own module entirely, which is the actual break: for the first time, you can change how round() folds without touching how + is parsed, or the reverse.

The whitespace rule is a good example of what that separation buys you. CSS requires +/- used as binary operators to have whitespace on both sides — calc(2px+3px) is invalid CSS, not calc(2px + 3px) with tight spacing. That’s a one-line check in the +/- infix parselet (requireSurroundingWs), sitting right next to the operator it governs, instead of a special case bolted onto a shared reduce action somewhere else in the grammar.

One file per spec section

With parsing isolated, everything downstream became a straight pipeline instead of a single tangled stage:

calc body string
  → tokenizer.js  → tokens
  → parser.js     → AST
  → simplify.js (+ simplify/*.js, one module per function) → reduced AST
  → serialize.js  → output string

node.js defines the AST — a Num / Dim / Ident / Call / Sum / Product shape where the constructors themselves enforce the canonical-form invariants (no Sum inside a Sum, a -5 is always Num(-5) and never a wrapped negation, zero-valued Nums are dropped, and so on). simplify/ has nineteen small modules — product.js, sum.js, round.js, mod-rem.js, min-max.js, clamp.js, the trig functions, pow/sqrt/exp/log/hypot — each one implementing exactly the corresponding piece of §10.10. This is the payoff of the break: adding min()/max()/clamp() support, which the legacy parser never had, is adding three new files, not modifying a grammar. And when a bug report says “round() gives the wrong answer for an infinite step,” there’s exactly one 40-line file to open, not a grammar-plus-action pair to untangle.

Tokenization isn’t hand-rolled anymore, either. A maintainer suggested reusing @csstools/css-tokenizer instead of maintaining a parallel implementation of CSS Syntax Level 3, and pointed at romainmenke/css-tokenizer-tests as a conformance suite. Both landed: the plugin now delegates tokenization entirely, and scripts/tokenizer-suite.mjs runs that shared corpus against it in CI. One less spec to keep reimplemented and in sync by hand — which is exactly the kind of debt this whole rewrite was meant to stop accumulating.

Proving the break was safe

Passing the old tests only proves the new parser copied the old behavior, not that it’s right. Validating a from-scratch reimplementation of a spec needs signals that don’t depend on the code you just wrote to check itself, so the suite leans on a handful of genuinely independent ones:

  • Property tests. fast-check generates random ASTs and asserts algebraic invariants — commutativity, associativity, round-trip stability. Instead of encoding “here’s the answer for this input,” these encode “here’s a rule that should hold for every input,” which catches shapes no one thought to hand-write a fixture for.
  • Differential fuzzing. Every generated case also runs through @csstools/css-calc, an independent implementation of the same spec section. Disagreement is treated as a bug in one of the two — usually mine, since it’s the newer code. This is the closest thing to a second opinion a solo rewrite can get.
  • Real-world corpus. 21,260 calc() expressions harvested from public GitHub repos (via gh search code, since no single query returns more than 1,000 results), run through the same differential check. Hand-written test cases reflect what I thought to test; a corpus this size reflects what people actually write.
  • Conformance suites. Subsets of Web Platform Tests for css-values, plus the shared @csstools tokenizer test corpus — proof against the actual browser-interop test suites, not just my reading of the spec text.
  • Legacy regression. The original 190-fixture suite, still green. This is the one that answers “did the break actually preserve the behavior worth preserving,” as opposed to just being new and different.
  • Mutation testing. Stryker mechanically flips comparisons, boundaries, and arithmetic operators inside simplify, serialize, and node, then checks whether the test suite notices. A suite can have 100% line coverage and still miss a < that should’ve been <= — mutation testing is the check for exactly that gap, which none of the other methods here actually cover.

1,309 tests altogether, on top of the corpus and mutation runs.

Results

All that validation answers “is it safe.” It doesn’t answer “was it worth it” — that’s a different question, and the honest way to answer it is against the reason the rewrite happened in the first place: issue coverage the old parser couldn’t reach.

I ran every open calc-related issue’s repro through the new pipeline. 11 closed outright — nested calc() inside a var() fallback, three-or-more variable fallbacks, lvh/lvw no longer throwing UNKNOWN_DIMENSION, relative-color calc() inside oklch(from …) — bugs that had sat open for years because fixing them meant touching the old grammar. Two turned into supported features outright: min()/max() now reduce the same way calc() does, and pow() reduces. One improved without being byte-identical to what the original reporter asked for. Three stayed open as deliberate spec-aligned design calls rather than bugs — 100% / 3 reducing to a repeating decimal is correct, not a defect.

Coverage against @csstools/css-calc, the independent implementation the differential suite fuzzes against, is close but not identical, and “close” isn’t a number I was willing to just believe — so I went and looked at where it actually disagrees. All four throw-vs-accept gaps are custom-property names built from emoji and mathematical Unicode, like var(--√𝟤): a genuine tokenization edge case, not a rounding difference, now tracked instead of quietly swept under 99.98%. One more case produces a different but equally spec-correct output — atan(.5) folds to degrees here, csstools leaves it in radians, both defensible reads of the same spec line.

Lower down the priority list, but worth a line: I also benchmarked the raw pipeline against @csstools/css-calc over the same 21,260-expression harvest, mostly out of curiosity rather than because speed was ever the goal.

postcss-calc (pratt)   1.87 µs/expr   accepted 21256/21260
@csstools/css-calc     2.93 µs/expr   accepted 21260/21260

1.56× faster — a purpose-built parser that only ever has to understand + - * /, function calls, and numbers has less generality to pay for than @csstools/css-calc, which is built on a component-value parser meant to serve any CSS syntax.

None of these numbers are the actual payoff, though. It’s that the next CSS Values spec addition is now one new simplify/*.js module, not a grammar rewrite — which is the whole reason min()/max()/clamp() sat unimplemented for a decade in the first place.

Negotiating how clean the break should be

The first review round didn’t touch correctness at all — it was about how much of the old world the new code was still carrying. It had shipped as TypeScript with three “legacy compatibility” options (accept CSS without required whitespace, preserve non-canonical operand order, drop zero-value identity terms) meant to bridge old and new behavior, and the new code lived nested under src/pratt/, next to the untouched original — a rewrite still standing half inside the thing it was replacing.

The maintainer’s pushback was, in effect: this isn’t a clean break yet. The compat options meant valid and invalid CSS could produce different output shapes depending on a flag — a surprise a minifier shouldn’t have, and a way for legacy behavior to keep leaking into the new pipeline’s default output indefinitely. The project’s other plugins use plain JS with JSDoc, not TypeScript, so matching that mattered more than keeping the type system I’d started with. And the nested pratt/ directory didn’t make sense once it was the implementation, not a prototype sitting next to one.

All three landed, and each one removed a way for the old behavior to survive by default. The compat options are gone — the pipeline follows the spec unconditionally now, and calc(1px+2px) is invalid CSS, full stop, with no flag to make it otherwise. The source converted from .ts to .js + JSDoc under checkJs: true. The directory got flattened: src/pratt/src/core/parser.ts became src/lib/parser.js, no wrapper folder implying “this is still a prototype living next to the real thing.”

None of it changed a single simplification rule. All of it was the difference between “a new parser that can fall back to old behavior” and an actual replacement — which is a different, and in this case more important, kind of correctness than the spec-conformance work above.

Lessons

  • The cost of legacy isn’t the bugs it has — it’s the ones it prevents you from fixing, because fixing them means understanding a decade of accreted special cases well enough not to break the rest.
  • Separate “what does this mean” from “how do I simplify it” before doing either. Once parsing stopped doing arithmetic, adding a whole new function became a new file, not a new grammar rule plus a new reduce action.
  • Passing your own old tests proves nothing — it proves you copied the old behavior. You need an independent signal, ideally a second implementation of the same spec, to know if you’re actually right.
  • A break isn’t clean until someone else checks how much of the old thing you’re still quietly carrying. The compat flags and the nested directory weren’t correctness issues, and they were still the highest-leverage feedback in the whole review.
  • The payoff isn’t the diff you shipped — it’s that the next feature is now a new file, not a redesign.

A decade-old parser doesn’t get replaced because someone found a clever new algorithm. It gets replaced because, one day, patching it costs more than starting over does — and the hard part was never writing the new parser. It was proving, line by line, that letting go of the old one was actually safe.