This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new a9ead47e27 GROOVY-12147: Add locale-aware number, currency and percent
formatting/parsing to the GDK
a9ead47e27 is described below
commit a9ead47e279e365ce4466afe6e1d42b879b890b7
Author: Paul King <[email protected]>
AuthorDate: Sat Jul 11 10:40:01 2026 +1000
GROOVY-12147: Add locale-aware number, currency and percent
formatting/parsing to the GDK
---
.../groovy/runtime/DefaultGroovyMethods.java | 62 ++++++++++++
.../groovy/runtime/StringGroovyMethods.java | 105 +++++++++++++++++++++
.../groovy/runtime/StringGroovyMethodsTest.java | 36 +++++++
3 files changed, 203 insertions(+)
diff --git
a/src/main/java/org/codehaus/groovy/runtime/DefaultGroovyMethods.java
b/src/main/java/org/codehaus/groovy/runtime/DefaultGroovyMethods.java
index 8c3a14acac..b73727b319 100644
--- a/src/main/java/org/codehaus/groovy/runtime/DefaultGroovyMethods.java
+++ b/src/main/java/org/codehaus/groovy/runtime/DefaultGroovyMethods.java
@@ -114,6 +114,7 @@ import java.math.RoundingMode;
import java.net.URL;
import java.security.CodeSource;
import java.text.MessageFormat;
+import java.text.NumberFormat;
import java.util.AbstractCollection;
import java.util.AbstractMap;
import java.util.ArrayList;
@@ -132,6 +133,7 @@ import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.ListIterator;
+import java.util.Locale;
import java.util.Map;
import java.util.NavigableMap;
import java.util.NavigableSet;
@@ -16503,6 +16505,66 @@ public class DefaultGroovyMethods extends
DefaultGroovyMethodsSupport {
return NumberMath.toBigInteger(self);
}
+
//--------------------------------------------------------------------------
+ // toCurrencyString
+
+ /**
+ * Formats a Number as a currency String using the default locale.
+ *
+ * @param self a Number
+ * @return the currency-formatted String
+ * @since 6.0.0
+ */
+ public static String toCurrencyString(Number self) {
+ return NumberFormat.getCurrencyInstance().format(self);
+ }
+
+ /**
+ * Formats a Number as a currency String using the given locale.
+ * <pre class="groovyTestCase">
+ * assert 1234.5.toCurrencyString(Locale.US) == '$1,234.50'
+ * </pre>
+ *
+ * @param self a Number
+ * @param locale the locale defining the currency format
+ * @return the currency-formatted String
+ * @since 6.0.0
+ */
+ public static String toCurrencyString(Number self, Locale locale) {
+ return NumberFormat.getCurrencyInstance(locale).format(self);
+ }
+
+
//--------------------------------------------------------------------------
+ // toPercentString
+
+ /**
+ * Formats a Number as a percent String using the default locale.
+ * The value is scaled by 100 (e.g. {@code 0.945} becomes {@code 94.5%}).
+ *
+ * @param self a Number
+ * @return the percent-formatted String
+ * @since 6.0.0
+ */
+ public static String toPercentString(Number self) {
+ return NumberFormat.getPercentInstance().format(self);
+ }
+
+ /**
+ * Formats a Number as a percent String using the given locale.
+ * The value is scaled by 100 (e.g. {@code 0.945} becomes {@code 94.5%}).
+ * <pre class="groovyTestCase">
+ * assert 0.5.toPercentString(Locale.US) == '50%'
+ * </pre>
+ *
+ * @param self a Number
+ * @param locale the locale defining the percent format
+ * @return the percent-formatted String
+ * @since 6.0.0
+ */
+ public static String toPercentString(Number self, Locale locale) {
+ return NumberFormat.getPercentInstance(locale).format(self);
+ }
+
//--------------------------------------------------------------------------
// toDouble
diff --git a/src/main/java/org/codehaus/groovy/runtime/StringGroovyMethods.java
b/src/main/java/org/codehaus/groovy/runtime/StringGroovyMethods.java
index b50f3e631e..2d7f8248ae 100644
--- a/src/main/java/org/codehaus/groovy/runtime/StringGroovyMethods.java
+++ b/src/main/java/org/codehaus/groovy/runtime/StringGroovyMethods.java
@@ -37,6 +37,9 @@ import java.io.IOException;
import java.io.Writer;
import java.math.BigDecimal;
import java.math.BigInteger;
+import java.text.DecimalFormat;
+import java.text.NumberFormat;
+import java.text.ParsePosition;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@@ -3466,6 +3469,108 @@ public class StringGroovyMethods extends
DefaultGroovyMethodsSupport {
return new BigInteger(self.toString().trim());
}
+ /**
+ * Parses a CharSequence into a Number using the given locale, honouring
+ * locale-specific grouping and decimal symbols. Like {@link
#toBigDecimal(CharSequence)},
+ * parsing is strict: the entire (trimmed) CharSequence must be a valid
number.
+ * <pre class="groovyTestCase">
+ * assert '1.234,5'.toNumber(Locale.GERMANY) == 1234.5
+ * </pre>
+ *
+ * @param self a CharSequence
+ * @param locale the locale defining the number symbols
+ * @return the parsed Number
+ * @throws NumberFormatException if the CharSequence cannot be parsed in
full
+ *
+ * @since 6.0.0
+ */
+ public static Number toNumber(final CharSequence self, final Locale
locale) {
+ return parseFully(NumberFormat.getNumberInstance(locale), self,
"number");
+ }
+
+ /**
+ * Parses a CharSequence in full using the given format, throwing a
+ * {@link NumberFormatException} if the trimmed text is not entirely
consumed.
+ */
+ private static Number parseFully(final NumberFormat format, final
CharSequence self, final String kind) {
+ String text = self.toString().trim();
+ ParsePosition pos = new ParsePosition(0);
+ Number result = format.parse(text, pos);
+ if (result == null || pos.getIndex() != text.length()) {
+ int at = result == null ? pos.getErrorIndex() : pos.getIndex();
+ throw new NumberFormatException("Unparseable " + kind + ": \"" +
self + "\" (at index " + at + ")");
+ }
+ return result;
+ }
+
+ /**
+ * Parses a CharSequence into a Number using the currency format of the
default locale.
+ * The result is an exact {@link BigDecimal}.
+ *
+ * @param self a CharSequence
+ * @return the parsed Number (a BigDecimal)
+ * @throws NumberFormatException if the CharSequence cannot be parsed
+ *
+ * @since 6.0.0
+ */
+ public static Number toCurrencyNumber(final CharSequence self) {
+ return toCurrencyNumber(self, Locale.getDefault());
+ }
+
+ /**
+ * Parses a CharSequence into a Number using the currency format of the
given locale.
+ * The result is an exact {@link BigDecimal}.
+ * <pre class="groovyTestCase">
+ * assert '$1,234.50'.toCurrencyNumber(Locale.US) == 1234.50g
+ * </pre>
+ *
+ * @param self a CharSequence
+ * @param locale the locale defining the currency format
+ * @return the parsed Number (a BigDecimal)
+ * @throws NumberFormatException if the CharSequence cannot be parsed
+ *
+ * @since 6.0.0
+ */
+ public static Number toCurrencyNumber(final CharSequence self, final
Locale locale) {
+ NumberFormat nf = NumberFormat.getCurrencyInstance(locale);
+ if (nf instanceof DecimalFormat) ((DecimalFormat)
nf).setParseBigDecimal(true);
+ Number result = parseFully(nf, self, "currency");
+ // guarantee the documented BigDecimal type even for non-DecimalFormat
providers
+ return (result instanceof BigDecimal) ? result : new
BigDecimal(result.toString());
+ }
+
+ /**
+ * Parses a CharSequence into a Number using the percent format of the
default locale.
+ * The percent value is unscaled (divided by 100).
+ *
+ * @param self a CharSequence
+ * @return the parsed Number
+ * @throws NumberFormatException if the CharSequence cannot be parsed
+ *
+ * @since 6.0.0
+ */
+ public static Number toPercentNumber(final CharSequence self) {
+ return toPercentNumber(self, Locale.getDefault());
+ }
+
+ /**
+ * Parses a CharSequence into a Number using the percent format of the
given locale.
+ * The percent value is unscaled (divided by 100).
+ * <pre class="groovyTestCase">
+ * assert '50%'.toPercentNumber(Locale.US) == 0.5
+ * </pre>
+ *
+ * @param self a CharSequence
+ * @param locale the locale defining the percent format
+ * @return the parsed Number
+ * @throws NumberFormatException if the CharSequence cannot be parsed
+ *
+ * @since 6.0.0
+ */
+ public static Number toPercentNumber(final CharSequence self, final Locale
locale) {
+ return parseFully(NumberFormat.getPercentInstance(locale), self,
"percent");
+ }
+
/**
* Converts the given string into a Boolean object. If the trimmed string
is
* "true", "y" or "1" (ignoring case) then the result is true otherwise it
is false.
diff --git
a/src/test/groovy/org/codehaus/groovy/runtime/StringGroovyMethodsTest.java
b/src/test/groovy/org/codehaus/groovy/runtime/StringGroovyMethodsTest.java
index 88805d7331..68755760a0 100644
--- a/src/test/groovy/org/codehaus/groovy/runtime/StringGroovyMethodsTest.java
+++ b/src/test/groovy/org/codehaus/groovy/runtime/StringGroovyMethodsTest.java
@@ -25,11 +25,13 @@ import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.Collections;
import java.util.List;
+import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
public final class StringGroovyMethodsTest {
@@ -261,4 +263,38 @@ public final class StringGroovyMethodsTest {
assertEquals(Boolean.FALSE, StringGroovyMethods.toBoolean("n"));
assertEquals(Boolean.FALSE, StringGroovyMethods.toBoolean("0"));
}
+
+ @Test
+ public void testToNumberWithLocale() {
+ assertEquals(1234.5, StringGroovyMethods.toNumber("1,234.5",
Locale.US).doubleValue(), 0.0);
+ assertEquals(1234.5, StringGroovyMethods.toNumber("1.234,5",
Locale.GERMANY).doubleValue(), 0.0);
+ assertEquals(42.0, StringGroovyMethods.toNumber(" 42 ",
Locale.US).doubleValue(), 0.0); // surrounding whitespace trimmed
+ assertThrows(NumberFormatException.class, () ->
StringGroovyMethods.toNumber("abc", Locale.US));
+ // strict: the whole input must parse, trailing junk is rejected (not
silently truncated)
+ assertThrows(NumberFormatException.class, () ->
StringGroovyMethods.toNumber("12abc", Locale.US));
+ assertThrows(NumberFormatException.class, () ->
StringGroovyMethods.toNumber("", Locale.US));
+ }
+
+ @Test
+ public void testToCurrencyNumberWithLocale() {
+ Number us = StringGroovyMethods.toCurrencyNumber("$1,234.50",
Locale.US);
+ assertEquals(new BigDecimal("1234.50"), us);
+ assertTrue(us instanceof BigDecimal, "currency should parse to an
exact BigDecimal");
+ // round-trip through the formatter to avoid hard-coding
locale-specific symbols/spacing
+ String formatted = DefaultGroovyMethods.toCurrencyString(new
BigDecimal("1234.50"), Locale.GERMANY);
+ assertEquals(new BigDecimal("1234.50"),
StringGroovyMethods.toCurrencyNumber(formatted, Locale.GERMANY));
+ assertThrows(NumberFormatException.class, () ->
StringGroovyMethods.toCurrencyNumber("nope", Locale.US));
+ // strict: trailing text after the amount is rejected
+ assertThrows(NumberFormatException.class, () ->
StringGroovyMethods.toCurrencyNumber("$1,234.50 and more", Locale.US));
+ }
+
+ @Test
+ public void testToPercentNumberWithLocale() {
+ assertEquals(0.5, StringGroovyMethods.toPercentNumber("50%",
Locale.US).doubleValue(), 0.0);
+ // Turkish places the percent sign before the number
+ assertEquals(0.5, StringGroovyMethods.toPercentNumber("%50", new
Locale("tr", "TR")).doubleValue(), 0.0);
+ assertThrows(NumberFormatException.class, () ->
StringGroovyMethods.toPercentNumber("x", Locale.US));
+ // strict: trailing text after the percent is rejected
+ assertThrows(NumberFormatException.class, () ->
StringGroovyMethods.toPercentNumber("50% off", Locale.US));
+ }
}