Skip to content
Draft
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
201 changes: 201 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
# postcss-calc v11 Roadmap

Spec-first rewrite of the plugin's internals, targeting [CSS Values and Units Module Level 4 §10](https://www.w3.org/TR/css-values-4/#calc-notation). The public plugin API (options, plugin shape) is preserved; every consumer on v10.x should upgrade without code changes.

## Why v11

Current state (v10.1.1) has known gaps that the spec closes:

- No `min()` / `max()` / `clamp()` support — treated as opaque blobs.
- Typed division (`calc(100vw / 1px)`) throws *"Cannot divide by 'px', number expected"*.
- No folding of `pi` / `e` / `infinity` / `NaN` into arithmetic.
- JISON-generated LR parser is hard to extend: every new math function requires editing the grammar and regenerating.
- Ad-hoc reducer reinvents a weaker version of the spec's simplification algorithm, which means every edge case is discovered by users instead of derived from the standard.

v11 replaces the parser + reducer + stringifier with direct transcriptions of the spec. The existing 190 regression tests become the contract we ship against.

## Non-goals (deliberate deferrals)

- **Relative color math** (`lch(from X l c calc(h + 80))`) — channel-keyword scope is out-of-band; requires caller context the plugin doesn't have at transform time.
- **`calc-size()`** — separate, evolving spec; continue to leave as-is.
- **Property-context percentage resolution** — a PostCSS transform has no property-type info; spec explicitly allows plugins to leave percentages unresolved.
- **Transpiling modern math functions to legacy `calc()`** — a separate concern; belongs in a companion plugin if wanted.

## Architecture

Five modules, each one-to-one with a spec section. Every module file starts with the exact spec URL it implements, so a contributor (or reviewer) can check conformance section-by-section.

| Module | Spec section | Purpose |
|---|---|---|
| `tokenizer.ts` | [§10.1](https://www.w3.org/TR/css-values-4/#calc-syntax) | Numbers, dimensions (incl. `1px-2` single-token rule), idents, punct |
| `parser.ts` | [§10.1](https://www.w3.org/TR/css-values-4/#calc-syntax) | Pratt parser + parselet registry → AST |
| `type.ts` | [§10.2](https://www.w3.org/TR/css-values-4/#calc-type-checking) | Calculation-type arithmetic (unit × power maps) |
| `simplify.ts` | [§10.10](https://www.w3.org/TR/css-values-4/#calc-simplification) | Post-order simplification |
| `serialize.ts` | [§10.12](https://www.w3.org/TR/css-values-4/#serialize-a-calculation-tree) | AST → CSS text with correct `calc()` wrapping |

## Current status

**Done** (on `master`, in `playground/pratt/`):

- Pratt parser skeleton in TypeScript, parselet registries for prefix + infix.
- Tokenizer for numbers / idents / punct.
- Evaluator harness (number-only) proving the parser works for `min`/`max`/`clamp`/`pow`/trig/stepped-value/etc. as generic call nodes.
- 48 tests covering precedence, associativity, unary stacking, function calls, and negative-syntax / pinned-behavior cases.
- `tsx` runtime + strict `tsconfig.json` for the playground; `pnpm test:pratt` runs the TS suite directly.

The playground has validated the parser design on a clean slate. What remains is dimensions, the type system, simplification, and serialization — the three load-bearing spec pieces plus tokenizer extension.

---

## Release plan

Each release is scoped to ship on its own. Earlier releases do **not** block on later ones.

### v11.0 — Foundation

**Goal:** spec-correct parser + simplification core with `min`/`max`/`clamp` + typed division. Ships 80% of real-world value.

**Scope:**

- Replace `parser.jison` with the Pratt parser from the playground, promoted into `src/`.
- Drop `jison-gho` devDep; delete `parser.jison`; remove `build` script.
- Dimension token rule including spec-required `1px-2` single-token behavior.
- Calculation type system (§10.2).
- `simplify()` core covering:
- Nested `calc()` flattening (inner calc → bare parens).
- Like-term combination across `+` / `-`.
- Numeric folding for resolvable subtrees.
- Unit conversion within a family (port `src/lib/convertUnit.js`).
- Opaque-leaf preservation: `var()`, `env()`, `attr()`, and unknown functions are AST leaves that simplification flows around.
- `min()`, `max()`, `clamp()` as first-class AST nodes with spec-correct reduction.
- Typed division: `<length> / <length> → <number>`, `<time> / <time> → <number>`, etc.
- Serializer matching current output shape for all v10.x regression cases.

**Test coverage:**

| Suite | File | Count (estimate) |
|---|---|---|
| Regression (preserves v10 behavior) | `test/index.js` | 190 (existing) |
| `min` / `max` / `clamp` reduction | `test/v11-min-max-clamp.test.ts` | ~30 |
| Typed division and unit arithmetic | `test/v11-typed-arithmetic.test.ts` | ~15 |
| Opaque-leaf preservation (`var()`, `env()`, unknown fns) | `test/v11-opaque.test.ts` | ~15 |
| Type-checking error cases (invalid mixes) | `test/v11-type-errors.test.ts` | ~10 |
| WPT conformance subset | `test/wpt/calc-basic.test.ts` | ~25 |

**Acceptance:** all 190 existing + ~95 new ≈ **~285 tests green**.

---

### v11.1 — Stepped-value functions

**Goal:** spec coverage for the rounding / modulo family (§10.8).

**Scope:**

- `round([<rounding-strategy>,]? A, B?)` with `nearest | up | down | to-zero`; default `nearest`; if `B` is omitted, rounds to `1` in `A`'s type.
- `mod(A, B)` — result sign follows divisor.
- `rem(A, B)` — result sign follows dividend.
- `abs(A)` — preserves type.
- `sign(A)` — returns `<number>` regardless of input type.
- Parser: one bespoke parselet for `round` to peek for a keyword first-argument.

**Test coverage:**

| Suite | File | Count (estimate) |
|---|---|---|
| `round` with all four strategies × (A) / (A,B) × resolvable/opaque | `test/v11-round.test.ts` | ~20 |
| `mod` / `rem` sign behavior and opaque passthrough | `test/v11-mod-rem.test.ts` | ~12 |
| `abs` / `sign` incl. type preservation and sign-return-type | `test/v11-abs-sign.test.ts` | ~10 |
| WPT conformance subset (`round`, `mod`, `rem`) | `test/wpt/round-mod-rem.test.ts` | ~25 |

**Acceptance:** adds ~**67 tests**, cumulative **~352**.

---

### v11.2 — Trigonometric functions

**Goal:** spec coverage for trig (§10.8) including angle-unit coercion.

**Scope:**

- `sin(A)`, `cos(A)`, `tan(A)` — accept `<angle>` (deg/rad/grad/turn) or bare `<number>` (treated as radians per spec).
- `asin(A)`, `acos(A)`, `atan(A)` — return `<angle>` in degrees.
- `atan2(Y, X)` — returns `<angle>` in degrees; accepts any matching types.
- Angle-unit normalization inside trig argument reduction.
- Calc keyword `pi` folds into trig arguments (`sin(pi / 2)` → `1`).

**Test coverage:**

| Suite | File | Count (estimate) |
|---|---|---|
| `sin` / `cos` / `tan` × (deg, rad, grad, turn, bare number) | `test/v11-trig.test.ts` | ~25 |
| `asin` / `acos` / `atan` — return type is degrees | `test/v11-trig-inverse.test.ts` | ~12 |
| `atan2` two-argument type matching | `test/v11-atan2.test.ts` | ~8 |
| Trig with `pi` folding | `test/v11-trig-pi.test.ts` | ~10 |
| WPT conformance subset (trig) | `test/wpt/trig.test.ts` | ~25 |

**Acceptance:** adds ~**80 tests**, cumulative **~432**.

---

### v11.3 — Exponential family + degenerate-numeric finalization

**Goal:** complete the math-function set (§10.8) and pin spec-correct serialization of degenerate numbers (§10.9, §10.12).

**Scope:**

- `pow(A, B)`, `sqrt(A)`, `hypot(A, B, …)`.
- `log(A)` (natural log), `log(A, B)` (log base B), `exp(A)`.
- Unit handling: `pow`/`sqrt`/`hypot` resolve to bare numbers where required by spec; preserve through opaque subtrees.
- Full `<calc-keyword>` arithmetic: `e`, `pi`, `infinity`, `-infinity`, `NaN` fold per IEEE-754.
- Degenerate-numeric serialization: `calc(1px / 0)` → `calc(infinity * 1px)`; `calc(0 / 0 * 1px)` → `calc(NaN * 1px)`.
- Case-sensitivity: `NaN` is case-sensitive (spec); all other keywords are case-insensitive.

**Test coverage:**

| Suite | File | Count (estimate) |
|---|---|---|
| `pow` / `sqrt` / `hypot` with type handling | `test/v11-exponential.test.ts` | ~18 |
| `log` / `exp` incl. two-arg `log(a, b)` | `test/v11-log-exp.test.ts` | ~10 |
| Calc-keyword folding in arithmetic | `test/v11-calc-keywords.test.ts` | ~15 |
| Degenerate numeric serialization (infinity, NaN) | `test/v11-degenerate.test.ts` | ~12 |
| WPT conformance subset (exp family, keywords) | `test/wpt/exp-keywords.test.ts` | ~25 |

**Acceptance:** adds ~**80 tests**, cumulative **~512**.

---

## Testing strategy

Three tiers, each preserving a distinct guarantee:

1. **Regression — `test/index.js` (190 tests).** The v10.x contract. Only ever extended, never deleted. A new v11 implementation is considered correct only if every one of these passes unchanged.
2. **Spec — `test/v11-*.test.ts`.** One file per feature cluster. Every test has a `// §10.x` comment citing the spec paragraph it exercises. This makes correctness reviewable section-by-section.
3. **Conformance — `test/wpt/*.test.ts`.** Cribbed from [web-platform-tests `css/css-values/`](https://github.com/web-platform-tests/wpt/tree/master/css/css-values) for each version's scope. Test names mirror upstream so drift is detectable. Keeps us honest about cross-implementation interop.

All tiers run under `pnpm test` via `node --test` (and `tsx` for the TS suites).

## Running test-count totals

| Version | New tests (est.) | Cumulative |
|---|---|---|
| baseline | 190 | 190 |
| v11.0 | ~95 | ~285 |
| v11.1 | ~67 | ~352 |
| v11.2 | ~80 | ~432 |
| v11.3 | ~80 | ~512 |

Counts are forecasts, not commitments — they guide scope, not effort estimates.

## Open questions to resolve before v11.0

- **Strict whitespace around `+`/`-`.** Spec requires it; current plugin is permissive. Leaning strict, with a `strict: false` escape hatch for legacy inputs.
- **Output-format opt-out.** Do we add `spec: 'css-values-4'` (default true) so projects that pattern-match on current serialization can stay on the old format for one major? Probably yes at low cost.
- **Deprecation of `mediaQueries` / `selectors` options.** Carry forward unchanged — no reason to remove.

## What will NOT change in v11

- Plugin name, export, and `pluginCreator.postcss = true`.
- Options surface (`precision`, `preserve`, `warnWhenCannotResolve`, `mediaQueries`, `selectors`).
- `peerDependencies` on `postcss ^8`.
- Semver: v11 means internal-breaking + output-difference in edge cases (typed division, degenerate numerics). No API-breaking changes for normal consumers.
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@
"scripts": {
"prepare": "pnpm run build && tsc",
"build": "jison ./parser.jison -o src/parser.js",
"lint": "eslint . && tsc",
"test": "node --test"
"lint": "eslint . && tsc && tsc -p playground/pratt",
"test": "node --test",
"test:pratt": "node --import tsx --test playground/pratt/test.ts playground/pratt/test-tokenizer.ts playground/pratt/test-simplify.ts playground/pratt/test-typed.ts playground/pratt/test-opaque.ts playground/pratt/test-serialize.ts playground/pratt/test-wpt.ts"
},
"author": "Andy Jansson",
"license": "MIT",
Expand All @@ -39,6 +40,7 @@
"jison-gho": "0.6.1-216",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"tsx": "^4.21.0",
"typescript": "~5.8.3"
},
"dependencies": {
Expand Down
105 changes: 105 additions & 0 deletions playground/pratt/evaluator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { Node } from './parser.ts';

export type Func = (...args: number[]) => number;

export const defaultFuncs: Record<string, Func> = {
// Comparison
min: Math.min,
max: Math.max,
clamp: (lo, v, hi) => Math.min(Math.max(v, lo), hi),

// Sign / absolute value
abs: Math.abs,
sign: Math.sign,

// Exponential
pow: Math.pow,
sqrt: Math.sqrt,
hypot: Math.hypot,
log: Math.log,
exp: Math.exp,

// Trig (radians)
sin: Math.sin,
cos: Math.cos,
tan: Math.tan,
asin: Math.asin,
acos: Math.acos,
atan: Math.atan,
atan2: Math.atan2,

// Stepped value
round: Math.round,
floor: Math.floor,
ceil: Math.ceil,
trunc: Math.trunc,
// Floor-mod (result sign follows divisor); `rem` uses JS `%` (sign follows dividend).
mod: (a, b) => ((a % b) + b) % b,
rem: (a, b) => a % b,
};

export const defaultConsts: Record<string, number> = {
pi: Math.PI,
e: Math.E,
infinity: Infinity,
};

export interface EvalContext {
funcs?: Record<string, Func>;
consts?: Record<string, number>;
}

export function evaluate(node: Node, ctx: EvalContext = {}): number {
const funcs = ctx.funcs ?? defaultFuncs;
const consts = ctx.consts ?? defaultConsts;

switch (node.type) {
case 'Num':
return node.value;

case 'Dim':
throw new Error(
`Cannot evaluate dimension ${node.value}${node.unit} as a plain number`
);

case 'Ident': {
const v = consts[node.name.toLowerCase()];
if (v === undefined) {
throw new Error(`Unknown identifier "${node.name}"`);
}
return v;
}

case 'Unary': {
const v = evaluate(node.arg, { funcs, consts });
return node.op === '-' ? -v : +v;
}

case 'Binary': {
const l = evaluate(node.left, { funcs, consts });
const r = evaluate(node.right, { funcs, consts });
switch (node.op) {
case '+':
return l + r;
case '-':
return l - r;
case '*':
return l * r;
case '/':
if (r === 0) {
throw new Error('Division by zero');
}
return l / r;
}
}

case 'Call': {
const fn = funcs[node.name.toLowerCase()];
if (!fn) {
throw new Error(`Unknown function "${node.name}"`);
}
const args = node.args.map((a) => evaluate(a, { funcs, consts }));
return fn(...args);
}
}
}
Loading