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