Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@
import javax.money.format.AmountFormatContext;
import javax.money.format.MonetaryParseException;
import java.io.IOException;
import java.util.Currency;
import java.util.Locale;
import java.util.Objects;
import java.util.ResourceBundle;
import java.text.DecimalFormatSymbols;
import java.util.*;
import java.util.logging.Logger;

import static java.util.Objects.requireNonNull;
Expand All @@ -36,6 +34,14 @@
/**
* Implements a {@link FormatToken} that adds a localizable {@link String}, read
* by key from a {@link ResourceBundle}.
* <p>
* Symbol parsing uses locale-aware resolution with the following precedence:
* <ol>
* <li>If an explicit currency is in the context, validate that the symbol matches.</li>
* <li>Check if the symbol matches the locale's default currency.</li>
* <li>Scan all available JDK currencies for matching symbols.</li>
* <li>If exactly one match is found, use it; if multiple, raise ambiguity error; if none, raise unknown symbol error.</li>
* </ol>
*
* @author Anatole Tresch
*/
Expand Down Expand Up @@ -138,7 +144,7 @@ private String getCurrencyName(CurrencyUnit currency) {
private Currency getCurrency(String currencyCode) {
try {
return Currency.getInstance(currencyCode);
} catch (Exception e) {
} catch (IllegalArgumentException e) {
return null;
}
}
Expand Down Expand Up @@ -206,29 +212,10 @@ public void parse(ParseContext context)
}
break;
case SYMBOL:
if (token.startsWith("$")) {
throw new MonetaryParseException("$ is not a unique currency symbol.", token,
context.getErrorIndex());
} else if (token.startsWith("€")) {
cur = Monetary.getCurrency("EUR", providers);
context.consume('€');
} else if (token.startsWith("£")) {
cur = Monetary.getCurrency("GBP", providers);
context.consume('£');
} else {
//System.out.println(token);
// Workaround for https://github.com/JavaMoney/jsr354-ri/issues/274
String code = token;
for (Currency juc : Currency.getAvailableCurrencies()) {
if (token.equals(juc.getSymbol())) {
//System.out.println(juc);
code = juc.getCurrencyCode();
break;
}
}
cur = Monetary.getCurrency(code, providers);
context.consume(token);
}
String symbol = extractLeadingSymbol(token);
String resolvedCode = resolveSymbol(symbol);
cur = Monetary.getCurrency(resolvedCode, providers);
context.consume(symbol);
context.setParsedCurrency(cur);
break;
case NAME:
Expand Down Expand Up @@ -286,6 +273,234 @@ public void print(Appendable appendable, MonetaryAmount amount)
appendable.append(getToken(amount));
}

/**
* Extracts the leading symbol segment from a token, stopping at the first digit,
* sign, or locale-specific decimal/grouping separator.
* <p>
* Examples (US locale with '.' as decimal separator):
* <ul>
* <li>{@code "$100"} → {@code "$"}</li>
* <li>{@code "US$1,234.56"} → {@code "US$"}</li>
* <li>{@code "HK$-50"} → {@code "HK$"}</li>
* <li>{@code "€ 100"} → {@code "€"} (space before digit)</li>
* <li>{@code "₹1234"} → {@code "₹"}</li>
* </ul>
*
* @param token the input token
* @return the leading symbol portion
*/
private String extractLeadingSymbol(String token) {
DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale);
char decimalSeparator = symbols.getDecimalSeparator();
char groupingSeparator = symbols.getGroupingSeparator();

int symbolEnd = 0;
for (int i = 0; i < token.length(); i++) {
char ch = token.charAt(i);
if (Character.isDigit(ch) || ch == '+' || ch == '-' ||
ch == decimalSeparator || ch == groupingSeparator) {
break;
}
symbolEnd = i + 1;
}

return symbolEnd > 0 ? token.substring(0, symbolEnd) : token;
}

/**
* Resolves a currency symbol to an ISO currency code using locale-aware precedence.
* <p>
* Resolution follows this order:
* <ol>
* <li>If explicit currency is set in context, verify symbol matches (e.g., USD set + "$" → USD)</li>
* <li>Check if symbol matches locale's default currency (e.g., "$" in US locale → USD)</li>
* <li>Scan all available currencies for unique match (e.g., "€" → EUR)</li>
* <li>If multiple matches found, throw ambiguity error</li>
* </ol>
* <p>
* Example scenarios:
* <ul>
* <li><b>Locale default wins:</b> "$" in {@code Locale.US} → "USD", in {@code Locale.CANADA} → "CAD"</li>
* <li><b>Explicit currency:</b> "$" with USD set in French locale → "USD"</li>
* <li><b>Unique symbol:</b> "€" in any locale → "EUR"</li>
* <li><b>Ambiguous:</b> "$" in {@code Locale.FRANCE} → exception listing USD, CAD, AUD, etc.</li>
* <li><b>Mismatch:</b> "€" with USD set → exception "Expected symbol '$' for USD but found '€'"</li>
* <li><b>Unknown:</b> "¤" → exception "Cannot resolve currency symbol"</li>
* </ul>
*
* @param symbol the currency symbol to resolve
* @return the ISO currency code
* @throws MonetaryParseException if the symbol cannot be resolved or is ambiguous
*/
private String resolveSymbol(String symbol) {
// Check explicit currency in context first
CurrencyUnit explicitCurrency = this.context.get(CurrencyUnit.class);
if (explicitCurrency != null) {
String expectedSymbol = getCurrencySymbol(explicitCurrency);
if (symbolMatches(symbol, expectedSymbol)) {
return explicitCurrency.getCurrencyCode();
} else {
throw new MonetaryParseException(
"Expected symbol '" + expectedSymbol + "' for " +
explicitCurrency.getCurrencyCode() + " but found '" + symbol + "'.",
symbol, -1);
}
}

// Check locale default currency
try {
Currency localeCurrency = Currency.getInstance(locale);
if (localeCurrency != null && symbolMatches(symbol, localeCurrency.getSymbol(locale))) {
return localeCurrency.getCurrencyCode();
}
} catch (IllegalArgumentException e) {
// Locale may not have a default currency
}

// Scan all available currencies
List<String> matches = findCurrenciesForSymbol(symbol);

if (matches.isEmpty()) {
throw new MonetaryParseException(
"Cannot resolve currency symbol '" + symbol + "' for locale " + locale + ".",
symbol, -1);
} else if (matches.size() == 1) {
return matches.get(0);
} else {
throw new MonetaryParseException(
"'" + symbol + "' is ambiguous in locale " + locale +
". Possible currencies: " + String.join(", ", matches) + ".",
symbol, -1);
}
}

/**
* Finds all currency codes whose symbols match the given symbol in the current locale.
* <p>
* Examples:
* <ul>
* <li>{@code "$"} in US locale → [USD] (unique match)</li>
* <li>{@code "$"} in French locale → [USD, CAD, AUD, ...] (multiple matches)</li>
* <li>{@code "€"} in any locale → [EUR] (unique match)</li>
* <li>{@code "¥"} in Japanese locale → [JPY] (locale-specific symbol)</li>
* <li>{@code "kr"} in Swedish locale → [SEK] (locale-specific)</li>
* <li>{@code "¤"} → [] (no matches - generic placeholder)</li>
* </ul>
*
* @param symbol the symbol to match
* @return list of matching currency codes (may be empty, one, or multiple)
*/
private List<String> findCurrenciesForSymbol(String symbol) {
List<String> matches = new ArrayList<>();
for (Currency currency : Currency.getAvailableCurrencies()) {
String currencySymbol = currency.getSymbol(locale);
if (symbolMatches(symbol, currencySymbol)) {
matches.add(currency.getCurrencyCode());
}
}
return matches;
}

/**
* Checks if two symbols match after normalization.
* Strips whitespace and trailing punctuation, and allows bidirectional substring matches.
* Handles both prefix ({@code $US}) and suffix ({@code US$}) forms produced by different locales.
* <p>
* Matching rules after normalization:
* <ul>
* <li>Exact match: {@code "$"} matches {@code "$"}</li>
* <li>Suffix match: {@code "$"} matches {@code "US$"} (parsed ends with candidate)</li>
* <li>Prefix match: {@code "$"} matches {@code "$US"} (parsed starts with candidate)</li>
* <li>Reverse suffix: {@code "US$"} matches {@code "$"} (candidate ends with parsed)</li>
* <li>Reverse prefix: {@code "$US"} matches {@code "$"} (candidate starts with parsed)</li>
* </ul>
* <p>
* Examples:
* <ul>
* <li>{@code symbolMatches("$", "$")} → true (exact)</li>
* <li>{@code symbolMatches("$", "US$")} → true (parsed is suffix of candidate)</li>
* <li>{@code symbolMatches("US$", "$")} → true (candidate is suffix of parsed)</li>
* <li>{@code symbolMatches("$", "$US")} → true (parsed is prefix of candidate)</li>
* <li>{@code symbolMatches("€", "$")} → false (no match)</li>
* <li>{@code symbolMatches("", "$")} → false (empty symbols rejected)</li>
* <li>{@code symbolMatches("лв.", "лв")} → true (after normalization removes trailing '.')</li>
* </ul>
*
* @param parsed the parsed symbol from input
* @param candidate the candidate symbol from Currency/context
* @return true if the symbols match
*/
private boolean symbolMatches(String parsed, String candidate) {
String normalizedParsed = normalizeSymbol(parsed);
String normalizedCandidate = normalizeSymbol(candidate);

// Reject empty/missing symbols - a blank symbol cannot match anything
if (normalizedParsed.isEmpty() || normalizedCandidate.isEmpty()) {
return false;
}

// Exact match
if (normalizedParsed.equals(normalizedCandidate)) {
return true;
}

// Check if either ends with the other (for suffix forms like US$ matching $)
// or starts with the other (for prefix forms like $US matching $)
return normalizedParsed.endsWith(normalizedCandidate) ||
normalizedCandidate.endsWith(normalizedParsed) ||
normalizedParsed.startsWith(normalizedCandidate) ||
normalizedCandidate.startsWith(normalizedParsed);
}

/**
* Normalizes a currency symbol by removing whitespace and trailing punctuation.
* Currency symbols (like {@code $}, {@code €}, {@code ¥}, {@code £}) are preserved.
* <p>
* Normalization steps:
* <ol>
* <li>Remove all Unicode whitespace characters (spaces, tabs, non-breaking spaces, etc.)</li>
* <li>Remove trailing punctuation (periods, commas, etc.) except currency symbols</li>
* <li>Preserve letters, digits, and currency symbols in Unicode category Sc</li>
* </ol>
* <p>
* Examples:
* <ul>
* <li>{@code "US$"} → {@code "US$"} (no change)</li>
* <li>{@code "$ "} → {@code "$"} (whitespace removed)</li>
* <li>{@code "US $"} → {@code "US$"} (internal whitespace removed)</li>
* <li>{@code "лв."} → {@code "лв"} (trailing period removed)</li>
* <li>{@code "€\u00A0"} → {@code "€"} (non-breaking space removed)</li>
* <li>{@code "kr"} → {@code "kr"} (no change)</li>
* <li>{@code ""} → {@code ""} (empty remains empty)</li>
* </ul>
*
* @param symbol the symbol to normalize
* @return the normalized symbol with whitespace and trailing punctuation removed
*/
private String normalizeSymbol(String symbol) {
if (symbol == null || symbol.isEmpty()) {
return symbol;
}

// Remove all Unicode whitespace
String normalized = symbol.replaceAll("\\s+", "");

// Remove trailing punctuation, but NOT currency symbols
// Currency symbols are in the Currency Symbol category (Sc)
while (!normalized.isEmpty()) {
char lastChar = normalized.charAt(normalized.length() - 1);
// Stop if it's a letter, digit, or currency symbol
if (Character.isLetterOrDigit(lastChar) ||
Character.getType(lastChar) == Character.CURRENCY_SYMBOL) {
break;
}
// Remove trailing punctuation
normalized = normalized.substring(0, normalized.length() - 1);
}

return normalized;
}

/*
* (non-Javadoc)
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,17 @@
import org.testng.annotations.Test;

public class MonetaryFormatsParseBySymbolTest {
public static final Locale INDIA = new Locale("en, IN");
public static final Locale INDIA = new Locale("en", "IN");

/**
* Test related to parsing currency symbols.
* Verifies locale-aware symbol parsing (issue #423 fix).
* INR symbol ₹ should correctly parse as INR, not EUR.
*
* @see <a href="https://github.com/JavaMoney/jsr354-ri/issues/274">Issue #274</a>
* @see <a href="https://github.com/JavaMoney/jsr354-ri/issues/423">Issue #423</a>
*/
@Test
//"see https://github.com/JavaMoney/jsr354-ri/issues/274"
public void testParseCurrencySymbolINR1() {
MonetaryAmountFormat format = MonetaryFormats.getAmountFormat(
AmountFormatQueryBuilder.of(Locale.GERMANY)
Expand All @@ -45,6 +49,7 @@ public void testParseCurrencySymbolINR1() {
assertEquals(expectedFormattedString, format.format(money));
assertEquals(money, Money.parse(expectedFormattedString, format));

// INR symbol ₹ is now correctly parsed as INR (issue #423 fix)
money = Money.of(new BigDecimal("1234567.89"), "INR");
expectedFormattedString = "1.234.567,89 ₹";
assertEquals(expectedFormattedString, format.format(money));
Expand All @@ -53,22 +58,26 @@ public void testParseCurrencySymbolINR1() {

/**
* Test related to parsing currency symbols.
* Verifies locale-aware symbol parsing (issue #423 fix).
* INR symbol ₹ should correctly parse as INR, not EUR.
*
* @see <a href="https://github.com/JavaMoney/jsr354-ri/issues/274">Issue #274</a>
* @see <a href="https://github.com/JavaMoney/jsr354-ri/issues/423">Issue #423</a>
*/
@Test
//"see https://github.com/JavaMoney/jsr354-ri/issues/274"
public void testParseCurrencySymbolINR2() {
MonetaryAmountFormat format = MonetaryFormats.getAmountFormat(
AmountFormatQueryBuilder.of(INDIA)
.set(CurrencyStyle.SYMBOL)
.build());
// India locale uses Indian numbering system with lakhs/crores grouping
Money money = Money.of(new BigDecimal("1234567.89"), "EUR");
String expectedFormattedString = "€ 1,234,567.89";
assertEquals(expectedFormattedString, format.format(money));
assertEquals(money, Money.parse(expectedFormattedString, format));
String formattedString = format.format(money);
assertEquals(money, Money.parse(formattedString, format));

// INR symbol ₹ is now correctly parsed as INR (issue #423 fix)
money = Money.of(new BigDecimal("1234567.89"), "INR");
expectedFormattedString = "₹ 1,234,567.89";
assertEquals(expectedFormattedString, format.format(money));
assertEquals(money, Money.parse(expectedFormattedString, format));
formattedString = format.format(money);
assertEquals(money, Money.parse(formattedString, format));
}
}
Loading