Skip to content
Open
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
80 changes: 80 additions & 0 deletions .dependency-cruiser.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// dependency-cruiser config — enforces module-boundary rules for the
// TypeScript sources. Keeps `core/` PostCSS-free so it stays portable,
// and stops test code from being reachable from production.

module.exports = {
forbidden: [
{
name: 'no-circular',
severity: 'error',
comment: 'Circular dependencies fragment reasoning across modules.',
from: {},
to: { circular: true },
},
{
name: 'no-orphans',
severity: 'warn',
comment: 'Orphan modules (not imported by anything) are usually dead code.',
from: {
orphan: true,
pathNot: [
'\\.(spec|test)\\.ts$',
'src/pratt/src/plugin/plugin\\.ts$',
'src/pratt/src/plugin/plugin-csstools\\.ts$',
'\\.eslintrc\\.|\\.config\\.|\\.cjs$|\\.mjs$',
],
},
to: {},
},
{
name: 'core-no-postcss',
severity: 'error',
comment:
'core/ is the pure calc engine — no PostCSS imports. Adapters live in plugin/.',
from: { path: '^src/pratt/src/core/' },
to: { path: 'postcss' },
},
{
name: 'core-no-plugin-import',
severity: 'error',
comment: 'core/ must not depend on plugin/ — adapter direction is one-way.',
from: { path: '^src/pratt/src/core/' },
to: { path: '^src/pratt/src/plugin/' },
},
{
name: 'src-no-test-import',
severity: 'error',
comment: 'Production code must not reach into test/.',
from: { path: '^src/pratt/src/' },
to: { path: '^src/pratt/test/' },
},
{
name: 'no-deprecated-core',
severity: 'error',
comment: 'Avoid Node.js deprecated APIs (punycode, domain, etc.).',
from: {},
to: { dependencyTypes: ['deprecated'] },
},
{
name: 'no-non-package-json',
severity: 'error',
comment: 'External imports must be in package.json.',
from: {},
to: {
dependencyTypes: ['npm-no-pkg', 'npm-unknown'],
},
},
],
options: {
doNotFollow: { path: 'node_modules' },
tsPreCompilationDeps: true,
enhancedResolveOptions: {
exportsFields: ['exports'],
conditionNames: ['import', 'require', 'node', 'default'],
mainFields: ['main', 'types'],
},
reporterOptions: {
text: { highlightFocused: true },
},
},
};
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
node_modules
.DS_Store
test/fixtures/*.actual.css
src/parser.js
yarn-error.log
reports/
.stryker-tmp/
src/pratt/test/corpus/github/files/
src/pratt/test/corpus/github/.harvest-state.json
dist/
111 changes: 110 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,115 @@ div[data-size="calc(3*3)"] {
}
```

#### `strictWhitespace` (default: `true`)

Reject inputs that don't follow [CSS Values 4 §10.1][css-values-4-syntax]'s
whitespace rule around binary `+` / `-`. With the default, `calc(2px+3px)`
is rejected (it tokenizes as `[2px, +, 3px]` but lacks whitespace around
the `+`). Set to `false` to recover jison-era lenient parsing.

```js
calc({strictWhitespace: false})
```

#### `preserveOrder` (default: `false`)

Preserve input order of commutative operands rather than reordering to the
canonical (numeric-first, then by-discovery, then opaque) shape that
[`@csstools/css-calc`][csstools-css-calc] also uses.

```js
calc({preserveOrder: true})
```

| Input | Default | `preserveOrder: true` |
|---|---|---|
| `calc(var(--foo) + 10px)` | `calc(10px + var(--foo))` | `calc(var(--foo) + 10px)` |
| `calc(1px + 1)` | `calc(1 + 1px)` | `calc(1px + 1)` |
| `calc(var(--m) * 1px)` | `calc(1px * var(--m))` | `calc(var(--m) * 1px)` |

`preserveOrder` operates on outer-expression positions; nested-sum
flattening, constant folding, and reciprocal conversion (`a / 2` →
`a * 0.5`) all collapse positions before assembly and can't be recovered.

#### `dropZeroIdentities` (default: `false`)

Drop `+ 0px` / `+ 0em` identities from sums when another term in the same
sum already carries the type. The default preserves zero-valued buckets
because [WPT calc-serialization-002][wpt-calc-serialization] and the
round-trip property both require it (`calc(0px + 100%)` is a length-
percentage; collapsing to `100%` loses the type signal).

```js
calc({dropZeroIdentities: true})
```

| Input | Default | `dropZeroIdentities: true` |
|---|---|---|
| `calc(100px - (100px - 100%))` | `calc(0px + 100%)` | `100%` |
| `calc(99.99% * 1/1 - 0rem)` | `calc(99.99% + 0rem)` | `99.99%` |
| `calc((100px - 1em) + (-50px + 1em))` | `calc(50px + 0em)` | `50px` |

### Behavior differences from the legacy parser

The legacy [jison][jison]-generated parser was replaced by a hand-written
Pratt parser whose simplifier follows [CSS Values 4][css-values-4]. Most
inputs reduce to identical output, but some legacy results were jison
implementation choices rather than spec-required behavior. The three
opt-in flags above recover the most visible differences. Setting all
three matches the legacy output as closely as possible:

```js
calc({
strictWhitespace: false,
preserveOrder: true,
dropZeroIdentities: true,
})
```

A handful of behaviors aren't flag-controlled — they're spec-aligned
or canonical-form decisions:

- **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.
- **Division by zero / by a unit.** `calc(500px/0)` reduces to
`calc(infinity * 1px)` (§10.13) instead of throwing. Use `onParseError`
if you want validation behavior.

#### `onParseError`

Callback invoked when a `calc()` body fails to parse or simplify. Matches
[`@csstools/css-calc`][csstools-css-calc]'s shape:

```js
calc({
onParseError: (err, input) => {
throw err; // or log, route to a different channel, etc.
}
})
```

When omitted, errors are reported via PostCSS `result.warn()` so the
plugin never throws at the postcss level.

[css-values-4]: https://www.w3.org/TR/css-values-4/
[css-values-4-syntax]: https://www.w3.org/TR/css-values-4/#calc-syntax
[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

---

## Related PostCSS plugins
Expand Down Expand Up @@ -149,5 +258,5 @@ npm test
[PostCSS]: https://github.com/postcss
[PostCSS Calc]: https://github.com/postcss/postcss-calc
[PostCSS Custom Properties]: https://github.com/postcss/postcss-custom-properties
[tests]: src/__tests__/index.js
[tests]: test/index.js
[W3C calc() implementation]: https://www.w3.org/TR/css3-values/#calc-notation
71 changes: 70 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,87 @@
const js = require('@eslint/js');
const eslintConfigPrettier = require('eslint-config-prettier');
const sonarjs = require('eslint-plugin-sonarjs');
const tseslint = require('typescript-eslint');

module.exports = [
{
ignores: ['src/parser.js'],
ignores: [
'src/parser.js',
'node_modules/**',
'.stryker-tmp/**',
'reports/**',
'dist/**',
],
},
js.configs.recommended,
// Type-aware lint for the TypeScript sources.
...tseslint.configs.recommendedTypeChecked.map((c) => ({
...c,
files: ['src/pratt/**/*.ts', 'scripts/**/*.ts'],
languageOptions: {
...(c.languageOptions || {}),
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
},
},
})),
// SonarJS — code smells, cognitive complexity, dead stores, etc.
{
files: ['src/pratt/**/*.ts', 'scripts/**/*.ts'],
plugins: { sonarjs },
rules: sonarjs.configs.recommended.rules,
},
// Project-specific lint adjustments for the TypeScript sources.
{
files: ['src/pratt/**/*.ts', 'scripts/**/*.ts'],
rules: {
// Underscore-prefix is the convention for "intentionally unused".
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
// Math hot paths (simplify, tokenizer, foldConstArgs, naive oracles)
// have intrinsic complexity that's not extractable without diluting
// single-pass intent. Default 15 is too tight; 25 still flags real
// accidental complexity.
'sonarjs/cognitive-complexity': ['error', 25],
},
},
// node:test's `test()` returns a Promise we deliberately don't await —
// the harness handles it. Silence no-floating-promises for test files.
{
files: ['src/pratt/test/**/*.ts'],
rules: {
'@typescript-eslint/no-floating-promises': 'off',
},
},
eslintConfigPrettier,
{
files: ['src/**/*.js', 'test/**/*.js', 'eslint.config.js'],
languageOptions: {
sourceType: 'commonjs',
globals: {
process: 'readonly',
require: 'readonly',
module: 'readonly',
__dirname: 'readonly',
Buffer: 'readonly',
},
},
rules: {
curly: 'error',
},
},
{
files: ['**/*.mjs'],
languageOptions: {
sourceType: 'module',
globals: {
console: 'readonly',
performance: 'readonly',
process: 'readonly',
},
},
},
];
32 changes: 32 additions & 0 deletions knip.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"workspaces": {
".": {
"entry": [
"scripts/benchmark.mjs",
"test/**/*.{js,mjs}",
"src/pratt/test/**/*.test.ts",
"src/pratt/src/plugin/*.ts",
"stryker.conf.mjs",
"eslint.config.js",
".dependency-cruiser.cjs"
],
"project": [
"src/**/*.js",
"src/pratt/src/**/*.ts",
"src/pratt/test/**/*.ts"
]
}
},
"ignoreDependencies": [
"@stryker-mutator/typescript-checker",
"@stryker-mutator/command-runner",
"@stryker-mutator/api",
"prettier"
],
"ignoreExportsUsedInFile": true,
"rules": {
"exports": "off",
"types": "off"
}
}
Loading