///|
/// A base-10 value represented without binary floating-point rounding.
///
/// `Decimal::new(12345, 2)` represents `123.45`. Trailing fractional zeroes
/// are normalized so equality is based on the numeric value.
pub struct Decimal {
coefficient : Int
scale : Int
} derive(Eq, Debug)
///|
pub fn Decimal::new(coefficient : Int, scale : Int) -> Decimal {
let safe_scale = if scale < 0 { 0 } else { scale }
normalize_decimal(coefficient, safe_scale)
}
///|
pub fn Decimal::from_int(value : Int) -> Decimal {
{ coefficient: value, scale: 0 }
}
///|
pub fn Decimal::coefficient(self : Decimal) -> Int {
self.coefficient
}
///|
pub fn Decimal::scale(self : Decimal) -> Int {
self.scale
}
///|
pub fn Decimal::is_zero(self : Decimal) -> Bool {
self.coefficient == 0
}
///|
fn normalize_decimal(coefficient : Int, scale : Int) -> Decimal {
let mut value = coefficient
let mut remaining_scale = scale
while remaining_scale > 0 && value % 10 == 0 {
value = value / 10
remaining_scale = remaining_scale - 1
}
{ coefficient: value, scale: remaining_scale }
}
///|
fn power_of_ten(exponent : Int) -> Int {
let mut value = 1
for index = 0; index < exponent; index = index + 1 {
value = value * 10
}
value
}
///|
fn positive_int(value : Int) -> Int {
if value < 0 {
-value
} else {
value
}
}
///|
/// Rounds to a fixed number of fractional digits, with halfway values rounded
/// away from zero.
pub fn Decimal::round(self : Decimal, fraction_digits : Int) -> Decimal {
let target = if fraction_digits < 0 { 0 } else { fraction_digits }
if self.scale <= target {
return self
}
let divisor = power_of_ten(self.scale - target)
let magnitude = positive_int(self.coefficient)
let mut rounded = magnitude / divisor
if magnitude % divisor * 2 >= divisor {
rounded = rounded + 1
}
let signed = if self.coefficient < 0 { -rounded } else { rounded }
{ coefficient: signed, scale: target }
}
///|
/// Multiplies a decimal by an integer factor.
pub fn Decimal::times_int(self : Decimal, factor : Int) -> Decimal {
Decimal::new(self.coefficient * factor, self.scale)
}
///|
/// Controls how a currency identifier is displayed.
pub(all) enum CurrencyDisplay {
Symbol
NarrowSymbol
Code
Name
} derive(Eq, Debug)
///|
/// Controls whether a percentage suffix is separated from the value.
pub(all) enum PercentSpacing {
LocaleDefault
Compact
Spaced
} derive(Eq, Debug)
///|
/// Locale-aware options for decimal, percentage, and currency output.
pub struct DecimalFormatter {
locale : Locale
grouping : Bool
minimum_fraction_digits : Int
maximum_fraction_digits : Int
sign_display : SignDisplay
} derive(Eq, Debug)
///|
pub fn DecimalFormatter::new(locale : Locale) -> DecimalFormatter {
{
locale,
grouping: true,
minimum_fraction_digits: 0,
maximum_fraction_digits: 3,
sign_display: Auto,
}
}
///|
pub fn DecimalFormatter::locale(self : DecimalFormatter) -> Locale {
self.locale
}
///|
pub fn DecimalFormatter::with_grouping(
self : DecimalFormatter,
enabled : Bool,
) -> DecimalFormatter {
{ ..self, grouping: enabled }
}
///|
/// Sets the minimum fraction digits, clamped to 0 through 12.
pub fn DecimalFormatter::with_minimum_fraction_digits(
self : DecimalFormatter,
digits : Int,
) -> DecimalFormatter {
let minimum = clamp_fraction_digits(digits)
let maximum = if self.maximum_fraction_digits < minimum {
minimum
} else {
self.maximum_fraction_digits
}
{ ..self, minimum_fraction_digits: minimum, maximum_fraction_digits: maximum }
}
///|
/// Sets the maximum fraction digits, clamped to 0 through 12.
pub fn DecimalFormatter::with_maximum_fraction_digits(
self : DecimalFormatter,
digits : Int,
) -> DecimalFormatter {
let maximum = clamp_fraction_digits(digits)
let minimum = if self.minimum_fraction_digits > maximum {
maximum
} else {
self.minimum_fraction_digits
}
{ ..self, minimum_fraction_digits: minimum, maximum_fraction_digits: maximum }
}
///|
pub fn DecimalFormatter::with_sign_display(
self : DecimalFormatter,
display : SignDisplay,
) -> DecimalFormatter {
{ ..self, sign_display: display }
}
///|
fn clamp_fraction_digits(value : Int) -> Int {
if value < 0 {
0
} else if value > 12 {
12
} else {
value
}
}
///|
fn decimal_digit_parts(
value : Decimal,
visible_scale : Int,
) -> (Bool, String, String) {
let rounded = value.round(visible_scale)
let negative = rounded.coefficient < 0
let expanded = positive_int(rounded.coefficient) *
power_of_ten(visible_scale - rounded.scale)
let raw = expanded.to_string()
let padding = visible_scale + 1 - raw.length()
let digits = if padding > 0 { repeat_string("0", padding) + raw } else { raw }
if visible_scale == 0 {
(negative, digits, "")
} else {
let boundary = digits.length() - visible_scale
(negative, "\{digits[:boundary]}", "\{digits[boundary:]}")
}
}
///|
fn trim_fraction(fraction : String, minimum : Int) -> String {
let mut length = fraction.length()
while length > minimum && fraction[length - 1] == 48 {
length = length - 1
}
"\{fraction[:length]}"
}
///|
fn format_decimal_body(
locale : Locale,
value : Decimal,
grouping : Bool,
minimum_fraction : Int,
maximum_fraction : Int,
) -> (Bool, Bool, String) {
let symbols = number_symbols(locale)
let (negative, integer_digits, untrimmed_fraction) = decimal_digit_parts(
value, maximum_fraction,
)
let fraction = trim_fraction(untrimmed_fraction, minimum_fraction)
let integer = if grouping {
group_digits(
integer_digits,
symbols.group,
symbols.primary_group,
symbols.secondary_group,
)
} else {
integer_digits
}
let body = if fraction.is_empty() {
integer
} else {
integer + symbols.decimal + fraction
}
(negative, value.round(maximum_fraction).coefficient == 0, body)
}
///|
/// Formats a decimal with deterministic base-10 rounding.
pub fn DecimalFormatter::format(
self : DecimalFormatter,
value : Decimal,
) -> String {
let (negative, zero, body) = format_decimal_body(
self.locale,
value,
self.grouping,
self.minimum_fraction_digits,
self.maximum_fraction_digits,
)
let symbols = number_symbols(self.locale)
number_sign(negative, zero, self.sign_display, symbols) + body
}
///|
fn percent_separator(locale : Locale, spacing : PercentSpacing) -> String {
match spacing {
Compact => ""
Spaced => "\u{a0}"
LocaleDefault =>
match locale.language() {
"fr" | "ru" | "uk" | "pl" | "sv" | "no" | "fi" | "cs" | "sk" => "\u{a0}"
_ => ""
}
}
}
///|
/// Formats a ratio as a percentage. `Decimal::new(125, 3)` becomes `12.5%`.
pub fn DecimalFormatter::format_percent(
self : DecimalFormatter,
value : Decimal,
spacing : PercentSpacing,
) -> String {
self.format(value.times_int(100)) +
percent_separator(self.locale, spacing) +
"%"
}
///|
/// Returns the conventional number of minor-unit digits for a currency.
pub fn currency_fraction_digits(code : String) -> Int {
match code.to_upper() {
"BHD" | "JOD" | "KWD" | "OMR" | "TND" => 3
"CLP"
| "DJF"
| "GNF"
| "ISK"
| "JPY"
| "KRW"
| "PYG"
| "RWF"
| "UGX"
| "VND"
| "VUV"
| "XAF"
| "XOF"
| "XPF" => 0
_ => 2
}
}
///|
fn currency_symbol(
code : String,
locale : Locale,
display : CurrencyDisplay,
) -> String {
let normalized = code.to_upper()
match display {
Code => normalized
Name => currency_name(normalized, locale)
NarrowSymbol =>
match normalized {
"USD" => "$"
"CAD" => "$"
"AUD" => "$"
"CNY" | "JPY" => "\u{a5}"
"EUR" => "\u{20ac}"
"GBP" => "\u{a3}"
"KRW" => "\u{20a9}"
"INR" => "\u{20b9}"
_ => normalized
}
Symbol =>
match normalized {
"USD" => if locale.region() == Some("US") { "$" } else { "US$" }
"CAD" => if locale.region() == Some("CA") { "$" } else { "CA$" }
"AUD" => if locale.region() == Some("AU") { "$" } else { "A$" }
"CNY" => "\u{a5}"
"JPY" => "JP\u{a5}"
"EUR" => "\u{20ac}"
"GBP" => "\u{a3}"
"KRW" => "\u{20a9}"
"INR" => "\u{20b9}"
_ => normalized
}
}
}
///|
fn currency_name(code : String, locale : Locale) -> String {
match (locale.language(), code) {
("zh", "CNY") => "\u{4eba}\u{6c11}\u{5e01}"
("zh", "USD") => "\u{7f8e}\u{5143}"
("zh", "EUR") => "\u{6b27}\u{5143}"
("en", "USD") => "US dollars"
("en", "EUR") => "euros"
("en", "GBP") => "British pounds"
(_, _) => code
}
}
///|
fn currency_uses_suffix(locale : Locale) -> Bool {
match locale.language() {
"fr"
| "de"
| "es"
| "it"
| "pt"
| "ru"
| "uk"
| "pl"
| "sv"
| "no"
| "fi"
| "cs"
| "sk" => true
_ => false
}
}
///|
/// Formats a monetary value using common currency digits and locale placement.
pub fn format_currency(
locale : Locale,
value : Decimal,
code : String,
display : CurrencyDisplay,
) -> String {
let digits = currency_fraction_digits(code)
let formatter = DecimalFormatter::new(locale)
.with_minimum_fraction_digits(digits)
.with_maximum_fraction_digits(digits)
let amount = formatter.format(value)
let label = currency_symbol(code, locale, display)
if display == Name || display == Code || currency_uses_suffix(locale) {
amount + "\u{a0}" + label
} else {
label + amount
}
}