@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.
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/README.zh.md +115 -0
- package/dist/index.cjs +1354 -0
- package/dist/index.d.cts +449 -0
- package/dist/index.d.mts +449 -0
- package/dist/index.mjs +1324 -0
- package/package.json +67 -0
- package/src/index.ts +31 -0
- package/src/utils/aggregate.ts +109 -0
- package/src/utils/calc.ts +100 -0
- package/src/utils/chain.ts +127 -0
- package/src/utils/config.ts +59 -0
- package/src/utils/format.ts +366 -0
- package/src/utils/parser.ts +310 -0
- package/src/utils/precision.ts +442 -0
- package/src/utils/standalone.ts +126 -0
|
@@ -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
|
+
}
|