NumberFunctionFactory.java
// © 2022 and later: Unicode, Inc. and others.
// License & terms of use: https://www.unicode.org/copyright.html
package com.ibm.icu.message2;
import com.ibm.icu.math.BigDecimal;
import com.ibm.icu.message2.MFDataModel.CatchallKey;
import com.ibm.icu.number.FormattedNumber;
import com.ibm.icu.number.LocalizedNumberFormatter;
import com.ibm.icu.number.Notation;
import com.ibm.icu.number.NumberFormatter;
import com.ibm.icu.number.NumberFormatter.GroupingStrategy;
import com.ibm.icu.number.NumberFormatter.SignDisplay;
import com.ibm.icu.number.NumberFormatter.UnitWidth;
import com.ibm.icu.number.Precision;
import com.ibm.icu.number.UnlocalizedNumberFormatter;
import com.ibm.icu.text.FormattedValue;
import com.ibm.icu.text.NumberingSystem;
import com.ibm.icu.text.PluralRules;
import com.ibm.icu.text.PluralRules.PluralType;
import com.ibm.icu.util.Currency;
import com.ibm.icu.util.CurrencyAmount;
import com.ibm.icu.util.NoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* Creates a {@link Function} doing numeric formatting, similar to <code>{exp, number}</code> in
* {@link com.ibm.icu.text.MessageFormat}, and plural selection, similar to <code>{exp, plural}
* </code> and to <code>{exp, selectordinal}</code> in {@link com.ibm.icu.text.MessageFormat}.
*/
class NumberFunctionFactory implements FunctionFactory {
private final String kind;
public NumberFunctionFactory(String kind) {
switch (kind) {
case "number": // $FALL-THROUGH$
case "integer":
case "currency":
case "percent":
case "offset":
break;
default:
// Default to number
kind = "number";
}
this.kind = kind;
}
/** {@inheritDoc} */
@Override
public Function create(Locale locale, Map<String, Object> fixedOptions) {
boolean reportErrors = OptUtils.reportErrors(fixedOptions);
String type = OptUtils.getString(fixedOptions, "select", "");
PluralType pluralType;
switch (type) {
case "exact":
pluralType = null;
break;
case "ordinal":
pluralType = PluralType.ORDINAL;
break;
case "": // $FALL-THROUGH$
case "cardinal":
pluralType = PluralType.CARDINAL;
break;
default:
if (reportErrors) {
throw new IllegalArgumentException(
"bad-option: invalid value `" + type + "` for `select`.");
}
pluralType = PluralType.CARDINAL;
}
PluralRules rules = pluralType == null ? null : PluralRules.forLocale(locale, pluralType);
return new NumberFunctionImpl(locale, rules, fixedOptions, kind);
}
static class NumberFunctionImpl implements Function {
private static final String NO_MATCH = "\uFFFDNO_MATCH\uFFFE"; // Unlikely to show in a key
private final Locale locale;
private final Map<String, Object> fixedOptions;
private final LocalizedNumberFormatter icuFormatter;
private final String kind;
private final PluralRules rules;
NumberFunctionImpl(
Locale locale, PluralRules rules, Map<String, Object> fixedOptions, String kind) {
this.locale = OptUtils.getBestLocale(fixedOptions, locale);
this.fixedOptions = new HashMap<>(fixedOptions);
String skeleton = OptUtils.getString(fixedOptions, "icu:skeleton");
boolean fancy = skeleton != null;
this.icuFormatter = functionForOptions(this.locale, fixedOptions, kind);
this.kind = kind;
this.rules = rules;
}
LocalizedNumberFormatter getIcuFormatter() {
return icuFormatter;
}
/** {@inheritDoc} */
@Override
public String formatToString(Object toFormat, Map<String, Object> variableOptions) {
return format(toFormat, variableOptions).toString();
}
/** {@inheritDoc} */
@Override
public FormattedPlaceholder format(Object toFormat, Map<String, Object> variableOptions) {
LocalizedNumberFormatter realFormatter;
Map<String, Object> mergedOptions = new HashMap<>(fixedOptions);
if (variableOptions.isEmpty()) {
realFormatter = this.icuFormatter;
} else {
mergedOptions.putAll(variableOptions);
// This is really wasteful, as we don't use the existing
// formatter if even one option is variable.
// We can optimize, but for now will have to do.
realFormatter = functionForOptions(locale, mergedOptions, kind);
}
if (toFormat == null) {
// This is also what MessageFormat does.
throw new NullPointerException("Argument to format can't be null");
}
FormattedValue result;
if (toFormat instanceof CurrencyAmount) {
result = realFormatter.format((CurrencyAmount) toFormat);
} else {
boolean isInt = kind.equals("integer");
if (isInt) {
if (toFormat instanceof CharSequence) {
toFormat = OptUtils.asNumber(toFormat);
}
if (toFormat instanceof Number) {
toFormat = ((Number) toFormat).longValue();
}
}
Number toFormatAdjusted = resolveValue(toFormat, variableOptions);
if (toFormatAdjusted == null) {
String strValue = Objects.toString(toFormat);
result = new PlainStringFormattedValue("{|" + strValue + "|}");
} else {
result = realFormatter.format(toFormatAdjusted);
}
}
Directionality dir = OptUtils.getBestDirectionality(variableOptions, locale);
return new FormattedPlaceholder(toFormat, result, dir, false);
}
/** {@inheritDoc} */
@Override
public List<String> matches(
Object value, List<String> keys, Map<String, Object> variableOptions) {
if (kind.equals("currency")) {
// Can't do selection on `currency`
return null;
}
List<String> result = new ArrayList<>();
if (value == null) {
return result;
}
for (String key : keys) {
if (matches(value, key, variableOptions)) {
result.add(key);
} else {
result.add(NO_MATCH);
}
}
result.sort(NumberFunctionImpl::pluralComparator);
return result;
}
// The order is exact values, key, other
// There is no need to be very strict, as these are keys that are already equal
// So we will not get to compare "1" vs "2", or "one" vs "few".
// TODO: This is quite ugly, change when time.
private static int pluralComparator(String o1, String o2) {
if (o1.equals(o2)) {
return 0;
}
if (NO_MATCH.equals(o1)) {
return 1;
}
if (NO_MATCH.equals(o2)) {
return -1;
}
// * sorts last
if (CatchallKey.isCatchAll(o1)) {
return 1;
}
if (CatchallKey.isCatchAll(o2)) {
return -1;
}
// Numbers sort first
if (OptUtils.asNumber(o1) != null) {
return -1;
}
if (OptUtils.asNumber(o2) != null) {
return 1;
}
// At this point they are both strings
// We should never get here, so the order does not really matter
return o1.compareTo(o2);
}
private boolean matches(Object value, String key, Map<String, Object> variableOptions) {
if (CatchallKey.isCatchAll(key)) {
return true;
}
boolean reportErrors = OptUtils.reportErrors(fixedOptions);
Number valToCheck = Double.MIN_VALUE;
if (value instanceof FormattedPlaceholder) {
FormattedPlaceholder fph = (FormattedPlaceholder) value;
value = fph.getInput();
}
if (value instanceof Number || value instanceof CharSequence) {
valToCheck = resolveValue(value, variableOptions);
if (valToCheck == null) {
return false;
}
} else {
return false;
}
if (Objects.equals(kind, "integer")) {
valToCheck = valToCheck.longValue();
}
Number keyNrVal = OptUtils.asNumber(key);
if (keyNrVal != null && valToCheck.doubleValue() == keyNrVal.doubleValue()) {
return true;
}
FormattedNumber formatted = icuFormatter.format(valToCheck.doubleValue());
String match;
if (rules != null) {
match = rules.select(formatted);
} else {
match = key.equals(formatted.toString()) ? key : "other";
}
if (match.equals("other")) {
match = CatchallKey.AS_KEY_STRING;
}
return match.equals(key);
}
private Number resolveValue(Object toFormat, Map<String, Object> variableOptions) {
Map<String, Object> mergedOptions = new HashMap<>(fixedOptions);
if (!variableOptions.isEmpty()) {
mergedOptions.putAll(variableOptions);
}
boolean reportErrors = OptUtils.reportErrors(mergedOptions);
Integer offset = OptUtils.getInteger(mergedOptions, reportErrors, "icu:offset");
if (offset == null && fixedOptions != null) {
offset = OptUtils.getInteger(fixedOptions, reportErrors, "icu:offset");
}
if (offset == null) {
offset = 0;
}
int offsetOperand = 0;
if (Objects.equals(kind, "offset")) {
ResolvedOffsetOptions resolvedOffsetOptions =
ResolvedOffsetOptions.of(mergedOptions);
offsetOperand = resolvedOffsetOptions.operand;
}
if (kind.equals("currency")) {
String currencyCode = getCurrency(mergedOptions);
if (currencyCode == null && !(toFormat instanceof CurrencyAmount)) {
// Error, we need a currency code, either from the message,
// with the {... :currency currency=<iso_code>}, or from the thing to format
throw new IllegalArgumentException(
"bad-option: the `currency` must be an ISO 4217 code.");
}
}
boolean isPercent = kind.equals("percent");
boolean isInt = kind.equals("integer");
if (toFormat == null) {
// This is also what MessageFormat does.
throw new NullPointerException("Argument to format can't be null");
} else if (toFormat instanceof Double) {
if (isInt) {
toFormat = Math.floor((double) toFormat);
}
double toFormatAdjusted = (double) toFormat - offset + offsetOperand;
if (isPercent) {
toFormatAdjusted *= 100;
}
return toFormatAdjusted;
} else if (toFormat instanceof Long) {
long toFormatAdjusted = (long) toFormat - offset + offsetOperand;
if (isPercent) {
toFormatAdjusted *= 100;
}
return toFormatAdjusted;
} else if (toFormat instanceof Integer) {
int toFormatAdjusted = (int) toFormat - offset + offsetOperand;
if (isPercent) {
toFormatAdjusted *= 100;
}
return toFormatAdjusted;
} else if (toFormat instanceof BigDecimal) {
BigDecimal toFormatAdjusted = (BigDecimal) toFormat;
if (isPercent) {
toFormatAdjusted = toFormatAdjusted.multiply(BigDecimal.valueOf(100));
}
if (isInt) {
toFormat = toFormatAdjusted.longValue();
}
toFormatAdjusted = toFormatAdjusted.subtract(BigDecimal.valueOf(offset));
if (offsetOperand != 0) {
toFormatAdjusted = toFormatAdjusted.add(BigDecimal.valueOf(offsetOperand));
}
return toFormatAdjusted;
} else if (toFormat instanceof Number) {
if (isInt) {
toFormat = Math.floor(((Number) toFormat).doubleValue());
}
double toFormatAdjusted =
((Number) toFormat).doubleValue() - offset + offsetOperand;
if (isPercent) {
toFormatAdjusted *= 100;
}
return toFormatAdjusted;
} else if (toFormat instanceof CurrencyAmount) {
return ((CurrencyAmount) toFormat).getNumber();
} else {
// The behavior is not in the spec, will be in the registry.
// We can return "NaN", or try to parse the string as a number
String strValue = Objects.toString(toFormat);
Number nrValue = OptUtils.asNumber(reportErrors, "argument", strValue);
if (nrValue != null) {
double toFormatAdjusted =
isInt
? nrValue.intValue()
: nrValue.doubleValue() - offset + offsetOperand;
if (isPercent) {
toFormatAdjusted *= 100;
}
return toFormatAdjusted;
}
}
return null;
}
}
// Currency ISO code
private static final Pattern CURRENCY_ISO_CODE =
Pattern.compile("^[A-Z][A-Z][A-Z]$", Pattern.CASE_INSENSITIVE);
private static LocalizedNumberFormatter functionForOptions(
Locale locale, Map<String, Object> fixedOptions, String kind) {
boolean reportErrors = OptUtils.reportErrors(fixedOptions);
UnlocalizedNumberFormatter nf;
String skeleton = OptUtils.getString(fixedOptions, "icu:skeleton");
if (skeleton != null) {
return NumberFormatter.forSkeleton(skeleton).locale(locale);
}
Integer option;
String strOption;
nf = NumberFormatter.with();
// These options don't apply to `:integer`
if (Objects.equals(kind, "number")) {
Notation notation;
switch (OptUtils.getString(fixedOptions, "notation", "standard")) {
case "scientific":
notation = Notation.scientific();
break;
case "engineering":
notation = Notation.engineering();
break;
case "compact":
{
switch (OptUtils.getString(fixedOptions, "compactDisplay", "short")) {
case "long":
notation = Notation.compactLong();
break;
case "short": // $FALL-THROUGH$
default:
notation = Notation.compactShort();
}
}
break;
case "standard": // $FALL-THROUGH$
default:
notation = Notation.simple();
}
nf = nf.notation(notation);
strOption = OptUtils.getString(fixedOptions, "style", "decimal");
option = OptUtils.getInteger(fixedOptions, reportErrors, "minimumFractionDigits");
if (option != null) {
nf = nf.precision(Precision.minFraction(option));
}
option = OptUtils.getInteger(fixedOptions, reportErrors, "maximumFractionDigits");
if (option != null) {
nf = nf.precision(Precision.maxFraction(option));
}
option = OptUtils.getInteger(fixedOptions, reportErrors, "minimumSignificantDigits");
if (option != null) {
nf = nf.precision(Precision.minSignificantDigits(option));
}
} // end of `:number` specific options
strOption = OptUtils.getString(fixedOptions, "numberingSystem", "");
if (!strOption.isEmpty()) {
strOption = strOption.toLowerCase(Locale.US);
// No good way to validate, there are too many.
NumberingSystem ns = NumberingSystem.getInstanceByName(strOption);
nf = nf.symbols(ns);
}
// The options below apply to both `:number` and `:integer`
option = OptUtils.getInteger(fixedOptions, reportErrors, "minimumIntegerDigits");
if (option != null) {
// TODO! Ask Shane. nf.integerWidth(null) ?
}
option = OptUtils.getInteger(fixedOptions, reportErrors, "maximumSignificantDigits");
if (option != null) {
nf = nf.precision(Precision.maxSignificantDigits(option));
}
strOption = OptUtils.getString(fixedOptions, "signDisplay", "auto");
SignDisplay signDisplay;
switch (strOption) {
case "always":
signDisplay = SignDisplay.ALWAYS;
break;
case "exceptZero":
signDisplay = SignDisplay.EXCEPT_ZERO;
break;
case "negative":
signDisplay = SignDisplay.NEGATIVE;
break;
case "never":
signDisplay = SignDisplay.NEVER;
break;
case "auto": // $FALL-THROUGH$
default:
signDisplay = SignDisplay.AUTO;
}
nf = nf.sign(signDisplay);
GroupingStrategy grp;
strOption = OptUtils.getString(fixedOptions, "useGrouping", "auto");
switch (strOption) {
case "always":
grp = GroupingStrategy.ON_ALIGNED;
break; // TODO: check with Shane
case "never":
grp = GroupingStrategy.OFF;
break;
case "min2":
grp = GroupingStrategy.MIN2;
break;
case "auto": // $FALL-THROUGH$
default:
grp = GroupingStrategy.AUTO;
}
nf = nf.grouping(grp);
if (kind.equals("integer")) {
nf = nf.precision(Precision.integer());
}
if (kind.equals("currency")) {
strOption = getCurrency(fixedOptions);
if (strOption != null) {
nf = nf.unit(Currency.getInstance(strOption));
}
strOption = OptUtils.getString(fixedOptions, "currencySign", "standard");
switch (strOption) {
case "accounting":
case "standard":
break;
}
strOption = OptUtils.getString(fixedOptions, "currencyDisplay", "symbol");
UnitWidth width;
switch (strOption) {
case "narrowSymbol":
width = UnitWidth.NARROW;
break;
case "symbol":
width = UnitWidth.SHORT;
break;
case "name":
width = UnitWidth.FULL_NAME;
break;
case "code":
width = UnitWidth.ISO_CODE;
break;
case "formalSymbol":
width = UnitWidth.FORMAL;
break;
case "never":
width = UnitWidth.HIDDEN;
break;
default:
width = UnitWidth.SHORT;
}
nf = nf.unitWidth(width);
}
if (kind.equals("percent")) {
nf = nf.unit(NoUnit.PERCENT);
}
return nf.locale(locale);
}
static String getCurrency(Map<String, Object> options) {
String value = OptUtils.getString(options, "currency", null);
if (value != null) {
if (CURRENCY_ISO_CODE.matcher(value).find()) {
return value;
} else {
if (OptUtils.reportErrors(options)) {
throw new IllegalArgumentException(
"bad-option: the `currency` must be an ISO 4217 code.");
}
}
}
return null;
}
private static class ResolvedOffsetOptions {
final int operand;
final boolean reportErrors;
ResolvedOffsetOptions(int operand, boolean reportErrors) {
this.operand = operand;
this.reportErrors = reportErrors;
}
static ResolvedOffsetOptions of(Map<String, Object> options) {
boolean reportErrors = OptUtils.reportErrors(options);
int operand = 0;
Integer addOption = OptUtils.getInteger(options, reportErrors, "add");
Integer subtractOption = OptUtils.getInteger(options, reportErrors, "subtract");
if (addOption == null) {
if (subtractOption == null) { // both null
throw new IllegalArgumentException(
"bad-option: :offset function needs an `add` or `subtract` option.");
} else {
operand =
-OptUtils.asNumber(reportErrors, "subtract", subtractOption).intValue();
}
} else {
if (subtractOption == null) {
operand = OptUtils.asNumber(reportErrors, "add", addOption).intValue();
} else { // both set
throw new IllegalArgumentException(
"bad-option: :offset function can't have both `add` and `subtract` options.");
}
}
return new ResolvedOffsetOptions(operand, reportErrors);
}
}
}