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));
+    }
 }

Reply via email to