postcss-calc 11.1.2 → 11.2.1

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.
@@ -0,0 +1,80 @@
1
+ import { parse } from './parser.js';
2
+ import { simplify } from './simplify.js';
3
+ import { analyze } from './analyze.js';
4
+
5
+ /** @typedef {import('./scan.js').Candidate} Candidate */
6
+ /** @typedef {import('../reduce.js').ResolvedReduceCalcOptions} ResolvedReduceCalcOptions */
7
+ /** @typedef {import('../reduce.js').Replacement} Replacement */
8
+ /** @typedef {import('@csstools/css-tokenizer').CSSToken} CSSToken */
9
+ /** @typedef {ReturnType<typeof import('./block-index.js').indexBlocks>} BlockIndex */
10
+ /** @typedef {{options: ResolvedReduceCalcOptions, value: string, tokens: CSSToken[], index: BlockIndex}} CompileContext */
11
+
12
+ /**
13
+ * Parse, analyze, and simplify one candidate. Analysis is the validity/status
14
+ * gate over the original tree; simplification then runs independently as a
15
+ * composable AST transformation that may synthesize nodes.
16
+ *
17
+ * @param {Candidate} candidate
18
+ * @param {CompileContext} ctx
19
+ * @return {Replacement}
20
+ */
21
+ function compileCandidate(candidate, ctx) {
22
+ if (!candidate.closed) {
23
+ throw new Error(
24
+ `Unclosed ${candidate.name}( at position ${candidate.start}`
25
+ );
26
+ }
27
+ const parsed = parse(
28
+ ctx.tokens,
29
+ candidate.sliceStart,
30
+ candidate.sliceEnd,
31
+ ctx.index
32
+ );
33
+ const analysis = analyze(parsed);
34
+ if (!analysis.valid) {
35
+ throw new Error('Invalid CSS calculation type');
36
+ }
37
+ const tree = simplify(parsed);
38
+ const original =
39
+ analysis.unresolved && !candidate.calculation
40
+ ? ctx.value.slice(candidate.start, candidate.end)
41
+ : undefined;
42
+ return {
43
+ start: candidate.start,
44
+ end: candidate.end,
45
+ result: {
46
+ tree,
47
+ status: analysis.unresolved ? 'unresolved' : 'resolved',
48
+ rootName: candidate.normalizedName,
49
+ rootSpelling: candidate.rootSpelling,
50
+ calculation: candidate.calculation,
51
+ original,
52
+ },
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Compile candidates independently so one malformed calculation is preserved
58
+ * without preventing unrelated candidates from being reduced.
59
+ *
60
+ * @param {Candidate[]} candidates
61
+ * @param {CompileContext} ctx
62
+ * @return {Replacement[]}
63
+ */
64
+ function compileCandidates(candidates, ctx) {
65
+ /** @type {Replacement[]} */
66
+ const replacements = [];
67
+ for (const candidate of candidates) {
68
+ try {
69
+ replacements.push(compileCandidate(candidate, ctx));
70
+ } catch (error) {
71
+ ctx.options.onParseError?.(
72
+ error instanceof Error ? error : new Error('Error', { cause: error }),
73
+ ctx.value.slice(candidate.start, candidate.end)
74
+ );
75
+ }
76
+ }
77
+ return replacements;
78
+ }
79
+
80
+ export { compileCandidate, compileCandidates };
@@ -0,0 +1,394 @@
1
+ import { simplifyMinMax } from './simplify/min-max.js';
2
+ import { simplifyClamp } from './simplify/clamp.js';
3
+ import { simplifyAbs } from './simplify/abs.js';
4
+ import { simplifySign } from './simplify/sign.js';
5
+ import { simplifyModRem } from './simplify/mod-rem.js';
6
+ import { ROUND_STRATEGIES, simplifyRound } from './simplify/round.js';
7
+ import { simplifyTrig } from './simplify/trig.js';
8
+ import { simplifyInverseTrig } from './simplify/inverse-trig.js';
9
+ import { simplifyAtan2 } from './simplify/atan2.js';
10
+ import { simplifyPow } from './simplify/pow.js';
11
+ import { simplifySqrt } from './simplify/sqrt.js';
12
+ import { simplifyExp } from './simplify/exp.js';
13
+ import { simplifyLog } from './simplify/log.js';
14
+ import { simplifyHypot } from './simplify/hypot.js';
15
+
16
+ /** @typedef {import('./node.js').Node} Node */
17
+ /**
18
+ * `percent` marks a value that resolves in the same percentage context as its
19
+ * peers (a pure percentage). It is only produced at leaves, by abs(), and by
20
+ * homogeneous sums and calls — never by a product, where an unpaired
21
+ * percentage could no longer cancel against anything.
22
+ * @typedef {{kind: 'number'} | {kind: 'dimension', base: string | null} | {kind: 'unknown', percent?: true} | {kind: 'failure'}} CalculationType
23
+ */
24
+ /** @typedef {(name: string, args: Node[]) => Node} MathSimplifier */
25
+ /** @typedef {(args: CalculationType[], nodes: Node[]) => CalculationType} TypeAnalyzer */
26
+ /** @typedef {{analyze: TypeAnalyzer, simplify?: MathSimplifier, isKeyword?: (node: Node, index: number) => boolean, calculation?: boolean}} MathFunction */
27
+
28
+ /** @type {CalculationType} */ const numberType = { kind: 'number' };
29
+ /** @type {CalculationType} */ const unknownType = { kind: 'unknown' };
30
+ /** @type {CalculationType} */ const percentageType = {
31
+ kind: 'unknown',
32
+ percent: true,
33
+ };
34
+ /** @type {CalculationType} */ const failureType = { kind: 'failure' };
35
+
36
+ /** @param {CalculationType} type @return {boolean} */
37
+ function isFailure(type) {
38
+ return type.kind === 'failure';
39
+ }
40
+
41
+ /** @param {CalculationType} type @return {boolean} */
42
+ function isPercentage(type) {
43
+ return type.kind === 'unknown' && type.percent === true;
44
+ }
45
+
46
+ /** @param {CalculationType} a @param {CalculationType} b @return {CalculationType} */
47
+ function addTypes(a, b) {
48
+ if (isFailure(a) || isFailure(b)) return failureType;
49
+ if (a.kind === 'unknown' || b.kind === 'unknown') {
50
+ // A pure percentage keeps its contextual type so a surrounding product
51
+ // can cancel `% / %`; mixing it with any other opaque operand loses the
52
+ // guarantee that it resolves in the same context as its peers. A known
53
+ // number + percentage sum is likewise kept unknown and valid: the spec
54
+ // resolves the percentage against its surrounding context, which this
55
+ // coarse type model does not track.
56
+ return isPercentage(a) && isPercentage(b) ? percentageType : unknownType;
57
+ }
58
+ if (a.kind === 'number' && b.kind === 'number') return numberType;
59
+ if (a.kind === 'dimension' && b.kind === 'dimension') {
60
+ if (a.base === null || b.base === null) return unknownType;
61
+ return a.base === b.base ? a : failureType;
62
+ }
63
+ return failureType;
64
+ }
65
+
66
+ /**
67
+ * Check an all-number function without assuming anything about an unresolved
68
+ * operand. A concrete dimension can never become a number, so it is still a
69
+ * definite error when another argument is opaque.
70
+ * @param {CalculationType[]} args
71
+ * @param {number} min
72
+ * @param {number} max
73
+ * @return {CalculationType}
74
+ */
75
+ function numberArguments(args, min, max) {
76
+ if (args.length < min || args.length > max) return failureType;
77
+ let hasUnknown = false;
78
+ for (const arg of args) {
79
+ if (arg.kind === 'dimension') return failureType;
80
+ hasUnknown = hasUnknown || arg.kind === 'unknown';
81
+ }
82
+ return hasUnknown ? unknownType : numberType;
83
+ }
84
+
85
+ /**
86
+ * Check values that must share a calculation type. Unknown operands remain
87
+ * unknown: they might resolve to the concrete type required by their peers.
88
+ * @param {CalculationType[]} args
89
+ * @param {number} min
90
+ * @param {number} max
91
+ * @return {CalculationType}
92
+ */
93
+ function matchingArguments(args, min, max) {
94
+ if (args.length < min || args.length > max) return failureType;
95
+ let result = args[0];
96
+ for (let index = 1; index < args.length; index++) {
97
+ result = addTypes(result, args[index]);
98
+ if (isFailure(result)) return failureType;
99
+ }
100
+ return result;
101
+ }
102
+
103
+ /** @param {CalculationType[]} args @return {CalculationType} */
104
+ function analyzeTrig(args) {
105
+ return args.length === 1 &&
106
+ (args[0].kind === 'unknown' ||
107
+ args[0].kind === 'number' ||
108
+ (args[0].kind === 'dimension' && args[0].base === 'angle'))
109
+ ? numberType
110
+ : failureType;
111
+ }
112
+
113
+ /** @param {CalculationType[]} args @return {CalculationType} */
114
+ function analyzeInverseTrig(args) {
115
+ if (args.length !== 1 || args[0].kind === 'dimension') {
116
+ return failureType;
117
+ }
118
+ return args[0].kind === 'unknown'
119
+ ? unknownType
120
+ : { kind: 'dimension', base: 'angle' };
121
+ }
122
+
123
+ /** @param {CalculationType[]} args @return {CalculationType} */
124
+ function analyzeIdentity(args) {
125
+ return args.length === 1 ? args[0] : failureType;
126
+ }
127
+
128
+ /** @param {CalculationType[]} args @return {CalculationType} */
129
+ function analyzeSign(args) {
130
+ return args.length === 1 ? numberType : failureType;
131
+ }
132
+
133
+ /** @param {CalculationType[]} args @param {Node[]} nodes @return {CalculationType} */
134
+ function analyzeRound(args, nodes) {
135
+ const strategy = nodes[0];
136
+ const hasStrategy =
137
+ strategy?.type === 'Ident' &&
138
+ ROUND_STRATEGIES.has(strategy.name.toLowerCase());
139
+ const start = hasStrategy ? 1 : 0;
140
+ const valueCount = args.length - start;
141
+ if (valueCount === 1) {
142
+ const value = args[start];
143
+ if (value.kind === 'dimension') return failureType;
144
+ if (value.kind === 'unknown') return unknownType;
145
+ return numberType;
146
+ }
147
+ if (valueCount !== 2) return failureType;
148
+ const type = addTypes(args[start], args[start + 1]);
149
+ return isFailure(type) ? failureType : type;
150
+ }
151
+
152
+ /** @param {CalculationType[]} args @return {CalculationType} */
153
+ function analyzeAtan2(args) {
154
+ const type = matchingArguments(args, 2, 2);
155
+ if (isFailure(type)) return type;
156
+ // The type table gives atan2() «["angle" → 1]»; an unresolved result is
157
+ // plain unknown, never the percentage of its arguments.
158
+ if (type.kind === 'unknown') return unknownType;
159
+ return { kind: 'dimension', base: 'angle' };
160
+ }
161
+
162
+ /** @param {CalculationType[]} args @return {CalculationType} */
163
+ function analyzeCalc(args) {
164
+ return args.length === 1 ? args[0] : failureType;
165
+ }
166
+
167
+ /** @param {Node} node @return {boolean} */
168
+ function isRoundStrategy(node) {
169
+ return node.type === 'Ident' && ROUND_STRATEGIES.has(node.name.toLowerCase());
170
+ }
171
+
172
+ /** @param {Node} node @param {number} index @return {boolean} */
173
+ function isClampKeyword(node, index) {
174
+ return (
175
+ (index === 0 || index === 2) &&
176
+ node.type === 'Ident' &&
177
+ node.name.toLowerCase() === 'none'
178
+ );
179
+ }
180
+
181
+ /** @param {CalculationType[]} args @param {Node[]} nodes @return {CalculationType} */
182
+ function analyzeClamp(args, nodes) {
183
+ if (args.length !== 3) return failureType;
184
+ let valueCount = 0;
185
+ /** @type {CalculationType} */ let result = failureType;
186
+ for (let index = 0; index < args.length; index++) {
187
+ if (isClampKeyword(nodes[index], index)) continue;
188
+ result = valueCount === 0 ? args[index] : addTypes(result, args[index]);
189
+ valueCount++;
190
+ }
191
+ if (valueCount < 1 || isFailure(result)) return failureType;
192
+ return result;
193
+ }
194
+
195
+ const mathFunctions = new Map(
196
+ /** @type {[string, MathFunction][]} */ ([
197
+ ['calc', { analyze: analyzeCalc, calculation: true }],
198
+ ['-webkit-calc', { analyze: analyzeCalc, calculation: true }],
199
+ ['-moz-calc', { analyze: analyzeCalc, calculation: true }],
200
+ [
201
+ 'min',
202
+ {
203
+ analyze: (args) => matchingArguments(args, 1, Infinity),
204
+ simplify: simplifyMinMax,
205
+ },
206
+ ],
207
+ [
208
+ 'max',
209
+ {
210
+ analyze: (args) => matchingArguments(args, 1, Infinity),
211
+ simplify: simplifyMinMax,
212
+ },
213
+ ],
214
+ [
215
+ 'clamp',
216
+ {
217
+ analyze: analyzeClamp,
218
+ simplify: (_name, args) => simplifyClamp(args),
219
+ isKeyword: isClampKeyword,
220
+ },
221
+ ],
222
+ [
223
+ 'abs',
224
+ {
225
+ analyze: analyzeIdentity,
226
+ simplify: (_name, args) => simplifyAbs(args),
227
+ },
228
+ ],
229
+ [
230
+ 'sign',
231
+ {
232
+ analyze: analyzeSign,
233
+ simplify: (_name, args) => simplifySign(args),
234
+ },
235
+ ],
236
+ [
237
+ 'mod',
238
+ {
239
+ analyze: (args) => matchingArguments(args, 2, 2),
240
+ simplify: (_name, args) => simplifyModRem('mod', args),
241
+ },
242
+ ],
243
+ [
244
+ 'rem',
245
+ {
246
+ analyze: (args) => matchingArguments(args, 2, 2),
247
+ simplify: (_name, args) => simplifyModRem('rem', args),
248
+ },
249
+ ],
250
+ [
251
+ 'round',
252
+ {
253
+ analyze: analyzeRound,
254
+ simplify: (_name, args) => simplifyRound(args),
255
+ isKeyword: (node, index) => index === 0 && isRoundStrategy(node),
256
+ },
257
+ ],
258
+ [
259
+ 'sin',
260
+ {
261
+ analyze: analyzeTrig,
262
+ simplify: (_name, args) => simplifyTrig('sin', args),
263
+ },
264
+ ],
265
+ [
266
+ 'cos',
267
+ {
268
+ analyze: analyzeTrig,
269
+ simplify: (_name, args) => simplifyTrig('cos', args),
270
+ },
271
+ ],
272
+ [
273
+ 'tan',
274
+ {
275
+ analyze: analyzeTrig,
276
+ simplify: (_name, args) => simplifyTrig('tan', args),
277
+ },
278
+ ],
279
+ [
280
+ 'asin',
281
+ {
282
+ analyze: analyzeInverseTrig,
283
+ simplify: (_name, args) => simplifyInverseTrig('asin', args),
284
+ },
285
+ ],
286
+ [
287
+ 'acos',
288
+ {
289
+ analyze: analyzeInverseTrig,
290
+ simplify: (_name, args) => simplifyInverseTrig('acos', args),
291
+ },
292
+ ],
293
+ [
294
+ 'atan',
295
+ {
296
+ analyze: analyzeInverseTrig,
297
+ simplify: (_name, args) => simplifyInverseTrig('atan', args),
298
+ },
299
+ ],
300
+ [
301
+ 'atan2',
302
+ {
303
+ analyze: analyzeAtan2,
304
+ simplify: (_name, args) => simplifyAtan2(args),
305
+ },
306
+ ],
307
+ [
308
+ 'pow',
309
+ {
310
+ analyze: (args) => numberArguments(args, 2, 2),
311
+ simplify: (_name, args) => simplifyPow(args),
312
+ },
313
+ ],
314
+ [
315
+ 'sqrt',
316
+ {
317
+ analyze: (args) => numberArguments(args, 1, 1),
318
+ simplify: (_name, args) => simplifySqrt(args),
319
+ },
320
+ ],
321
+ [
322
+ 'hypot',
323
+ {
324
+ analyze: (args) => matchingArguments(args, 1, Infinity),
325
+ simplify: (_name, args) => simplifyHypot(args),
326
+ },
327
+ ],
328
+ [
329
+ 'log',
330
+ {
331
+ analyze: (args) => numberArguments(args, 1, 2),
332
+ simplify: (_name, args) => simplifyLog(args),
333
+ },
334
+ ],
335
+ [
336
+ 'exp',
337
+ {
338
+ analyze: (args) => numberArguments(args, 1, 1),
339
+ simplify: (_name, args) => simplifyExp(args),
340
+ },
341
+ ],
342
+ ])
343
+ );
344
+
345
+ /**
346
+ * @param {string} name
347
+ * @return {{normalizedName: string, definition: MathFunction} | undefined}
348
+ */
349
+ function lookupMathFunction(name) {
350
+ const normalizedName = name.toLowerCase();
351
+ const definition = mathFunctions.get(normalizedName);
352
+ return definition === undefined ? undefined : { normalizedName, definition };
353
+ }
354
+
355
+ const mathFunctionNames = [...mathFunctions.keys()];
356
+ const QUICK_MATH_TEST = new RegExp(
357
+ `(?:${mathFunctionNames.join('|')})\\(`,
358
+ 'i'
359
+ );
360
+
361
+ /** @param {string} name @return {boolean} */
362
+ function isCalculationFunction(name) {
363
+ return mathFunctions.get(name.toLowerCase())?.calculation === true;
364
+ }
365
+
366
+ /** @param {string} name @return {boolean} */
367
+ function isSupportedMathFunction(name) {
368
+ const normalizedName = name.toLowerCase();
369
+ const definition = mathFunctions.get(normalizedName);
370
+ return definition !== undefined && definition.calculation !== true;
371
+ }
372
+
373
+ /** @param {string} value @return {boolean} */
374
+ function hasPotentialMathFunction(value) {
375
+ return (
376
+ value.includes('(') && (QUICK_MATH_TEST.test(value) || value.includes('\\'))
377
+ );
378
+ }
379
+
380
+ export {
381
+ addTypes,
382
+ failureType,
383
+ hasPotentialMathFunction,
384
+ isCalculationFunction,
385
+ isFailure,
386
+ isPercentage,
387
+ isSupportedMathFunction,
388
+ lookupMathFunction,
389
+ mathFunctions,
390
+ numberType,
391
+ percentageType,
392
+ QUICK_MATH_TEST,
393
+ unknownType,
394
+ };
@@ -0,0 +1,49 @@
1
+ /** @typedef {import('./node.js').Node} Node */
2
+
3
+ const MAX_CALCULATION_DEPTH = 1024;
4
+
5
+ /** @param {number} depth @return {void} */
6
+ function assertDepth(depth) {
7
+ if (depth > MAX_CALCULATION_DEPTH) {
8
+ throw new Error(
9
+ `Calculation nesting exceeds the limit of ${MAX_CALCULATION_DEPTH}`
10
+ );
11
+ }
12
+ }
13
+
14
+ /** @param {unknown} part @param {number} depth @return {void} */
15
+ function checkOpaquePartDepth(part, depth) {
16
+ assertDepth(depth);
17
+ if (typeof part === 'string') return;
18
+ if (Array.isArray(part)) {
19
+ for (const child of part) checkOpaquePartDepth(child, depth + 1);
20
+ return;
21
+ }
22
+ checkCalculationDepth(/** @type {Node} */ (part), depth + 1);
23
+ }
24
+
25
+ /** @param {Node} node @param {number} [depth] @return {void} */
26
+ function checkCalculationDepth(node, depth = 0) {
27
+ assertDepth(depth);
28
+ switch (node.type) {
29
+ case 'Sum':
30
+ for (const term of node.terms) {
31
+ checkCalculationDepth(term.node, depth + 1);
32
+ }
33
+ return;
34
+ case 'Product':
35
+ for (const factor of node.factors) {
36
+ checkCalculationDepth(factor.node, depth + 1);
37
+ }
38
+ return;
39
+ case 'Call':
40
+ for (const child of node.args) checkCalculationDepth(child, depth + 1);
41
+ return;
42
+ case 'OpaqueCall':
43
+ for (const part of node.components) {
44
+ checkOpaquePartDepth(part, depth + 1);
45
+ }
46
+ }
47
+ }
48
+
49
+ export { MAX_CALCULATION_DEPTH, assertDepth, checkCalculationDepth };
package/src/lib/node.js CHANGED
@@ -11,7 +11,11 @@
11
11
  // - No Product directly contains another Product (flattened).
12
12
  // - A Sum/Product with one positive element collapses to that element.
13
13
  // - A Sum/Product with no elements collapses to Num(0) / Num(1).
14
- // - Zero-valued Nums are dropped from sums (they contribute nothing).
14
+ // - Positive zero-valued Nums are dropped from all-number sums. They are
15
+ // retained in mixed or unresolved sums because they constrain the other
16
+ // terms to <number>. Negative zero is retained until calculation
17
+ // evaluation has finished, because it is an IEEE-754 value with observable
18
+ // math-function behavior.
15
19
  // Zero-valued Dims are kept — the unit carries type info.
16
20
 
17
21
  /**
@@ -19,11 +23,13 @@
19
23
  * @typedef {{type: 'Dim', value: number, unit: string, rawUnit?: string}} Dim
20
24
  * @typedef {{type: 'Ident', name: string, rawName?: string}} Ident
21
25
  * @typedef {{type: 'Call', name: string, args: Node[], rawName?: string}} Call
26
+ * @typedef {string | Node | OpaqueComponent[]} OpaqueComponent
27
+ * @typedef {{type: 'OpaqueCall', name: string, components: OpaqueComponent[], rawName?: string}} OpaqueCall
22
28
  * @typedef {{sign: 1 | -1, node: Node}} SumTerm Sign is always +1 when node is Num or Dim.
23
29
  * @typedef {{type: 'Sum', terms: SumTerm[], grouped?: boolean}} Sum
24
30
  * @typedef {{exponent: 1 | -1, node: Node}} ProductFactor exponent +1 = numerator, -1 = denominator.
25
31
  * @typedef {{type: 'Product', factors: ProductFactor[]}} Product
26
- * @typedef {Num | Dim | Ident | Call | Sum | Product} Node
32
+ * @typedef {Num | Dim | Ident | Call | OpaqueCall | Sum | Product} Node
27
33
  */
28
34
 
29
35
  /**
@@ -69,6 +75,18 @@ function call(name, args, rawName) {
69
75
  : { type: 'Call', name, args, rawName };
70
76
  }
71
77
 
78
+ /**
79
+ * @param {string} name
80
+ * @param {OpaqueComponent[]} components
81
+ * @param {string} [rawName]
82
+ * @return {OpaqueCall}
83
+ */
84
+ function opaqueCall(name, components, rawName) {
85
+ return rawName === undefined
86
+ ? { type: 'OpaqueCall', name, components }
87
+ : { type: 'OpaqueCall', name, components, rawName };
88
+ }
89
+
72
90
  /**
73
91
  * @param {SumTerm[]} rawTerms
74
92
  * @return {Node}
@@ -76,13 +94,32 @@ function call(name, args, rawName) {
76
94
  function mkSum(rawTerms) {
77
95
  /** @type {SumTerm[]} */
78
96
  const flat = [];
97
+ let hasNegativeZero = false;
79
98
  for (const t of rawTerms) {
80
- pushSumTerm(flat, t);
99
+ if (pushSumTerm(flat, t)) hasNegativeZero = true;
81
100
  }
82
- if (flat.length === 0) {
101
+ const zeroIsTypeAnchor = flat.some((term) => term.node.type !== 'Num');
102
+ // `+0 + -0` evaluates to +0. Keep positive zero terms when the sum also
103
+ // contains -0 so simplification can perform that IEEE-754 operation before
104
+ // the canonical zero-elision below.
105
+ let length = 0;
106
+ for (let i = 0; i < flat.length; i++) {
107
+ const term = flat[i];
108
+ if (
109
+ !hasNegativeZero &&
110
+ !zeroIsTypeAnchor &&
111
+ term.node.type === 'Num' &&
112
+ term.node.value === 0
113
+ ) {
114
+ continue;
115
+ }
116
+ flat[length++] = term;
117
+ }
118
+ flat.length = length;
119
+ if (length === 0) {
83
120
  return num(0);
84
121
  }
85
- if (flat.length === 1 && flat[0].sign === 1) {
122
+ if (length === 1 && flat[0].sign === 1) {
86
123
  return flat[0].node;
87
124
  }
88
125
  return { type: 'Sum', terms: flat };
@@ -91,19 +128,24 @@ function mkSum(rawTerms) {
91
128
  /**
92
129
  * @param {SumTerm[]} out
93
130
  * @param {SumTerm} term
94
- * @return {void}
131
+ * @return {boolean} Whether the appended terms contain negative zero.
95
132
  */
96
133
  function pushSumTerm(out, term) {
97
134
  let { sign, node } = term;
98
135
 
99
136
  if (node.type === 'Sum' && !node.grouped) {
137
+ let hasNegativeZero = false;
100
138
  for (const inner of node.terms) {
101
- pushSumTerm(out, {
102
- sign: /** @type {1 | -1} */ (sign * inner.sign),
103
- node: inner.node,
104
- });
139
+ if (
140
+ pushSumTerm(out, {
141
+ sign: /** @type {1 | -1} */ (sign * inner.sign),
142
+ node: inner.node,
143
+ })
144
+ ) {
145
+ hasNegativeZero = true;
146
+ }
105
147
  }
106
- return;
148
+ return hasNegativeZero;
107
149
  }
108
150
 
109
151
  // sign=-1 around a Num/Dim leaf collapses into the value's sign — the
@@ -113,12 +155,8 @@ function pushSumTerm(out, term) {
113
155
  sign = 1;
114
156
  }
115
157
 
116
- // Drop zero-valued Nums. Dims with value 0 stay — the unit carries type.
117
- if (node.type === 'Num' && node.value === 0) {
118
- return;
119
- }
120
-
121
158
  out.push({ sign, node });
159
+ return node.type === 'Num' && Object.is(node.value, -0);
122
160
  }
123
161
 
124
162
  /**
@@ -176,19 +214,23 @@ function negate(node) {
176
214
  return dim(-node.value, node.unit, node.rawUnit);
177
215
  }
178
216
  if (node.type === 'Sum') {
217
+ // A grouped sum may contain opaque terms whose meaning depends on the
218
+ // surrounding context. Keep the group intact so `-1 * (a + b)` cannot turn
219
+ // into `-a - b` while it is still unresolved.
220
+ if (node.grouped) {
221
+ return mkSum([{ sign: -1, node }]);
222
+ }
179
223
  const result = mkSum(
180
224
  node.terms.map((t) => ({
181
225
  sign: /** @type {1 | -1} */ (-t.sign),
182
226
  node: t.node,
183
227
  }))
184
228
  );
185
- return node.grouped && result.type === 'Sum'
186
- ? { ...result, grouped: true }
187
- : result;
229
+ return result;
188
230
  }
189
- // Opaque (Ident, Call, Product): wrap as a single negative-sign term —
231
+ // Opaque (Ident, Call, OpaqueCall, Product): wrap as a single negative-sign term —
190
232
  // the only case where sign=-1 remains on a SumTerm.
191
233
  return mkSum([{ sign: -1, node }]);
192
234
  }
193
235
 
194
- export { num, dim, ident, call, mkSum, mkProduct, negate };
236
+ export { num, dim, ident, call, opaqueCall, mkSum, mkProduct, negate };