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: + *

    + *
  1. If an explicit currency is in the context, validate that the symbol matches.
  2. + *
  3. Check if the symbol matches the locale's default currency.
  4. + *
  5. Scan all available JDK currencies for matching symbols.
  6. + *
  7. If exactly one match is found, use it; if multiple, raise ambiguity error; if none, raise unknown symbol error.
  8. + *
* * @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): + *

+ * + * @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: + *

    + *
  1. If explicit currency is set in context, verify symbol matches (e.g., USD set + "$" → USD)
  2. + *
  3. Check if symbol matches locale's default currency (e.g., "$" in US locale → USD)
  4. + *
  5. Scan all available currencies for unique match (e.g., "€" → EUR)
  6. + *
  7. If multiple matches found, throw ambiguity error
  8. + *
+ *

+ * Example scenarios: + *

+ * + * @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: + *

+ * + * @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: + *

    + *
  1. Remove all Unicode whitespace characters (spaces, tabs, non-breaking spaces, etc.)
  2. + *
  3. Remove trailing punctuation (periods, commas, etc.) except currency symbols
  4. + *
  5. Preserve letters, digits, and currency symbols in Unicode category Sc
  6. + *
+ *

+ * 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