Paul King created GROOVY-12147:
----------------------------------
Summary: 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
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)