diff --git a/moneta-core/src/main/java/org/javamoney/moneta/spi/format/CurrencyToken.java b/moneta-core/src/main/java/org/javamoney/moneta/spi/format/CurrencyToken.java
index aa970c70..56e4cc5f 100644
--- a/moneta-core/src/main/java/org/javamoney/moneta/spi/format/CurrencyToken.java
+++ b/moneta-core/src/main/java/org/javamoney/moneta/spi/format/CurrencyToken.java
@@ -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;
@@ -36,6 +34,14 @@
/**
* Implements a {@link FormatToken} that adds a localizable {@link String}, read
* by key from a {@link ResourceBundle}.
+ *
+ * Symbol parsing uses locale-aware resolution with the following precedence:
+ *
+ * - If an explicit currency is in the context, validate that the symbol matches.
+ * - Check if the symbol matches the locale's default currency.
+ * - Scan all available JDK currencies for matching symbols.
+ * - If exactly one match is found, use it; if multiple, raise ambiguity error; if none, raise unknown symbol error.
+ *
*
* @author Anatole Tresch
*/
@@ -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:
@@ -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.
+ *
+ * Examples (US locale with '.' as decimal separator):
+ *
+ * - {@code "$100"} → {@code "$"}
+ * - {@code "US$1,234.56"} → {@code "US$"}
+ * - {@code "HK$-50"} → {@code "HK$"}
+ * - {@code "€ 100"} → {@code "€"} (space before digit)
+ * - {@code "₹1234"} → {@code "₹"}
+ *
+ *
+ * @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.
+ *
+ * Resolution follows this order:
+ *
+ * - If explicit currency is set in context, verify symbol matches (e.g., USD set + "$" → USD)
+ * - Check if symbol matches locale's default currency (e.g., "$" in US locale → USD)
+ * - Scan all available currencies for unique match (e.g., "€" → EUR)
+ * - If multiple matches found, throw ambiguity error
+ *
+ *
+ * Example scenarios:
+ *
+ * - Locale default wins: "$" in {@code Locale.US} → "USD", in {@code Locale.CANADA} → "CAD"
+ * - Explicit currency: "$" with USD set in French locale → "USD"
+ * - Unique symbol: "€" in any locale → "EUR"
+ * - Ambiguous: "$" in {@code Locale.FRANCE} → exception listing USD, CAD, AUD, etc.
+ * - Mismatch: "€" with USD set → exception "Expected symbol '$' for USD but found '€'"
+ * - Unknown: "¤" → exception "Cannot resolve currency symbol"
+ *
+ *
+ * @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 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.
+ *
+ * Examples:
+ *
+ * - {@code "$"} in US locale → [USD] (unique match)
+ * - {@code "$"} in French locale → [USD, CAD, AUD, ...] (multiple matches)
+ * - {@code "€"} in any locale → [EUR] (unique match)
+ * - {@code "¥"} in Japanese locale → [JPY] (locale-specific symbol)
+ * - {@code "kr"} in Swedish locale → [SEK] (locale-specific)
+ * - {@code "¤"} → [] (no matches - generic placeholder)
+ *
+ *
+ * @param symbol the symbol to match
+ * @return list of matching currency codes (may be empty, one, or multiple)
+ */
+ private List findCurrenciesForSymbol(String symbol) {
+ List 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.
+ *
+ * Matching rules after normalization:
+ *
+ * - Exact match: {@code "$"} matches {@code "$"}
+ * - Suffix match: {@code "$"} matches {@code "US$"} (parsed ends with candidate)
+ * - Prefix match: {@code "$"} matches {@code "$US"} (parsed starts with candidate)
+ * - Reverse suffix: {@code "US$"} matches {@code "$"} (candidate ends with parsed)
+ * - Reverse prefix: {@code "$US"} matches {@code "$"} (candidate starts with parsed)
+ *
+ *
+ * Examples:
+ *
+ * - {@code symbolMatches("$", "$")} → true (exact)
+ * - {@code symbolMatches("$", "US$")} → true (parsed is suffix of candidate)
+ * - {@code symbolMatches("US$", "$")} → true (candidate is suffix of parsed)
+ * - {@code symbolMatches("$", "$US")} → true (parsed is prefix of candidate)
+ * - {@code symbolMatches("€", "$")} → false (no match)
+ * - {@code symbolMatches("", "$")} → false (empty symbols rejected)
+ * - {@code symbolMatches("лв.", "лв")} → true (after normalization removes trailing '.')
+ *
+ *
+ * @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.
+ *
+ * Normalization steps:
+ *
+ * - Remove all Unicode whitespace characters (spaces, tabs, non-breaking spaces, etc.)
+ * - Remove trailing punctuation (periods, commas, etc.) except currency symbols
+ * - Preserve letters, digits, and currency symbols in Unicode category Sc
+ *
+ *
+ * Examples:
+ *
+ * - {@code "US$"} → {@code "US$"} (no change)
+ * - {@code "$ "} → {@code "$"} (whitespace removed)
+ * - {@code "US $"} → {@code "US$"} (internal whitespace removed)
+ * - {@code "лв."} → {@code "лв"} (trailing period removed)
+ * - {@code "€\u00A0"} → {@code "€"} (non-breaking space removed)
+ * - {@code "kr"} → {@code "kr"} (no change)
+ * - {@code ""} → {@code ""} (empty remains empty)
+ *
+ *
+ * @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)
*
diff --git a/moneta-core/src/test/java/org/javamoney/moneta/format/MonetaryFormatsParseBySymbolTest.java b/moneta-core/src/test/java/org/javamoney/moneta/format/MonetaryFormatsParseBySymbolTest.java
index b0e471c9..01c35268 100644
--- a/moneta-core/src/test/java/org/javamoney/moneta/format/MonetaryFormatsParseBySymbolTest.java
+++ b/moneta-core/src/test/java/org/javamoney/moneta/format/MonetaryFormatsParseBySymbolTest.java
@@ -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 Issue #274
+ * @see Issue #423
*/
@Test
- //"see https://github.com/JavaMoney/jsr354-ri/issues/274"
public void testParseCurrencySymbolINR1() {
MonetaryAmountFormat format = MonetaryFormats.getAmountFormat(
AmountFormatQueryBuilder.of(Locale.GERMANY)
@@ -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));
@@ -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 Issue #274
+ * @see Issue #423
*/
@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));
}
}
diff --git a/moneta-core/src/test/java/org/javamoney/moneta/spi/format/CurrencyTokenTest.java b/moneta-core/src/test/java/org/javamoney/moneta/spi/format/CurrencyTokenTest.java
index a8b7f219..ed105a58 100644
--- a/moneta-core/src/test/java/org/javamoney/moneta/spi/format/CurrencyTokenTest.java
+++ b/moneta-core/src/test/java/org/javamoney/moneta/spi/format/CurrencyTokenTest.java
@@ -69,27 +69,114 @@ public void testParse_SYMBOL_EUR() {
@Test
public void testParse_SYMBOL_GBP() {
+ // GBP in UK locale shows as £
+ CurrencyToken token = new CurrencyToken(SYMBOL, AmountFormatContextBuilder.of(UK).build());
+ ParseContext context = new ParseContext("£");
+ token.parse(context);
+ assertEquals(context.getParsedCurrency().getCurrencyCode(), "GBP");
+ assertEquals(context.getIndex(), 1);
+ }
+
+ @Test
+ public void testParse_SYMBOL_USD_in_US_locale() {
+ CurrencyToken token = new CurrencyToken(SYMBOL, AmountFormatContextBuilder.of(US).build());
+ ParseContext context = new ParseContext("$");
+ token.parse(context);
+ assertEquals(context.getParsedCurrency().getCurrencyCode(), "USD");
+ assertEquals(context.getIndex(), 1);
+ }
+
+ @Test
+ public void testParse_SYMBOL_CAD_in_Canada_locale() {
+ CurrencyToken token = new CurrencyToken(SYMBOL, AmountFormatContextBuilder.of(CANADA).build());
+ ParseContext context = new ParseContext("$");
+ token.parse(context);
+ assertEquals(context.getParsedCurrency().getCurrencyCode(), "CAD");
+ assertEquals(context.getIndex(), 1);
+ }
+
+ @Test
+ public void testParse_SYMBOL_JPY_in_Japan() {
+ // JPY in Japan locale shows as ¥ (full-width yen sign)
+ CurrencyToken token = new CurrencyToken(SYMBOL, AmountFormatContextBuilder.of(JAPAN).build());
+ ParseContext context = new ParseContext("¥");
+ token.parse(context);
+ assertEquals(context.getParsedCurrency().getCurrencyCode(), "JPY");
+ }
+
+ @Test
+ public void testParse_SYMBOL_CNY_in_China() {
+ CurrencyToken token = new CurrencyToken(SYMBOL, AmountFormatContextBuilder.of(CHINA).build());
+ ParseContext context = new ParseContext("¥");
+ token.parse(context);
+ assertEquals(context.getParsedCurrency().getCurrencyCode(), "CNY");
+ }
+
+ @Test
+ public void testParse_SYMBOL_HKD() {
+ CurrencyToken token = new CurrencyToken(SYMBOL, AmountFormatContextBuilder.of(new Locale("en", "HK")).build());
+ ParseContext context = new ParseContext("HK$");
+ token.parse(context);
+ assertEquals(context.getParsedCurrency().getCurrencyCode(), "HKD");
+ assertEquals(context.getIndex(), 3);
+ }
+
+ @Test
+ public void testParse_SYMBOL_with_number_attached() {
+ CurrencyToken token = new CurrencyToken(SYMBOL, AmountFormatContextBuilder.of(US).build());
+ ParseContext context = new ParseContext("$100");
+ token.parse(context);
+ assertEquals(context.getParsedCurrency().getCurrencyCode(), "USD");
+ assertEquals(context.getIndex(), 1);
+ assertEquals(context.getInput().toString(), "100");
+ }
+
+ @Test
+ public void testParse_SYMBOL_backward_compat_EUR() {
CurrencyToken token = new CurrencyToken(SYMBOL, AmountFormatContextBuilder.of(FRANCE).build());
+ ParseContext context = new ParseContext("€");
+ token.parse(context);
+ assertEquals(context.getParsedCurrency().getCurrencyCode(), "EUR");
+ assertEquals(context.getIndex(), 1);
+ }
+
+ @Test
+ public void testParse_SYMBOL_backward_compat_GBP() {
+ CurrencyToken token = new CurrencyToken(SYMBOL, AmountFormatContextBuilder.of(UK).build());
ParseContext context = new ParseContext("£");
token.parse(context);
+ assertEquals(context.getParsedCurrency().getCurrencyCode(), "GBP");
assertEquals(context.getIndex(), 1);
}
@Test
- public void testParse_SYMBOL_ambiguous_dollar() {
+ public void testParse_SYMBOL_ambiguous_dollar_in_neutral_locale() {
+ // France uses EUR, so $ should be ambiguous (USD, CAD, AUD, etc.)
CurrencyToken token = new CurrencyToken(SYMBOL, AmountFormatContextBuilder.of(FRANCE).build());
ParseContext context = new ParseContext("$");
try {
token.parse(context);
+ fail("Expected MonetaryParseException for ambiguous symbol");
} catch (MonetaryParseException e) {
assertEquals(e.getInput(), "$");
- assertEquals(e.getErrorIndex(), -1);
- assertEquals(e.getMessage(), "$ is not a unique currency symbol.");
+ assertTrue(e.getMessage().contains("ambiguous"), "Error message should mention ambiguity");
+ assertTrue(e.getMessage().contains("USD") || e.getMessage().contains("CAD"),
+ "Error message should list candidate currencies");
}
- assertEquals(context.getIndex(), 0);
- assertFalse(context.isComplete());
assertTrue(context.hasError());
- assertEquals(context.getErrorMessage(), "$ is not a unique currency symbol.");
+ }
+
+ @Test
+ public void testParse_SYMBOL_ambiguous_dollar_with_number() {
+ CurrencyToken token = new CurrencyToken(SYMBOL, AmountFormatContextBuilder.of(FRANCE).build());
+ ParseContext context = new ParseContext("$100");
+ try {
+ token.parse(context);
+ fail("Expected MonetaryParseException for ambiguous symbol");
+ } catch (MonetaryParseException e) {
+ assertEquals(e.getInput(), "$");
+ assertTrue(e.getMessage().contains("ambiguous"));
+ }
}
@Test