Functional Weave
Code in Rust

money.parse@1.0.0

impl/rust.rs

5,175 bytes · the Rust implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::money_amount::{money, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune
use super::money_currency_digits::currency_digits;  ← from money.currency-digits ^1.0.0 · built alongside by fune
use super::money_currency_digits_data::CURRENCY_DIGITS;  ← money.currency-digits’s rule data (^1.0.0) · built alongside by fune
use super::money_parse_data::CURRENCY_SYMBOLS;  ← this capability’s own data, compiled from data/currency-symbols.json into the same file by fune build

const MAX_SAFE: u128 = 9_007_199_254_740_991;
// Space, no-break space, narrow no-break space: what real documents use.
const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}'];

fn style_of(style: &str) -> (&'static [char], char) {
    match style {
        "comma-dot" => (&[','], '.'),
        "dot-comma" => (&['.'], ','),
        "space-comma" => (SPACES, ','),
        "apostrophe-dot" => (&['\'', '\u{2019}'], '.'),
        other => panic!("unknown number style \"{}\"", other),
    }
}

fn is_digits(s: &str) -> bool {
    !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}

/// The longest known symbol or ISO code that `matches`, or None.
fn longest_symbol(matches: impl Fn(&str) -> bool) -> Option<&'static str> {
    let mut best: Option<&'static str> = None;
    let candidates = CURRENCY_SYMBOLS
        .iter()
        .map(|r| r.symbol)
        .chain(CURRENCY_DIGITS.iter().map(|r| r.code));
    for symbol in candidates {
        // Compare in characters, as TypeScript and Python do.
        let longer = match best {
            None => true,
            Some(b) => symbol.chars().count() > b.chars().count(),
        };
        if matches(symbol) && longer {
            best = Some(symbol);
        }
    }
    best
}

/// Whole digits and fraction digits, or None when the text is not a number in this style.
fn split_number(s: &str, groups: &[char], decimal: char) -> Option<(String, String)> {
    let parts: Vec<&str> = s.split(decimal).collect();
    if parts.len() > 2 {
        return None;
    }
    let fraction = if parts.len() == 2 { parts[1] } else { "" };
    if parts.len() == 2 && !is_digits(fraction) {
        return None;
    }

    let chunks: Vec<&str> = parts[0].split(|c: char| groups.contains(&c)).collect();
    if !chunks.iter().all(|c| is_digits(c)) {
        return None;
    }
    if chunks.len() > 1 {
        // Real thousands grouping only: 1-3 digits without a leading zero, then threes.
        let first = chunks[0];
        if first.len() > 3 || first.starts_with('0') {
            return None;
        }
        if !chunks[1..].iter().all(|c| c.len() == 3) {
            return None;
        }
    }
    Some((chunks.concat(), fraction.to_string()))
}

/// Parse an amount written as text into integer minor units.
///
/// Strict by design: the caller names the currency and the number style, and
/// anything ambiguous (too many decimals, odd grouping, a symbol for another
/// currency) is an error rather than a guess.
///
/// # Panics
/// Panics on an unknown style or currency, a symbol for another currency, text
/// that is not an amount in the style, too many decimal places, or an amount
/// beyond 2^53 - 1 minor units.
pub fn parse_money(text: &str, currency: &str, style: &str) -> Money {
    let (groups, decimal) = style_of(style);
    let digits = currency_digits(currency) as usize;

    let mut s: &str = text.trim_matches(' ');
    let mut negative = false;
    if let Some(rest) = s.strip_prefix('-') {
        negative = true;
        s = rest;
    }

    let mut symbol = longest_symbol(|k| s.starts_with(k));
    if let Some(found) = symbol {
        s = &s[found.len()..];
        if let Some(rest) = s.strip_prefix(SPACES) {
            s = rest;
        }
        if !negative {
            if let Some(rest) = s.strip_prefix('-') {
                negative = true;
                s = rest;
            }
        }
    } else {
        symbol = longest_symbol(|k| s.ends_with(k));
        if let Some(found) = symbol {
            s = &s[..s.len() - found.len()];
            if let Some(rest) = s.strip_suffix(SPACES) {
                s = rest;
            }
        }
    }

    if let Some(found) = symbol {
        if found != currency
            && !CURRENCY_SYMBOLS
                .iter()
                .any(|r| r.code == currency && r.symbol == found)
        {
            panic!("currency symbol \"{}\" does not match {}", found, currency);
        }
    }

    let (whole, fraction) = match split_number(s, groups, decimal) {
        Some(parts) => parts,
        None => panic!("\"{}\" is not a valid amount", text),
    };
    if fraction.len() > digits {
        panic!("too many decimal places for {} (at most {})", currency, digits);
    }

    let all = format!("{}{}{}", whole, fraction, "0".repeat(digits - fraction.len()));
    let significant = all.trim_start_matches('0');
    let minor: u128 = if significant.len() > 16 {
        MAX_SAFE + 1
    } else if significant.is_empty() {
        0
    } else {
        significant.parse().unwrap()
    };
    if minor > MAX_SAFE {
        panic!("amount is too large: \"{}\" exceeds 2^53 - 1 minor units", text);
    }
    let value = minor as i64;
    money(if negative { -value } else { value }, currency)
}

pub fn fune_vector(args: &[Value]) -> Value {
    money_to_value(&parse_money(
        args[0].as_str(),
        args[1].as_str(),
        args[2].as_str(),
    ))
}