// lang_currency.mbt — native currency mode for all seven languages.
//
// Amount semantics: the amount is a decimal *string* and the integer part is
// the major unit (12.34 = twelve euros and thirty-four cents). Upstream
// `num2words(1234, to="currency")` treats a bare int as *cents* (12.34) while
// `num2words(1234.0, ...)` treats it as 1234 units; that type-dependent quirk
// is not reproduced — see README / DIFF_REPORT.
//
// Rounding: exact decimal half-up on the third fractional digit. Upstream
// rounds through a binary float, so exact `x.xx5` inputs can differ (e.g.
// 1.005 -> upstream 1.00, here 1.01); such samples are excluded from the
// differential fixtures and the rule is documented.
//
// Languages differ in how the numeral agrees with the currency noun:
// * es: feminine units and hundreds (una libra, doscientas libras) and the
// apocope un/veintiún/treinta y un before the noun;
// * ru: three plural forms plus gender (один рубль / два рубля / пять
// рублей, одна копейка / две копейки);
// * pt: only BRL, zero cents are omitted, and an exact multiple of 10^6
// takes "de" (um milhão de reais).
// --- tables -----------------------------------------------------------------
///|
/// Noun forms for one currency. Russian uses the three plural forms; Spanish
/// uses `feminine` for numeral agreement, and every language selects a cent
/// form independently.
priv struct CurrencyForm {
unit_one : String
unit_few : String // Russian 2..4; a duplicate of `unit_many` elsewhere
unit_many : String
cent_one : String
cent_few : String
cent_many : String
feminine : Bool
cent_feminine : Bool
}
///|
fn currency_upper(code : String) -> String {
let buf = StringBuilder()
for i = 0; i < code.length(); i = i + 1 {
let c = code[i]
if c >= 97 && c <= 122 {
buf.write_char((c - 32).to_char().unwrap())
} else {
buf.write_char(c.to_char().unwrap())
}
}
buf.to_string()
}
///|
fn currency_entry(
unit_one : String,
unit_many : String,
cent_one : String,
cent_many : String,
feminine : Bool,
) -> CurrencyForm {
{
unit_one,
unit_few: unit_many,
unit_many,
cent_one,
cent_few: cent_many,
cent_many,
feminine,
cent_feminine: false,
}
}
///|
fn currency_entry_ru(
unit_one : String,
unit_few : String,
unit_many : String,
cent_one : String,
cent_few : String,
cent_many : String,
feminine : Bool,
cent_feminine : Bool,
) -> CurrencyForm {
{
unit_one,
unit_few,
unit_many,
cent_one,
cent_few,
cent_many,
feminine,
cent_feminine,
}
}
///|
fn currency_form_en(code : String) -> Result[CurrencyForm, String] {
match code {
"EUR" => Ok(currency_entry("euro", "euro", "cent", "cents", false))
"USD" => Ok(currency_entry("dollar", "dollars", "cent", "cents", false))
"GBP" =>
Ok(
currency_entry(
"pound sterling", "pounds sterling", "penny", "pence", false,
),
)
"RUB" => Ok(currency_entry("rouble", "roubles", "kopek", "kopeks", false))
"CAD" => Ok(currency_entry("dollar", "dollars", "cent", "cents", false))
"AUD" => Ok(currency_entry("dollar", "dollars", "cent", "cents", false))
"INR" => Ok(currency_entry("rupee", "rupees", "paisa", "paise", false))
"SEK" => Ok(currency_entry("krona", "kronor", "öre", "öre", false))
"NOK" => Ok(currency_entry("krone", "kroner", "øre", "øre", false))
"PLN" => Ok(currency_entry("zloty", "zlotys", "grosz", "groszy", false))
"MXN" => Ok(currency_entry("peso", "pesos", "cent", "cents", false))
_ => Err(code)
}
}
///|
fn currency_form_fr(code : String) -> Result[CurrencyForm, String] {
match code {
"EUR" => Ok(currency_entry("euro", "euros", "centime", "centimes", false))
"USD" => Ok(currency_entry("dollar", "dollars", "cent", "cents", false))
"GBP" => Ok(currency_entry("livre", "livres", "penny", "pence", false))
"CNY" => Ok(currency_entry("yuan", "yuans", "fen", "jiaos", false))
_ => Err(code)
}
}
///|
fn currency_form_de(code : String) -> Result[CurrencyForm, String] {
match code {
"EUR" => Ok(currency_entry("Euro", "Euro", "Cent", "Cent", false))
"USD" => Ok(currency_entry("Dollar", "Dollar", "Cent", "Cent", false))
"GBP" => Ok(currency_entry("Pfund", "Pfund", "Penny", "Pence", false))
"CNY" => Ok(currency_entry("Yuan", "Yuan", "Jiao", "Fen", false))
_ => Err(code)
}
}
///|
fn currency_form_it(code : String) -> Result[CurrencyForm, String] {
match code {
"EUR" => Ok(currency_entry("euro", "euro", "centesimo", "centesimi", false))
"USD" =>
Ok(currency_entry("dollaro", "dollari", "centesimo", "centesimi", false))
"GBP" => Ok(currency_entry("sterlina", "sterline", "penny", "penny", true))
"CNY" => Ok(currency_entry("yuan", "yuan", "fen", "fen", false))
_ => Err(code)
}
}
///|
/// Spanish currency forms. Upstream supports 20 codes here; `CNY` keeps its
/// irregular "un fen / dos jiaos" pair and `UAH` is treated as masculine.
fn currency_form_es(code : String) -> Result[CurrencyForm, String] {
match code {
"EUR" => Ok(currency_entry("euro", "euros", "céntimo", "céntimos", false))
"USD" =>
Ok(currency_entry("dólar", "dólares", "centavo", "centavos", false))
"GBP" => Ok(currency_entry("libra", "libras", "penique", "peniques", true))
"RUB" => Ok(currency_entry("rublo", "rublos", "kopeyka", "kopeykas", false))
"JPY" => Ok(currency_entry("yen", "yenes", "sen", "sen", false))
"CNY" => Ok(currency_entry("yuan", "yuanes", "fen", "jiaos", false))
"CHF" =>
Ok(currency_entry("franco", "francos", "céntimo", "céntimos", false))
"CAD" =>
Ok(currency_entry("dólar", "dólares", "centavo", "centavos", false))
"AUD" =>
Ok(currency_entry("dólar", "dólares", "centavo", "centavos", false))
"BRL" => Ok(currency_entry("real", "reales", "centavo", "centavos", false))
"INR" => Ok(currency_entry("rupia", "rupias", "paisa", "paisas", true))
"KRW" => Ok(currency_entry("won", "wones", "jeon", "jeon", false))
"SEK" => Ok(currency_entry("corona", "coronas", "öre", "öre", true))
"NOK" => Ok(currency_entry("corona", "coronas", "øre", "øre", true))
"PLN" => Ok(currency_entry("zloty", "zlotys", "grosz", "groszy", false))
"UAH" =>
Ok(currency_entry("hryvnia", "hryvnias", "kopiyka", "kopiykas", false))
"KZT" => Ok(currency_entry("tenge", "tenges", "tïın", "tïın", false))
"TRY" => Ok(currency_entry("lira", "liras", "kuruş", "kuruş", true))
"MXN" => Ok(currency_entry("peso", "pesos", "centavo", "centavos", false))
"ZAR" => Ok(currency_entry("rand", "rands", "céntimo", "céntimos", false))
_ => Err(code)
}
}
///|
/// Russian currency forms. Upstream supports six codes and declines both the
/// unit and the cent noun. `PLN` carries its adjective ("польский злотый");
/// upstream's 2..4 form contains a typo ("слотых"), which is normalised to
/// "злотых" here (see DIFF_REPORT).
fn currency_form_ru(code : String) -> Result[CurrencyForm, String] {
match code {
"EUR" =>
Ok(
currency_entry_ru(
"евро", "евро", "евро", "цент", "цента", "центов",
false, false,
),
)
"USD" =>
Ok(
currency_entry_ru(
"доллар", "доллара", "долларов", "цент", "цента",
"центов", false, false,
),
)
"RUB" =>
Ok(
currency_entry_ru(
"рубль", "рубля", "рублей", "копейка", "копейки",
"копеек", false, true,
),
)
"PLN" =>
Ok(
currency_entry_ru(
"польский злотый", "польских злотых", "польских злотых",
"грош", "гроша", "грошей", false, false,
),
)
"UAH" =>
Ok(
currency_entry_ru(
"гривна", "гривны", "гривен", "копейка", "копейки",
"копеек", true, true,
),
)
"KZT" =>
Ok(
currency_entry_ru(
"тенге", "тенге", "тенге", "тиын", "тиына", "тиынов",
false, false,
),
)
_ => Err(code)
}
}
///|
/// Brazilian Portuguese only has BRL (upstream's `pt_BR.to_currency` takes no
/// currency code, and it is the only variant this project implements).
fn currency_form_pt(code : String) -> Result[CurrencyForm, String] {
match code {
"BRL" => Ok(currency_entry("real", "reais", "centavo", "centavos", false))
_ => Err(code)
}
}
///|
/// European Portuguese supports EUR/USD/GBP/CAD/AUD, always in "cêntimo(s)".
/// Upstream uses the masculine article with "libra" ("um libra") and the
/// singular "péni" (plural "pence"); both are preserved.
fn currency_form_pt_pt(code : String) -> Result[CurrencyForm, String] {
match code {
"EUR" => Ok(currency_entry("euro", "euros", "cêntimo", "cêntimos", false))
"USD" =>
Ok(currency_entry("dólar", "dólares", "cêntimo", "cêntimos", false))
"GBP" => Ok(currency_entry("libra", "libras", "péni", "pence", false))
"CAD" =>
Ok(currency_entry("dólar", "dólares", "cêntimo", "cêntimos", false))
"AUD" =>
Ok(currency_entry("dólar", "dólares", "cêntimo", "cêntimos", false))
_ => Err(code)
}
}
///|
fn currency_supported_lang(lang : String) -> Bool {
lang == "en" ||
lang == "fr" ||
lang == "de" ||
lang == "it" ||
lang == "es" ||
lang == "pt" ||
lang == "pt_PT" ||
lang == "ru"
}
///|
fn currency_default_code(lang : String) -> String {
if lang == "pt" {
"BRL"
} else {
"EUR"
}
}
///|
fn currency_form(lang : String, code : String) -> Result[CurrencyForm, String] {
match lang {
"en" => currency_form_en(code)
"fr" => currency_form_fr(code)
"de" => currency_form_de(code)
"it" => currency_form_it(code)
"es" => currency_form_es(code)
"pt" => currency_form_pt(code)
"pt_PT" => currency_form_pt_pt(code)
"ru" => currency_form_ru(code)
_ => Err(code)
}
}
// --- amount parsing ---------------------------------------------------------
///|
fn currency_digit(s : String, i : Int) -> Int {
s[i].to_int() - 48
}
///|
/// Parse `-?\d+(\.\d+)?` into (major units, cents 0..99, negative), rounding
/// the fraction half-up at the third digit.
fn parse_currency_amount(
amount : String,
) -> Result[(BigInt, BigInt, Bool), String] {
if !is_valid_number(amount) {
return Err("invalid number: " + amount)
}
let neg = amount.length() > 0 && amount[0:1].to_owned() == "-"
let body = if neg { amount[1:amount.length()].to_owned() } else { amount }
let parts = body.split(".").map(fn(w) { w.to_owned() }).collect()
let mut units = BigInt::from_string(parts[0])
let mut cents = BigInt::from_int(0)
if parts.length() > 1 {
let frac = parts[1]
let d0 = if frac.length() > 0 { currency_digit(frac, 0) } else { 0 }
let d1 = if frac.length() > 1 { currency_digit(frac, 1) } else { 0 }
cents = BigInt::from_int(d0 * 10 + d1)
if frac.length() > 2 && currency_digit(frac, 2) >= 5 {
cents = cents + BigInt::from_int(1)
}
if cents == BigInt::from_int(100) {
cents = BigInt::from_int(0)
units = units + BigInt::from_int(1)
}
}
Ok((units, cents, neg))
}
// --- rendering --------------------------------------------------------------
///|
fn currency_pick(
one : String,
few : String,
many : String,
idx : Int,
) -> String {
if idx == 0 {
one
} else if idx == 1 {
few
} else {
many
}
}
///|
/// 0 = one, 1 = few (Russian 2..4), 2 = many. Languages other than Russian
/// only distinguish one from the rest (zero uses the plural).
fn currency_form_index(lang : String, n : BigInt) -> Int {
if lang == "ru" {
let form = ru_form((n % BigInt::from_int(100)).to_int())
if form == 1 {
0
} else if form == 2 {
1
} else {
2
}
} else if n == BigInt::from_int(1) {
0
} else {
2
}
}
///|
/// Spanish numeral agreement before the currency noun: 1 -> un/una,
/// 21/31/... -> veintiún/veintiuna or ... y un/una, and the hundreds take
/// their feminine form. Upstream leaves "quinientos" masculine, which is
/// preserved here.
fn es_currency_num(n : BigInt, feminine : Bool) -> String {
let card = get_lang("es").cardinal(n)
let tokens = card.split(" ").map(fn(w) { w.to_owned() }).collect()
let out : Array[String] = []
for i = 0; i < tokens.length(); i = i + 1 {
let t = tokens[i]
let mapped = if t == "veintiuno" {
if feminine {
"veintiuna"
} else {
"veintiún"
}
} else if t == "uno" {
if feminine {
"una"
} else {
"un"
}
} else if feminine && t == "doscientos" {
"doscientas"
} else if feminine && t == "trescientos" {
"trescientas"
} else if feminine && t == "cuatrocientos" {
"cuatrocientas"
} else if feminine && t == "seiscientos" {
"seiscientas"
} else if feminine && t == "setecientos" {
"setecientas"
} else if feminine && t == "ochocientos" {
"ochocientas"
} else if feminine && t == "novecientos" {
"novecientas"
} else {
t
}
out.push(mapped)
}
join_parts(out, " ")
}
///|
/// Spell the major/cents amount before the currency nouns. German drops the
/// final `s` of `eins` (`ein Euro`, `einhundertein`); Italian apocopates
/// `uno` (`un euro`, `ventun euro`) and agrees with feminine units; Spanish
/// and Russian agree with the noun's gender.
fn currency_num(lang : String, n : BigInt, feminine : Bool) -> String {
let card = get_lang(lang).cardinal(n)
if lang == "de" {
if ends_with(card, "eins") {
return card[0:card.length() - 4].to_owned() + "ein"
}
return card
}
if lang == "it" {
if n == BigInt::from_int(1) {
return if feminine { "una" } else { "un" }
}
if ends_with(card, "uno") {
return card[0:card.length() - 3].to_owned() + "un"
}
return card
}
if lang == "es" {
return es_currency_num(n, feminine)
}
if lang == "ru" {
return get_lang("ru").cardinal_ru_gendered(n, feminine)
}
card
}
///|
fn currency_join(
lang : String,
left : String,
unit : String,
right : String,
cent : String,
) -> String {
match lang {
"en" => left + " " + unit + ", " + right + " " + cent
"fr" => left + " " + unit + " et " + right + " " + cent
"de" => left + " " + unit + " und " + right + " " + cent
"it" => left + " " + unit + " e " + right + " " + cent
"es" => left + " " + unit + " con " + right + " " + cent
"ru" => left + " " + unit + ", " + right + " " + cent
_ => ""
}
}
///|
/// Spell a currency amount. `code` is an ISO-4217 code (case-insensitive) or
/// `""` for the language default (EUR, or BRL for `pt`).
///
/// Supported code sets match upstream for every language (see COMPAT.md §4).
///
/// Returns `Err` for malformed amounts, unsupported languages or codes, and
/// amounts past the language's scale table.
pub fn num2words_currency(
amount : String,
lang : String,
code : String,
) -> Result[String, String] {
match parse_currency_amount(amount) {
Err(msg) => Err(msg)
Ok((units, cents, neg)) => {
if !currency_supported_lang(lang) {
return Err("currency not implemented for language: " + lang)
}
let resolved = if code == "" {
currency_default_code(lang)
} else {
currency_upper(code)
}
match currency_form(lang, resolved) {
Err(c) => Err("currency code not supported for " + lang + ": " + c)
Ok(form) => {
let l = get_lang(lang)
if !l.supports(units) {
return Err("magnitude out of supported range: " + amount)
}
let unit = currency_pick(
form.unit_one,
form.unit_few,
form.unit_many,
currency_form_index(lang, units),
)
let cent = currency_pick(
form.cent_one,
form.cent_few,
form.cent_many,
currency_form_index(lang, cents),
)
let left = currency_num(lang, units, form.feminine)
let right = currency_num(lang, cents, form.cent_feminine)
// pt/pt_PT normally omit zero cents, but European Portuguese GBP
// always spells them ("um libra e zero pence").
let show_zero_cents = if lang == "pt" {
false
} else if lang == "pt_PT" {
resolved == "GBP"
} else {
true
}
let core = if lang == "pt" || lang == "pt_PT" {
// pt/pt_PT omit zero cents and use "de" for exact 10^6 multiples.
let million = BigInt::from_int(1000000)
let exact_million = units >= million &&
units % million == BigInt::from_int(0)
let mut phrase = if exact_million {
left + " de " + unit
} else {
left + " " + unit
}
if cents > BigInt::from_int(0) || show_zero_cents {
phrase = phrase + " e " + right + " " + cent
}
phrase
} else {
currency_join(lang, left, unit, right, cent)
}
// Upstream omits the sign when the rounded amount is exactly zero.
let nonzero = units > BigInt::from_int(0) ||
cents > BigInt::from_int(0)
if neg && nonzero {
Ok(l.negative + " " + core)
} else {
Ok(core)
}
}
}
}
}
}