@onrail-xyz/amount 1.0.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 (47) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +617 -0
  3. package/dist/aggregating.d.ts +31 -0
  4. package/dist/aggregating.d.ts.map +1 -0
  5. package/dist/aggregating.js +31 -0
  6. package/dist/allocating.d.ts +9 -0
  7. package/dist/allocating.d.ts.map +1 -0
  8. package/dist/allocating.js +31 -0
  9. package/dist/amount.d.ts +68 -0
  10. package/dist/amount.d.ts.map +1 -0
  11. package/dist/amount.js +184 -0
  12. package/dist/format.d.ts +15 -0
  13. package/dist/format.d.ts.map +1 -0
  14. package/dist/format.js +226 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +8 -0
  18. package/dist/jsonCodecs.d.ts +9 -0
  19. package/dist/jsonCodecs.d.ts.map +1 -0
  20. package/dist/jsonCodecs.js +46 -0
  21. package/dist/kind.d.ts +126 -0
  22. package/dist/kind.d.ts.map +1 -0
  23. package/dist/kind.js +118 -0
  24. package/dist/rate.d.ts +58 -0
  25. package/dist/rate.d.ts.map +1 -0
  26. package/dist/rate.js +178 -0
  27. package/dist/rational.d.ts +49 -0
  28. package/dist/rational.d.ts.map +1 -0
  29. package/dist/rational.js +378 -0
  30. package/dist/segmenting.d.ts +15 -0
  31. package/dist/segmenting.d.ts.map +1 -0
  32. package/dist/segmenting.js +97 -0
  33. package/dist/unitSpecs.d.ts +47 -0
  34. package/dist/unitSpecs.d.ts.map +1 -0
  35. package/dist/unitSpecs.js +8 -0
  36. package/package.json +56 -0
  37. package/src/aggregating.ts +160 -0
  38. package/src/allocating.ts +70 -0
  39. package/src/amount.ts +291 -0
  40. package/src/format.ts +330 -0
  41. package/src/index.ts +8 -0
  42. package/src/jsonCodecs.ts +63 -0
  43. package/src/kind.ts +442 -0
  44. package/src/rate.ts +316 -0
  45. package/src/rational.ts +471 -0
  46. package/src/segmenting.ts +154 -0
  47. package/src/unitSpecs.ts +37 -0
@@ -0,0 +1,471 @@
1
+ import { type Opts, range, mapTo } from "@onrail-xyz/utils";
2
+
3
+ type ubigint = bigint; //just an annotation that these bigints are always guaranteed to be unsigned
4
+
5
+ export type Rationalish = Rational | number | bigint;
6
+ export type ThousandsSep = "" | "," | "_";
7
+ export type ToFixedOptions = { thousandsSep: ThousandsSep; trimZeros: boolean };
8
+
9
+ const maxTabulatedOom = 30; //should comfortably span every unit scale in practice
10
+ const tenPows = mapTo(range(maxTabulatedOom + 1))(exp => 10n ** BigInt(exp));
11
+ export const tenToThe = (exp: number): bigint => tenPows[exp] ?? 10n ** BigInt(exp);
12
+
13
+ export class Rational {
14
+ static powerOfTen(oom: number): Rational {
15
+ return oom < 0
16
+ ? new Rational(1n, tenToThe(-oom))
17
+ : new Rational(tenToThe(oom), 1n);
18
+ }
19
+
20
+ private readonly n: bigint;
21
+ private readonly d: ubigint;
22
+
23
+ //class invariants: n and d are always coprime, d is always positive
24
+ private constructor(n: bigint, d: ubigint) {
25
+ this.n = n;
26
+ this.d = d;
27
+ }
28
+
29
+ static from(value: Rationalish | string): Rational;
30
+ static from(numerator: bigint, denominator?: bigint): Rational;
31
+ static from(valueOrNumerator: Rationalish | string, denominator?: bigint): Rational {
32
+ switch (typeof valueOrNumerator) {
33
+ case "number": {
34
+ const value = valueOrNumerator;
35
+ if (Number.isSafeInteger(value))
36
+ return new Rational(BigInt(value), 1n);
37
+
38
+ if (!Number.isFinite(value))
39
+ throw new Error("Invalid value");
40
+
41
+ //A double only ever approximates the value that named it, and two readings compete: the
42
+ // decimal it prints as, and a compact fraction it might be the rounding of (1/3 out of
43
+ // 0.3333...). A decimal short enough to round-trip through the double is certainly the
44
+ // spelling that produced it, so it wins outright - which guarantees that the number and
45
+ // string paths agree on every decimal literal of up to 15 significant digits.
46
+ const dec = value.toString();
47
+ if (dec.replace(/^-|\.|e[+-]?\d+$/gi, "").replace(/^0+/, "").length <= maxReliableDecimals)
48
+ return Rational.from(dec);
49
+
50
+ //Otherwise hunt for the compact fraction via continued fractions. A convergent is only
51
+ // credible as the intended value if it could hardly have landed within half an ulp of
52
+ // the double by accident: fractions with denominator <= D cover a random interval of
53
+ // width u with probability ~D²u, so capping D²·ulp(target) at 2^-16 bounds the odds of
54
+ // fitting noise at ~10^-5, while any deliberate fraction the double can faithfully carry
55
+ // clears the cap with a >= 2^8 margin in the denominator. With ulp ~ target·2^-52 the
56
+ // cap reads D = 2^18/√target - which also keeps every tried convergent exact in doubles
57
+ // (d <= 2^36, n ~ target·d <= 2^44). Whatever fails it defers to the exact decimal -
58
+ // toString hands it over by definition. Either way the value is never approximated.
59
+ const sign = value < 0 ? -1n : 1n;
60
+ const target = Math.abs(value);
61
+ const maxCredibleDenom = 2 ** 18 / Math.sqrt(target);
62
+ let cur = target;
63
+
64
+ let n2 = 0, n1 = 1, d2 = 1, d1 = 0;
65
+ while (true) {
66
+ const a = Math.floor(cur);
67
+ const n = a * n1 + n2;
68
+ const d = a * d1 + d2;
69
+
70
+ if (d > maxCredibleDenom)
71
+ return Rational.from(dec);
72
+
73
+ if (n / d === target)
74
+ return new Rational(sign * BigInt(n), BigInt(d));
75
+
76
+ const next = 1 / (cur - a);
77
+ if (!Number.isFinite(next)) //the recurrence ran out of double precision
78
+ return Rational.from(dec);
79
+
80
+ n2 = n1; n1 = n; d2 = d1; d1 = d;
81
+ cur = next;
82
+ }
83
+ }
84
+
85
+ case "bigint": {
86
+ if (denominator === undefined)
87
+ return new Rational(valueOrNumerator, 1n);
88
+
89
+ if (denominator === 0n)
90
+ throw new Error("Denominator cannot be zero");
91
+
92
+ let [num, den] = [valueOrNumerator, denominator];
93
+ if (den < 0n)
94
+ [num, den] = [-num, -den];
95
+
96
+ return new Rational(...Rational.normalize(num, den));
97
+ }
98
+
99
+ case "string": {
100
+ const mixedMatch = mixedRegex.exec(valueOrNumerator);
101
+ if (mixedMatch) {
102
+ const [, sign, intStr, numStr, denStr] = mixedMatch;
103
+ const den = parseBigInt(denStr!);
104
+ if (den === 0n)
105
+ throw new Error("Denominator cannot be zero");
106
+ const num = parseBigInt(intStr!) * den + parseBigInt(numStr!);
107
+ return new Rational(...Rational.normalize(sign === "-" ? -num : num, den));
108
+ }
109
+
110
+ const ratioMatch = ratioRegex.exec(valueOrNumerator);
111
+ if (ratioMatch) {
112
+ const [, sign, numStr, denStr] = ratioMatch;
113
+ const num = (sign === "-" ? -1n : 1n) * parseBigInt(numStr!);
114
+ const den = parseBigInt(denStr!);
115
+ if (den === 0n)
116
+ throw new Error("Denominator cannot be zero");
117
+ return new Rational(...Rational.normalize(num, den));
118
+ }
119
+
120
+ const decMatch = decimalRegex.exec(valueOrNumerator);
121
+ if (!decMatch)
122
+ throw new Error(`Invalid rational value: ${valueOrNumerator}`);
123
+
124
+ const [, sign, intStr, frac, expStr] = decMatch;
125
+ //apply the sign to the assembled absolute value, not the integer part: for values in
126
+ // (-1, 0) the integer part is 0, which would swallow the sign (0n is not < 0n)
127
+ const neg = sign === "-";
128
+ //appending the fraction to the integer digits scales by 10^frac.length, which the exponent
129
+ // then offsets - so both only ever move the decimal point
130
+ const absValue = parseBigInt(intStr! + (frac ?? ""));
131
+ const signed = neg ? -absValue : absValue;
132
+ const oom = Number(expStr ?? 0) - (frac?.length ?? 0);
133
+
134
+ return oom >= 0
135
+ ? new Rational(signed * tenToThe(oom), 1n)
136
+ : new Rational(...Rational.normalize(signed, tenToThe(-oom)));
137
+ }
138
+
139
+ default:
140
+ return valueOrNumerator;
141
+ }
142
+ }
143
+
144
+ unwrap(): [bigint, bigint] {
145
+ return [this.n, this.d];
146
+ }
147
+
148
+ //the only lossy operation on Rationals: the result is the nearest double/+-inf, ties to even
149
+ toNumber(): number {
150
+ if (this.n === 0n)
151
+ return 0;
152
+
153
+ if (-maxExactInt <= this.n && this.n <= maxExactInt && this.d <= maxExactInt)
154
+ return Number(this.n) / Number(this.d);
155
+
156
+ const negative = this.n < 0n;
157
+ const num = negative ? -this.n : this.n;
158
+
159
+ //floor(log2(num/d)) - the difference in bit lengths is right to within one, and a single
160
+ // comparison settles which
161
+ const bitDiff = bitLength(num) - bitLength(this.d);
162
+ const exponent = (
163
+ bitDiff >= 0 ? num >= this.d << BigInt(bitDiff) : num << BigInt(-bitDiff) >= this.d
164
+ ) ? bitDiff : bitDiff - 1;
165
+
166
+ //the exponent of the mantissa's last bit, clamped because a subnormal has fewer bits to spend
167
+ // and so has to be rounded further left. Fixing it up front is what reduces the whole
168
+ // conversion to a single division and a single rounding step.
169
+ const lastBitExp = Math.max(exponent - (mantissaBits - 1), minSubnormalExp);
170
+ const [scaledNum, scaledDen] = lastBitExp <= 0
171
+ ? [num << BigInt(-lastBitExp), this.d]
172
+ : [num, this.d << BigInt(lastBitExp)];
173
+
174
+ let mantissa = scaledNum / scaledDen;
175
+ const twiceRest = (scaledNum % scaledDen) * 2n;
176
+ if (twiceRest > scaledDen || (twiceRest === scaledDen && (mantissa & 1n) === 1n))
177
+ ++mantissa; //carrying into an extra bit is fine: the scaling below absorbs it
178
+
179
+ //mantissa fits a double exactly, and so does the power of two, so the product is exact - or
180
+ // Infinity, which is the right answer when the value overflows
181
+ const absValue = Number(mantissa) * 2 ** lastBitExp;
182
+ return negative ? -absValue : absValue;
183
+ }
184
+
185
+ //exact: the terminating decimal expansion when there is one, the mixed-number form
186
+ // otherwise ("333 1/3", "-1/3") — either way `from` parses it back to the same value
187
+ toString(opts?: Opts<{ thousandsSep: ThousandsSep }>): string {
188
+ const decimals = this.decimalPlaces();
189
+ if (decimals !== undefined)
190
+ return this.toFixed(decimals, opts);
191
+
192
+ const thousandsSep = opts?.thousandsSep ?? "";
193
+ const nAbs = Rational.stripSign(this.n);
194
+ const int = nAbs / this.d;
195
+ const intStr = int === 0n ? "" : Rational.addSep(int.toString(), thousandsSep) + " ";
196
+ const frac = Rational.addSep((nAbs % this.d).toString(), thousandsSep) +
197
+ "/" + Rational.addSep(this.d.toString(), thousandsSep);
198
+ return (this.n < 0n ? "-" : "") + intStr + frac;
199
+ }
200
+
201
+ //same exact form — the method only exists because JSON.stringify never consults toString and
202
+ // would otherwise trip over the private bigint fields
203
+ toJSON(): string {
204
+ return this.toString();
205
+ }
206
+
207
+ toFixed(precision: number = 0, opts?: Opts<ToFixedOptions>): string {
208
+ const thousandsSep = opts?.thousandsSep ?? "";
209
+ const trimZeros = opts?.trimZeros ?? false;
210
+
211
+ const multiplier = tenToThe(precision);
212
+ const nAbs = Rational.stripSign(this.n);
213
+ //half away from zero, as in round(); d truncating on halving is harmless because a tie
214
+ // requires an even denominator, where the halving is exact
215
+ const val = (nAbs * multiplier + this.d / 2n) / this.d;
216
+ const sign = this.n < 0n && val !== 0n ? "-" : "";
217
+
218
+ if (precision === 0)
219
+ return sign + Rational.addSep(val.toString(), thousandsSep);
220
+
221
+ const valStr = val.toString().padStart(precision + 1, "0");
222
+ const intPartStr = Rational.addSep(valStr.slice(0, -precision), thousandsSep);
223
+ let fracPartStr = valStr.slice(-precision);
224
+
225
+ if (trimZeros) {
226
+ fracPartStr = fracPartStr.replace(/0+$/, "");
227
+ if (fracPartStr === "")
228
+ return sign + intPartStr;
229
+ }
230
+
231
+ return sign + intPartStr + "." + fracPartStr;
232
+ }
233
+
234
+ isInteger(): boolean {
235
+ return this.d === 1n;
236
+ }
237
+
238
+ //decimal places of the exact expansion, or undefined when it does not terminate (i.e. the
239
+ // denominator has prime factors other than 2 and 5)
240
+ decimalPlaces(): number | undefined {
241
+ let [d, twos, fives] = [this.d, 0, 0];
242
+ for (; (d & 1n) === 0n; d >>= 1n) ++twos;
243
+ for (; d % 5n === 0n; d /= 5n) ++fives;
244
+ return d === 1n ? Math.max(twos, fives) : undefined;
245
+ }
246
+
247
+ sign(): -1 | 0 | 1 {
248
+ return this.n < 0n ? -1 : this.n > 0n ? 1 : 0;
249
+ }
250
+
251
+ ceil(): bigint {
252
+ const intPart = this.n / this.d;
253
+ return this.n > 0n && this.n % this.d !== 0n ? intPart + 1n : intPart;
254
+ }
255
+
256
+ //round half away from zero, so round(-x) === -round(x) (unlike Math.round's half-to-+inf)
257
+ round(): bigint {
258
+ const nAbs = Rational.stripSign(this.n);
259
+ const q = (nAbs * 2n + this.d) / (this.d * 2n);
260
+ return this.n < 0n ? -q : q;
261
+ }
262
+
263
+ floor(): bigint {
264
+ const intPart = this.n / this.d;
265
+ return this.n < 0n && this.n % this.d !== 0n ? intPart - 1n : intPart;
266
+ }
267
+
268
+ abs(): Rational {
269
+ return this.n < 0n ? this.neg() : this;
270
+ }
271
+
272
+ neg(): Rational {
273
+ return this.n === 0n ? this : new Rational(-this.n, this.d);
274
+ }
275
+
276
+ inv(): Rational {
277
+ if (this.n === 0n)
278
+ throw new Error("Cannot invert zero");
279
+
280
+ return (this.n < 0n)
281
+ ? new Rational(-this.d, -this.n)
282
+ : new Rational(this.d, this.n);
283
+ }
284
+
285
+ eq(other: Rationalish): boolean {
286
+ switch (typeof other) {
287
+ case "number":
288
+ return this.eq(Rational.from(other));
289
+
290
+ case "bigint":
291
+ return this.n === other && this.d === 1n;
292
+
293
+ default:
294
+ return this.n === other.n && this.d === other.d;
295
+ }
296
+ }
297
+
298
+ ne(other: Rationalish): boolean {
299
+ return !this.eq(other);
300
+ }
301
+
302
+ gt(other: Rationalish): boolean {
303
+ switch (typeof other) {
304
+ case "number":
305
+ return this.gt(Rational.from(other));
306
+
307
+ case "bigint":
308
+ return this.n > other * this.d;
309
+
310
+ default:
311
+ return this.n * other.d > other.n * this.d;
312
+ }
313
+ }
314
+
315
+ ge(other: Rationalish): boolean {
316
+ switch (typeof other) {
317
+ case "number":
318
+ return this.ge(Rational.from(other));
319
+
320
+ case "bigint":
321
+ return this.n >= other * this.d;
322
+
323
+ default:
324
+ return this.n * other.d >= other.n * this.d;
325
+ }
326
+ }
327
+
328
+ lt(other: Rationalish): boolean {
329
+ return !this.ge(other);
330
+ }
331
+
332
+ //a comparator's answer from one cross-multiplication - `sub(other).sign()` would normalize a
333
+ // whole difference just to read its sign
334
+ cmp(other: Rationalish): -1 | 0 | 1 {
335
+ switch (typeof other) {
336
+ case "number":
337
+ return this.cmp(Rational.from(other));
338
+
339
+ case "bigint": {
340
+ const rhs = other * this.d;
341
+ return this.n < rhs ? -1 : this.n > rhs ? 1 : 0;
342
+ }
343
+ default: {
344
+ const lhs = this.n * other.d;
345
+ const rhs = other.n * this.d;
346
+ return lhs < rhs ? -1 : lhs > rhs ? 1 : 0;
347
+ }
348
+ }
349
+ }
350
+
351
+ le(other: Rationalish): boolean {
352
+ return !this.gt(other);
353
+ }
354
+
355
+ add(other: Rationalish): Rational {
356
+ switch (typeof other) {
357
+ case "number":
358
+ return this.add(Number.isSafeInteger(other) ? BigInt(other) : Rational.from(other));
359
+
360
+ case "bigint":
361
+ //gcd(n + k·d, d) = gcd(n, d) = 1, so the sum is already in lowest terms
362
+ return new Rational(this.n + other * this.d, this.d);
363
+
364
+ default: {
365
+ //Knuth's reduction: over the lcm, only a factor of gcd(d1, d2) can still cancel, so the
366
+ // sum is normalized against that instead of against the whole lcm
367
+ const g = Rational.gcd(this.d, other.d);
368
+ const thisCofactor = this.d / g;
369
+ const otherCofactor = other.d / g;
370
+ const num = this.n * otherCofactor + other.n * thisCofactor;
371
+ if (num === 0n)
372
+ return new Rational(0n, 1n);
373
+
374
+ const g2 = Rational.gcd(Rational.stripSign(num), g);
375
+ return new Rational(num / g2, thisCofactor * (other.d / g2));
376
+ }
377
+ }
378
+ }
379
+
380
+ sub(other: Rationalish): Rational {
381
+ return this.add(typeof other === "bigint" || typeof other === "number" ? -other : other.neg());
382
+ }
383
+
384
+ mul(other: Rationalish): Rational {
385
+ switch (typeof other) {
386
+ case "number":
387
+ return this.mul(Number.isSafeInteger(other) ? BigInt(other) : Rational.from(other));
388
+
389
+ case "bigint": {
390
+ const gcd = Rational.gcd(Rational.stripSign(other), this.d);
391
+ return new Rational(this.n * (other / gcd), this.d / gcd);
392
+ }
393
+
394
+ default: {
395
+ const gcd1 = Rational.gcd(Rational.stripSign(this.n), other.d);
396
+ const gcd2 = Rational.gcd(Rational.stripSign(other.n), this.d);
397
+ const num = (this.n / gcd1) * (other.n / gcd2);
398
+ const den = (this.d / gcd2) * (other.d / gcd1);
399
+ return new Rational(num, den);
400
+ }
401
+ }
402
+ }
403
+
404
+ div(other: Rationalish): Rational {
405
+ switch (typeof other) {
406
+ case "number":
407
+ return this.div(Number.isSafeInteger(other) ? BigInt(other) : Rational.from(other));
408
+
409
+ case "bigint": {
410
+ if (other === 0n)
411
+ throw new Error("Cannot divide by zero");
412
+
413
+ //keep the sign on the numerator to maintain the class invariant
414
+ const [num, den] = Rational.normalize(this.n, Rational.stripSign(other));
415
+ return new Rational(other < 0n ? -num : num, den * this.d);
416
+ }
417
+
418
+ default:
419
+ if (other.n === 0n)
420
+ throw new Error("Cannot divide by zero");
421
+
422
+ return this.mul(other.inv());
423
+ }
424
+ }
425
+
426
+ //floored modulo, i.e. the result carries the sign of the divisor (unlike bigint's %)
427
+ mod(other: Rationalish): Rational {
428
+ const divisor = Rational.from(other);
429
+ return this.sub(divisor.mul(this.div(divisor).floor()));
430
+ }
431
+
432
+ private static addSep(intStr: string, thousandsSep: ThousandsSep): string {
433
+ return thousandsSep ? intStr.replace(/\B(?=(\d{3})+(?!\d))/g, thousandsSep) : intStr;
434
+ }
435
+
436
+ private static gcd(a: ubigint, b: ubigint): ubigint {
437
+ while (b !== 0n)
438
+ [a, b] = [b, a % b];
439
+
440
+ return a;
441
+ }
442
+
443
+ private static stripSign(n: bigint): ubigint {
444
+ return n < 0n ? -n : n;
445
+ }
446
+
447
+ private static normalize(n: bigint, d: ubigint): [bigint, ubigint] {
448
+ const gcd = Rational.gcd(Rational.stripSign(n), d);
449
+ return [n / gcd, d / gcd];
450
+ }
451
+ }
452
+
453
+ // ---- IEEE double facts ----
454
+
455
+ //an IEEE double carries 53 significand bits, and 2^-1074 is its least representable step
456
+ const mantissaBits = 53;
457
+ const minSubnormalExp = -1074;
458
+ const maxExactInt = 2n ** BigInt(mantissaBits);
459
+ const bitLength = (value: bigint) => value.toString(2).length;
460
+
461
+ //the mantissa can reliably represent log10(2^53) ~= 15 decimal places
462
+ const maxReliableDecimals = 15;
463
+
464
+ // ---- Conversion/Parsing impl ----
465
+
466
+ //separate alternatives per separator flavor, so "," and "_" cannot mix within one integer
467
+ const intPat = "\\d+|\\d{1,3}(?:,\\d{3})+|\\d{1,3}(?:_\\d{3})+";
468
+ const parseBigInt = (s: string) => BigInt(s.replace(/[,_]/g, ""));
469
+ const mixedRegex = new RegExp(`^(-?)(${intPat}) (${intPat})/(${intPat})$`);
470
+ const ratioRegex = new RegExp(`^(-?)(${intPat})/(${intPat})$`);
471
+ const decimalRegex = new RegExp(`^(-?)(${intPat})(?:\\.(\\d+))?(?:[eE]([+-]?\\d+))?$`);
@@ -0,0 +1,154 @@
1
+ import type { StrRecord } from "@onrail-xyz/utils";
2
+
3
+ type Span = [number, number];
4
+
5
+ export type SegmentPair = {
6
+ value: Span;
7
+ symbol: Span;
8
+ };
9
+
10
+ type SegmentResult = {
11
+ negative: boolean;
12
+ pairs: SegmentPair[];
13
+ };
14
+
15
+ //unit symbols are identifiers, so they compare as Unicode prescribes for those (UAX #31): by NFKC
16
+ // form, under which "m3" finds "m³", "℃" finds "°C", and the lookalikes Greek mu (U+03BC) and
17
+ // micro sign (U+00B5) find each other
18
+ export const symbolKey = (symbol: string) => symbol.normalize("NFKC");
19
+
20
+ const keyedUnits = new WeakMap<object, Map<string, unknown>>();
21
+
22
+ export function findUnit<U>(units: Readonly<StrRecord<U>>, symbol: string): U | undefined {
23
+ if (Object.hasOwn(units, symbol))
24
+ return units[symbol];
25
+
26
+ let keyed = keyedUnits.get(units) as Map<string, U> | undefined;
27
+ if (keyed === undefined) {
28
+ keyed = new Map(Object.entries(units).map(([s, unit]) => [symbolKey(s), unit]));
29
+ keyedUnits.set(units, keyed);
30
+ }
31
+ return keyed.get(symbolKey(symbol));
32
+ }
33
+
34
+ type StartResult = {
35
+ pos: number;
36
+ negative: boolean;
37
+ };
38
+
39
+ type PairResult = {
40
+ pair: SegmentPair;
41
+ end: number;
42
+ };
43
+
44
+ const asciiSpace = 32;
45
+ const asciiZero = 48;
46
+ const asciiNine = 57;
47
+ const asciiComma = 44;
48
+ const asciiUnderscore = 95;
49
+ const asciiDot = 46;
50
+ const asciiSlash = 47;
51
+
52
+ const charIsDigit = (c: number) =>
53
+ asciiZero <= c && c <= asciiNine;
54
+
55
+ const charIsNumPart = (c: number) =>
56
+ charIsDigit(c) ||
57
+ c === asciiComma ||
58
+ c === asciiUnderscore ||
59
+ c === asciiDot ||
60
+ c === asciiSlash;
61
+
62
+ const isValChar = charIsNumPart;
63
+ const isSymChar = (c: number) => c !== asciiSpace && !charIsNumPart(c);
64
+ //a symbol that follows its value is delimited by the next space, so digits may continue it
65
+ const isPostfixSymChar = (c: number) => c !== asciiSpace && (charIsDigit(c) || !charIsNumPart(c));
66
+
67
+ const parseSpan = (
68
+ str: string,
69
+ pos: number,
70
+ isValid: (c: number) => boolean,
71
+ expected: string
72
+ ): Span => {
73
+ const start = pos;
74
+ while (pos < str.length && isValid(str.charCodeAt(pos)))
75
+ ++pos;
76
+
77
+ if (pos === start)
78
+ throw new Error(`Expected ${expected} at position ${start}`);
79
+
80
+ return [start, pos];
81
+ };
82
+
83
+ const parseSign = (str: string): StartResult => {
84
+ let pos = 0;
85
+ if (str.length === 0)
86
+ throw new Error("Empty input");
87
+
88
+ const negative = str[0] === "-";
89
+ if (negative && ++pos === str.length)
90
+ throw new Error("Sign only input");
91
+
92
+ return { pos, negative };
93
+ };
94
+
95
+ const startsFraction = (str: string, at: number) =>
96
+ str.charCodeAt(at) === asciiSpace && charIsDigit(str.charCodeAt(at + 1));
97
+
98
+ //a value is either a plain number or a mixed number ("1,763 3/7") whose interior space is
99
+ // folded into one span — the digit after the space is what disambiguates a fraction from a
100
+ // symbol, which can never start with one. Rational.from owns the value grammar either way.
101
+ const parseValue = (str: string, pos: number): Span => {
102
+ const first = parseSpan(str, pos, isValChar, "value");
103
+ if (!startsFraction(str, first[1]))
104
+ return first;
105
+
106
+ const frac = parseSpan(str, first[1] + 1, isValChar, "fraction");
107
+ return [first[0], frac[1]];
108
+ };
109
+
110
+ const parsePair = (str: string, pos: number): PairResult => {
111
+ //the only leniency while parsing: we accept a space, even if the symbol is compact
112
+ const skipSpace = (p: number, expected: string) => {
113
+ if (str.charCodeAt(p) !== asciiSpace)
114
+ return p;
115
+ if (++p >= str.length)
116
+ throw new Error(`Unexpected end of input: expected ${expected} next`);
117
+ return p;
118
+ };
119
+
120
+ if (charIsDigit(str.charCodeAt(pos))) {
121
+ const value = parseValue(str, pos);
122
+ const symbol = parseSpan(str, skipSpace(value[1], "symbol"), isPostfixSymChar, "symbol");
123
+ return { pair: { value, symbol }, end: symbol[1] };
124
+ }
125
+
126
+ const symbol = parseSpan(str, pos, isSymChar, "symbol");
127
+ const value = parseValue(str, skipSpace(symbol[1], "value"));
128
+ return { pair: { value, symbol }, end: value[1] };
129
+ };
130
+
131
+ export const segment = (str: string): SegmentResult => {
132
+ const start = parseSign(str);
133
+ let { pos } = start;
134
+ const { negative } = start;
135
+ const pairs: SegmentPair[] = [];
136
+
137
+ while (true) {
138
+ const { pair, end } = parsePair(str, pos);
139
+ pairs.push(pair);
140
+ pos = end;
141
+
142
+ if (pos === str.length)
143
+ break;
144
+
145
+ if (str.charCodeAt(pos) !== asciiSpace)
146
+ throw new Error("Expected space after value/symbol pair");
147
+
148
+ ++pos;
149
+ if (pos === str.length)
150
+ throw new Error("Unexpected end of input: Expected another value/symbol pair");
151
+ }
152
+
153
+ return { negative, pairs };
154
+ };
@@ -0,0 +1,37 @@
1
+ import type { RoTuple, RoPair, RoNeTuple } from "@onrail-xyz/utils";
2
+ import { otherCap } from "@onrail-xyz/utils";
3
+ import type { Rationalish } from "./rational.js";
4
+ import type { SymbolSpec } from "./kind.js";
5
+
6
+ export const withPluralS = <S extends string>(symbol: S) =>
7
+ ({ symbol, plural: (symbol + "s") as `${S}s` } as const);
8
+
9
+ export const allowPluralS = <S extends string>(symbol: S) =>
10
+ [{ symbol }, { symbol: (symbol + "s") as `${S}s` }] as const;
11
+
12
+ export const allowOtherCap = <S extends string>(symbol: S) =>
13
+ [{ symbol }, { symbol: otherCap(symbol) }] as const;
14
+
15
+ export const withPluralSBothCaps = <S extends string>(symbol: S) =>
16
+ [withPluralS(symbol), withPluralS(otherCap(symbol))] as const;
17
+
18
+ export const allowPluralSBothCaps = <S extends string>(symbol: S) =>
19
+ [...allowPluralS(symbol), ...allowPluralS(otherCap(symbol))] as const;
20
+
21
+ type UnitRow<V> = RoPair<V, RoNeTuple<SymbolSpec>>;
22
+
23
+ type ToUnits<K extends "scale" | "oom", T extends RoTuple<UnitRow<unknown>>> =
24
+ { [I in keyof T]: T[I] extends RoPair<infer V, infer Symbols>
25
+ ? K extends "scale"
26
+ ? { scale: V, symbols: Symbols }
27
+ : { oom: V, symbols: Symbols }
28
+ : never
29
+ };
30
+
31
+ export const toScaleUnits =
32
+ <const T extends RoTuple<UnitRow<Rationalish>>>(spec: T): ToUnits<"scale", T> =>
33
+ spec.map(([scale, symbols]) => ({ scale, symbols })) as any;
34
+
35
+ export const toDecimalUnits =
36
+ <const T extends RoTuple<UnitRow<number>>>(spec: T): ToUnits<"oom", T> =>
37
+ spec.map(([oom, symbols]) => ({ oom, symbols })) as any;