postcss-calc 10.1.0 → 11.0.0-rc.0

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.
Files changed (67) hide show
  1. package/README.md +56 -1
  2. package/package.json +19 -13
  3. package/src/index.js +122 -27
  4. package/src/lib/convertUnits.js +105 -0
  5. package/src/lib/node.js +189 -0
  6. package/src/lib/parser.js +314 -0
  7. package/src/lib/serialize.js +226 -0
  8. package/src/lib/simplify/abs.js +21 -0
  9. package/src/lib/simplify/atan2.js +25 -0
  10. package/src/lib/simplify/bucket.js +48 -0
  11. package/src/lib/simplify/call.js +59 -0
  12. package/src/lib/simplify/cancel.js +39 -0
  13. package/src/lib/simplify/clamp.js +26 -0
  14. package/src/lib/simplify/exp.js +18 -0
  15. package/src/lib/simplify/fold.js +63 -0
  16. package/src/lib/simplify/hypot.js +22 -0
  17. package/src/lib/simplify/inverse-trig.js +29 -0
  18. package/src/lib/simplify/log.js +25 -0
  19. package/src/lib/simplify/min-max.js +23 -0
  20. package/src/lib/simplify/mod-rem.js +47 -0
  21. package/src/lib/simplify/pow.js +20 -0
  22. package/src/lib/simplify/product.js +133 -0
  23. package/src/lib/simplify/round.js +113 -0
  24. package/src/lib/simplify/sign.js +22 -0
  25. package/src/lib/simplify/sqrt.js +18 -0
  26. package/src/lib/simplify/sum.js +83 -0
  27. package/src/lib/simplify/trig.js +40 -0
  28. package/src/lib/simplify.js +40 -0
  29. package/src/lib/tokenizer.js +115 -0
  30. package/types/index.d.ts +15 -17
  31. package/types/lib/convertUnits.d.ts +16 -0
  32. package/types/lib/node.d.ts +90 -0
  33. package/types/lib/parser.d.ts +39 -0
  34. package/types/lib/serialize.d.ts +20 -0
  35. package/types/lib/simplify/abs.d.ts +7 -0
  36. package/types/lib/simplify/atan2.d.ts +7 -0
  37. package/types/lib/simplify/bucket.d.ts +19 -0
  38. package/types/lib/simplify/call.d.ts +12 -0
  39. package/types/lib/simplify/cancel.d.ts +19 -0
  40. package/types/lib/simplify/clamp.d.ts +7 -0
  41. package/types/lib/simplify/exp.d.ts +7 -0
  42. package/types/lib/simplify/fold.d.ts +12 -0
  43. package/types/lib/simplify/hypot.d.ts +7 -0
  44. package/types/lib/simplify/inverse-trig.d.ts +7 -0
  45. package/types/lib/simplify/log.d.ts +7 -0
  46. package/types/lib/simplify/min-max.d.ts +8 -0
  47. package/types/lib/simplify/mod-rem.d.ts +8 -0
  48. package/types/lib/simplify/pow.d.ts +7 -0
  49. package/types/lib/simplify/product.d.ts +16 -0
  50. package/types/lib/simplify/round.d.ts +8 -0
  51. package/types/lib/simplify/sign.d.ts +7 -0
  52. package/types/lib/simplify/sqrt.d.ts +7 -0
  53. package/types/lib/simplify/sum.d.ts +18 -0
  54. package/types/lib/simplify/trig.d.ts +7 -0
  55. package/types/lib/simplify.d.ts +18 -0
  56. package/types/lib/tokenizer.d.ts +19 -0
  57. package/types/lib/type.d.ts +16 -0
  58. package/src/lib/convertUnit.js +0 -160
  59. package/src/lib/reducer.js +0 -390
  60. package/src/lib/stringifier.js +0 -98
  61. package/src/lib/transform.js +0 -109
  62. package/src/parser.d.ts +0 -79
  63. package/src/parser.js +0 -4200
  64. package/types/lib/convertUnit.d.ts +0 -8
  65. package/types/lib/reducer.d.ts +0 -14
  66. package/types/lib/stringifier.d.ts +0 -5
  67. package/types/lib/transform.d.ts +0 -6
package/README.md CHANGED
@@ -120,6 +120,61 @@ div[data-size="calc(3*3)"] {
120
120
  }
121
121
  ```
122
122
 
123
+ #### `onParseError`
124
+
125
+ Callback invoked when a `calc()` body fails to parse or simplify. Matches
126
+ [`@csstools/css-calc`][csstools-css-calc]'s shape:
127
+
128
+ ```js
129
+ calc({
130
+ onParseError: (err, input) => {
131
+ throw err; // or log, route to a different channel, etc.
132
+ }
133
+ })
134
+ ```
135
+
136
+ When omitted, errors are reported via PostCSS `result.warn()` so the
137
+ plugin never throws at the postcss level.
138
+
139
+ ### Behavior differences from the legacy parser
140
+
141
+ The legacy [jison][jison]-generated parser was replaced by a hand-written
142
+ Pratt parser whose simplifier follows [CSS Values 4][css-values-4]. Most
143
+ inputs reduce to identical output; the differences are spec-aligned or
144
+ canonical-form decisions:
145
+
146
+ - **Strict whitespace (§10.1).** `calc(2px+3px)` is invalid CSS (binary
147
+ `+` / `-` require surrounding whitespace) and is preserved with a
148
+ warning instead of reduced.
149
+ - **Canonical operand order.** Commutative operands serialize
150
+ numeric-first, matching [`@csstools/css-calc`][csstools-css-calc]:
151
+ `calc(var(--foo) + 10px)` → `calc(10px + var(--foo))`.
152
+ - **Zero buckets are kept.** `calc(100px - (100px - 100%))` →
153
+ `calc(0px + 100%)`, not `100%` — [WPT calc-serialization-002][wpt-calc-serialization]
154
+ requires the zero term because it carries the length-percentage type.
155
+ - **Constant folding.** `calc(43 + pi)` now folds to `46.14159` (§10.7.1).
156
+ Previously `pi` / `e` stayed symbolic.
157
+ - **Reciprocal conversion.** `calc(var(--x) / 2)` becomes
158
+ `calc(var(--x) * 0.5)`. The two are mathematically equivalent;
159
+ previously the division shape was kept.
160
+ - **Distributive multiplication.** `calc(0.5 * (100vw - 10px))` becomes
161
+ `calc(50vw - 5px)`.
162
+ - **Unit case normalization.** `2PX` becomes `2px` (CSS units are case-
163
+ insensitive; lowercase is conventional).
164
+ - **Calc unwrap (§10.6).** `calc(var(--foo))` becomes `var(--foo)` — a
165
+ `calc()` containing a single value is replaced by that value.
166
+ - **Spec-style spaced operators.** `2px*var(--x)` is serialized as
167
+ `2px * var(--x)`. The tokenizer is unaffected; only output spacing
168
+ differs.
169
+ - **Division by zero / by a unit.** `calc(500px/0)` reduces to
170
+ `calc(infinity * 1px)` (§10.13) instead of throwing. Use `onParseError`
171
+ if you want validation behavior.
172
+
173
+ [css-values-4]: https://www.w3.org/TR/css-values-4/
174
+ [csstools-css-calc]: https://www.npmjs.com/package/@csstools/css-calc
175
+ [wpt-calc-serialization]: https://github.com/web-platform-tests/wpt/blob/master/css/css-values/calc-serialization-002.html
176
+ [jison]: https://github.com/zaach/jison
177
+
123
178
  ---
124
179
 
125
180
  ## Related PostCSS plugins
@@ -149,5 +204,5 @@ npm test
149
204
  [PostCSS]: https://github.com/postcss
150
205
  [PostCSS Calc]: https://github.com/postcss/postcss-calc
151
206
  [PostCSS Custom Properties]: https://github.com/postcss/postcss-custom-properties
152
- [tests]: src/__tests__/index.js
207
+ [tests]: test/index.js
153
208
  [W3C calc() implementation]: https://www.w3.org/TR/css3-values/#calc-notation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postcss-calc",
3
- "version": "10.1.0",
3
+ "version": "11.0.0-rc.0",
4
4
  "description": "PostCSS plugin to reduce calc()",
5
5
  "keywords": [
6
6
  "css",
@@ -24,27 +24,33 @@
24
24
  "author": "Andy Jansson",
25
25
  "license": "MIT",
26
26
  "engines": {
27
- "node": "^18.12 || ^20.9 || >=22.0"
27
+ "node": "^22.12 || ^24.11 || >=26.0"
28
28
  },
29
29
  "devDependencies": {
30
- "@types/node": "^22.10.2",
31
- "eslint": "^9.17.0",
32
- "eslint-config-prettier": "^9.1.0",
33
- "jison-gho": "0.6.1-216",
34
- "postcss": "^8.4.49",
35
- "prettier": "^3.4.2",
36
- "typescript": "~5.7.2"
30
+ "@csstools/css-calc": "^3.2.1",
31
+ "@eslint/js": "^10.0.1",
32
+ "@rmenke/css-tokenizer-tests": "^1.2.0",
33
+ "@stryker-mutator/core": "^9.6.1",
34
+ "@stryker-mutator/typescript-checker": "^9.6.1",
35
+ "@types/node": "^26.1.1",
36
+ "eslint": "^10.7.0",
37
+ "eslint-config-prettier": "^10.1.8",
38
+ "eslint-plugin-sonarjs": "^4.2.0",
39
+ "fast-check": "^4.9.0",
40
+ "postcss": "^8.5.19",
41
+ "prettier": "^3.9.5",
42
+ "typescript": "~6.0.3"
37
43
  },
38
44
  "dependencies": {
39
- "postcss-selector-parser": "^7.0.0",
45
+ "@csstools/css-tokenizer": "^4.0.0",
40
46
  "postcss-value-parser": "^4.2.0"
41
47
  },
42
48
  "peerDependencies": {
43
- "postcss": "^8.4.38"
49
+ "postcss": "^8.5.19"
44
50
  },
45
51
  "scripts": {
46
- "build": "jison ./parser.jison -o src/parser.js",
47
52
  "lint": "eslint . && tsc",
48
- "test": "node --test"
53
+ "test": "node --test",
54
+ "test:mutation": "stryker run"
49
55
  }
50
56
  }
package/src/index.js CHANGED
@@ -1,45 +1,140 @@
1
1
  'use strict';
2
- const transform = require('./lib/transform.js');
2
+
3
+ // PostCSS adapter. Walks declaration values (and optionally @rule params
4
+ // and selectors), feeds calc() bodies through tokenize → parse → simplify
5
+ // → serialize, and writes the result back.
6
+
7
+ const valueParser = require('postcss-value-parser');
8
+
9
+ const { tokenize } = require('./lib/tokenizer.js');
10
+ const { parse } = require('./lib/parser.js');
11
+ const { simplify } = require('./lib/simplify.js');
12
+ const { serialize } = require('./lib/serialize.js');
13
+
14
+ const MATCH_CALC = /^(?:-(?:moz|webkit)-)?calc$/i;
15
+
16
+ // Bare math-function calls (no calc() wrapper) — fed to the same pipeline.
17
+ // Mirrors the dispatch in lib/simplify/call.js.
18
+ const MATH_FUNCTIONS = new Set([
19
+ 'min', 'max', 'clamp',
20
+ 'abs', 'sign',
21
+ 'mod', 'rem', 'round',
22
+ 'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'atan2',
23
+ 'pow', 'sqrt', 'hypot', 'log', 'exp',
24
+ ]);
3
25
 
4
26
  /**
5
- * @typedef {{precision?: number | false,
6
- * preserve?: boolean,
7
- * warnWhenCannotResolve?: boolean,
8
- * mediaQueries?: boolean,
9
- * selectors?: boolean}} PostCssCalcOptions
27
+ * @typedef {object} PluginOptions
28
+ * @property {number | false} [precision]
29
+ * @property {boolean} [preserve]
30
+ * @property {boolean} [warnWhenCannotResolve]
31
+ * @property {boolean} [mediaQueries]
32
+ * @property {boolean} [selectors]
33
+ * @property {(error: Error, input: string) => void} [onParseError] Invoked when parse/simplify throws. Replaces the default `result.warn`.
10
34
  */
35
+
36
+ /** @typedef {Required<Omit<PluginOptions, 'onParseError'>> & Pick<PluginOptions, 'onParseError'>} ResolvedOptions */
37
+
38
+ /**
39
+ * @param {string} value
40
+ * @param {ResolvedOptions} options
41
+ * @param {import('postcss').Result} result
42
+ * @param {import('postcss').ChildNode} item
43
+ * @return {string}
44
+ */
45
+ function transformValue(value, options, result, item) {
46
+ return valueParser(value)
47
+ .walk((node) => {
48
+ if (node.type !== 'function') {return;}
49
+ const isCalc = MATCH_CALC.test(node.value);
50
+ const isMath = !isCalc && MATH_FUNCTIONS.has(node.value.toLowerCase());
51
+ if (!isCalc && !isMath) {return;}
52
+
53
+ // calc(): feed the body. Bare math: feed the whole call.
54
+ const inner = valueParser.stringify(node.nodes);
55
+ const contents = isCalc ? inner : `${node.value}(${inner})`;
56
+ try {
57
+ const simplified = simplify(parse(tokenize(contents)));
58
+ const str = serialize(simplified, {
59
+ precision: options.precision,
60
+ calcName: isCalc ? node.value : 'calc', // preserve vendor prefix on calc()
61
+ });
62
+
63
+ if (options.warnWhenCannotResolve && str.startsWith(`${node.value}(`)) {
64
+ result.warn('Could not reduce expression: ' + value, {
65
+ plugin: 'postcss-calc',
66
+ node: item,
67
+ });
68
+ }
69
+
70
+ // Re-tag as `word` so value-parser emits `str` verbatim instead of
71
+ // re-wrapping it as `name(...)`. Cast widens the `'function'` literal.
72
+ /** @type {{type: string}} */ (node).type = 'word';
73
+ node.value = str;
74
+ } catch (error) {
75
+ const err = error instanceof Error ? error : new Error('Error');
76
+ if (options.onParseError) {
77
+ options.onParseError(err, contents);
78
+ } else {
79
+ result.warn(err.message, { node: item });
80
+ }
81
+ }
82
+ return false;
83
+ })
84
+ .toString();
85
+ }
86
+
11
87
  /**
12
- * @type {import('postcss').PluginCreator<PostCssCalcOptions>}
13
- * @param {PostCssCalcOptions} opts
88
+ * @type {import('postcss').PluginCreator<PluginOptions>}
89
+ * @param {PluginOptions} [opts]
14
90
  * @return {import('postcss').Plugin}
15
91
  */
16
92
  function pluginCreator(opts) {
17
- const options = Object.assign(
18
- {
19
- precision: 5,
20
- preserve: false,
21
- warnWhenCannotResolve: false,
22
- mediaQueries: false,
23
- selectors: false,
24
- },
25
- opts
26
- );
93
+ /** @type {ResolvedOptions} */
94
+ const options = {
95
+ precision: 5,
96
+ preserve: false,
97
+ warnWhenCannotResolve: false,
98
+ mediaQueries: false,
99
+ selectors: false,
100
+ ...opts,
101
+ };
27
102
 
28
103
  return {
29
104
  postcssPlugin: 'postcss-calc',
30
105
  OnceExit(css, { result }) {
31
106
  css.walk((node) => {
32
- const { type } = node;
33
- if (type === 'decl') {
34
- transform(node, 'value', options, result);
107
+ if (node.type === 'decl') {
108
+ const next = transformValue(node.value, options, result, node);
109
+ if (options.preserve && node.value !== next && node.parent) {
110
+ const clone = node.clone();
111
+ clone.value = next;
112
+ node.parent.insertBefore(node, clone);
113
+ } else {
114
+ node.value = next;
115
+ }
35
116
  }
36
-
37
- if (type === 'atrule' && options.mediaQueries) {
38
- transform(node, 'params', options, result);
117
+ if (node.type === 'atrule' && options.mediaQueries) {
118
+ const next = transformValue(node.params, options, result, node);
119
+ if (options.preserve && node.params !== next && node.parent) {
120
+ const clone = node.clone();
121
+ clone.params = next;
122
+ node.parent.insertBefore(node, clone);
123
+ } else {
124
+ node.params = next;
125
+ }
39
126
  }
40
-
41
- if (type === 'rule' && options.selectors) {
42
- transform(node, 'selector', options, result);
127
+ if (node.type === 'rule' && options.selectors) {
128
+ // Reduces `:nth-child(calc(...))` via the function walk. calc() in a
129
+ // quoted attribute value is a literal match, so it's left untouched.
130
+ const next = transformValue(node.selector, options, result, node);
131
+ if (options.preserve && node.selector !== next && node.parent) {
132
+ const clone = node.clone();
133
+ clone.selector = next;
134
+ node.parent.insertBefore(node, clone);
135
+ } else {
136
+ node.selector = next;
137
+ }
43
138
  }
44
139
  });
45
140
  },
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ // Spec: https://www.w3.org/TR/css-values-4/#calc-type-checking
4
+
5
+ /**
6
+ * @typedef {'length' | 'angle' | 'time' | 'frequency' | 'resolution' | 'flex' | 'percentage'} BaseType
7
+ */
8
+
9
+ /** @type {Record<string, BaseType>} */
10
+ const UNIT_TO_BASE = {
11
+ px: 'length', cm: 'length', mm: 'length', q: 'length',
12
+ in: 'length', pt: 'length', pc: 'length',
13
+ em: 'length', ex: 'length', ch: 'length', rem: 'length',
14
+ lh: 'length', rlh: 'length', ic: 'length', cap: 'length',
15
+ vw: 'length', vh: 'length', vmin: 'length', vmax: 'length',
16
+ vb: 'length', vi: 'length',
17
+ svw: 'length', svh: 'length', svmin: 'length', svmax: 'length',
18
+ svb: 'length', svi: 'length',
19
+ lvw: 'length', lvh: 'length', lvmin: 'length', lvmax: 'length',
20
+ lvb: 'length', lvi: 'length',
21
+ dvw: 'length', dvh: 'length', dvmin: 'length', dvmax: 'length',
22
+ dvb: 'length', dvi: 'length',
23
+ cqw: 'length', cqh: 'length', cqi: 'length', cqb: 'length',
24
+ cqmin: 'length', cqmax: 'length',
25
+
26
+ deg: 'angle', grad: 'angle', rad: 'angle', turn: 'angle',
27
+
28
+ s: 'time', ms: 'time',
29
+
30
+ hz: 'frequency', khz: 'frequency',
31
+
32
+ dpi: 'resolution', dpcm: 'resolution', dppx: 'resolution', x: 'resolution',
33
+
34
+ fr: 'flex',
35
+
36
+ '%': 'percentage',
37
+ };
38
+
39
+ /**
40
+ * @param {string} unit
41
+ * @return {BaseType | null}
42
+ */
43
+ function baseOf(unit) {
44
+ return UNIT_TO_BASE[unit.toLowerCase()] ?? null;
45
+ }
46
+
47
+ // Conversion factors to each family's canonical unit. Units NOT listed
48
+ // (em, rem, vw, cqw, fr, % …) share a base type with something convertible
49
+ // but can't resolve statically — the simplifier preserves them as separate
50
+ // summands rather than merging.
51
+ /** @type {Record<string, number>} */
52
+ const TO_CANONICAL = {
53
+ px: 1,
54
+ cm: 96 / 2.54,
55
+ mm: 96 / 25.4,
56
+ q: 96 / 101.6,
57
+ in: 96,
58
+ pt: 96 / 72,
59
+ pc: 16,
60
+ deg: 1,
61
+ grad: 0.9,
62
+ rad: 180 / Math.PI,
63
+ turn: 360,
64
+ s: 1,
65
+ ms: 0.001,
66
+ hz: 1,
67
+ khz: 1000,
68
+ dppx: 1,
69
+ dpi: 1 / 96,
70
+ dpcm: 2.54 / 96,
71
+ x: 1,
72
+ // flex / percentage: identity — only combinable with the same unit.
73
+ fr: 1,
74
+ '%': 1,
75
+ };
76
+
77
+ /**
78
+ * Convert a value within a single conversion family. Returns null when
79
+ * either unit is missing from the table (em/rem/vw need runtime context)
80
+ * or when the units belong to different base types.
81
+ * @param {number} value
82
+ * @param {string} from
83
+ * @param {string} to
84
+ * @return {number | null}
85
+ */
86
+ function convert(value, from, to) {
87
+ const fromKey = from.toLowerCase();
88
+ const toKey = to.toLowerCase();
89
+ if (fromKey === toKey) {
90
+ return value;
91
+ }
92
+ const f = TO_CANONICAL[fromKey];
93
+ const t = TO_CANONICAL[toKey];
94
+ if (f === undefined || t === undefined) {
95
+ return null;
96
+ }
97
+ // Cross-family guard: `px` and `s` both have entry 1, so without this
98
+ // `convert(1, 'px', 's')` would silently return 1.
99
+ if (UNIT_TO_BASE[fromKey] !== UNIT_TO_BASE[toKey]) {
100
+ return null;
101
+ }
102
+ return (value * f) / t;
103
+ }
104
+
105
+ module.exports = { baseOf, convert };
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ // Canonical AST. N-ary Sum and Product with signed numeric leaves.
4
+ // Invariants enforced by the constructors below:
5
+ //
6
+ // - Num/Dim values may be any finite number (negatives allowed); a `-5`
7
+ // is `Num(-5)`, never `Sum([{sign:-1, Num(5)}])`. One form per value.
8
+ // - In a SumTerm with Num/Dim node, sign is always +1; the sign slot is
9
+ // reserved for opaque nodes (Ident, Call, Product, multi-term Sum).
10
+ // - No Sum directly contains another Sum (flattened on construction).
11
+ // - No Product directly contains another Product (flattened).
12
+ // - A Sum/Product with one positive element collapses to that element.
13
+ // - A Sum/Product with no elements collapses to Num(0) / Num(1).
14
+ // - Zero-valued Nums are dropped from sums (they contribute nothing).
15
+ // Zero-valued Dims are kept — the unit carries type info.
16
+
17
+ /**
18
+ * @typedef {{type: 'Num', value: number}} Num
19
+ * @typedef {{type: 'Dim', value: number, unit: string}} Dim
20
+ * @typedef {{type: 'Ident', name: string}} Ident
21
+ * @typedef {{type: 'Call', name: string, args: Node[]}} Call
22
+ * @typedef {{sign: 1 | -1, node: Node}} SumTerm Sign is always +1 when node is Num or Dim.
23
+ * @typedef {{type: 'Sum', terms: SumTerm[]}} Sum
24
+ * @typedef {{exponent: 1 | -1, node: Node}} ProductFactor exponent +1 = numerator, -1 = denominator.
25
+ * @typedef {{type: 'Product', factors: ProductFactor[]}} Product
26
+ * @typedef {Num | Dim | Ident | Call | Sum | Product} Node
27
+ */
28
+
29
+ /**
30
+ * @param {number} value
31
+ * @return {Num}
32
+ */
33
+ function num(value) {
34
+ return { type: 'Num', value };
35
+ }
36
+
37
+ /**
38
+ * @param {number} value
39
+ * @param {string} unit
40
+ * @return {Dim}
41
+ */
42
+ function dim(value, unit) {
43
+ return { type: 'Dim', value, unit };
44
+ }
45
+
46
+ /**
47
+ * @param {string} name
48
+ * @return {Ident}
49
+ */
50
+ function ident(name) {
51
+ return { type: 'Ident', name };
52
+ }
53
+
54
+ /**
55
+ * @param {string} name
56
+ * @param {Node[]} args
57
+ * @return {Call}
58
+ */
59
+ function call(name, args) {
60
+ return { type: 'Call', name, args };
61
+ }
62
+
63
+ /**
64
+ * @param {SumTerm[]} rawTerms
65
+ * @return {Node}
66
+ */
67
+ function mkSum(rawTerms) {
68
+ /** @type {SumTerm[]} */
69
+ const flat = [];
70
+ for (const t of rawTerms) {
71
+ pushSumTerm(flat, t);
72
+ }
73
+ if (flat.length === 0) {
74
+ return { type: 'Num', value: 0 };
75
+ }
76
+ if (flat.length === 1 && flat[0].sign === 1) {
77
+ return flat[0].node;
78
+ }
79
+ return { type: 'Sum', terms: flat };
80
+ }
81
+
82
+ /**
83
+ * @param {SumTerm[]} out
84
+ * @param {SumTerm} term
85
+ * @return {void}
86
+ */
87
+ function pushSumTerm(out, term) {
88
+ let { sign, node } = term;
89
+
90
+ if (node.type === 'Sum') {
91
+ for (const inner of node.terms) {
92
+ pushSumTerm(out, {
93
+ sign: /** @type {1 | -1} */ (sign * inner.sign),
94
+ node: inner.node,
95
+ });
96
+ }
97
+ return;
98
+ }
99
+
100
+ // sign=-1 around a Num/Dim leaf collapses into the value's sign — the
101
+ // canonical-form rule downstream code relies on.
102
+ if (sign === -1) {
103
+ if (node.type === 'Num') {
104
+ node = { type: 'Num', value: -node.value };
105
+ sign = 1;
106
+ } else if (node.type === 'Dim') {
107
+ node = { type: 'Dim', value: -node.value, unit: node.unit };
108
+ sign = 1;
109
+ }
110
+ }
111
+
112
+ // Drop zero-valued Nums. Dims with value 0 stay — the unit carries type.
113
+ if (node.type === 'Num' && node.value === 0) {
114
+ return;
115
+ }
116
+
117
+ out.push({ sign, node });
118
+ }
119
+
120
+ /**
121
+ * @param {ProductFactor[]} rawFactors
122
+ * @return {Node}
123
+ */
124
+ function mkProduct(rawFactors) {
125
+ /** @type {ProductFactor[]} */
126
+ const flat = [];
127
+ for (const f of rawFactors) {
128
+ pushProductFactor(flat, f);
129
+ }
130
+ if (flat.length === 0) {
131
+ return { type: 'Num', value: 1 };
132
+ }
133
+ if (flat.length === 1 && flat[0].exponent === 1) {
134
+ return flat[0].node;
135
+ }
136
+ return { type: 'Product', factors: flat };
137
+ }
138
+
139
+ /**
140
+ * @param {ProductFactor[]} out
141
+ * @param {ProductFactor} f
142
+ * @return {void}
143
+ */
144
+ function pushProductFactor(out, f) {
145
+ const n = f.node;
146
+ if (n.type === 'Product') {
147
+ for (const inner of n.factors) {
148
+ out.push({
149
+ exponent: /** @type {1 | -1} */ (f.exponent * inner.exponent),
150
+ node: inner.node,
151
+ });
152
+ }
153
+ return;
154
+ }
155
+ // Factor of 1 contributes nothing regardless of exponent (1/1 = 1).
156
+ if (n.type === 'Num' && n.value === 1) {
157
+ return;
158
+ }
159
+ out.push(f);
160
+ }
161
+
162
+ /**
163
+ * Negate any node, preserving canonical form.
164
+ * @param {Node} node
165
+ * @return {Node}
166
+ */
167
+ function negate(node) {
168
+ if (node.type === 'Num') {
169
+ return num(-node.value);
170
+ }
171
+ if (node.type === 'Dim') {
172
+ return dim(-node.value, node.unit);
173
+ }
174
+ if (node.type === 'Sum') {
175
+ return mkSum(
176
+ node.terms.map((t) => ({
177
+ sign: /** @type {1 | -1} */ (-t.sign),
178
+ node: t.node,
179
+ }))
180
+ );
181
+ }
182
+ // Opaque (Ident, Call, Product): wrap as a single negative-sign term —
183
+ // the only case where sign=-1 remains on a SumTerm.
184
+ return { type: 'Sum', terms: [{ sign: -1, node }] };
185
+ }
186
+
187
+ // Stryker disable next-line all: instrumenting this line breaks Node's
188
+ // cjs-module-lexer named-export detection for .mjs `import { x } from` consumers.
189
+ module.exports = { num, dim, ident, call, mkSum, mkProduct, negate };