postcss-calc 11.0.1 → 11.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -206,6 +206,8 @@ when changing parsing/simplification behavior:
206
206
  pnpm test:corpus:full
207
207
  ```
208
208
 
209
+ Profile long arithmetic parser chains with `pnpm benchmark:arithmetic-chains`.
210
+
209
211
  ## [Changelog](CHANGELOG.md)
210
212
 
211
213
  ## [License](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postcss-calc",
3
- "version": "11.0.1",
3
+ "version": "11.0.3",
4
4
  "type": "module",
5
5
  "description": "PostCSS plugin to reduce calc()",
6
6
  "keywords": [
@@ -13,7 +13,7 @@
13
13
  "homepage": "https://github.com/postcss/postcss-calc",
14
14
  "repository": {
15
15
  "type": "git",
16
- "url": "https://github.com/postcss/postcss-calc.git"
16
+ "url": "git+https://github.com/postcss/postcss-calc.git"
17
17
  },
18
18
  "exports": {
19
19
  ".": {
@@ -34,13 +34,13 @@
34
34
  "devEngines": {
35
35
  "packageManager": {
36
36
  "name": "pnpm",
37
- "version": "11.24.0"
37
+ "version": "11.25.0"
38
38
  }
39
39
  },
40
40
  "devDependencies": {
41
41
  "@csstools/css-calc": "^3.3.0",
42
42
  "@rmenke/css-tokenizer-tests": "^1.2.0",
43
- "@types/node": "^26.3.0",
43
+ "@types/node": "^26.4.0",
44
44
  "fast-check": "^4.9.0",
45
45
  "oxfmt": "^0.65.0",
46
46
  "oxlint": "^1.80.0",
@@ -48,7 +48,6 @@
48
48
  "typescript": "~7.0.2"
49
49
  },
50
50
  "dependencies": {
51
- "@csstools/css-parser-algorithms": "^4.0.0",
52
51
  "@csstools/css-tokenizer": "^4.0.0"
53
52
  },
54
53
  "peerDependencies": {
@@ -57,6 +56,7 @@
57
56
  "scripts": {
58
57
  "lint": "oxlint . && tsc && oxfmt --check",
59
58
  "fmt": "oxfmt",
59
+ "benchmark:arithmetic-chains": "node scripts/benchmark-arithmetic-chains.mjs",
60
60
  "test": "node --test --test-reporter=dot 'test/**/*.test.mjs' test/index.cjs test/convertUnit.cjs",
61
61
  "test:mutation:corpus": "node test/mutation/corpus-selection.mjs",
62
62
  "test:corpus:full": "POSTCSS_CALC_FULL_CORPUS=1 node --test test/conformance/corpus.test.mjs"
package/src/index.js CHANGED
@@ -1,27 +1,24 @@
1
1
  // PostCSS adapter. Walks declaration values (and optionally @rule params
2
2
  // and selectors), feeds calc() bodies through tokenize → parse → simplify
3
3
  // → serialize, and writes the result back.
4
- import { tokenize as cssTokenize } from '@csstools/css-tokenizer';
5
4
  import {
6
- isFunctionNode,
7
- isSimpleBlockNode,
8
- parseListOfComponentValues,
9
- } from '@csstools/css-parser-algorithms';
10
- import { tokenize } from './lib/tokenizer.js';
5
+ tokenize as cssTokenize,
6
+ TokenType as CssType,
7
+ } from '@csstools/css-tokenizer';
8
+ import { tokenizeTokens } from './lib/tokenizer.js';
11
9
  import { parse } from './lib/parser.js';
12
10
  import { simplify } from './lib/simplify.js';
13
11
  import { isSupportedMathFunction } from './lib/simplify/call.js';
14
12
  import { serialize } from './lib/serialize.js';
15
13
 
16
- // The outer walk is deliberately forgiving: it only needs to locate calc()/
17
- // math-function boundaries in otherwise arbitrary (and possibly non-standard)
18
- // CSS values, so parse errors from the outer tokenizer/parser are swallowed.
19
- // Genuine syntax problems inside a matched call are
20
- // caught below via our own tokenize/parse/simplify pipeline.
21
- const NOOP_PARSE_ERROR = { onParseError: () => {} };
22
-
23
14
  const MATCH_CALC = /^(?:-(?:moz|webkit)-)?calc$/i;
24
15
 
16
+ const BLOCK_CLOSE = new Map([
17
+ [CssType.OpenParen, CssType.CloseParen],
18
+ [CssType.OpenSquare, CssType.CloseSquare],
19
+ [CssType.OpenCurly, CssType.CloseCurly],
20
+ ]);
21
+
25
22
  /**
26
23
  * @typedef {object} PostCssCalcOptions
27
24
  * @property {number | false} [precision]
@@ -34,7 +31,7 @@ const MATCH_CALC = /^(?:-(?:moz|webkit)-)?calc$/i;
34
31
  /** @typedef {Required<Omit<PostCssCalcOptions, 'onParseError'>> & Pick<PostCssCalcOptions, 'onParseError'>} ResolvedOptions */
35
32
 
36
33
  /**
37
- * Fields threaded unchanged through the recursive `transformList` walk.
34
+ * Fields threaded unchanged through the token-range walk.
38
35
  * `value` is the original full property text, used only for the
39
36
  * warnWhenCannotResolve message.
40
37
  *
@@ -43,59 +40,76 @@ const MATCH_CALC = /^(?:-(?:moz|webkit)-)?calc$/i;
43
40
  * @property {import('postcss').Result} result
44
41
  * @property {import('postcss').ChildNode} item
45
42
  * @property {string} value
43
+ * @property {import('@csstools/css-tokenizer').CSSToken[]} tokens
44
+ * @property {Replacement[]} replacements
45
+ */
46
+
47
+ /**
48
+ * @typedef {object} Replacement
49
+ * @property {number} start
50
+ * @property {number} end
51
+ * @property {import('./lib/node.js').Node} node
52
+ * @property {string} calcName
53
+ * @property {string} matchedName
46
54
  */
47
55
 
48
56
  /**
49
- * Walks a list of component values in place, replacing matched calc()/math
50
- * function nodes with their simplified form. Unlike the library's generic
51
- * `walk` helper, this recurses manually so a matched node's own (stale,
52
- * pre-simplification) children are never independently re-visited.
57
+ * Walk one component-value level. Unsupported functions and simple blocks are
58
+ * traversed, while a supported function is treated as one opaque calculation
59
+ * even when parsing it fails. A missing closer consumes through EOF, matching
60
+ * CSS component-value parsing's error recovery.
53
61
  *
54
- * @param {import('@csstools/css-parser-algorithms').ComponentValue[]} list
62
+ * @param {number} start
63
+ * @param {import('@csstools/css-tokenizer').TokenType | undefined} expectedClose
55
64
  * @param {TransformContext} ctx
56
- * @return {void}
65
+ * @param {boolean} transform
66
+ * @return {number} Index of the matching closer, or the EOF token.
57
67
  */
58
- function transformList(list, ctx) {
59
- for (let i = 0; i < list.length; i++) {
60
- const node = list[i];
61
- if (!isFunctionNode(node)) {
62
- if (isSimpleBlockNode(node)) {
63
- transformList(node.value, ctx);
64
- }
68
+ function walkTokens(start, expectedClose, ctx, transform) {
69
+ for (let i = start; i < ctx.tokens.length; i++) {
70
+ const token = ctx.tokens[i];
71
+ if (token[0] === CssType.EOF || token[0] === expectedClose) {
72
+ return i;
73
+ }
74
+
75
+ const blockClose = BLOCK_CLOSE.get(token[0]);
76
+ if (blockClose) {
77
+ i = walkTokens(i + 1, blockClose, ctx, transform);
65
78
  continue;
66
79
  }
67
80
 
68
- const name = node.getName();
81
+ if (token[0] !== CssType.Function) {
82
+ continue;
83
+ }
84
+
85
+ const name = token[4].value;
69
86
  const isCalc = MATCH_CALC.test(name);
70
87
  const isMath = !isCalc && isSupportedMathFunction(name);
71
- if (!isCalc && !isMath) {
72
- transformList(node.value, ctx);
88
+ if (!transform || (!isCalc && !isMath)) {
89
+ i = walkTokens(i + 1, CssType.CloseParen, ctx, transform);
73
90
  continue;
74
91
  }
75
92
 
76
- // calc(): feed the body. Bare math: feed the whole call.
77
- const inner = node.value.map((child) => child.toString()).join('');
78
- const contents = isCalc ? inner : `${name}(${inner})`;
93
+ // Locate the complete outer function without transforming its children.
94
+ const close = walkTokens(i + 1, CssType.CloseParen, ctx, false);
95
+ const closed = ctx.tokens[close][0] === CssType.CloseParen;
96
+ const end = closed ? ctx.tokens[close][3] + 1 : ctx.value.length;
97
+ const sliceStart = isCalc ? i + 1 : i;
98
+ const sliceEnd = closed ? close + (isCalc ? 0 : 1) : close;
99
+ const inputStart = isCalc ? token[3] + 1 : token[2];
100
+ const inputEnd = closed && isCalc ? ctx.tokens[close][2] : end;
101
+ const contents = ctx.value.slice(inputStart, inputEnd);
79
102
  try {
80
- const simplified = simplify(parse(tokenize(contents)));
81
- const str = serialize(simplified, {
82
- precision: ctx.options.precision,
83
- calcName: isCalc ? name : 'calc', // preserve vendor prefix on calc()
84
- });
85
-
86
- if (ctx.options.warnWhenCannotResolve && str.startsWith(`${name}(`)) {
87
- ctx.result.warn('Could not reduce expression: ' + ctx.value, {
88
- plugin: 'postcss-calc',
89
- node: ctx.item,
90
- });
91
- }
92
-
93
- const replacement = parseListOfComponentValues(
94
- cssTokenize({ css: str }),
95
- NOOP_PARSE_ERROR
103
+ const node = simplify(
104
+ parse(tokenizeTokens(ctx.tokens.slice(sliceStart, sliceEnd), end))
96
105
  );
97
- list.splice(i, 1, ...replacement);
98
- i += replacement.length - 1;
106
+ ctx.replacements.push({
107
+ start: token[2],
108
+ end,
109
+ node,
110
+ calcName: isCalc ? name : 'calc',
111
+ matchedName: name,
112
+ });
99
113
  } catch (error) {
100
114
  const err = error instanceof Error ? error : new Error('Error');
101
115
  if (ctx.options.onParseError) {
@@ -104,7 +118,10 @@ function transformList(list, ctx) {
104
118
  ctx.result.warn(err.message, { node: ctx.item });
105
119
  }
106
120
  }
121
+ i = close;
107
122
  }
123
+
124
+ return ctx.tokens.length - 1;
108
125
  }
109
126
 
110
127
  /**
@@ -115,14 +132,39 @@ function transformList(list, ctx) {
115
132
  * @return {string}
116
133
  */
117
134
  function transformValue(value, options, result, item) {
118
- const componentValues = parseListOfComponentValues(
119
- cssTokenize({ css: value }),
120
- NOOP_PARSE_ERROR
121
- );
135
+ const tokens = cssTokenize({ css: value });
136
+ /** @type {Replacement[]} */
137
+ const replacements = [];
138
+ const ctx = { options, result, item, value, tokens, replacements };
139
+ walkTokens(0, undefined, ctx, true);
122
140
 
123
- transformList(componentValues, { options, result, item, value });
141
+ /** @type {(Replacement & {text: string})[]} */
142
+ const serialized = replacements.map((replacement) => {
143
+ const text = serialize(replacement.node, {
144
+ precision: options.precision,
145
+ calcName: replacement.calcName,
146
+ });
147
+ if (
148
+ options.warnWhenCannotResolve &&
149
+ text.startsWith(`${replacement.matchedName}(`)
150
+ ) {
151
+ result.warn('Could not reduce expression: ' + value, {
152
+ plugin: 'postcss-calc',
153
+ node: item,
154
+ });
155
+ }
156
+ return { ...replacement, text };
157
+ });
124
158
 
125
- return componentValues.map((node) => node.toString()).join('');
159
+ let output = value;
160
+ for (let i = serialized.length - 1; i >= 0; i--) {
161
+ const replacement = serialized[i];
162
+ output =
163
+ output.slice(0, replacement.start) +
164
+ replacement.text +
165
+ output.slice(replacement.end);
166
+ }
167
+ return output;
126
168
  }
127
169
 
128
170
  /**
package/src/lib/parser.js CHANGED
@@ -1,14 +1,13 @@
1
1
  // Pratt parser. +/- emit Sum nodes; */÷ emit Product nodes. node.js
2
2
  // constructors flatten and normalize on construction, while parenthesized
3
3
  // sums retain a grouping marker for the opaque-subtraction invariant.
4
- import { mkSum, mkProduct, negate, ident, call } from './node.js';
4
+ import { mkSum, mkProduct, negate, num, dim, ident, call } from './node.js';
5
5
 
6
6
  /**
7
7
  * @typedef {import('./tokenizer.js').Token} Token
8
8
  * @typedef {import('./tokenizer.js').TokenType} TokenType
9
9
  * @typedef {import('./node.js').Node} Node
10
10
  * @typedef {(p: Parser, token: Token) => Node} PrefixParselet
11
- * @typedef {{lbp: number, parse: (p: Parser, left: Node, token: Token) => Node}} InfixParselet
12
11
  */
13
12
 
14
13
  /**
@@ -30,17 +29,17 @@ function foldCalcKeyword(name) {
30
29
  // form arrives as a single ident because CSS Syntax tokenizes leading
31
30
  // `-` + ident-start as one ident-token.
32
31
  if (name === 'NaN' || name === '-NaN') {
33
- return { type: 'Num', value: Number.NaN };
32
+ return num(Number.NaN);
34
33
  }
35
34
  switch (name.toLowerCase()) {
36
35
  case 'pi':
37
- return { type: 'Num', value: Math.PI };
36
+ return num(Math.PI);
38
37
  case 'e':
39
- return { type: 'Num', value: Math.E };
38
+ return num(Math.E);
40
39
  case 'infinity':
41
- return { type: 'Num', value: Infinity };
40
+ return num(Infinity);
42
41
  case '-infinity':
43
- return { type: 'Num', value: -Infinity };
42
+ return num(-Infinity);
44
43
  }
45
44
  return null;
46
45
  }
@@ -102,40 +101,58 @@ class Parser {
102
101
  if (!rule || rule.lbp < minBp) {
103
102
  break;
104
103
  }
105
- this.next();
106
- left = rule.parse(this, left, nxt);
104
+ if (infixKey === '+' || infixKey === '-') {
105
+ /** @type {import('./node.js').SumTerm[]} */
106
+ const terms = [{ sign: /** @type {1} */ (1), node: left }];
107
+ do {
108
+ const token = this.next();
109
+ requireSurroundingWs(this, token);
110
+ terms.push({
111
+ sign: /** @type {1 | -1} */ (token.value === '+' ? 1 : -1),
112
+ node: this.parseExpr(ADD_BP + 1),
113
+ });
114
+ const next = this.peek();
115
+ if (
116
+ next.type !== 'punct' ||
117
+ (next.value !== '+' && next.value !== '-')
118
+ ) {
119
+ break;
120
+ }
121
+ } while (ADD_BP >= minBp);
122
+ left = mkSum(terms);
123
+ continue;
124
+ }
125
+
126
+ if (infixKey === '*' || infixKey === '/') {
127
+ /** @type {import('./node.js').ProductFactor[]} */
128
+ const factors = [{ exponent: /** @type {1} */ (1), node: left }];
129
+ do {
130
+ const token = this.next();
131
+ factors.push({
132
+ exponent: /** @type {1 | -1} */ (token.value === '*' ? 1 : -1),
133
+ node: this.parseExpr(MUL_BP + 1),
134
+ });
135
+ const next = this.peek();
136
+ if (
137
+ next.type !== 'punct' ||
138
+ (next.value !== '*' && next.value !== '/')
139
+ ) {
140
+ break;
141
+ }
142
+ } while (MUL_BP >= minBp);
143
+ left = mkProduct(factors);
144
+ continue;
145
+ }
146
+
147
+ // Every infix operator is handled above. Keep this defensive exit in
148
+ // case a future parselet is added without a chain implementation.
149
+ break;
107
150
  }
108
151
 
109
152
  return left;
110
153
  }
111
154
  }
112
155
 
113
- /**
114
- * @param {Node} left
115
- * @param {Node} right
116
- * @param {1 | -1} rightSign
117
- * @return {Node}
118
- */
119
- function addTerm(left, right, rightSign) {
120
- return mkSum([
121
- { sign: 1, node: left },
122
- { sign: rightSign, node: right },
123
- ]);
124
- }
125
-
126
- /**
127
- * @param {Node} left
128
- * @param {Node} right
129
- * @param {1 | -1} rightExp
130
- * @return {Node}
131
- */
132
- function mulFactor(left, right, rightExp) {
133
- return mkProduct([
134
- { exponent: 1, node: left },
135
- { exponent: rightExp, node: right },
136
- ]);
137
- }
138
-
139
156
  const ADD_BP = 1;
140
157
  const MUL_BP = 3;
141
158
  const UNARY_BP = 7;
@@ -228,14 +245,14 @@ function requireSurroundingWs(p, token) {
228
245
 
229
246
  /** @type {Record<string, PrefixParselet>} */
230
247
  const PREFIX = {
231
- number: (_p, t) => ({ type: 'Num', value: Number.parseFloat(t.value) }),
248
+ number: (_p, t) => num(Number.parseFloat(t.value)),
232
249
 
233
250
  // Unit case normalization per §10.12: `1PX` serializes as `1px`.
234
- dimension: (_p, t) => ({
235
- type: 'Dim',
236
- value: Number.parseFloat(t.value),
237
- unit: t.unit === '%' ? '%' : /** @type {string} */ (t.unit).toLowerCase(),
238
- }),
251
+ dimension: (_p, t) =>
252
+ dim(
253
+ Number.parseFloat(t.value),
254
+ t.unit === '%' ? '%' : /** @type {string} */ (t.unit).toLowerCase()
255
+ ),
239
256
 
240
257
  ident: (p, t) => {
241
258
  const nxt = p.peek();
@@ -273,30 +290,12 @@ const PREFIX = {
273
290
  '+': (p) => p.parseExpr(UNARY_BP),
274
291
  };
275
292
 
276
- /** @type {Record<string, InfixParselet>} */
293
+ /** @type {Record<string, {lbp: number}>} */
277
294
  const INFIX = {
278
- '+': {
279
- lbp: ADD_BP,
280
- parse: (p, left, token) => {
281
- requireSurroundingWs(p, token);
282
- return addTerm(left, p.parseExpr(ADD_BP + 1), 1);
283
- },
284
- },
285
- '-': {
286
- lbp: ADD_BP,
287
- parse: (p, left, token) => {
288
- requireSurroundingWs(p, token);
289
- return addTerm(left, p.parseExpr(ADD_BP + 1), -1);
290
- },
291
- },
292
- '*': {
293
- lbp: MUL_BP,
294
- parse: (p, left) => mulFactor(left, p.parseExpr(MUL_BP + 1), 1),
295
- },
296
- '/': {
297
- lbp: MUL_BP,
298
- parse: (p, left) => mulFactor(left, p.parseExpr(MUL_BP + 1), -1),
299
- },
295
+ '+': { lbp: ADD_BP },
296
+ '-': { lbp: ADD_BP },
297
+ '*': { lbp: MUL_BP },
298
+ '/': { lbp: MUL_BP },
300
299
  };
301
300
 
302
301
  /**
@@ -23,6 +23,19 @@ const NUMERIC_RAW = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?/;
23
23
  * @return {Token[]}
24
24
  */
25
25
  function tokenize(input) {
26
+ return tokenizeTokens(tokenizeCss({ css: input }), input.length);
27
+ }
28
+
29
+ /**
30
+ * Convert a slice of an existing CSS token stream into the token subset used
31
+ * by the calculation parser. Token positions remain relative to the original
32
+ * source text, which keeps parse errors useful to adapter callers.
33
+ *
34
+ * @param {import('@csstools/css-tokenizer').CSSToken[]} cssTokens
35
+ * @param {number} eofPosition
36
+ * @return {Token[]}
37
+ */
38
+ function tokenizeTokens(cssTokens, eofPosition) {
26
39
  /** @type {Token[]} */
27
40
  const tokens = [];
28
41
  let ws = true;
@@ -52,7 +65,7 @@ function tokenize(input) {
52
65
  ws = false;
53
66
  }
54
67
 
55
- for (const t of tokenizeCss({ css: input })) {
68
+ for (const t of cssTokens) {
56
69
  switch (t[0]) {
57
70
  case CssType.Whitespace:
58
71
  case CssType.Comment:
@@ -97,8 +110,7 @@ function tokenize(input) {
97
110
  tokens.push({ type: 'punct', value: t[4].value, pos: t[2], ws });
98
111
  break;
99
112
  case CssType.EOF:
100
- tokens.push({ type: 'eof', value: '', pos: input.length, ws });
101
- break;
113
+ continue;
102
114
  default:
103
115
  throw new Error(
104
116
  `Unexpected character "${t[1][0] ?? ''}" at position ${t[2]}`
@@ -107,7 +119,9 @@ function tokenize(input) {
107
119
  ws = false;
108
120
  }
109
121
 
122
+ tokens.push({ type: 'eof', value: '', pos: eofPosition, ws });
123
+
110
124
  return tokens;
111
125
  }
112
126
 
113
- export { tokenize };
127
+ export { tokenize, tokenizeTokens };
package/types/index.d.ts CHANGED
@@ -14,6 +14,15 @@ export type TransformContext = {
14
14
  result: import('postcss').Result;
15
15
  item: import('postcss').ChildNode;
16
16
  value: string;
17
+ tokens: import('@csstools/css-tokenizer').CSSToken[];
18
+ replacements: Replacement[];
19
+ };
20
+ export type Replacement = {
21
+ start: number;
22
+ end: number;
23
+ node: import('./lib/node.js').Node;
24
+ calcName: string;
25
+ matchedName: string;
17
26
  };
18
27
  /**
19
28
  * @param {PostCssCalcOptions} [opts]
@@ -2,10 +2,6 @@ export type Token = import('./tokenizer.js').Token;
2
2
  export type TokenType = import('./tokenizer.js').TokenType;
3
3
  export type Node = import('./node.js').Node;
4
4
  export type PrefixParselet = (p: Parser, token: Token) => Node;
5
- export type InfixParselet = {
6
- lbp: number;
7
- parse: (p: Parser, left: Node, token: Token) => Node;
8
- };
9
5
  declare class Parser {
10
6
  /** @private */
11
7
  i;
@@ -17,4 +17,14 @@ export type Token = {
17
17
  * @return {Token[]}
18
18
  */
19
19
  declare function tokenize(input: string): Token[];
20
- export { tokenize };
20
+ /**
21
+ * Convert a slice of an existing CSS token stream into the token subset used
22
+ * by the calculation parser. Token positions remain relative to the original
23
+ * source text, which keeps parse errors useful to adapter callers.
24
+ *
25
+ * @param {import('@csstools/css-tokenizer').CSSToken[]} cssTokens
26
+ * @param {number} eofPosition
27
+ * @return {Token[]}
28
+ */
29
+ declare function tokenizeTokens(cssTokens: import('@csstools/css-tokenizer').CSSToken[], eofPosition: number): Token[];
30
+ export { tokenize, tokenizeTokens };