[ 
https://issues.apache.org/jira/browse/GROOVY-12147?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Paul King updated GROOVY-12147:
-------------------------------
    Description: 
h2. Summary

[EDIT: updated to match what it would be after PR#2699]

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) // EDIT: removed
public static Number toCurrencyNumber(CharSequence self, Locale locale)
// public static Number toPercentNumber(CharSequence self) // EDIT: removed
public static Number toPercentNumber(CharSequence self, Locale locale)
{code}

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}}.

  was:
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}}.


> 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
> [EDIT: updated to match what it would be after PR#2699]
> 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) // EDIT: removed
> public static Number toCurrencyNumber(CharSequence self, Locale locale)
> // public static Number toPercentNumber(CharSequence self) // EDIT: removed
> public static Number toPercentNumber(CharSequence self, Locale locale)
> {code}
> 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