use std::{ops::Range, str::FromStr};
use miette::Diagnostic;
use rust_decimal::Decimal;
use thiserror::Error;
use crate::{Currency, Money};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct Parser {
assumed: Option<Currency>,
group_separator: char,
decimal_separator: char,
}
impl Parser {
#[must_use]
pub const fn new() -> Self {
Self {
assumed: None,
group_separator: ',',
decimal_separator: '.',
}
}
#[must_use]
pub const fn separators(mut self, group: char, decimal: char) -> Self {
self.group_separator = group;
self.decimal_separator = decimal;
self
}
#[must_use]
pub const fn assume_currency(mut self, currency: Currency) -> Self {
self.assumed = Some(currency);
self
}
pub fn parse(self, text: &str) -> Result<Money, ParseMoneyError> {
let mut s = text.trim();
let mut negative = false;
if let Some(inner) = s.strip_prefix('(').and_then(|r| r.strip_suffix(')')) {
negative = true;
s = inner.trim();
}
if let Some(rest) = s.strip_prefix('-') {
if negative {
return Err(ParseMoneyError::InvalidAmount {
text: text.to_string(),
at: span_of(text, &s[..1]),
});
}
negative = true;
s = rest.trim_start();
}
let no_digits = || ParseMoneyError::InvalidAmount {
text: text.to_string(),
at: span_of(text, s),
};
let first = s.find(|c: char| c.is_ascii_digit()).ok_or_else(no_digits)?;
let last = s
.rfind(|c: char| c.is_ascii_digit())
.ok_or_else(no_digits)?;
let mut prefix = s[..first].trim_end();
let suffix = s[last + 1..].trim_start();
if let Some(stripped) = prefix.strip_suffix('-') {
if negative {
return Err(ParseMoneyError::InvalidAmount {
text: text.to_string(),
at: span_of(text, &prefix[stripped.len()..]),
});
}
negative = true;
prefix = stripped.trim_end();
}
let currency = self.identify(text, prefix, suffix)?.ok_or_else(|| {
ParseMoneyError::MissingCurrency {
text: text.to_string(),
at: span_of(text, s),
}
})?;
let amount = self.read_amount(text, &s[first..=last], negative)?;
Ok(Money::from_decimal(amount, currency))
}
fn identify(
self,
text: &str,
prefix: &str,
suffix: &str,
) -> Result<Option<Currency>, ParseMoneyError> {
let mut coded: Option<(Currency, Range<usize>)> = None;
let mut symbols = [None, None];
for (slot, token) in symbols.iter_mut().zip([prefix, suffix]) {
if token.is_empty() {
continue;
}
match Currency::from_alphabetic_code(&token.to_ascii_uppercase()) {
Some(found) => {
if let Some((seen, first)) = &coded
&& *seen != found
{
return Err(ParseMoneyError::ConflictingCurrencies {
text: text.to_string(),
first: first.clone(),
second: span_of(text, token),
});
}
coded = Some((found, span_of(text, token)));
}
None => *slot = Some(token),
}
}
let mut currency = coded.map(|(found, _)| found);
for token in symbols.into_iter().flatten() {
let claimed = currency
.or(self.assumed)
.filter(|c| c.symbol() == token)
.ok_or_else(|| ParseMoneyError::UnknownCurrency {
text: text.to_string(),
token: token.to_string(),
at: span_of(text, token),
})?;
currency = Some(claimed);
}
Ok(currency.or(self.assumed))
}
fn read_amount(
self,
text: &str,
digits: &str,
negative: bool,
) -> Result<Decimal, ParseMoneyError> {
let mut normalized = String::with_capacity(digits.len() + 1);
if negative {
normalized.push('-');
}
let mut in_fraction = false;
for (i, c) in digits.char_indices() {
if c.is_ascii_digit() {
normalized.push(c);
} else if c == self.decimal_separator && !in_fraction {
in_fraction = true;
normalized.push('.');
} else if c == self.group_separator && !in_fraction {
} else {
let start = span_of(text, digits).start + i;
return Err(ParseMoneyError::InvalidAmount {
text: text.to_string(),
at: start..start + c.len_utf8(),
});
}
}
Decimal::from_str(&normalized).map_err(|_| ParseMoneyError::InvalidAmount {
text: text.to_string(),
at: span_of(text, digits),
})
}
}
fn span_of(outer: &str, inner: &str) -> Range<usize> {
let start = inner.as_ptr().addr() - outer.as_ptr().addr();
start..start + inner.len()
}
impl Default for Parser {
fn default() -> Self {
Self::new()
}
}
impl FromStr for Money {
type Err = ParseMoneyError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Parser::new().parse(s)
}
}
#[derive(Clone, Debug, Diagnostic, Error, Eq, PartialEq)]
#[diagnostic(url(docsrs))]
#[non_exhaustive]
pub enum ParseMoneyError {
#[error("the text names no currency, and the parser assumes none")]
#[diagnostic(
code(lucre::money::parse::missing_currency),
help("name a currency in the text, or give the `Parser` one to assume")
)]
MissingCurrency {
#[source_code]
text: String,
#[label("no currency here")]
at: Range<usize>,
},
#[error("no currency matches {token:?}")]
#[diagnostic(
code(lucre::money::parse::unknown_currency),
help("name a currency by its ISO 4217 code, or by the symbol of the one assumed")
)]
UnknownCurrency {
#[source_code]
text: String,
token: String,
#[label("matches no currency")]
at: Range<usize>,
},
#[error("the text names more than one currency")]
#[diagnostic(
code(lucre::money::parse::conflicting_currencies),
help("an amount is in one currency; drop whichever of the two the text does not mean")
)]
ConflictingCurrencies {
#[source_code]
text: String,
#[label("one currency")]
first: Range<usize>,
#[label("a different currency")]
second: Range<usize>,
},
#[error("the amount is missing, malformed, or too large for a decimal")]
#[diagnostic(
code(lucre::money::parse::invalid_amount),
help("a `Decimal` holds up to 28 significant digits")
)]
InvalidAmount {
#[source_code]
text: String,
#[label("not part of a valid amount")]
at: Range<usize>,
},
}
impl ParseMoneyError {
#[must_use]
pub fn text(&self) -> &str {
match self {
Self::MissingCurrency { text, .. }
| Self::UnknownCurrency { text, .. }
| Self::ConflictingCurrencies { text, .. }
| Self::InvalidAmount { text, .. } => text,
}
}
#[must_use]
pub fn span(&self) -> Range<usize> {
match self {
Self::MissingCurrency { at, .. }
| Self::UnknownCurrency { at, .. }
| Self::InvalidAmount { at, .. } => at.clone(),
Self::ConflictingCurrencies { first, .. } => first.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Format;
use rust_decimal::prelude::*;
#[test]
fn code_suffix_test() {
let money: Money = "1,500.00 USD".parse().unwrap();
assert_eq!(money, Money::from_minor(150_000, Currency::USD));
}
#[test]
fn code_prefix_test() {
let money: Money = "USD 1,500.00".parse().unwrap();
assert_eq!(money, Money::from_minor(150_000, Currency::USD));
}
#[test]
fn code_without_space_test() {
assert_eq!(
"1.50USD".parse::<Money>().unwrap(),
Money::from_minor(150, Currency::USD)
);
assert_eq!(
"USD1.50".parse::<Money>().unwrap(),
Money::from_minor(150, Currency::USD)
);
}
#[test]
fn code_is_case_insensitive_test() {
assert_eq!(
"1.50 usd".parse::<Money>().unwrap(),
Money::from_minor(150, Currency::USD)
);
}
#[test]
fn surrounding_whitespace_test() {
assert_eq!(
" 1.50 USD ".parse::<Money>().unwrap(),
Money::from_minor(150, Currency::USD)
);
}
#[test]
fn negative_minus_placements_test() {
let expected = Money::from_minor(-150, Currency::USD);
assert_eq!("-1.50 USD".parse::<Money>().unwrap(), expected);
assert_eq!("-USD 1.50".parse::<Money>().unwrap(), expected);
assert_eq!("USD -1.50".parse::<Money>().unwrap(), expected);
}
#[test]
fn negative_parentheses_test() {
assert_eq!(
"(1,500.00 USD)".parse::<Money>().unwrap(),
Money::from_minor(-150_000, Currency::USD)
);
}
#[test]
fn doubled_sign_is_invalid_test() {
assert_eq!(
"--1.50 USD".parse::<Money>(),
Err(ParseMoneyError::InvalidAmount {
text: "--1.50 USD".to_string(),
at: 1..2
})
);
assert_eq!(
"(-1.50 USD)".parse::<Money>(),
Err(ParseMoneyError::InvalidAmount {
text: "(-1.50 USD)".to_string(),
at: 1..2
})
);
}
#[test]
fn missing_currency_test() {
assert_eq!(
"1.50".parse::<Money>(),
Err(ParseMoneyError::MissingCurrency {
text: "1.50".to_string(),
at: 0..4
})
);
}
#[test]
fn unknown_currency_test() {
assert_eq!(
"1.50 ZZZ".parse::<Money>(),
Err(ParseMoneyError::UnknownCurrency {
text: "1.50 ZZZ".to_string(),
token: "ZZZ".to_string(),
at: 5..8
})
);
}
#[test]
fn conflicting_currencies_test() {
assert_eq!(
"EUR 1.50 USD".parse::<Money>(),
Err(ParseMoneyError::ConflictingCurrencies {
text: "EUR 1.50 USD".to_string(),
first: 0..3,
second: 9..12
})
);
}
#[test]
fn symbol_agreeing_with_code_test() {
assert_eq!(
"$1.50 USD".parse::<Money>().unwrap(),
Money::from_minor(150, Currency::USD)
);
assert_eq!(
"€1.50 USD".parse::<Money>(),
Err(ParseMoneyError::UnknownCurrency {
text: "€1.50 USD".to_string(),
token: "€".to_string(),
at: 0..3
})
);
}
#[test]
fn symbol_without_assumption_is_unknown_test() {
assert_eq!(
"$1.50".parse::<Money>(),
Err(ParseMoneyError::UnknownCurrency {
text: "$1.50".to_string(),
token: "$".to_string(),
at: 0..1
})
);
}
#[test]
fn assumed_currency_test() {
let parser = Parser::new().assume_currency(Currency::USD);
assert_eq!(
parser.parse("1.50").unwrap(),
Money::from_minor(150, Currency::USD)
);
assert_eq!(
parser.parse("$1.50").unwrap(),
Money::from_minor(150, Currency::USD)
);
assert_eq!(
parser.parse("$-1.50").unwrap(),
Money::from_minor(-150, Currency::USD)
);
}
#[test]
fn code_overrides_assumed_currency_test() {
let parser = Parser::new().assume_currency(Currency::USD);
assert_eq!(
parser.parse("1.50 EUR").unwrap(),
Money::from_minor(150, Currency::EUR)
);
}
#[test]
fn wrong_symbol_for_assumed_currency_test() {
let parser = Parser::new().assume_currency(Currency::EUR);
assert_eq!(
parser.parse("$1.50"),
Err(ParseMoneyError::UnknownCurrency {
text: "$1.50".to_string(),
token: "$".to_string(),
at: 0..1
})
);
}
#[test]
fn separators_test() {
let parser = Parser::new().separators('.', ',');
assert_eq!(
parser.parse("1.500,00 EUR").unwrap(),
Money::from_minor(150_000, Currency::EUR)
);
}
#[test]
fn grouping_spacing_is_not_checked_test() {
assert_eq!(
"1,00,0.50 USD".parse::<Money>().unwrap(),
Money::from_decimal(dec!(1000.50), Currency::USD)
);
}
#[test]
fn group_separator_in_fraction_is_invalid_test() {
assert_eq!(
"1.5,0 USD".parse::<Money>(),
Err(ParseMoneyError::InvalidAmount {
text: "1.5,0 USD".to_string(),
at: 3..4
})
);
}
#[test]
fn second_decimal_mark_is_invalid_test() {
assert_eq!(
"1.5.0 USD".parse::<Money>(),
Err(ParseMoneyError::InvalidAmount {
text: "1.5.0 USD".to_string(),
at: 3..4
})
);
}
#[test]
fn no_digits_is_invalid_test() {
assert_eq!(
"USD".parse::<Money>(),
Err(ParseMoneyError::InvalidAmount {
text: "USD".to_string(),
at: 0..3
})
);
assert_eq!(
"".parse::<Money>(),
Err(ParseMoneyError::InvalidAmount {
text: String::new(),
at: 0..0
})
);
}
#[test]
fn amount_beyond_decimal_range_is_invalid_test() {
assert_eq!(
"99999999999999999999999999999999999999 USD".parse::<Money>(),
Err(ParseMoneyError::InvalidAmount {
text: "99999999999999999999999999999999999999 USD".to_string(),
at: 0..38
})
);
}
#[test]
fn diagnostic_labels_the_token_that_matched_nothing_test() {
let error = "1.50 ZZZ".parse::<Money>().unwrap_err();
let labels: Vec<_> = error.labels().unwrap().collect();
assert!(error.source_code().is_some());
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].offset(), 5);
assert_eq!(labels[0].len(), 3);
assert_eq!(labels[0].label(), Some("matches no currency"));
}
#[test]
fn diagnostic_labels_both_conflicting_currencies_test() {
let error = "EUR 1.50 USD".parse::<Money>().unwrap_err();
let labels: Vec<_> = error.labels().unwrap().collect();
assert_eq!(labels.len(), 2);
assert_eq!((labels[0].offset(), labels[0].len()), (0, 3));
assert_eq!((labels[1].offset(), labels[1].len()), (9, 3));
}
#[test]
fn spans_count_from_the_untrimmed_start_test() {
assert_eq!(
" 1.50 ZZZ ".parse::<Money>(),
Err(ParseMoneyError::UnknownCurrency {
text: " 1.50 ZZZ ".to_string(),
token: "ZZZ".to_string(),
at: 8..11
})
);
}
#[test]
fn digits_are_kept_verbatim_test() {
let money: Money = "1.2345 USD".parse().unwrap();
assert_eq!(money.amount(), dec!(1.2345));
}
#[test]
fn zero_minor_digit_currency_test() {
assert_eq!(
"5 JPY".parse::<Money>().unwrap(),
Money::from_major(5, Currency::JPY)
);
}
#[test]
fn round_trips_format_renderings_test() {
let money = Money::from_minor(-1_234_567, Currency::USD);
let parser = Parser::new().assume_currency(Currency::USD);
for format in [
Format::new(),
Format::new().symbol(),
Format::new().amount_only(),
Format::new().prefix().no_space(),
Format::new().symbol().parentheses(),
Format::new().no_grouping(),
Format::new().grouping(&[3, 2]),
] {
let rendered = money.format_with(format).to_string();
assert_eq!(
parser.parse(&rendered).unwrap(),
money,
"rendering {rendered:?}"
);
}
}
}