@pbkware/dot-net-date-number-formatting 0.2.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.
@@ -0,0 +1,1108 @@
1
+ import { Err, Ok, Result } from "@pbkware/js-utils";
2
+ import { DotNetLocaleSettings } from "./locale-settings.js";
3
+ import {
4
+ DotNetNumberStyleId,
5
+ DotNetNumberStyleSet,
6
+ DotNetNumberStyles,
7
+ } from "./number-style.js";
8
+
9
+ type ElementType =
10
+ | "standard"
11
+ | "zero"
12
+ | "digit"
13
+ | "decimal"
14
+ | "thousands"
15
+ | "percent"
16
+ | "permille"
17
+ | "exponent"
18
+ | "literal"
19
+ | "section";
20
+
21
+ interface Element {
22
+ type: ElementType;
23
+ text?: string;
24
+ count?: number;
25
+ exponentCase?: "upper" | "lower";
26
+ exponentSign?: "+" | "-" | "none";
27
+ exponentDigits?: number;
28
+ }
29
+
30
+ interface FormatSection {
31
+ elements: Element[];
32
+ hasDecimal: boolean;
33
+ integerDigits: number;
34
+ decimalDigits: number;
35
+ scale: number; // Number of ,, dividers
36
+ }
37
+
38
+ /**
39
+ * Base class for formatting and parsing numbers using .NET-compatible format strings.
40
+ *
41
+ * Supports both standard format strings (C, D, E, F, G, N, P, R, X, B) and custom format strings
42
+ * with fine-grained control over digit placeholders, separators, and sections.
43
+ *
44
+ * @remarks
45
+ * This is a base class - use {@link DotNetIntegerFormatter}, {@link DotNetFloatFormatter},
46
+ * or {@link DotNetDecimalFormatter} for specific number types.
47
+ *
48
+ * @example
49
+ * ```typescript
50
+ * // Use derived classes instead
51
+ * const formatter = new DotNetFloatFormatter();
52
+ * formatter.localeSettings = DotNetLocaleSettings.createInvariant();
53
+ * formatter.trySetFormat('C2');
54
+ * console.log(formatter.toString(1234.56)); // "$1,234.56"
55
+ * ```
56
+ *
57
+ * @public
58
+ * @category Numeric Formatting
59
+ */
60
+ export class DotNetNumberFormatter {
61
+ protected format = "";
62
+ private formatIsStandard = false;
63
+ private precision = 0;
64
+ private sections: FormatSection[] = [];
65
+
66
+ /**
67
+ * The set of {@link DotNetNumberStyleId} flags that control which number formats are allowed during parsing.
68
+ *
69
+ * @example
70
+ * ```typescript
71
+ * // Use predefined styles
72
+ * formatter.styles = DotNetNumberStyles.number;
73
+ *
74
+ * // Or combine individual flags
75
+ * formatter.styles = new Set([
76
+ * DotNetNumberStyleId.AllowLeadingSign,
77
+ * DotNetNumberStyleId.AllowDecimalPoint
78
+ * ]);
79
+ * ```
80
+ */
81
+ styles: DotNetNumberStyleSet = new Set(DotNetNumberStyles.number);
82
+
83
+ /**
84
+ * The locale settings that determine decimal/thousands separators and other culture-specific formatting.
85
+ */
86
+ localeSettings = DotNetLocaleSettings.current;
87
+
88
+ /**
89
+ * Contains the error message from the last failed operation.
90
+ * Check this property if {@link DotNetNumberFormatter.trySetFormat} or parsing methods return an error.
91
+ */
92
+ parseErrorText = "";
93
+
94
+ protected setParseErrorText(value: string): false {
95
+ this.parseErrorText = value;
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * Sets the format string to use for formatting numbers.
101
+ *
102
+ * The format string is tokenized to confirm validity and to optimize formatting/parsing operations.
103
+ *
104
+ * @param value - A standard format string (e.g., "C", "N2", "E3") or custom format string (e.g., "#,##0.00").
105
+ * @returns A Result indicating success or containing an error message if the format string is invalid.
106
+ *
107
+ * @example
108
+ * ```typescript
109
+ * // Standard format
110
+ * formatter.trySetFormat('C2'); // Currency with 2 decimal places
111
+ *
112
+ * // Custom format
113
+ * formatter.trySetFormat('#,##0.00'); // Number with thousands separator
114
+ *
115
+ * // Check for errors
116
+ * const result = formatter.trySetFormat('INVALID');
117
+ * if (result.isErr()) {
118
+ * console.error(result.error);
119
+ * }
120
+ * ```
121
+ */
122
+ trySetFormat(value: string): Result<void> {
123
+ if (value.length === 0) {
124
+ return new Err("Format string cannot be empty");
125
+ }
126
+
127
+ this.format = value;
128
+ this.formatIsStandard =
129
+ value.length === 1 ||
130
+ (value.length === 2 && /^\w\d$/.test(value)) ||
131
+ (value.length === 3 && /^\w\d\d$/.test(value));
132
+
133
+ if (this.formatIsStandard) {
134
+ // Parse standard format: C, C2, D, D5, etc.
135
+ const formatChar = value[0]; // Keep original case
136
+ const formatCharUpper = formatChar.toUpperCase();
137
+ const precisionStr = value.length > 1 ? value.slice(1) : "";
138
+ this.precision = precisionStr ? parseInt(precisionStr, 10) || 0 : -1; // -1 means no precision specified
139
+
140
+ // Validate standard format specifiers
141
+ if (!"BCDEFGNPRX".includes(formatCharUpper)) {
142
+ return new Err(`Unknown standard format specifier: ${formatChar}`);
143
+ }
144
+
145
+ this.sections = [
146
+ {
147
+ elements: [{ type: "standard", text: formatChar }], // Store original case
148
+ hasDecimal: false,
149
+ integerDigits: 0,
150
+ decimalDigits: 0,
151
+ scale: 0,
152
+ },
153
+ ];
154
+ return new Ok(undefined);
155
+ }
156
+
157
+ // Parse custom format
158
+ const parsed = this.parseCustomFormat(value);
159
+ if (parsed.isErr()) {
160
+ return parsed.createType<void>();
161
+ }
162
+
163
+ this.sections = parsed.value;
164
+ return new Ok(undefined);
165
+ }
166
+
167
+ private parseCustomFormat(format: string): Result<FormatSection[]> {
168
+ const sections: FormatSection[] = [];
169
+ let currentSection: Element[] = [];
170
+ let i = 0;
171
+
172
+ while (i < format.length) {
173
+ const char = format[i];
174
+
175
+ if (char === ";") {
176
+ sections.push(this.analyzeSection(currentSection));
177
+ currentSection = [];
178
+ i++;
179
+ continue;
180
+ }
181
+
182
+ if (char === "\\") {
183
+ // Escape character
184
+ if (i + 1 < format.length) {
185
+ currentSection.push({ type: "literal", text: format[i + 1] });
186
+ i += 2;
187
+ } else {
188
+ return new Err("Trailing escape character");
189
+ }
190
+ continue;
191
+ }
192
+
193
+ if (char === '"' || char === "'") {
194
+ // Literal string
195
+ const endQuote = format.indexOf(char, i + 1);
196
+ if (endQuote === -1) {
197
+ return new Err(
198
+ `Unterminated string literal starting at position ${i}`,
199
+ );
200
+ }
201
+ const literal = format.slice(i + 1, endQuote);
202
+ if (literal.length > 0) {
203
+ currentSection.push({ type: "literal", text: literal });
204
+ }
205
+ i = endQuote + 1;
206
+ continue;
207
+ }
208
+
209
+ if (char === "0") {
210
+ let count = 1;
211
+ while (i + count < format.length && format[i + count] === "0") {
212
+ count++;
213
+ }
214
+ currentSection.push({ type: "zero", count });
215
+ i += count;
216
+ continue;
217
+ }
218
+
219
+ if (char === "#") {
220
+ let count = 1;
221
+ while (i + count < format.length && format[i + count] === "#") {
222
+ count++;
223
+ }
224
+ currentSection.push({ type: "digit", count });
225
+ i += count;
226
+ continue;
227
+ }
228
+
229
+ if (char === ".") {
230
+ currentSection.push({ type: "decimal" });
231
+ i++;
232
+ continue;
233
+ }
234
+
235
+ if (char === ",") {
236
+ currentSection.push({ type: "thousands" });
237
+ i++;
238
+ continue;
239
+ }
240
+
241
+ if (char === "%") {
242
+ currentSection.push({ type: "percent" });
243
+ i++;
244
+ continue;
245
+ }
246
+
247
+ if (char === "‰") {
248
+ currentSection.push({ type: "permille" });
249
+ i++;
250
+ continue;
251
+ }
252
+
253
+ if (char === "E" || char === "e") {
254
+ // Exponential notation: E+0, E-0, e+00, etc.
255
+ const exponentCase = char === "E" ? "upper" : "lower";
256
+ let exponentSign: "+" | "-" | "none" = "none";
257
+ let j = i + 1;
258
+
259
+ if (j < format.length && (format[j] === "+" || format[j] === "-")) {
260
+ exponentSign = format[j] as "+" | "-";
261
+ j++;
262
+ }
263
+
264
+ let exponentDigits = 0;
265
+ while (j < format.length && format[j] === "0") {
266
+ exponentDigits++;
267
+ j++;
268
+ }
269
+
270
+ if (exponentDigits > 0) {
271
+ currentSection.push({
272
+ type: "exponent",
273
+ exponentCase,
274
+ exponentSign,
275
+ exponentDigits,
276
+ });
277
+ i = j;
278
+ continue;
279
+ }
280
+ }
281
+
282
+ // Any other character is treated as literal
283
+ currentSection.push({ type: "literal", text: char });
284
+ i++;
285
+ }
286
+
287
+ if (currentSection.length > 0) {
288
+ sections.push(this.analyzeSection(currentSection));
289
+ }
290
+
291
+ if (sections.length === 0) {
292
+ return new Err("Empty format string");
293
+ }
294
+
295
+ return new Ok(sections);
296
+ }
297
+
298
+ private analyzeSection(elements: Element[]): FormatSection {
299
+ let hasDecimal = false;
300
+ let decimalIndex = -1;
301
+ let integerDigits = 0;
302
+ let decimalDigits = 0;
303
+ let scale = 0;
304
+
305
+ for (let i = 0; i < elements.length; i++) {
306
+ const elem = elements[i];
307
+ if (elem.type === "decimal") {
308
+ hasDecimal = true;
309
+ decimalIndex = i;
310
+ }
311
+ }
312
+
313
+ // Count scale (number of ,, at the end before decimal or at end)
314
+ let scaleCheckIdx =
315
+ decimalIndex >= 0 ? decimalIndex - 1 : elements.length - 1;
316
+ while (scaleCheckIdx >= 0 && elements[scaleCheckIdx].type === "thousands") {
317
+ scale++;
318
+ scaleCheckIdx--;
319
+ }
320
+
321
+ // Count integer and decimal digit placeholders
322
+ for (let i = 0; i < elements.length; i++) {
323
+ const elem = elements[i];
324
+ if (elem.type === "zero" || elem.type === "digit") {
325
+ if (hasDecimal && i > decimalIndex) {
326
+ decimalDigits += elem.count || 1;
327
+ } else if (!hasDecimal || i < decimalIndex) {
328
+ integerDigits += elem.count || 1;
329
+ }
330
+ }
331
+ }
332
+
333
+ return { elements, hasDecimal, integerDigits, decimalDigits, scale };
334
+ }
335
+
336
+ protected formatNumber(
337
+ value: number | bigint,
338
+ allowDecimal: boolean = true,
339
+ ): string {
340
+ if (this.format === "") {
341
+ return value.toString();
342
+ }
343
+
344
+ const numValue = typeof value === "bigint" ? Number(value) : value;
345
+
346
+ if (this.formatIsStandard) {
347
+ return this.formatStandard(numValue, allowDecimal);
348
+ }
349
+
350
+ return this.formatCustom(numValue, allowDecimal);
351
+ }
352
+
353
+ private formatStandard(value: number, allowDecimal: boolean): string {
354
+ const formatChar = this.sections[0].elements[0].text || "G";
355
+ const precision = this.precision;
356
+
357
+ switch (formatChar.toUpperCase()) {
358
+ case "C": // Currency
359
+ return this.formatCurrency(value, precision);
360
+ case "D": // Decimal (integers only)
361
+ return this.formatDecimal(value, precision);
362
+ case "E": // Exponential
363
+ return this.formatExponential(value, precision, formatChar === "E");
364
+ case "F": // Fixed-point
365
+ return this.formatFixedPoint(value, precision);
366
+ case "G": // General
367
+ return this.formatGeneral(value, precision, formatChar === "G");
368
+ case "N": // Number with group separators
369
+ return this.formatNumber_(value, precision);
370
+ case "P": // Percent
371
+ return this.formatPercent(value, precision);
372
+ case "R": // Round-trip
373
+ return value.toString();
374
+ case "X": // Hexadecimal
375
+ return this.formatHex(value, precision, formatChar === "X");
376
+ case "B": // Binary
377
+ return this.formatBinary(value, precision, formatChar === "B");
378
+ default:
379
+ return value.toString();
380
+ }
381
+ }
382
+
383
+ private formatCurrency(value: number, precision: number): string {
384
+ const p = precision > 0 ? precision : 2;
385
+ const formatted = new Intl.NumberFormat(this.localeSettings.name, {
386
+ style: "currency",
387
+ currency: "USD",
388
+ minimumFractionDigits: p,
389
+ maximumFractionDigits: p,
390
+ }).format(value);
391
+ return formatted.replace("$", this.localeSettings.currencyString);
392
+ }
393
+
394
+ private formatDecimal(value: number, precision: number): string {
395
+ const intValue = Math.trunc(value);
396
+ const str = Math.abs(intValue).toString();
397
+ const p = this.precision >= 0 ? this.precision : 0;
398
+ const padded = p > 0 ? str.padStart(p, "0") : str;
399
+ return intValue < 0 ? "-" + padded : padded;
400
+ }
401
+
402
+ private formatExponential(
403
+ value: number,
404
+ precision: number,
405
+ uppercase: boolean,
406
+ ): string {
407
+ const p = this.precision >= 0 ? this.precision : 6;
408
+ const formatted = value.toExponential(p);
409
+ return uppercase ? formatted.toUpperCase() : formatted.toLowerCase();
410
+ }
411
+
412
+ private formatFixedPoint(value: number, precision: number): string {
413
+ const p = this.precision >= 0 ? this.precision : 2;
414
+ return value.toFixed(p).replace(".", this.localeSettings.decimalSeparator);
415
+ }
416
+
417
+ private formatGeneral(
418
+ value: number,
419
+ precision: number,
420
+ uppercase: boolean,
421
+ ): string {
422
+ // General format uses the more compact of fixed-point or scientific
423
+ const absValue = Math.abs(value);
424
+ if (absValue === 0) {
425
+ return "0";
426
+ }
427
+
428
+ const exp = Math.floor(Math.log10(absValue));
429
+ const p = precision > 0 ? precision : 15;
430
+
431
+ // Use scientific notation if exponent is < -4 or >= 10
432
+ if (exp < -4 || exp >= 10) {
433
+ const formatted = value.toExponential(Math.max(0, p - 1));
434
+ let result = uppercase
435
+ ? formatted.toUpperCase()
436
+ : formatted.toLowerCase();
437
+ // Remove trailing zeros from mantissa
438
+ result = result.replace(/(\..+?)0+([eE])/, "$1$2");
439
+ return result;
440
+ }
441
+
442
+ // Use fixed-point
443
+ let formatted = value.toPrecision(p);
444
+ // Remove trailing zeros after decimal point
445
+ if (formatted.includes(".")) {
446
+ formatted = formatted.replace(/\.?0+$/, "");
447
+ }
448
+ return formatted.replace(".", this.localeSettings.decimalSeparator);
449
+ }
450
+
451
+ private formatNumber_(value: number, precision: number): string {
452
+ const p = this.precision >= 0 ? this.precision : 2;
453
+ return new Intl.NumberFormat(this.localeSettings.name, {
454
+ minimumFractionDigits: p,
455
+ maximumFractionDigits: p,
456
+ useGrouping: true,
457
+ }).format(value);
458
+ }
459
+
460
+ private formatPercent(value: number, precision: number): string {
461
+ const p = precision >= 0 ? precision : 2;
462
+ return new Intl.NumberFormat(this.localeSettings.name, {
463
+ style: "percent",
464
+ minimumFractionDigits: p,
465
+ maximumFractionDigits: p,
466
+ }).format(value);
467
+ }
468
+
469
+ private formatHex(
470
+ value: number,
471
+ precision: number,
472
+ uppercase: boolean,
473
+ ): string {
474
+ const intValue = Math.trunc(value);
475
+ let hex = Math.abs(intValue).toString(16);
476
+ if (precision > 0) {
477
+ hex = hex.padStart(precision, "0");
478
+ }
479
+ return uppercase ? hex.toUpperCase() : hex.toLowerCase();
480
+ }
481
+
482
+ private formatBinary(
483
+ value: number,
484
+ precision: number,
485
+ uppercase: boolean,
486
+ ): string {
487
+ const intValue = Math.trunc(value);
488
+ let binary = (intValue >>> 0).toString(2);
489
+ if (precision > 0) {
490
+ binary = binary.padStart(precision, "0");
491
+ }
492
+ return binary;
493
+ }
494
+
495
+ private formatCustom(value: number, allowDecimal: boolean): string {
496
+ // Determine which section to use based on value
497
+ let section: FormatSection;
498
+ if (this.sections.length === 1) {
499
+ section = this.sections[0];
500
+ } else if (this.sections.length === 2) {
501
+ section = value >= 0 ? this.sections[0] : this.sections[1];
502
+ value = Math.abs(value);
503
+ } else {
504
+ // 3 sections: positive, negative, zero
505
+ if (value > 0) {
506
+ section = this.sections[0];
507
+ } else if (value < 0) {
508
+ section = this.sections[1];
509
+ value = Math.abs(value);
510
+ } else {
511
+ section = this.sections[2];
512
+ }
513
+ }
514
+
515
+ return this.formatWithSection(value, section, allowDecimal);
516
+ }
517
+
518
+ private formatWithSection(
519
+ value: number,
520
+ section: FormatSection,
521
+ allowDecimal: boolean,
522
+ ): string {
523
+ let workingValue = value;
524
+ let hasPercent = false;
525
+ let hasPermille = false;
526
+ let hasExponent = false;
527
+
528
+ // Check for percent/permille/exponent
529
+ for (const elem of section.elements) {
530
+ if (elem.type === "percent") {
531
+ hasPercent = true;
532
+ workingValue *= 100;
533
+ } else if (elem.type === "permille") {
534
+ hasPermille = true;
535
+ workingValue *= 1000;
536
+ } else if (elem.type === "exponent") {
537
+ hasExponent = true;
538
+ }
539
+ }
540
+
541
+ // Apply scaling
542
+ if (section.scale > 0) {
543
+ workingValue /= Math.pow(1000, section.scale);
544
+ }
545
+
546
+ // Format based on whether we have exponential notation
547
+ if (hasExponent) {
548
+ return this.formatCustomExponential(workingValue, section);
549
+ }
550
+
551
+ // Round to appropriate decimal places
552
+ const absValue = Math.abs(workingValue);
553
+ const rounded =
554
+ section.hasDecimal && section.decimalDigits > 0
555
+ ? absValue.toFixed(section.decimalDigits)
556
+ : Math.round(absValue).toString();
557
+
558
+ const [intPart, fracPart] = rounded.split(".");
559
+ const intStr = intPart || "0";
560
+ const fracStr = fracPart || "";
561
+
562
+ // Build the result by processing elements in order
563
+ let result = "";
564
+ let intPrinted = false;
565
+ let fracPrinted = false;
566
+
567
+ // Find decimal position
568
+ let decimalPos = -1;
569
+ let hasThousands = false;
570
+ for (let i = 0; i < section.elements.length; i++) {
571
+ if (section.elements[i].type === "decimal") {
572
+ decimalPos = i;
573
+ }
574
+ if (section.elements[i].type === "thousands") {
575
+ hasThousands = true;
576
+ }
577
+ }
578
+
579
+ for (let i = 0; i < section.elements.length; i++) {
580
+ const elem = section.elements[i];
581
+
582
+ if (elem.type === "literal") {
583
+ result += elem.text || "";
584
+ } else if (elem.type === "decimal") {
585
+ if (allowDecimal && (fracStr.length > 0 || section.decimalDigits > 0)) {
586
+ result += this.localeSettings.decimalSeparator;
587
+ }
588
+ } else if (elem.type === "percent") {
589
+ result += "%";
590
+ } else if (elem.type === "permille") {
591
+ result += "‰";
592
+ } else if (
593
+ (elem.type === "zero" || elem.type === "digit") &&
594
+ !intPrinted &&
595
+ (decimalPos === -1 || i < decimalPos)
596
+ ) {
597
+ // This is the first integer placeholder - format entire integer part
598
+ let formattedInt = intStr;
599
+
600
+ // Add thousands separators if needed
601
+ if (hasThousands && formattedInt.length > 3) {
602
+ const parts: string[] = [];
603
+ for (let j = formattedInt.length; j > 0; j -= 3) {
604
+ const start = Math.max(0, j - 3);
605
+ parts.unshift(formattedInt.slice(start, j));
606
+ }
607
+ formattedInt = parts.join(this.localeSettings.thousandSeparator);
608
+ }
609
+
610
+ result += formattedInt;
611
+ intPrinted = true;
612
+ } else if (
613
+ (elem.type === "zero" || elem.type === "digit") &&
614
+ !fracPrinted &&
615
+ decimalPos >= 0 &&
616
+ i > decimalPos
617
+ ) {
618
+ // This is the first fractional placeholder - format entire fractional part
619
+ result += fracStr;
620
+ fracPrinted = true;
621
+ }
622
+ // Skip thousands separators as they're handled above
623
+ }
624
+
625
+ return result;
626
+ }
627
+
628
+ private formatCustomExponential(
629
+ value: number,
630
+ section: FormatSection,
631
+ ): string {
632
+ // Find exponent element
633
+ let exponentElem: Element | undefined;
634
+ for (const elem of section.elements) {
635
+ if (elem.type === "exponent") {
636
+ exponentElem = elem;
637
+ break;
638
+ }
639
+ }
640
+
641
+ if (!exponentElem) {
642
+ return value.toString();
643
+ }
644
+
645
+ const exp = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)));
646
+ const mantissa = value / Math.pow(10, exp);
647
+
648
+ // Format mantissa using the format elements before 'E'
649
+ const mantissaSection = { ...section };
650
+ mantissaSection.elements = section.elements.filter(
651
+ (e) => e.type !== "exponent",
652
+ );
653
+
654
+ let result = this.formatWithSection(mantissa, mantissaSection, true);
655
+
656
+ // Add exponent
657
+ const expChar = exponentElem.exponentCase === "upper" ? "E" : "e";
658
+ const expSign = exp >= 0 ? "+" : "-";
659
+ const absExp = Math.abs(exp);
660
+ const expStr = absExp
661
+ .toString()
662
+ .padStart(exponentElem.exponentDigits || 1, "0");
663
+
664
+ if (exponentElem.exponentSign === "+") {
665
+ result += expChar + expSign + expStr;
666
+ } else if (exponentElem.exponentSign === "-") {
667
+ result += expChar + (exp < 0 ? "-" : "") + expStr;
668
+ } else {
669
+ result += expChar + expStr;
670
+ }
671
+
672
+ return result;
673
+ }
674
+
675
+ protected trimTrailingPadZeros(value: string): string {
676
+ const decimal = this.localeSettings.decimalSeparator;
677
+ const decimalIdx = value.indexOf(decimal);
678
+ if (decimalIdx < 0) {
679
+ return value;
680
+ }
681
+
682
+ let firstPadZeroIdx = -1;
683
+ for (let idx = decimalIdx + 1; idx < value.length; idx += 1) {
684
+ if (value[idx] !== "0") {
685
+ firstPadZeroIdx = -1;
686
+ } else if (firstPadZeroIdx < 0) {
687
+ firstPadZeroIdx = idx;
688
+ }
689
+ }
690
+
691
+ if (firstPadZeroIdx < 0) {
692
+ return value;
693
+ }
694
+
695
+ if (firstPadZeroIdx === decimalIdx + 1) {
696
+ return value.slice(0, decimalIdx);
697
+ }
698
+
699
+ return value.slice(0, firstPadZeroIdx);
700
+ }
701
+
702
+ hasExponentChar(value: string): boolean {
703
+ return value.includes("E") || value.includes("e");
704
+ }
705
+
706
+ hasDecimalChar(value: string): boolean {
707
+ return (
708
+ value.includes(this.localeSettings.decimalSeparator) ||
709
+ value.includes(".")
710
+ );
711
+ }
712
+
713
+ hasDigitChar(value: string): boolean {
714
+ return /\d/.test(value);
715
+ }
716
+
717
+ static tryHexToInt64(hex: string): Result<bigint> {
718
+ const trimmed = hex.trim();
719
+ if (
720
+ trimmed.length === 0 ||
721
+ trimmed.length > 16 ||
722
+ !/^[0-9a-fA-F]+$/.test(trimmed)
723
+ ) {
724
+ return new Err("Invalid hex format");
725
+ }
726
+
727
+ try {
728
+ return new Ok(BigInt(`0x${trimmed}`));
729
+ } catch {
730
+ return new Err("Invalid hex format");
731
+ }
732
+ }
733
+
734
+ protected unstyleNumberString(
735
+ value: string,
736
+ ): Result<{ unstyled: string; negated: boolean }> {
737
+ let unstyled = value;
738
+
739
+ if (
740
+ this.styles.has(DotNetNumberStyleId.AllowLeadingWhite) &&
741
+ this.styles.has(DotNetNumberStyleId.AllowTrailingWhite)
742
+ ) {
743
+ unstyled = unstyled.trim();
744
+ } else if (this.styles.has(DotNetNumberStyleId.AllowLeadingWhite)) {
745
+ unstyled = unstyled.trimStart();
746
+ } else if (this.styles.has(DotNetNumberStyleId.AllowTrailingWhite)) {
747
+ unstyled = unstyled.trimEnd();
748
+ }
749
+
750
+ if (this.styles.has(DotNetNumberStyleId.AllowThousands)) {
751
+ unstyled = unstyled.split(this.localeSettings.thousandSeparator).join("");
752
+ }
753
+
754
+ if (unstyled.length === 0) {
755
+ return new Err("No digit character");
756
+ }
757
+
758
+ if (/^\s/.test(unstyled)) {
759
+ return new Err("Unallowed leading whitespace characters");
760
+ }
761
+
762
+ if (/\s$/.test(unstyled)) {
763
+ return new Err("Unallowed trailing whitespace characters");
764
+ }
765
+
766
+ if (
767
+ this.styles.has(DotNetNumberStyleId.AllowCurrencySymbol) &&
768
+ unstyled.startsWith(this.localeSettings.currencyString)
769
+ ) {
770
+ unstyled = unstyled.slice(this.localeSettings.currencyString.length);
771
+ }
772
+
773
+ if (unstyled.length === 0) {
774
+ return new Err("No digit character");
775
+ }
776
+
777
+ let negated = false;
778
+ if (
779
+ this.styles.has(DotNetNumberStyleId.AllowParentheses) &&
780
+ unstyled.startsWith("(") &&
781
+ unstyled.endsWith(")")
782
+ ) {
783
+ unstyled = unstyled.slice(1, -1);
784
+ negated = true;
785
+ } else if (this.styles.has(DotNetNumberStyleId.AllowTrailingSign)) {
786
+ if (unstyled.endsWith("+")) {
787
+ unstyled = unstyled.slice(0, -1);
788
+ negated = false;
789
+ } else if (unstyled.endsWith("-")) {
790
+ unstyled = unstyled.slice(0, -1);
791
+ negated = true;
792
+ }
793
+ }
794
+
795
+ if (unstyled.length === 0) {
796
+ return new Err("No digit character");
797
+ }
798
+
799
+ return new Ok({ unstyled, negated });
800
+ }
801
+ }
802
+
803
+ /**
804
+ * Formatter for integer values (bigint) using .NET-compatible format strings.
805
+ *
806
+ * Supports formatting integers with standard format strings like D (decimal), X (hexadecimal),
807
+ * B (binary), and custom format strings.
808
+ *
809
+ * @example
810
+ * ```typescript
811
+ * const formatter = new DotNetIntegerFormatter();
812
+ * formatter.localeSettings = DotNetLocaleSettings.createInvariant();
813
+ *
814
+ * // Decimal with padding
815
+ * formatter.trySetFormat('D8');
816
+ * console.log(formatter.toString(123n)); // "00000123"
817
+ *
818
+ * // Hexadecimal
819
+ * formatter.trySetFormat('X');
820
+ * console.log(formatter.toString(255n)); // "FF"
821
+ *
822
+ * // Parsing
823
+ * formatter.styles = DotNetNumberStyles.integer;
824
+ * const result = formatter.tryFromString('-12345');
825
+ * if (result.isOk()) {
826
+ * console.log(result.value); // -12345n
827
+ * }
828
+ * ```
829
+ *
830
+ * @public
831
+ * @category Numeric Formatting
832
+ */
833
+ export class DotNetIntegerFormatter extends DotNetNumberFormatter {
834
+ /**
835
+ * Formats a bigint value using the current format string.
836
+ *
837
+ * @param value - The bigint value to format.
838
+ * @returns The formatted number string.
839
+ */
840
+ toString(value: bigint): string {
841
+ if (this.format === "") {
842
+ return value.toString();
843
+ }
844
+ return this.formatNumber(value, false);
845
+ }
846
+
847
+ /**
848
+ * Attempts to parse a bigint value from a string using the current parsing styles.
849
+ *
850
+ * @param strValue - The string to parse.
851
+ * @returns A Result containing the parsed bigint if successful, or an error if parsing fails.
852
+ *
853
+ * @example
854
+ * ```typescript
855
+ * formatter.styles = DotNetNumberStyles.integer;
856
+ * const result = formatter.tryFromString(' -123 ');
857
+ * if (result.isOk()) {
858
+ * console.log(result.value); // -123n
859
+ * }
860
+ * ```
861
+ */
862
+ tryFromString(strValue: string): Result<bigint> {
863
+ const unstyled = this.unstyleNumberString(strValue);
864
+ if (unstyled.isErr()) {
865
+ return unstyled.createOuter("Invalid number format");
866
+ }
867
+
868
+ let { unstyled: text, negated } = unstyled.value;
869
+
870
+ if (this.styles.has(DotNetNumberStyleId.AllowHexSpecifier)) {
871
+ if (this.styles.has(DotNetNumberStyleId.AllowLeadingSign)) {
872
+ if (text.startsWith("+")) {
873
+ text = text.slice(1);
874
+ } else if (text.startsWith("-")) {
875
+ text = text.slice(1);
876
+ negated = true;
877
+ }
878
+ }
879
+
880
+ const hex = DotNetNumberFormatter.tryHexToInt64(text);
881
+ if (hex.isErr()) {
882
+ return hex.createOuter("Invalid hex format");
883
+ }
884
+
885
+ return new Ok(negated ? -hex.value : hex.value);
886
+ }
887
+
888
+ const hasLeadingSign = text.startsWith("+") || text.startsWith("-");
889
+ if (
890
+ !this.styles.has(DotNetNumberStyleId.AllowLeadingSign) &&
891
+ hasLeadingSign
892
+ ) {
893
+ return new Err("Unallowed leading sign character");
894
+ }
895
+
896
+ const parseAsFloat =
897
+ (this.styles.has(DotNetNumberStyleId.AllowExponent) &&
898
+ this.hasExponentChar(text)) ||
899
+ (this.styles.has(DotNetNumberStyleId.AllowDecimalPoint) &&
900
+ this.hasDecimalChar(text));
901
+
902
+ if (parseAsFloat) {
903
+ const normalized = text.replace(
904
+ this.localeSettings.decimalSeparator,
905
+ ".",
906
+ );
907
+ const floatValue = Number.parseFloat(normalized);
908
+ if (!Number.isFinite(floatValue)) {
909
+ return new Err("Invalid float format (for Integer field)");
910
+ }
911
+ if (!Number.isInteger(floatValue)) {
912
+ return new Err("Value has fractional component");
913
+ }
914
+ return new Ok(BigInt(floatValue));
915
+ }
916
+
917
+ if (!/^[+-]?\d+$/.test(text)) {
918
+ return new Err("Invalid integer format");
919
+ }
920
+
921
+ try {
922
+ const value = BigInt(text);
923
+ return new Ok(negated ? -value : value);
924
+ } catch {
925
+ return new Err("Invalid integer format");
926
+ }
927
+ }
928
+ }
929
+
930
+ /**
931
+ * Formatter for floating-point numbers using .NET-compatible format strings.
932
+ *
933
+ * Supports all standard numeric format strings (C, D, E, F, G, N, P, R, X, B) and
934
+ * custom format strings with digit placeholders, separators, and sections.
935
+ *
936
+ * @example
937
+ * ```typescript
938
+ * const formatter = new DotNetFloatFormatter();
939
+ * formatter.localeSettings = DotNetLocaleSettings.createInvariant();
940
+ *
941
+ * // Currency format
942
+ * formatter.trySetFormat('C2');
943
+ * console.log(formatter.toString(1234.56)); // "$1,234.56"
944
+ *
945
+ * // Percentage format
946
+ * formatter.trySetFormat('P1');
947
+ * console.log(formatter.toString(0.1234)); // "12.3%"
948
+ *
949
+ * // Custom format with sections
950
+ * formatter.trySetFormat('#,##0.00;(#,##0.00)');
951
+ * console.log(formatter.toString(-1234.56)); // "(1,234.56)"
952
+ *
953
+ * // Parsing
954
+ * formatter.styles = DotNetNumberStyles.number;
955
+ * const result = formatter.tryFromString('1,234.56');
956
+ * if (result.isOk()) {
957
+ * console.log(result.value); // 1234.56
958
+ * }
959
+ * ```
960
+ *
961
+ * @public
962
+ * @category Numeric Formatting
963
+ */
964
+ export class DotNetFloatFormatter extends DotNetNumberFormatter {
965
+ /**
966
+ * Formats a number using the current format string.
967
+ *
968
+ * @param value - The number to format.
969
+ * @returns The formatted number string.
970
+ */
971
+ toString(value: number): string {
972
+ if (this.format === "") {
973
+ return this.localeSettings.defaultFloat.format(value);
974
+ }
975
+ return this.formatNumber(value, true);
976
+ }
977
+
978
+ /**
979
+ * Attempts to parse a number from a string using the current parsing styles.
980
+ *
981
+ * @param strValue - The string to parse.
982
+ * @returns A Result containing the parsed number if successful, or an error if parsing fails.
983
+ *
984
+ * @example
985
+ * ```typescript
986
+ * formatter.styles = DotNetNumberStyles.number;
987
+ * const result = formatter.tryFromString(' 1,234.56 ');
988
+ * if (result.isOk()) {
989
+ * console.log(result.value); // 1234.56
990
+ * }
991
+ * ```
992
+ */
993
+ tryFromString(strValue: string): Result<number> {
994
+ const unstyled = this.unstyleNumberString(strValue);
995
+ if (unstyled.isErr()) {
996
+ return unstyled.createOuter("Invalid number format");
997
+ }
998
+
999
+ const { unstyled: text, negated } = unstyled.value;
1000
+ const hasLeadingSign = text.startsWith("+") || text.startsWith("-");
1001
+
1002
+ if (
1003
+ !this.styles.has(DotNetNumberStyleId.AllowLeadingSign) &&
1004
+ hasLeadingSign
1005
+ ) {
1006
+ return new Err("Unallowed leading sign character");
1007
+ }
1008
+ // Note: We always allow exponent characters during input parsing, regardless of format.
1009
+ // The format string only affects output formatting, not input parsing capabilities.
1010
+ // Scientific notation like "1.23e5" should always be parseable.
1011
+ if (
1012
+ !this.styles.has(DotNetNumberStyleId.AllowDecimalPoint) &&
1013
+ this.hasDecimalChar(text)
1014
+ ) {
1015
+ return new Err("Unallowed decimal point character");
1016
+ }
1017
+ if (!this.hasDigitChar(text)) {
1018
+ return new Err("No digit character");
1019
+ }
1020
+
1021
+ // Handle scientific notation: normalize the mantissa part to respect culture decimal separator
1022
+ // but keep the exponent part as-is (always uses 'e' or 'E' and standard notation)
1023
+ let normalized = text;
1024
+ const expMatch = text.match(/[eE]/);
1025
+ if (expMatch) {
1026
+ // Split at the exponent indicator to separately handle mantissa and exponent
1027
+ const expIndex = text.indexOf(expMatch[0]);
1028
+ const mantissa = text.substring(0, expIndex);
1029
+ const exponent = text.substring(expIndex);
1030
+
1031
+ // Replace culture-specific decimal separator only in the mantissa part
1032
+ const normalizedMantissa = mantissa.replace(
1033
+ this.localeSettings.decimalSeparator,
1034
+ ".",
1035
+ );
1036
+ normalized = normalizedMantissa + exponent;
1037
+ } else {
1038
+ // No exponent, just replace decimal separator as before
1039
+ normalized = text.replace(this.localeSettings.decimalSeparator, ".");
1040
+ }
1041
+
1042
+ const parsed = Number.parseFloat(normalized);
1043
+ if (!Number.isFinite(parsed)) {
1044
+ return new Err("Invalid float format");
1045
+ }
1046
+
1047
+ return new Ok(negated ? -parsed : parsed);
1048
+ }
1049
+ }
1050
+
1051
+ /**
1052
+ * Formatter for decimal numbers with high precision using .NET-compatible format strings.
1053
+ *
1054
+ * Similar to {@link DotNetFloatFormatter} but with different default precision behavior.
1055
+ * When no format is specified, trailing zeros after the decimal point are automatically trimmed.
1056
+ *
1057
+ * @example
1058
+ * ```typescript
1059
+ * const formatter = new DotNetDecimalFormatter();
1060
+ * formatter.localeSettings = DotNetLocaleSettings.createInvariant();
1061
+ *
1062
+ * // High precision formatting
1063
+ * formatter.trySetFormat('F6');
1064
+ * console.log(formatter.toString(123.456)); // "123.456000"
1065
+ *
1066
+ * // Default behavior (trims trailing zeros)
1067
+ * console.log(formatter.toString(123.400)); // "123.4"
1068
+ * ```
1069
+ *
1070
+ * @public
1071
+ * @category Numeric Formatting
1072
+ */
1073
+ export class DotNetDecimalFormatter extends DotNetNumberFormatter {
1074
+ /**
1075
+ * Formats a number using the current format string.
1076
+ * When no format is specified, trailing zeros are trimmed.
1077
+ *
1078
+ * @param value - The number to format.
1079
+ * @returns The formatted number string.
1080
+ */
1081
+ toString(value: number): string {
1082
+ if (this.format === "") {
1083
+ return this.trimTrailingPadZeros(
1084
+ this.localeSettings.defaultDecimal.format(value),
1085
+ );
1086
+ }
1087
+ return this.formatNumber(value, true);
1088
+ }
1089
+
1090
+ /**
1091
+ * Attempts to parse a number from a string using the current parsing styles.
1092
+ *
1093
+ * @param strValue - The string to parse.
1094
+ * @returns A Result containing the parsed number if successful, or an error if parsing fails.
1095
+ */
1096
+ tryFromString(strValue: string): Result<number> {
1097
+ const doubleFormatter = new DotNetFloatFormatter();
1098
+ doubleFormatter.styles = this.styles;
1099
+ doubleFormatter.localeSettings = this.localeSettings;
1100
+
1101
+ const parsed = doubleFormatter.tryFromString(strValue);
1102
+ if (parsed.isErr()) {
1103
+ return parsed.createOuter(parsed.error.replace("float", "decimal"));
1104
+ }
1105
+
1106
+ return parsed;
1107
+ }
1108
+ }