[ 
https://issues.apache.org/jira/browse/GROOVY-12147?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18095484#comment-18095484
 ] 

ASF GitHub Bot commented on GROOVY-12147:
-----------------------------------------

blackdrag commented on code in PR #2688:
URL: https://github.com/apache/groovy/pull/2688#discussion_r3563222600


##########
src/main/java/org/codehaus/groovy/runtime/DefaultGroovyMethods.java:
##########
@@ -16503,6 +16505,66 @@ public static BigInteger toBigInteger(Number self) {
         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);
+    }
+

Review Comment:
   I usually work with multilingual Apps, already supporting Germany and 
Switzerland influences number formatting and of course currency symbols - the 
language is not even different for this case. The effect is that I have to 
avoid everything that uses some kind of default Locale. And don't get me 
started on date and time. Thus for my practical work these methods are useless. 
I would rather prefer a fixed format, that is not depending on the default 
locale - for everything that depends on the locale in DGM. Which would make the 
percent version ok, but I would still not offer the currency variant. 



##########
src/test/groovy/org/codehaus/groovy/runtime/StringGroovyMethodsTest.java:
##########
@@ -261,4 +263,38 @@ public void testToMethods() {
         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));
+    }

Review Comment:
   don't forget testing with default local. (saving Locale.getDefault, setting 
with Locale.setDefault before test and restoring with Locale.setDefault after 
the test). 





> Add locale-aware number, currency and percent formatting/parsing to the GDK
> ---------------------------------------------------------------------------
>
>                 Key: GROOVY-12147
>                 URL: https://issues.apache.org/jira/browse/GROOVY-12147
>             Project: Groovy
>          Issue Type: Improvement
>          Components: groovy-jdk
>            Reporter: Paul King
>            Assignee: Paul King
>            Priority: Major
>             Fix For: 6.0.0-beta-1
>
>
> h2. Summary
> Groovy can already format and parse numbers, but only in a locale-invariant 
> way. {{String.format(locale, ...)}} / {{sprintf}} cover locale-aware 
> _grouped/decimal_ output, and {{"1234.50".toBigDecimal()}} covers _canonical_ 
> parsing — but there is no idiomatic way to:
> * format a number as *locale currency* ({{"€1.234,50"}}) or *locale percent* 
> ({{"94,5 %"}}), or
> * parse a *locale-formatted* number, currency or percent string back to a 
> {{Number}}.
> These are exactly the cases {{java.util.Formatter}} ({{%f}}, {{%%}}) 
> structurally cannot express (no currency conversion; {{%%}} is a literal 
> sign, so no ÷100 scaling and wrong symbol placement in locales such as 
> Turkish {{%94,5}} or German {{94,5 %}}). This issue adds a small set of GDK 
> extension methods that wrap {{java.text.NumberFormat}} to close that gap.
> h2. Motivation
> * {{StringGroovyMethods}} conversions ({{toInteger}}, {{toDouble}}, 
> {{toBigDecimal}}, {{isNumber}}, …) are locale-invariant by construction 
> ({{new BigDecimal(str)}} / {{Double.valueOf(str)}}) — they reject grouping 
> separators, foreign decimal marks, currency symbols and percent signs.
> * {{sprintf}}/{{Formatter}} handles grouped/decimal numbers and localized 
> {{%t}} dates, so no new date/time or {{sprintf}} surface is needed.
> * The only genuinely missing capability is currency/percent formatting and 
> locale-aware parsing — a handful of thin {{NumberFormat}} wrappers.
> h2. Proposed API
> *Formatting* — extension methods on {{Number}} (in {{DefaultGroovyMethods}}):
> {code:java}
> public static String toCurrencyString(Number self)
> public static String toCurrencyString(Number self, Locale locale)
> public static String toPercentString(Number self)
> public static String toPercentString(Number self, Locale locale)
> {code}
> *Parsing* — extension methods on {{CharSequence}} (in 
> {{StringGroovyMethods}}, alongside {{toBigDecimal}}):
> {code:java}
> public static Number toNumber(CharSequence self, Locale locale)
> public static Number toCurrencyNumber(CharSequence self)
> public static Number toCurrencyNumber(CharSequence self, Locale locale)
> public static Number toPercentNumber(CharSequence self)
> public static Number toPercentNumber(CharSequence self, Locale locale)
> {code}
> h2. Reference implementation
> {code:java}
> // --- DefaultGroovyMethods (Number) ---
> public static String toCurrencyString(Number self) {
>     return NumberFormat.getCurrencyInstance().format(self);
> }
> public static String toCurrencyString(Number self, Locale locale) {
>     return NumberFormat.getCurrencyInstance(locale).format(self);
> }
> public static String toPercentString(Number self) {
>     return NumberFormat.getPercentInstance().format(self);
> }
> public static String toPercentString(Number self, Locale locale) {
>     return NumberFormat.getPercentInstance(locale).format(self);
> }
> // --- StringGroovyMethods (CharSequence) ---
> public static Number toNumber(CharSequence self, Locale locale) {
>     try {
>         return NumberFormat.getNumberInstance(locale).parse(self.toString());
>     } catch (ParseException e) {
>         throw new NumberFormatException("Unparseable number: \"" + self + 
> "\"");
>     }
> }
> public static Number toCurrencyNumber(CharSequence self) {
>     return toCurrencyNumber(self, Locale.getDefault());
> }
> public static Number toCurrencyNumber(CharSequence self, Locale locale) {
>     NumberFormat nf = NumberFormat.getCurrencyInstance(locale);
>     if (nf instanceof DecimalFormat) ((DecimalFormat) 
> nf).setParseBigDecimal(true); // exact money
>     try {
>         return nf.parse(self.toString());
>     } catch (ParseException e) {
>         throw new NumberFormatException("Unparseable currency: \"" + self + 
> "\"");
>     }
> }
> public static Number toPercentNumber(CharSequence self) {
>     return toPercentNumber(self, Locale.getDefault());
> }
> public static Number toPercentNumber(CharSequence self, Locale locale) {
>     try {
>         return 
> NumberFormat.getPercentInstance(locale).parse(self.toString()); // unscales 
> /100
>     } catch (ParseException e) {
>         throw new NumberFormatException("Unparseable percent: \"" + self + 
> "\"");
>     }
> }
> {code}
> h2. Design notes
> * *Exception convention:* {{NumberFormat.parse}} throws a checked 
> {{java.text.ParseException}}; the existing conversion family throws unchecked 
> {{NumberFormatException}} (e.g. {{new BigDecimal(str)}}). The parse methods 
> catch and rethrow as {{NumberFormatException}} to stay consistent. No cause 
> is chained, matching the current family; message style mirrors the JDK's 
> {{For input string}} form without relying on the non-public 
> {{NumberFormatException.forInputString}}.
> * *Money precision:* currency parsing sets {{setParseBigDecimal(true)}} so 
> amounts come back as exact {{BigDecimal}} rather than lossy {{Double}}. The 
> cast is {{instanceof}}-guarded because {{getCurrencyInstance}} is only 
> contractually a {{NumberFormat}}.
> * *Lenient vs strict:* unlike {{toBigDecimal}} (strict, whole-string), 
> {{NumberFormat.parse}} is lenient (leading numeric prefix, grouping-aware). 
> This is intentional and another reason not to retrofit the existing {{toXxx}} 
> methods.
> * *Naming:* {{to<Flavor><ReturnType>}} — pairs the parse side 
> ({{toCurrencyNumber}}/{{toPercentNumber}}) with the format side 
> ({{toCurrencyString}}/{{toPercentString}}), groups by flavor in IDE 
> autocomplete, and the suffix still names the return type in the spirit of 
> {{toBigDecimal}}. Bare {{toCurrency}} is avoided (reads like it returns a 
> {{java.util.Currency}}).
> h2. Scope
> *In scope:* 4 formatting + 5 parsing methods (9 total), all thin 
> {{java.text.NumberFormat}} wrappers.
> *Explicitly out of scope* (already covered or judged not worth the surface):
> * {{sprintf}}/{{printf}} {{Locale}} overloads on {{Object}} — would add 
> methods to every metaclass; {{String.format(locale, ...)}} already covers 
> grouped/decimal output.
> * {{java.time}} {{format}}/{{parse}} {{Locale}} variants — {{%t}} + 
> {{String.format}} cover the common cases.
> * A locale-invariant {{parseNumber}} — redundant with 
> {{toBigDecimal}}/{{toDouble}}.
> h2. Example
> {code:groovy}
> import java.util.Locale
> def de = Locale.GERMANY
> assert 1234.5.toCurrencyString(de)      == '1.234,50 €'
> assert 0.945.toPercentString(de)        == '94,5 %'
> assert '1.234,50 €'.toCurrencyNumber(de) == 1234.50G   // BigDecimal
> assert '94,5 %'.toPercentNumber(de)      == 0.945
> assert '1.234,50'.toNumber(de)           == 1234.5
> {code}
> h2. Tests
> * Round-trip format→parse for representative locales (US, Germany, France, 
> Turkey — Turkey to cover leading {{%}} placement).
> * Currency parse returns {{BigDecimal}}.
> * Percent parse applies ÷100 scaling.
> * Malformed input throws {{NumberFormatException}}.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to