1
2
3
4
5
6 package dev.metaschema.core.metapath.function.library;
7
8 import java.math.BigInteger;
9 import java.util.ArrayList;
10 import java.util.List;
11 import java.util.Locale;
12 import java.util.regex.Matcher;
13 import java.util.regex.Pattern;
14
15 import dev.metaschema.core.metapath.DynamicContext;
16 import dev.metaschema.core.metapath.MetapathConstants;
17 import dev.metaschema.core.metapath.function.FormatFunctionException;
18 import dev.metaschema.core.metapath.function.FunctionUtils;
19 import dev.metaschema.core.metapath.function.IArgument;
20 import dev.metaschema.core.metapath.function.IFunction;
21 import dev.metaschema.core.metapath.item.IItem;
22 import dev.metaschema.core.metapath.item.ISequence;
23 import dev.metaschema.core.metapath.item.atomic.IIntegerItem;
24 import dev.metaschema.core.metapath.item.atomic.IStringItem;
25 import dev.metaschema.core.util.ObjectUtils;
26 import edu.umd.cs.findbugs.annotations.NonNull;
27 import edu.umd.cs.findbugs.annotations.Nullable;
28
29
30
31
32
33
34
35
36
37
38 public final class FnFormatInteger {
39 private static final String NAME = "format-integer";
40
41 @NonNull
42 static final IFunction SIGNATURE_TWO_ARG = IFunction.builder()
43 .name(NAME)
44 .namespace(MetapathConstants.NS_METAPATH_FUNCTIONS)
45 .deterministic()
46 .contextDependent()
47 .focusIndependent()
48 .argument(IArgument.builder()
49 .name("value")
50 .type(IIntegerItem.type())
51 .zeroOrOne()
52 .build())
53 .argument(IArgument.builder()
54 .name("picture")
55 .type(IStringItem.type())
56 .one()
57 .build())
58 .returnType(IStringItem.type())
59 .returnOne()
60 .functionHandler(FnFormatInteger::executeTwoArg)
61 .build();
62
63 @NonNull
64 static final IFunction SIGNATURE_THREE_ARG = IFunction.builder()
65 .name(NAME)
66 .namespace(MetapathConstants.NS_METAPATH_FUNCTIONS)
67 .deterministic()
68 .contextIndependent()
69 .focusIndependent()
70 .argument(IArgument.builder()
71 .name("value")
72 .type(IIntegerItem.type())
73 .zeroOrOne()
74 .build())
75 .argument(IArgument.builder()
76 .name("picture")
77 .type(IStringItem.type())
78 .one()
79 .build())
80 .argument(IArgument.builder()
81 .name("lang")
82 .type(IStringItem.type())
83 .zeroOrOne()
84 .build())
85 .returnType(IStringItem.type())
86 .returnOne()
87 .functionHandler(FnFormatInteger::executeThreeArg)
88 .build();
89
90
91
92
93
94 private static final int[] ROMAN_VALUES = { 1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1 };
95
96
97
98
99 private static final String[] ROMAN_SYMBOLS = { "M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV",
100 "I" };
101
102 private static final String[] ONES
103 = { "", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
104 "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen",
105 "seventeen", "eighteen", "nineteen" };
106
107 private static final String[] TENS
108 = { "", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety" };
109
110
111
112
113
114
115 private static final Pattern MODIFIER_PATTERN
116 = Pattern.compile("^([co](\\(.+\\))?)?[at]?$");
117
118 private FnFormatInteger() {
119
120 }
121
122 @SuppressWarnings("unused")
123 @NonNull
124 private static ISequence<IStringItem> executeTwoArg(
125 @NonNull IFunction function,
126 @NonNull List<ISequence<?>> arguments,
127 @NonNull DynamicContext dynamicContext,
128 IItem focus) {
129
130 IIntegerItem value = FunctionUtils.asTypeOrNull(arguments.get(0).getFirstItem(true));
131 IStringItem picture = FunctionUtils.asType(
132 ObjectUtils.requireNonNull(arguments.get(1).getFirstItem(true)));
133
134 String lang = dynamicContext.getStaticContext().getDefaultLanguage();
135
136 return ISequence.of(IStringItem.valueOf(
137 fnFormatInteger(value, picture.asString(), lang)));
138 }
139
140 @SuppressWarnings("unused")
141 @NonNull
142 private static ISequence<IStringItem> executeThreeArg(
143 @NonNull IFunction function,
144 @NonNull List<ISequence<?>> arguments,
145 @NonNull DynamicContext dynamicContext,
146 IItem focus) {
147
148 IIntegerItem value = FunctionUtils.asTypeOrNull(arguments.get(0).getFirstItem(true));
149 IStringItem picture = FunctionUtils.asType(
150 ObjectUtils.requireNonNull(arguments.get(1).getFirstItem(true)));
151 IStringItem lang = FunctionUtils.asTypeOrNull(arguments.get(2).getFirstItem(true));
152
153 return ISequence.of(IStringItem.valueOf(
154 fnFormatInteger(value, picture.asString(), lang == null ? null : lang.asString())));
155 }
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172 @NonNull
173 public static String fnFormatInteger(
174 @Nullable IIntegerItem value,
175 @NonNull String picture,
176 @Nullable String lang) {
177
178
179 if (value == null) {
180 return "";
181 }
182
183 if (picture.isEmpty()) {
184 throw new FormatFunctionException(
185 FormatFunctionException.INVALID_FORMAT_TOKEN,
186 "The picture string for format-integer must not be empty.");
187 }
188
189
190
191
192 String[] parsed = parsePicture(picture);
193 String primaryToken = parsed[0];
194 String modifier = parsed[1];
195
196
197 boolean ordinal = false;
198 if (!modifier.isEmpty()) {
199 Matcher modMatcher = MODIFIER_PATTERN.matcher(modifier);
200 if (!modMatcher.matches()) {
201 throw new FormatFunctionException(
202 FormatFunctionException.INVALID_FORMAT_TOKEN,
203 String.format("Invalid format modifier '%s' in picture string '%s'.", modifier, picture));
204 }
205 String modLetters = modMatcher.group(1);
206 if (modLetters != null && modLetters.startsWith("o")) {
207 ordinal = true;
208 }
209 }
210
211 BigInteger bigValue = value.asInteger();
212 boolean negative = bigValue.signum() < 0;
213 BigInteger absValue = bigValue.abs();
214
215 String formatted = formatWithPrimaryToken(primaryToken, absValue, picture);
216
217
218
219
220
221
222 if (ordinal && isDecimalDigitPattern(primaryToken)) {
223 formatted = applyOrdinal(formatted, absValue);
224 }
225
226
227 if (negative) {
228 formatted = "-" + formatted;
229 }
230
231 return ObjectUtils.notNull(formatted);
232 }
233
234
235
236
237
238
239
240
241
242
243
244
245 @NonNull
246 private static String[] parsePicture(@NonNull String picture) {
247
248
249
250 for (int i = picture.length() - 1; i >= 0; i--) {
251 if (picture.charAt(i) == ';') {
252 String candidateModifier = picture.substring(i + 1);
253 String candidateToken = picture.substring(0, i);
254 if (MODIFIER_PATTERN.matcher(candidateModifier).matches()) {
255 return new String[] { candidateToken, candidateModifier };
256 }
257 }
258 }
259
260 return new String[] { picture, "" };
261 }
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276 @NonNull
277 private static String formatWithPrimaryToken(
278 @NonNull String primaryToken,
279 @NonNull BigInteger absValue,
280 @NonNull String picture) {
281
282 if (primaryToken.isEmpty()) {
283 throw new FormatFunctionException(
284 FormatFunctionException.INVALID_FORMAT_TOKEN,
285 String.format("The primary format token in picture string '%s' must not be empty.", picture));
286 }
287
288
289 if ("a".equals(primaryToken)) {
290 return formatAlphabetic(absValue, false);
291 }
292 if ("A".equals(primaryToken)) {
293 return formatAlphabetic(absValue, true);
294 }
295 if ("i".equals(primaryToken)) {
296 return formatRoman(absValue, false);
297 }
298 if ("I".equals(primaryToken)) {
299 return formatRoman(absValue, true);
300 }
301 if ("w".equals(primaryToken)) {
302 return formatWords(absValue, false, false);
303 }
304 if ("W".equals(primaryToken)) {
305 return formatWords(absValue, true, false);
306 }
307 if ("Ww".equals(primaryToken)) {
308 return formatWords(absValue, false, true);
309 }
310
311
312 return formatDecimalDigitPattern(primaryToken, absValue, picture);
313 }
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329 @NonNull
330 @SuppressWarnings("PMD.CyclomaticComplexity")
331 private static String formatDecimalDigitPattern(
332 @NonNull String pattern,
333 @NonNull BigInteger absValue,
334 @NonNull String picture) {
335
336
337
338
339 List<Character> patternChars = new ArrayList<>();
340 List<Boolean> isSeparator = new ArrayList<>();
341
342 int mandatoryCount = 0;
343 boolean foundMandatory = false;
344 boolean hasOptional = false;
345
346 for (int i = 0; i < pattern.length(); i++) {
347 char ch = pattern.charAt(i);
348 patternChars.add(ch);
349
350 if (ch >= '0' && ch <= '9') {
351 isSeparator.add(false);
352 mandatoryCount++;
353 foundMandatory = true;
354 } else if (ch == '#') {
355 if (foundMandatory) {
356
357 throw new FormatFunctionException(
358 FormatFunctionException.INVALID_FORMAT_TOKEN,
359 String.format(
360 "In picture string '%s', optional-digit-sign '#' must precede all mandatory-digit-signs.",
361 picture));
362 }
363 isSeparator.add(false);
364 hasOptional = true;
365 } else if (!Character.isLetterOrDigit(ch)) {
366
367 isSeparator.add(true);
368 } else {
369
370 return ObjectUtils.notNull(absValue.toString());
371 }
372 }
373
374 if (mandatoryCount == 0) {
375 throw new FormatFunctionException(
376 FormatFunctionException.INVALID_FORMAT_TOKEN,
377 String.format(
378 "The decimal digit pattern in picture string '%s' must contain at least one mandatory digit.",
379 picture));
380 }
381
382
383 validateSeparators(patternChars, isSeparator, picture);
384
385
386
387 char groupingSep = 0;
388 List<Integer> groupPositions = new ArrayList<>();
389 int digitIndex = 0;
390 for (int i = patternChars.size() - 1; i >= 0; i--) {
391 if (Boolean.TRUE.equals(isSeparator.get(i))) {
392 groupingSep = patternChars.get(i);
393 groupPositions.add(digitIndex);
394 } else {
395 digitIndex++;
396 }
397 }
398
399
400 String digits = absValue.toString();
401 if (digits.length() < mandatoryCount) {
402 StringBuilder padded = new StringBuilder();
403 for (int i = digits.length(); i < mandatoryCount; i++) {
404 padded.append('0');
405 }
406 padded.append(digits);
407 digits = padded.toString();
408 }
409
410
411 if (!groupPositions.isEmpty() && groupingSep != 0) {
412 digits = insertGroupingSeparators(digits, groupingSep, groupPositions, hasOptional);
413 }
414
415 return ObjectUtils.notNull(digits);
416 }
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431 private static void validateSeparators(
432 @NonNull List<Character> patternChars,
433 @NonNull List<Boolean> isSeparator,
434 @NonNull String picture) {
435
436 if (!patternChars.isEmpty()) {
437 if (Boolean.TRUE.equals(isSeparator.get(0))) {
438 throw new FormatFunctionException(
439 FormatFunctionException.INVALID_FORMAT_TOKEN,
440 String.format(
441 "Grouping separator must not appear at the start of the pattern in picture string '%s'.",
442 picture));
443 }
444 if (Boolean.TRUE.equals(isSeparator.get(isSeparator.size() - 1))) {
445 throw new FormatFunctionException(
446 FormatFunctionException.INVALID_FORMAT_TOKEN,
447 String.format(
448 "Grouping separator must not appear at the end of the pattern in picture string '%s'.",
449 picture));
450 }
451 for (int i = 1; i < isSeparator.size(); i++) {
452 if (Boolean.TRUE.equals(isSeparator.get(i)) && Boolean.TRUE.equals(isSeparator.get(i - 1))) {
453 throw new FormatFunctionException(
454 FormatFunctionException.INVALID_FORMAT_TOKEN,
455 String.format(
456 "Adjacent grouping separators are not allowed in picture string '%s'.",
457 picture));
458 }
459 }
460 }
461 }
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482 @NonNull
483 private static String insertGroupingSeparators(
484 @NonNull String digits,
485 char separator,
486 @NonNull List<Integer> positions,
487 boolean hasOptional) {
488
489
490 int groupSize = -1;
491 boolean regular = true;
492
493 List<Integer> sorted = new ArrayList<>(positions);
494 sorted.sort(null);
495
496 if (sorted.size() == 1) {
497 groupSize = sorted.get(0);
498 regular = true;
499 } else {
500
501 int candidate = sorted.get(0);
502 regular = true;
503 for (int i = 0; i < sorted.size(); i++) {
504 if (sorted.get(i) != candidate * (i + 1)) {
505 regular = false;
506 break;
507 }
508 }
509 if (regular) {
510 groupSize = candidate;
511 }
512 }
513
514
515 StringBuilder result = new StringBuilder();
516 int digitCount = digits.length();
517 int rightDigitCount = 0;
518
519 for (int i = digitCount - 1; i >= 0; i--) {
520 if (rightDigitCount > 0) {
521 boolean insertSep;
522 if (regular && groupSize > 0) {
523 insertSep = rightDigitCount % groupSize == 0;
524 } else {
525 insertSep = sorted.contains(rightDigitCount);
526 }
527 if (insertSep) {
528 result.insert(0, separator);
529 }
530 }
531 result.insert(0, digits.charAt(i));
532 rightDigitCount++;
533 }
534
535 return ObjectUtils.notNull(result.toString());
536 }
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551 @NonNull
552 private static String formatAlphabetic(@NonNull BigInteger absValue, boolean uppercase) {
553 if (absValue.signum() == 0) {
554 return "0";
555 }
556
557 char base = uppercase ? 'A' : 'a';
558 StringBuilder result = new StringBuilder();
559 BigInteger remaining = absValue;
560 BigInteger twentySix = BigInteger.valueOf(26);
561
562 while (remaining.signum() > 0) {
563 remaining = remaining.subtract(BigInteger.ONE);
564 int digit = remaining.mod(twentySix).intValue();
565 result.insert(0, (char) (base + digit));
566 remaining = remaining.divide(twentySix);
567 }
568
569 return ObjectUtils.notNull(result.toString());
570 }
571
572
573
574
575
576
577
578
579
580
581
582
583
584 @NonNull
585 private static String formatRoman(@NonNull BigInteger absValue, boolean uppercase) {
586 if (absValue.signum() == 0 || absValue.compareTo(BigInteger.valueOf(3999)) > 0) {
587
588 return ObjectUtils.notNull(absValue.toString());
589 }
590
591 int num = absValue.intValue();
592 StringBuilder result = new StringBuilder();
593 for (int i = 0; i < ROMAN_VALUES.length; i++) {
594 while (num >= ROMAN_VALUES[i]) {
595 result.append(ROMAN_SYMBOLS[i]);
596 num -= ROMAN_VALUES[i];
597 }
598 }
599
600 String roman = result.toString();
601 if (!uppercase) {
602 roman = roman.toLowerCase(Locale.ROOT);
603 }
604 return ObjectUtils.notNull(roman);
605 }
606
607
608
609
610
611
612
613
614
615
616
617
618
619 @NonNull
620 private static String formatWords(@NonNull BigInteger absValue, boolean allUppercase, boolean titleCase) {
621 String words = numberToWords(absValue);
622
623 if (allUppercase) {
624 words = words.toUpperCase(Locale.ROOT);
625 } else if (titleCase) {
626 words = toTitleCase(words);
627 }
628
629 return ObjectUtils.notNull(words);
630 }
631
632
633
634
635
636
637
638
639
640 @NonNull
641 @SuppressWarnings("PMD.CyclomaticComplexity")
642 private static String numberToWords(@NonNull BigInteger value) {
643 if (value.signum() == 0) {
644 return "zero";
645 }
646
647 if (value.compareTo(BigInteger.valueOf(20)) < 0) {
648 return ObjectUtils.notNull(ONES[value.intValue()]);
649 }
650
651 if (value.compareTo(BigInteger.valueOf(100)) < 0) {
652 int tens = value.intValue() / 10;
653 int ones = value.intValue() % 10;
654 if (ones == 0) {
655 return ObjectUtils.notNull(TENS[tens]);
656 }
657 return ObjectUtils.notNull(TENS[tens] + "-" + ONES[ones]);
658 }
659
660 if (value.compareTo(BigInteger.valueOf(1000)) < 0) {
661 int hundreds = value.intValue() / 100;
662 int remainder = value.intValue() % 100;
663 if (remainder == 0) {
664 return ONES[hundreds] + " hundred";
665 }
666 return ONES[hundreds] + " hundred " + numberToWords(BigInteger.valueOf(remainder));
667 }
668
669
670 return formatLargeNumber(value);
671 }
672
673
674
675
676
677
678
679
680
681 @NonNull
682 private static String formatLargeNumber(@NonNull BigInteger value) {
683 String[] scaleWords = { "", "thousand", "million", "billion", "trillion",
684 "quadrillion", "quintillion", "sextillion", "septillion" };
685 BigInteger oneThousand = BigInteger.valueOf(1000);
686
687
688 BigInteger maxSupported = BigInteger.TEN.pow((scaleWords.length) * 3);
689 if (value.compareTo(maxSupported) >= 0) {
690 return ObjectUtils.notNull(value.toString());
691 }
692
693
694 List<Integer> groups = new ArrayList<>();
695 BigInteger remaining = value;
696 while (remaining.signum() > 0) {
697 groups.add(remaining.mod(oneThousand).intValue());
698 remaining = remaining.divide(oneThousand);
699 }
700
701 StringBuilder result = new StringBuilder();
702 for (int i = groups.size() - 1; i >= 0; i--) {
703 int group = groups.get(i);
704 if (group == 0) {
705 continue;
706 }
707 if (result.length() > 0) {
708 result.append(' ');
709 }
710 result.append(numberToWords(BigInteger.valueOf(group)));
711 if (i > 0 && i < scaleWords.length) {
712 result.append(' ').append(scaleWords[i]);
713 }
714 }
715
716 return ObjectUtils.notNull(result.toString());
717 }
718
719
720
721
722
723
724
725
726
727 @NonNull
728 private static String toTitleCase(@NonNull String input) {
729 StringBuilder result = new StringBuilder();
730 boolean capitalizeNext = true;
731
732 for (int i = 0; i < input.length(); i++) {
733 char ch = input.charAt(i);
734 if (ch == ' ' || ch == '-') {
735 result.append(ch);
736 capitalizeNext = true;
737 } else if (capitalizeNext) {
738 result.append(Character.toUpperCase(ch));
739 capitalizeNext = false;
740 } else {
741 result.append(ch);
742 }
743 }
744
745 return ObjectUtils.notNull(result.toString());
746 }
747
748
749
750
751
752
753
754
755
756
757 private static boolean isDecimalDigitPattern(@NonNull String primaryToken) {
758 for (int i = 0; i < primaryToken.length(); i++) {
759 char ch = primaryToken.charAt(i);
760 if ((ch >= '0' && ch <= '9') || ch == '#') {
761 return true;
762 }
763 }
764 return false;
765 }
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784 @NonNull
785 private static String applyOrdinal(
786 @NonNull String formatted,
787 @NonNull BigInteger absValue) {
788
789 int num = absValue.mod(BigInteger.valueOf(100)).intValue();
790 int lastDigit = absValue.mod(BigInteger.valueOf(10)).intValue();
791
792 String suffix;
793 if (num >= 11 && num <= 13) {
794 suffix = "th";
795 } else if (lastDigit == 1) {
796 suffix = "st";
797 } else if (lastDigit == 2) {
798 suffix = "nd";
799 } else if (lastDigit == 3) {
800 suffix = "rd";
801 } else {
802 suffix = "th";
803 }
804
805 return ObjectUtils.notNull(formatted + suffix);
806 }
807 }