@wzo/calc 0.0.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,366 @@
1
+ // Number formatting: parse token string → options → format
2
+ import type { IGlobalConfig } from './config'
3
+ import { getConfig } from './config'
4
+ import { evaluate } from './parser'
5
+ import { abs, cmp, div, format as fmtDecimal, mul, parse, roundBanker, roundCeil, roundHalfUp, truncate } from './precision'
6
+
7
+ /**
8
+ * Rounding strategy. Includes JS-style aliases: `'round'`≡`'halfUp'` (same as `Math.round`),
9
+ * `'trunc'`≡`'truncate'` (same as `Math.trunc`, truncate toward zero),
10
+ * `'halfEven'`≡`'banker'` (same as `Intl.NumberFormat` `roundingMode: 'halfEven'`, banker's rounding).
11
+ */
12
+ export type Rounding = 'truncate' | 'trunc' | 'halfUp' | 'round' | 'banker' | 'halfEven' | 'ceil'
13
+
14
+ // Internal canonical rounding names (the 4 modes recognized by applyRounding)
15
+ type Canon = 'truncate' | 'halfUp' | 'banker' | 'ceil'
16
+
17
+ /**
18
+ * Format options object — passed to {@link fmt} / `calc`'s `_fmt` / chaining terminators.
19
+ * Equivalent to a format token string but with full type hints.
20
+ */
21
+ export interface IFormat {
22
+ /** Decimal places: number = fixed (`=N`); object = range (`{ max }`≡`<=N`, `{ min }`≡`>=N`) */
23
+ decimals?: number | { min?: number, max?: number }
24
+ /** Rounding strategy (`~5`/`~6`/`~-`/`~+`) */
25
+ rounding?: Rounding
26
+ /** Thousands separator: `true` = US style; or `'us'`/`'eu'`/`'in'` (≡ `,` / `!t:preset`) */
27
+ thousands?: boolean | 'us' | 'eu' | 'in'
28
+ /** Compact notation: `true` = K/M/B/T; or `'zh'` = 万/亿 (≡ `!c` / `!c:preset`) */
29
+ compact?: boolean | 'zh'
30
+ /** Clamp value to `[min, max]`; out-of-range values are clamped to the boundary (≡ `[min,max]`) */
31
+ clamp?: [number | string, number | string]
32
+ /** Output form (mutually exclusive): accepts human-readable words or token symbols (≡ `%%` / `//` / `!e` / `!n`) */
33
+ output?: 'percent' | '%%' | 'fraction' | '//' | 'scientific' | 'e' | 'number' | 'num'
34
+ /** Show explicit plus sign (≡ `+`) */
35
+ plus?: boolean
36
+ /** Left-pad the integer part with zeros to N digits (≡ `!i:N`) */
37
+ pad?: number
38
+ }
39
+
40
+ // Flat internal structure (output of normalizeFormat / input of formatValue)
41
+ export interface IFormatOpts {
42
+ fixed?: number // =N
43
+ max?: number // <=N
44
+ min?: number // >=N
45
+ thousands?: boolean // ,
46
+ thousandsPreset?: 'us' | 'eu' | 'in'
47
+ plus?: boolean // +
48
+ percent?: boolean // %%
49
+ fraction?: boolean // //
50
+ scientific?: boolean // !e
51
+ asNumber?: boolean // !n
52
+ compact?: boolean // !c
53
+ compactPreset?: string // !c:zh (Chinese 万/亿)
54
+ intPad?: number // !i:N
55
+ rounding?: Canon // ~- / ~5 / ~6 / ~+
56
+ clampMin?: string // [min,max] lower bound
57
+ clampMax?: string // [min,max] upper bound
58
+ }
59
+
60
+ // IFormat.rounding (including aliases) → internal canonical name
61
+ const ROUNDING_ALIAS: Record<Rounding, Canon> = {
62
+ truncate: 'truncate',
63
+ trunc: 'truncate',
64
+ halfUp: 'halfUp',
65
+ round: 'halfUp',
66
+ banker: 'banker',
67
+ halfEven: 'banker',
68
+ ceil: 'ceil',
69
+ }
70
+
71
+ // IFormat.output (human-readable word / token symbol) → flat boolean field
72
+ const OUTPUT_FLAG: Record<string, 'percent' | 'fraction' | 'scientific' | 'asNumber'> = {
73
+ 'percent': 'percent',
74
+ '%%': 'percent',
75
+ 'fraction': 'fraction',
76
+ '//': 'fraction',
77
+ 'scientific': 'scientific',
78
+ 'e': 'scientific',
79
+ 'number': 'asNumber',
80
+ 'num': 'asNumber',
81
+ }
82
+
83
+ // ───── Module-level regexes ─────
84
+ const RE_THOUSANDS_GROUPS = /\B(?=(?:\d{3})+(?!\d))/g
85
+ const RE_TRAIL_ZERO = /0+$/
86
+ const RE_DOT_END = /\.$/
87
+ const RE_ZERO_VAL = /^0+(?:\.0+)?$/
88
+ const RE_COMMA_GLOBAL = /,/g
89
+
90
+ // ───── Rounding ─────
91
+ const applyRounding = (value: string, decimals: number, mode: Canon): string => {
92
+ switch (mode) {
93
+ case 'halfUp': return roundHalfUp(value, decimals)
94
+ case 'banker': return roundBanker(value, decimals)
95
+ case 'ceil': return roundCeil(value, decimals)
96
+ case 'truncate':
97
+ default: return truncate(value, decimals)
98
+ }
99
+ }
100
+
101
+ // ───── Thousands separator (integer part only) ─────
102
+ const applyThousands = (intStr: string, preset?: 'us' | 'eu' | 'in'): string => {
103
+ if (preset === 'in') {
104
+ // Indian: last 3 digits, then groups of 2 (e.g. 123,45,678)
105
+ if (intStr.length <= 3) return intStr
106
+ const last3 = intStr.slice(-3)
107
+ const restPart = intStr.slice(0, -3)
108
+ const parts: string[] = []
109
+ let r = restPart
110
+ while (r.length > 2) {
111
+ parts.unshift(r.slice(-2))
112
+ r = r.slice(0, -2)
113
+ }
114
+ if (r) parts.unshift(r)
115
+ return `${parts.join(',')},${last3}`
116
+ }
117
+ return intStr.replace(RE_THOUSANDS_GROUPS, ',')
118
+ }
119
+
120
+ // ───── Compact presets ─────
121
+ const COMPACT_PRESETS: Record<string, Array<{ scale: number, suffix: string }>> = {
122
+ default: [
123
+ { scale: 12, suffix: 'T' },
124
+ { scale: 9, suffix: 'B' },
125
+ { scale: 6, suffix: 'M' },
126
+ { scale: 3, suffix: 'K' },
127
+ ],
128
+ // Chinese: 万 (10k) / 亿 (100M)
129
+ zh: [
130
+ { scale: 12, suffix: '万亿' },
131
+ { scale: 8, suffix: '亿' },
132
+ { scale: 4, suffix: '万' },
133
+ ],
134
+ }
135
+
136
+ const applyCompact = (value: string, preset: string | undefined): { num: string, suffix: string } => {
137
+ const presets = COMPACT_PRESETS[preset || 'default'] || COMPACT_PRESETS.default!
138
+ const absValue = abs(value)
139
+ for (const p of presets) {
140
+ const threshold = `1${'0'.repeat(p.scale)}`
141
+ if (cmp(absValue, threshold) >= 0) {
142
+ return { num: div(value, threshold, 20), suffix: p.suffix }
143
+ }
144
+ }
145
+ return { num: value, suffix: '' }
146
+ }
147
+
148
+ // ───── Scientific notation ─────
149
+ const applyScientific = (value: string): string => {
150
+ const d = parse(value)
151
+ if (d.digits === 0n) return '0e+0'
152
+ const digitStr = d.digits.toString()
153
+ const mantissaExp = digitStr.length - 1
154
+ const e = mantissaExp - d.exp
155
+ const mantissa = digitStr.length === 1
156
+ ? digitStr
157
+ : `${digitStr[0]}.${digitStr.slice(1).replace(RE_TRAIL_ZERO, '')}`.replace(RE_DOT_END, '')
158
+ const sign = d.sign < 0 ? '-' : ''
159
+ return `${sign}${mantissa}e${e >= 0 ? '+' : ''}${e}`
160
+ }
161
+
162
+ // ───── Fraction (reduced to lowest terms) ─────
163
+ const gcd = (a: bigint, b: bigint): bigint => b === 0n ? a : gcd(b, a % b)
164
+
165
+ const applyFraction = (value: string): string => {
166
+ const d = parse(value)
167
+ if (d.digits === 0n) return '0'
168
+ if (d.exp === 0) return d.sign < 0 ? `-${d.digits}/1` : `${d.digits}/1`
169
+ let num = d.digits
170
+ let den = 10n ** BigInt(d.exp)
171
+ const g = gcd(num, den)
172
+ num /= g
173
+ den /= g
174
+ return `${d.sign < 0 ? '-' : ''}${num}/${den}`
175
+ }
176
+
177
+ // ───── Decimal / integer zero-padding ─────
178
+ const padDecimals = (v: string, n: number): string => {
179
+ const dot = v.indexOf('.')
180
+ if (n === 0) return dot === -1 ? v : v.slice(0, dot)
181
+ if (dot === -1) return `${v}.${'0'.repeat(n)}`
182
+ const fracLen = v.length - dot - 1
183
+ if (fracLen >= n) return v
184
+ return v + '0'.repeat(n - fracLen)
185
+ }
186
+
187
+ const padIntegerZeros = (v: string, n: number): string => {
188
+ let sign = ''
189
+ let s = v
190
+ if (s.startsWith('-')) {
191
+ sign = '-'
192
+ s = s.slice(1)
193
+ } else if (s.startsWith('+')) {
194
+ sign = '+'
195
+ s = s.slice(1)
196
+ }
197
+ const dot = s.indexOf('.')
198
+ const intPart = dot === -1 ? s : s.slice(0, dot)
199
+ const rest = dot === -1 ? '' : s.slice(dot)
200
+ if (intPart.length >= n) return sign + intPart + rest
201
+ return sign + intPart.padStart(n, '0') + rest
202
+ }
203
+
204
+ // ───── Thousands separator + EU format ─────
205
+ const applyThousandsAndPreset = (v: string, preset?: 'us' | 'eu' | 'in'): string => {
206
+ let sign = ''
207
+ let s = v
208
+ if (s.startsWith('-')) {
209
+ sign = '-'
210
+ s = s.slice(1)
211
+ } else if (s.startsWith('+')) {
212
+ sign = '+'
213
+ s = s.slice(1)
214
+ }
215
+ const dot = s.indexOf('.')
216
+ const intPart = dot === -1 ? s : s.slice(0, dot)
217
+ const fracPart = dot === -1 ? '' : s.slice(dot + 1)
218
+ const grouped = applyThousands(intPart, preset)
219
+ if (preset === 'eu') {
220
+ // EU style: thousands separator is '.', decimal separator is ','
221
+ const groupedEu = grouped.replace(RE_COMMA_GLOBAL, '.')
222
+ return sign + groupedEu + (fracPart ? `,${fracPart}` : '')
223
+ }
224
+ return sign + grouped + (fracPart ? `.${fracPart}` : '')
225
+ }
226
+
227
+ // ───── Final decoration ─────
228
+ const finalDecorate = (v: string, opts: IFormatOpts, compactSuffix: string): string | number => {
229
+ let s = v
230
+ if (compactSuffix) s += compactSuffix
231
+ if (opts.percent) s += '%'
232
+ if (opts.asNumber) return Number(s)
233
+ return s
234
+ }
235
+
236
+ /**
237
+ * Formats a numeric string according to {@link IFormatOpts} (rounding, zero-padding,
238
+ * thousands separator, compact notation, percent, etc.).
239
+ *
240
+ * Most callers should prefer {@link fmt}; this function accepts already-normalized flat options.
241
+ *
242
+ * @param value Canonical numeric string (e.g. the result of a precision arithmetic operation)
243
+ * @param opts Formatting options produced by {@link normalizeFormat}
244
+ * @returns Formatted result — `number` when `output: 'number'`, `string` otherwise
245
+ */
246
+ export const formatValue = (value: string, opts: IFormatOpts): string | number => {
247
+ let v = value
248
+
249
+ // Clamp: constrain the value to [min, max] before any further formatting
250
+ if (opts.clampMin !== undefined && cmp(v, opts.clampMin) < 0) v = opts.clampMin
251
+ if (opts.clampMax !== undefined && cmp(v, opts.clampMax) > 0) v = opts.clampMax
252
+
253
+ if (opts.percent) v = mul(v, '100')
254
+
255
+ let compactSuffix = ''
256
+ if (opts.compact) {
257
+ const c = applyCompact(v, opts.compactPreset)
258
+ v = c.num
259
+ compactSuffix = c.suffix
260
+ }
261
+
262
+ if (opts.fraction) return finalDecorate(applyFraction(v), opts, compactSuffix)
263
+ if (opts.scientific) return finalDecorate(applyScientific(v), opts, compactSuffix)
264
+
265
+ const rounding = opts.rounding || 'truncate'
266
+ if (opts.fixed !== undefined) {
267
+ v = applyRounding(v, opts.fixed, rounding)
268
+ v = padDecimals(v, opts.fixed)
269
+ } else {
270
+ if (opts.max !== undefined) v = applyRounding(v, opts.max, rounding)
271
+ if (opts.min !== undefined) v = padDecimals(v, opts.min)
272
+ }
273
+
274
+ if (opts.intPad !== undefined) v = padIntegerZeros(v, opts.intPad)
275
+ if (opts.thousands) v = applyThousandsAndPreset(v, opts.thousandsPreset)
276
+
277
+ if (opts.plus && !v.startsWith('-') && !RE_ZERO_VAL.test(v)) v = `+${v}`
278
+
279
+ return finalDecorate(v, opts, compactSuffix)
280
+ }
281
+
282
+ /**
283
+ * Normalizes a high-level {@link IFormat} object into the internal flat options structure.
284
+ *
285
+ * @param format An {@link IFormat} object
286
+ * @returns Flat {@link IFormatOpts}
287
+ */
288
+ export const normalizeFormat = (format: IFormat | undefined): IFormatOpts => {
289
+ if (!format) return {}
290
+ const o: IFormatOpts = {}
291
+ const f = format
292
+ if (typeof f.decimals === 'number') {
293
+ o.fixed = f.decimals
294
+ } else if (f.decimals) {
295
+ if (f.decimals.max !== undefined) o.max = f.decimals.max
296
+ if (f.decimals.min !== undefined) o.min = f.decimals.min
297
+ }
298
+ if (f.rounding) o.rounding = ROUNDING_ALIAS[f.rounding]
299
+ if (f.thousands) {
300
+ o.thousands = true
301
+ if (f.thousands !== true) o.thousandsPreset = f.thousands
302
+ }
303
+ if (f.compact) {
304
+ o.compact = true
305
+ if (f.compact !== true) o.compactPreset = f.compact
306
+ }
307
+ if (f.clamp) {
308
+ o.clampMin = String(f.clamp[0])
309
+ o.clampMax = String(f.clamp[1])
310
+ }
311
+ const outFlag = f.output && OUTPUT_FLAG[f.output]
312
+ if (outFlag) o[outFlag] = true
313
+ if (f.plus) o.plus = true
314
+ if (f.pad !== undefined) o.intPad = f.pad
315
+ return o
316
+ }
317
+
318
+ /** Options for {@link fmt}: formatting fields (see {@link IFormat}) plus `_`-prefixed control options */
319
+ export interface IFmtOptions extends IFormat {
320
+ /** Error fallback value: defaults to the global `_error` (typically `'-'`). **Only `fmt` has a fallback */
321
+ _error?: string | number
322
+ /** Division precision used when evaluating expressions (defaults to the global `_precision`) */
323
+ _precision?: number
324
+ /** Enable unit mode (`%` etc., same as `calc`'s `_unit`) */
325
+ _unit?: boolean
326
+ }
327
+
328
+ const RE_PERCENT = /%/
329
+
330
+ /**
331
+ * Display-oriented formatting: the display counterpart of {@link calc} — same API,
332
+ * **supports arithmetic** (the first argument may be an arithmetic expression string),
333
+ * but **returns `_error` as a fallback on failure instead of throwing**, making it safe
334
+ * for direct use in template rendering (e.g. `{{ fmt(`${price} * ${qty}`, { decimals: 2 }) }}`).
335
+ *
336
+ * - Pass a `string` ⇒ evaluate as an arithmetic expression, then format (`fmt('1 + 2 * 3')` ⇒ `'7'`)
337
+ * - Pass a `number` / `bigint` ⇒ format directly
338
+ * - On error (invalid expression, etc.) ⇒ return the `_error` fallback
339
+ * (local `options._error` takes precedence over the global default `'-'`)
340
+ *
341
+ * @returns Formatted result; the `_error` fallback value on failure
342
+ * @example
343
+ * fmt(1234.5, { decimals: 2, thousands: true }) // '1,234.50'
344
+ * fmt('999.99 * 3', { decimals: 2 }) // '2,999.97' (evaluate first, then format)
345
+ * fmt('bad expr') // '-' (fallback, does not throw)
346
+ */
347
+ export function fmtWith(cfg: IGlobalConfig, value: string | number | bigint, options?: IFmtOptions): string | number {
348
+ try {
349
+ const v = typeof value === 'string'
350
+ ? evaluate(value, { unit: !!options?._unit, precision: options?._precision ?? cfg._precision, trace: undefined })
351
+ : fmtDecimal(parse(value))
352
+ const opts = normalizeFormat(options)
353
+ const out = formatValue(v, opts)
354
+ // Unit mode: if the expression contains % and no explicit output form is set, append % to the result
355
+ if (typeof value === 'string' && options?._unit && RE_PERCENT.test(value) && typeof out === 'string'
356
+ && !opts.percent && !opts.fraction && !opts.scientific && !opts.asNumber) {
357
+ return `${out}%`
358
+ }
359
+ return out
360
+ } catch {
361
+ // Fallback: local _error takes precedence; otherwise use the global _error (default '-')
362
+ return options?._error !== undefined ? options._error : cfg._error
363
+ }
364
+ }
365
+
366
+ export const fmt = (value: string | number | bigint, options?: IFmtOptions): string | number => fmtWith(getConfig(), value, options)
@@ -0,0 +1,310 @@
1
+ // Expression parsing + evaluation (pure arithmetic: four operations + parentheses + math functions;
2
+ // variables are not supported — embed values via template interpolation)
3
+ // Grammar (lowest → highest precedence):
4
+ // addSub := mulDiv (('+' | '-') mulDiv)*
5
+ // mulDiv := unary (('*' | '/') unary)*
6
+ // unary := ('+' | '-')? primary
7
+ // primary := NUMBER | IDENT | FN '(' args ')' | '(' addSub ')'
8
+
9
+ import { abs, add, cmp, div, mul, neg, sub, truncate } from './precision'
10
+
11
+ // ───── Tokenizer ─────
12
+ type TokenType
13
+ = | 'NUMBER' | 'IDENT' | 'UNIT'
14
+ | 'PLUS' | 'MINUS' | 'STAR' | 'SLASH'
15
+ | 'LPAREN' | 'RPAREN' | 'COMMA'
16
+ | 'EOF'
17
+
18
+ interface IToken { type: TokenType, value: string, pos: number }
19
+
20
+ const RE_WS = /\s/
21
+ const RE_DIGIT = /\d/
22
+ const RE_NUM_BODY = /[\d.]/
23
+ // CJK Unified Ideographs range: U+4E00 ~ U+9FA5 (explicit unicode escapes to satisfy lint "obscure range")
24
+ const RE_IDENT_START = /[a-z_$\u4E00-\u9FA5]/i
25
+ const RE_IDENT_BODY = /[\w$\u4E00-\u9FA5]/
26
+
27
+ const SINGLE_OPS: Record<string, TokenType> = {
28
+ '+': 'PLUS',
29
+ '-': 'MINUS',
30
+ '*': 'STAR',
31
+ '/': 'SLASH',
32
+ '(': 'LPAREN',
33
+ ')': 'RPAREN',
34
+ ',': 'COMMA',
35
+ }
36
+
37
+ const tokenize = (input: string): IToken[] => {
38
+ const tokens: IToken[] = []
39
+ let i = 0
40
+ while (i < input.length) {
41
+ const c = input[i]!
42
+ if (RE_WS.test(c)) {
43
+ i++
44
+ continue
45
+ }
46
+ // Number (including decimal / scientific notation)
47
+ if (RE_DIGIT.test(c) || (c === '.' && RE_DIGIT.test(input[i + 1] || ''))) {
48
+ const start = i
49
+ while (i < input.length && RE_NUM_BODY.test(input[i]!)) i++
50
+ if (input[i] === 'e' || input[i] === 'E') {
51
+ i++
52
+ if (input[i] === '+' || input[i] === '-') i++
53
+ while (i < input.length && RE_DIGIT.test(input[i]!)) i++
54
+ }
55
+ tokens.push({ type: 'NUMBER', value: input.slice(start, i), pos: start })
56
+ // Trailing unit: lone % ⇒ UNIT token (exclude %% which is a format token)
57
+ if (input[i] === '%' && input[i + 1] !== '%') {
58
+ tokens.push({ type: 'UNIT', value: '%', pos: i })
59
+ i++
60
+ }
61
+ continue
62
+ }
63
+ // Identifier (math function names such as max / min / clamp)
64
+ if (RE_IDENT_START.test(c)) {
65
+ const start = i
66
+ i++
67
+ while (i < input.length && RE_IDENT_BODY.test(input[i]!)) i++
68
+ tokens.push({ type: 'IDENT', value: input.slice(start, i), pos: start })
69
+ continue
70
+ }
71
+ // Single-character operators
72
+ if (c in SINGLE_OPS) {
73
+ tokens.push({ type: SINGLE_OPS[c]!, value: c, pos: i })
74
+ i++
75
+ continue
76
+ }
77
+ throw new Error(`Illegal character at position ${i}: "${c}"`)
78
+ }
79
+ tokens.push({ type: 'EOF', value: '', pos: input.length })
80
+ return tokens
81
+ }
82
+
83
+ // ───── Evaluation context ─────
84
+ /** Evaluation context for {@link evaluate} */
85
+ export interface IEvalContext {
86
+ /** Unit mode: when `true`, `%` is treated as a unit marker rather than division by 100 */
87
+ unit: boolean
88
+ /** Maximum decimal places to retain in division */
89
+ precision: number
90
+ /** When an array is provided, evaluation steps are recorded into it (used by `_debug`) */
91
+ trace?: string[]
92
+ }
93
+
94
+ // ───── Built-in math functions (all use BigInt precision primitives for exact results) ─────
95
+ /** Floor (toward -∞, same as Math.floor) */
96
+ const mathFloor = (x: string): string => {
97
+ const t = truncate(x, 0)
98
+ return (cmp(x, '0') < 0 && cmp(x, t) !== 0) ? sub(t, '1') : t
99
+ }
100
+ /** Ceiling (toward +∞, same as Math.ceil) */
101
+ const mathCeil = (x: string): string => {
102
+ const t = truncate(x, 0)
103
+ return (cmp(x, '0') > 0 && cmp(x, t) !== 0) ? add(t, '1') : t
104
+ }
105
+ /** Sign: -1 / 0 / 1 */
106
+ const mathSign = (x: string): string => {
107
+ const c = cmp(x, '0')
108
+ return c > 0 ? '1' : c < 0 ? '-1' : '0'
109
+ }
110
+ /** Integer exponentiation (exponent must be an integer; negative exponents use division) */
111
+ const mathPow = (base: string, expStr: string, precision: number): string => {
112
+ const e = Number(expStr)
113
+ if (!Number.isInteger(e)) throw new Error(`pow() exponent must be an integer: "${expStr}"`)
114
+ let r = '1'
115
+ for (let i = 0; i < Math.abs(e); i++) r = mul(r, base)
116
+ return e < 0 ? div('1', r, precision) : r
117
+ }
118
+ /** Modulo (remainder has the same sign as the dividend, same as JS %) */
119
+ const mathMod = (a: string, b: string, precision: number): string => {
120
+ if (cmp(b, '0') === 0) throw new Error('mod division by zero')
121
+ const q = truncate(div(a, b, precision), 0) // truncate-toward-zero quotient
122
+ return sub(a, mul(q, b))
123
+ }
124
+ /** Pick a value from args by comparison (used for min / max) */
125
+ const pickBy = (name: string, args: string[], keep: (c: number) => boolean): string => {
126
+ if (args.length === 0) throw new Error(`${name}() requires at least 1 argument`)
127
+ let r = args[0]!
128
+ for (let i = 1; i < args.length; i++) if (keep(cmp(args[i]!, r))) r = args[i]!
129
+ return r
130
+ }
131
+
132
+ // Fixed arity for each function (min/max are variadic and not in this table)
133
+ const FN_ARITY: Record<string, number> = {
134
+ abs: 1,
135
+ sign: 1,
136
+ floor: 1,
137
+ ceil: 1,
138
+ round: 1,
139
+ trunc: 1,
140
+ pow: 2,
141
+ mod: 2,
142
+ clamp: 3,
143
+ }
144
+
145
+ /** Built-in functions (available inside expressions) */
146
+ const applyFn = (name: string, args: string[], precision: number): string => {
147
+ const arity = FN_ARITY[name]
148
+ if (arity !== undefined && args.length !== arity) {
149
+ throw new Error(`${name}() expects ${arity} argument(s), got ${args.length}`)
150
+ }
151
+ switch (name) {
152
+ case 'abs':
153
+ return abs(args[0]!)
154
+ case 'sign':
155
+ return mathSign(args[0]!)
156
+ case 'floor':
157
+ return mathFloor(args[0]!)
158
+ case 'ceil':
159
+ return mathCeil(args[0]!)
160
+ case 'round':
161
+ return mathFloor(add(args[0]!, '0.5')) // same as Math.round (half rounds toward +∞)
162
+ case 'trunc':
163
+ return truncate(args[0]!, 0)
164
+ case 'pow':
165
+ return mathPow(args[0]!, args[1]!, precision)
166
+ case 'mod':
167
+ return mathMod(args[0]!, args[1]!, precision)
168
+ case 'min':
169
+ return pickBy('min', args, c => c < 0)
170
+ case 'max':
171
+ return pickBy('max', args, c => c > 0)
172
+ case 'clamp': {
173
+ const [x, lo, hi] = args as [string, string, string]
174
+ if (cmp(x, lo) < 0) return lo
175
+ if (cmp(x, hi) > 0) return hi
176
+ return x
177
+ }
178
+ default:
179
+ throw new Error(`Unknown function: "${name}()"`)
180
+ }
181
+ }
182
+
183
+ // ───── Parser + evaluator ─────
184
+ class Parser {
185
+ private tokens: IToken[]
186
+ private pos = 0
187
+
188
+ constructor(input: string, private ctx: IEvalContext) {
189
+ this.tokens = tokenize(input)
190
+ }
191
+
192
+ private peek(): IToken { return this.tokens[this.pos]! }
193
+ private consume(): IToken { return this.tokens[this.pos++]! }
194
+ private match(...types: TokenType[]): IToken | null {
195
+ if (types.includes(this.peek().type)) return this.consume()
196
+ return null
197
+ }
198
+
199
+ private expect(type: TokenType): IToken {
200
+ const t = this.peek()
201
+ if (t.type !== type) throw new Error(`Expected ${type} at position ${t.pos}, got ${t.type}("${t.value}")`)
202
+ return this.consume()
203
+ }
204
+
205
+ private step(line: string): void {
206
+ if (this.ctx.trace) this.ctx.trace.push(line)
207
+ }
208
+
209
+ public parse(): string {
210
+ const v = this.addSub()
211
+ if (this.peek().type !== 'EOF') {
212
+ const t = this.peek()
213
+ throw new Error(`Unexpected token "${t.value}" at position ${t.pos} — expression not fully parsed`)
214
+ }
215
+ return v
216
+ }
217
+
218
+ private addSub(): string {
219
+ let left = this.mulDiv()
220
+ while (true) {
221
+ const op = this.match('PLUS', 'MINUS')
222
+ if (!op) break
223
+ const right = this.mulDiv()
224
+ const prev = left
225
+ left = op.type === 'PLUS' ? add(left, right) : sub(left, right)
226
+ this.step(`${prev} ${op.value} ${right} = ${left}`)
227
+ }
228
+ return left
229
+ }
230
+
231
+ private mulDiv(): string {
232
+ let left = this.unary()
233
+ while (true) {
234
+ const op = this.match('STAR', 'SLASH')
235
+ if (!op) break
236
+ const right = this.unary()
237
+ const prev = left
238
+ left = op.type === 'STAR' ? mul(left, right) : div(left, right, this.ctx.precision)
239
+ this.step(`${prev} ${op.value} ${right} = ${left}`)
240
+ }
241
+ return left
242
+ }
243
+
244
+ private unary(): string {
245
+ if (this.match('PLUS')) return this.unary()
246
+ if (this.match('MINUS')) return neg(this.unary())
247
+ return this.primary()
248
+ }
249
+
250
+ private primary(): string {
251
+ const t = this.peek()
252
+ if (t.type === 'NUMBER') {
253
+ this.consume()
254
+ const val = t.value
255
+ if (this.peek().type === 'UNIT') {
256
+ const u = this.consume()
257
+ if (u.value === '%') {
258
+ // _unit=true ⇒ % is a unit marker; value is kept as-is (output layer appends %)
259
+ // _unit=false ⇒ % means divide by 100
260
+ if (this.ctx.unit) return val
261
+ return div(val, '100', this.ctx.precision)
262
+ }
263
+ }
264
+ return val
265
+ }
266
+ if (t.type === 'IDENT') {
267
+ this.consume()
268
+ // Identifiers can only be math function names (followed by parentheses); variables are not supported — use template interpolation
269
+ if (this.peek().type === 'LPAREN') return this.callFn(t.value)
270
+ throw new Error(`Unknown identifier: "${t.value}" (calc supports arithmetic and math functions only; use template interpolation for values)`)
271
+ }
272
+ if (t.type === 'LPAREN') {
273
+ this.consume()
274
+ const v = this.addSub()
275
+ this.expect('RPAREN')
276
+ return v
277
+ }
278
+ throw new Error(`Unexpected token at position ${t.pos}: ${t.type}("${t.value}")`)
279
+ }
280
+
281
+ private callFn(name: string): string {
282
+ this.expect('LPAREN')
283
+ const args: string[] = []
284
+ if (this.peek().type !== 'RPAREN') {
285
+ args.push(this.addSub())
286
+ while (this.match('COMMA')) args.push(this.addSub())
287
+ }
288
+ this.expect('RPAREN')
289
+ const r = applyFn(name, args, this.ctx.precision)
290
+ this.step(`${name}(${args.join(', ')}) = ${r}`)
291
+ return r
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Parse and evaluate an expression (the pure arithmetic part, without any format pipeline).
297
+ *
298
+ * Most callers should use {@link calc}; this is the low-level evaluator for cases where
299
+ * direct control over the evaluation context is needed.
300
+ *
301
+ * @param expr Pure expression string (no format pipe)
302
+ * @param ctx Evaluation context: unit mode flag and division precision
303
+ * @returns Canonical string representation of the evaluated result
304
+ * @throws Throws on lexical or syntax errors
305
+ * @example
306
+ * evaluate('1 + 2', { unit: false, precision: 50 }) // '3'
307
+ */
308
+ export const evaluate = (expr: string, ctx: IEvalContext): string => {
309
+ return new Parser(expr, ctx).parse()
310
+ }