diff --git a/components/experimental/src/dimension/currency/format.rs b/components/experimental/src/dimension/currency/format.rs index 6bce1574b96..8eed7ad3ca6 100644 --- a/components/experimental/src/dimension/currency/format.rs +++ b/components/experimental/src/dimension/currency/format.rs @@ -9,62 +9,145 @@ mod tests { use tinystr::*; use writeable::assert_writeable_eq; - use crate::dimension::currency::{CurrencyCode, formatter::CurrencyFormatter}; + use crate::dimension::currency::{ + CurrencyCode, + formatter::{CurrencyFormatter, CurrencyFormatterPreferences, Decimal}, + }; #[test] pub fn test_en_us() { - let locale = locale!("en-US").into(); + let prefs: CurrencyFormatterPreferences = locale!("en-US").into(); let currency_code = CurrencyCode(tinystr!(3, "USD")); - let fmt = CurrencyFormatter::try_new(locale, Default::default()).unwrap(); - // Positive case + // Short + let fmt_short = CurrencyFormatter::::try_new_short(prefs, ¤cy_code).unwrap(); let positive_value = "12345.67".parse().unwrap(); - let formatted_currency = fmt.format_fixed_decimal(&positive_value, ¤cy_code); - assert_writeable_eq!(formatted_currency, "$12,345.67"); - - // Negative case + assert_writeable_eq!( + fmt_short.format_fixed_decimal(&positive_value), + "$12,345.67" + ); let negative_value = "-12345.67".parse().unwrap(); - let formatted_currency = fmt.format_fixed_decimal(&negative_value, ¤cy_code); - assert_writeable_eq!(formatted_currency, "-$12,345.67"); + assert_writeable_eq!( + fmt_short.format_fixed_decimal(&negative_value), + "-$12,345.67" + ); + + // TODO(#8151): This should format to 2 decimal places ($123.46 or $123.00) once we use currency patterns. + // Currently it uses decimal patterns which do not pad '123' to 2 decimal places, and do not round '123.4567'. + let value_no_decimals = "123".parse().unwrap(); + assert_writeable_eq!(fmt_short.format_fixed_decimal(&value_no_decimals), "$123"); + let value_4_decimals = "123.4567".parse().unwrap(); + assert_writeable_eq!( + fmt_short.format_fixed_decimal(&value_4_decimals), + "$123.4567" + ); + + // Narrow + let fmt_narrow = + CurrencyFormatter::::try_new_narrow(prefs, ¤cy_code).unwrap(); + assert_writeable_eq!( + fmt_narrow.format_fixed_decimal(&positive_value), + "$12,345.67" + ); + assert_writeable_eq!( + fmt_narrow.format_fixed_decimal(&negative_value), + "-$12,345.67" + ); + + // TODO(#8151): This should format to 2 decimal places ($123.46 or $123.00) once we use currency patterns. + assert_writeable_eq!(fmt_narrow.format_fixed_decimal(&value_no_decimals), "$123"); + assert_writeable_eq!( + fmt_narrow.format_fixed_decimal(&value_4_decimals), + "$123.4567" + ); } #[test] pub fn test_fr_fr() { - let locale = locale!("fr-FR").into(); + let prefs: CurrencyFormatterPreferences = locale!("fr-FR").into(); let currency_code = CurrencyCode(tinystr!(3, "EUR")); - let fmt = CurrencyFormatter::try_new(locale, Default::default()).unwrap(); - // Positive case + // Short + let fmt_short = CurrencyFormatter::::try_new_short(prefs, ¤cy_code).unwrap(); let positive_value = "12345.67".parse().unwrap(); - let formatted_currency = fmt.format_fixed_decimal(&positive_value, ¤cy_code); - assert_writeable_eq!(formatted_currency, "12\u{202f}345,67\u{a0}€"); - - // Negative case + assert_writeable_eq!( + fmt_short.format_fixed_decimal(&positive_value), + "12\u{202f}345,67\u{a0}€" + ); let negative_value = "-12345.67".parse().unwrap(); - let formatted_currency = fmt.format_fixed_decimal(&negative_value, ¤cy_code); - assert_writeable_eq!(formatted_currency, "-12\u{202f}345,67\u{a0}€"); + assert_writeable_eq!( + fmt_short.format_fixed_decimal(&negative_value), + "-12\u{202f}345,67\u{a0}€" + ); + + // Narrow + let fmt_narrow = + CurrencyFormatter::::try_new_narrow(prefs, ¤cy_code).unwrap(); + assert_writeable_eq!( + fmt_narrow.format_fixed_decimal(&positive_value), + "12\u{202f}345,67\u{a0}€" + ); + assert_writeable_eq!( + fmt_narrow.format_fixed_decimal(&negative_value), + "-12\u{202f}345,67\u{a0}€" + ); } #[test] pub fn test_ar_eg() { - let locale = locale!("ar-EG").into(); + let prefs: CurrencyFormatterPreferences = locale!("ar-EG").into(); let currency_code = CurrencyCode(tinystr!(3, "EGP")); - let fmt = CurrencyFormatter::try_new(locale, Default::default()).unwrap(); - // Positive case + // Short + let fmt_short = CurrencyFormatter::::try_new_short(prefs, ¤cy_code).unwrap(); let positive_value = "12345.67".parse().unwrap(); - let formatted_currency = fmt.format_fixed_decimal(&positive_value, ¤cy_code); // TODO(#6064) - assert_writeable_eq!(formatted_currency, "\u{200f}١٢٬٣٤٥٫٦٧\u{a0}ج.م.\u{200f}"); - - // Negative case + assert_writeable_eq!( + fmt_short.format_fixed_decimal(&positive_value), + "\u{200f}١٢٬٣٤٥٫٦٧\u{a0}ج.م.\u{200f}" + ); let negative_value = "-12345.67".parse().unwrap(); - let formatted_currency = fmt.format_fixed_decimal(&negative_value, ¤cy_code); // TODO(#6064) assert_writeable_eq!( - formatted_currency, + fmt_short.format_fixed_decimal(&negative_value), "\u{61c}-\u{200f}١٢٬٣٤٥٫٦٧\u{a0}ج.م.\u{200f}" ); + + // Narrow + let fmt_narrow = + CurrencyFormatter::::try_new_narrow(prefs, ¤cy_code).unwrap(); + // TODO(#6064) + assert_writeable_eq!( + fmt_narrow.format_fixed_decimal(&positive_value), + "\u{200f}١٢٬٣٤٥٫٦٧\u{a0}E£" + ); + // TODO(#6064) + assert_writeable_eq!( + fmt_narrow.format_fixed_decimal(&negative_value), + "\u{61c}-\u{200f}١٢٬٣٤٥٫٦٧\u{a0}E£" + ); + } + + #[test] + pub fn test_usd_in_fr_fr() { + let prefs: CurrencyFormatterPreferences = locale!("fr-FR").into(); + let currency_code = CurrencyCode(tinystr!(3, "USD")); + let value = "12345.67".parse().unwrap(); + + // Short USD in fr-FR should be US$ or $US + let fmt_short = CurrencyFormatter::::try_new_short(prefs, ¤cy_code).unwrap(); + assert_writeable_eq!( + fmt_short.format_fixed_decimal(&value), + "12\u{202f}345,67\u{a0}$US" + ); + + // Narrow USD in fr-FR should be $ + let fmt_narrow = + CurrencyFormatter::::try_new_narrow(prefs, ¤cy_code).unwrap(); + assert_writeable_eq!( + fmt_narrow.format_fixed_decimal(&value), + "12\u{202f}345,67\u{a0}$" + ); } #[test] @@ -74,18 +157,52 @@ mod tests { let currency_code = CurrencyCode(tinystr!(3, "EGP")); let value = "12345.67".parse().unwrap(); - // 1. Default numbering system (arab) - let fmt_arab = CurrencyFormatter::try_new(prefs_arab, Default::default()).unwrap(); + // 1. Default numbering system (arab) - Short + let fmt_arab_short = + CurrencyFormatter::::try_new_short(prefs_arab, ¤cy_code).unwrap(); assert_writeable_eq!( - fmt_arab.format_fixed_decimal(&value, ¤cy_code), + fmt_arab_short.format_fixed_decimal(&value), "\u{200f}١٢٬٣٤٥٫٦٧\u{a0}ج.م.\u{200f}" ); - // 2. Locale extension override (latn) - let fmt_latn = CurrencyFormatter::try_new(prefs_latn, Default::default()).unwrap(); + // 2. Locale extension override (latn) - Short + let fmt_latn_short = + CurrencyFormatter::::try_new_short(prefs_latn, ¤cy_code).unwrap(); assert_writeable_eq!( - fmt_latn.format_fixed_decimal(&value, ¤cy_code), + fmt_latn_short.format_fixed_decimal(&value), "\u{200f}12,345.67\u{a0}ج.م.\u{200f}" ); + + // 3. Default numbering system (arab) - Narrow + let fmt_arab_narrow = + CurrencyFormatter::::try_new_narrow(prefs_arab, ¤cy_code).unwrap(); + assert_writeable_eq!( + fmt_arab_narrow.format_fixed_decimal(&value), + "\u{200f}١٢٬٣٤٥٫٦٧\u{a0}E£" + ); + + // 4. Locale extension override (latn) - Narrow + let fmt_latn_narrow = + CurrencyFormatter::::try_new_narrow(prefs_latn, ¤cy_code).unwrap(); + assert_writeable_eq!( + fmt_latn_narrow.format_fixed_decimal(&value), + "\u{200f}12,345.67\u{a0}E£" + ); + } + + #[test] + pub fn test_en_us_cad() { + let prefs: CurrencyFormatterPreferences = locale!("en-US").into(); + let currency_code = CurrencyCode(tinystr!(3, "CAD")); + let value = "12345.67".parse().unwrap(); + + // Short + let fmt_short = CurrencyFormatter::::try_new_short(prefs, ¤cy_code).unwrap(); + assert_writeable_eq!(fmt_short.format_fixed_decimal(&value), "CA$12,345.67"); + + // Narrow + let fmt_narrow = + CurrencyFormatter::::try_new_narrow(prefs, ¤cy_code).unwrap(); + assert_writeable_eq!(fmt_narrow.format_fixed_decimal(&value), "$12,345.67"); } } diff --git a/components/experimental/src/dimension/currency/formatter.rs b/components/experimental/src/dimension/currency/formatter.rs index 7331fbdc447..ad194767f45 100644 --- a/components/experimental/src/dimension/currency/formatter.rs +++ b/components/experimental/src/dimension/currency/formatter.rs @@ -3,8 +3,9 @@ // (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ). use core::fmt::Display; +use core::marker::PhantomData; -use fixed_decimal::Decimal; +use fixed_decimal::Decimal as FixedDecimal; use icu_decimal::{ DecimalFormatter, DecimalFormatterPreferences, options::DecimalFormatterOptions, }; @@ -15,7 +16,7 @@ use writeable::Writeable; use super::super::provider::currency::essentials::CurrencyEssentialsV1; use super::CurrencyCode; -use super::options::CurrencyFormatterOptions; +use super::options::{CurrencyFormatterOptions, Width}; use icu_pattern::DoublePlaceholderPattern; extern crate alloc; @@ -37,6 +38,16 @@ prefs_convert!(CurrencyFormatterPreferences, DecimalFormatterPreferences, { }); prefs_convert!(CurrencyFormatterPreferences, PluralRulesPreferences); +/// A trait for value representation in currency formatting. +pub trait ValueRepresentation {} + +/// Representation for decimal currency formatting. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct Decimal; + +impl ValueRepresentation for Decimal {} + /// A formatter for monetary values. /// /// [`CurrencyFormatter`] supports: @@ -45,9 +56,11 @@ prefs_convert!(CurrencyFormatterPreferences, PluralRulesPreferences); /// /// Read more about the options in the [`super::options`] module. #[derive(Debug)] -pub struct CurrencyFormatter { - /// Options bag for the currency formatter to determine the behavior of the formatter. - /// for example: currency width. +pub struct CurrencyFormatter { + /// Options bag for the currency formatter. + /// + /// Internal options (such as the currency width) are set automatically + /// by the constructors (e.g., `try_new_short` sets the width to `Short`). options: CurrencyFormatterOptions, /// Essential data for the currency formatter. @@ -55,28 +68,48 @@ pub struct CurrencyFormatter { /// A [`DecimalFormatter`] to format the currency value. decimal_formatter: DecimalFormatter, + + /// The currency code that this formatter is bound to. + bound_currency: CurrencyCode, + + _marker: PhantomData, } -impl CurrencyFormatter { +impl CurrencyFormatter { icu_provider::gen_buffer_data_constructors!( - (prefs: CurrencyFormatterPreferences, options: CurrencyFormatterOptions) -> error: DataError, + (prefs: CurrencyFormatterPreferences, currency_code: &CurrencyCode) -> error: DataError, functions: [ - try_new: skip, - try_new_with_buffer_provider, - try_new_unstable, + try_new_short: skip, + try_new_short_with_buffer_provider, + try_new_short_unstable, Self ] ); - /// Creates a new [`CurrencyFormatter`] from compiled locale data and an options bag. + icu_provider::gen_buffer_data_constructors!( + (prefs: CurrencyFormatterPreferences, currency_code: &CurrencyCode) -> error: DataError, + functions: [ + try_new_narrow: skip, + try_new_narrow_with_buffer_provider, + try_new_narrow_unstable, + Self + ] + ); + + // We manually implement the compiled constructors because of the cross-crate dependency + // on `icu_decimal` markers (which are not present in `icu_experimental`'s local `Baked` provider). + // TODO: When CurrencyFormatter is migrated out of experimental, check if we can use the + // macro-generated versions instead of these manual implementations. + + /// Creates a new [`CurrencyFormatter`] for short formatting from compiled locale data. /// /// ✨ *Enabled with the `compiled_data` Cargo feature.* /// /// [📚 Help choosing a constructor](icu_provider::constructors) #[cfg(feature = "compiled_data")] - pub fn try_new( + pub fn try_new_short( prefs: CurrencyFormatterPreferences, - options: CurrencyFormatterOptions, + currency_code: &CurrencyCode, ) -> Result { let locale = CurrencyEssentialsV1::make_locale(prefs.locale_preferences); // TODO: We should depend on the currency format patterns directly and not depend on @@ -98,17 +131,53 @@ impl CurrencyFormatter { } Ok(Self { - options, + options: Width::Short.into(), + essential, + decimal_formatter, + bound_currency: *currency_code, + _marker: PhantomData, + }) + } + + /// Creates a new [`CurrencyFormatter`] for narrow formatting from compiled locale data. + /// + /// ✨ *Enabled with the `compiled_data` Cargo feature.* + /// + /// [📚 Help choosing a constructor](icu_provider::constructors) + #[cfg(feature = "compiled_data")] + pub fn try_new_narrow( + prefs: CurrencyFormatterPreferences, + currency_code: &CurrencyCode, + ) -> Result { + let locale = CurrencyEssentialsV1::make_locale(prefs.locale_preferences); + let decimal_prefs = DecimalFormatterPreferences::from(&prefs); + let decimal_formatter = + DecimalFormatter::try_new(decimal_prefs, DecimalFormatterOptions::default())?; + + let req_id = decimal_prefs.nu_id(&locale); + let default_id = DataIdentifierBorrowed::for_locale(&locale); + let ids = req_id.into_iter().chain(core::iter::once(default_id)); + let essential = + load_with_fallback::(&crate::provider::Baked, ids)?.payload; + + if essential.get().standard_pattern().is_none() { + return Err(DataError::custom("missing standard pattern")); + } + + Ok(Self { + options: Width::Narrow.into(), essential, decimal_formatter, + bound_currency: *currency_code, + _marker: PhantomData, }) } - #[doc = icu_provider::gen_buffer_unstable_docs!(UNSTABLE, Self::try_new)] - pub fn try_new_unstable( + #[doc = icu_provider::gen_buffer_unstable_docs!(UNSTABLE, Self::try_new_short)] + pub fn try_new_short_unstable( provider: &D, prefs: CurrencyFormatterPreferences, - options: CurrencyFormatterOptions, + currency_code: &CurrencyCode, ) -> Result where D: ?Sized @@ -137,40 +206,81 @@ impl CurrencyFormatter { } Ok(Self { - options, + options: Width::Short.into(), essential, decimal_formatter, + bound_currency: *currency_code, + _marker: PhantomData, }) } - /// Formats a [`Decimal`] value for the given currency code. + #[doc = icu_provider::gen_buffer_unstable_docs!(UNSTABLE, Self::try_new_narrow)] + pub fn try_new_narrow_unstable( + provider: &D, + prefs: CurrencyFormatterPreferences, + currency_code: &CurrencyCode, + ) -> Result + where + D: ?Sized + + DataProvider + + DataProvider + + DataProvider, + { + let locale = CurrencyEssentialsV1::make_locale(prefs.locale_preferences); + let decimal_prefs = DecimalFormatterPreferences::from(&prefs); + let decimal_formatter = DecimalFormatter::try_new_unstable( + provider, + decimal_prefs, + DecimalFormatterOptions::default(), + )?; + let req_id = decimal_prefs.nu_id(&locale); + let default_id = DataIdentifierBorrowed::for_locale(&locale); + let ids = req_id.into_iter().chain(core::iter::once(default_id)); + let essential = load_with_fallback::(provider, ids)?.payload; + + if essential.get().standard_pattern().is_none() { + return Err(DataError::custom("missing standard pattern")); + } + + Ok(Self { + options: Width::Narrow.into(), + essential, + decimal_formatter, + bound_currency: *currency_code, + _marker: PhantomData, + }) + } + + /// Formats a [`FixedDecimal`] value. /// /// # Examples /// ``` - /// use icu::experimental::dimension::currency::formatter::CurrencyFormatter; + /// use icu::experimental::dimension::currency::formatter::{CurrencyFormatter, Decimal}; /// use icu::experimental::dimension::currency::CurrencyCode; /// use icu::locale::locale; /// use tinystr::*; /// use writeable::assert_writeable_eq; /// /// let locale = locale!("en-US").into(); - /// let fmt = CurrencyFormatter::try_new(locale, Default::default()).unwrap(); - /// let value = "12345.67".parse().unwrap(); /// let currency_code = CurrencyCode(tinystr!(3, "USD")); + /// let fmt = CurrencyFormatter::::try_new_short(locale, ¤cy_code).unwrap(); + /// let value = "12345.67".parse().unwrap(); /// assert_writeable_eq!( - /// fmt.format_fixed_decimal(&value, ¤cy_code), + /// fmt.format_fixed_decimal(&value), /// "$12,345.67" /// ); /// ``` pub fn format_fixed_decimal<'l>( &'l self, - value: &'l Decimal, - currency_code: &'l CurrencyCode, + value: &'l FixedDecimal, ) -> impl Writeable + Display + 'l { + // TODO(#6064): Support plural-specific patterns and full currency formatting spec. + // TODO(#8146): Evaluate if FixedDecimal is the correct input type or if we should use + // an exact decimal/money representation. let (currency_str, pattern, _pattern_selection) = self .essential .get() - .name_and_pattern(self.options.width, currency_code); + .name_and_pattern(self.options.width, &self.bound_currency); let pattern = pattern.unwrap_or_else(|| <&DoublePlaceholderPattern>::default()); diff --git a/components/experimental/src/dimension/currency/options.rs b/components/experimental/src/dimension/currency/options.rs index a223054fe77..a63e2421dc1 100644 --- a/components/experimental/src/dimension/currency/options.rs +++ b/components/experimental/src/dimension/currency/options.rs @@ -13,6 +13,8 @@ use serde::{Deserialize, Serialize}; #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[non_exhaustive] pub struct CurrencyFormatterOptions { + // TODO: Remove this option after migrating all formatters (including CompactCurrencyFormatter) + // to use specific constructors, as the width is determined at construction time. /// The width of the currency format. pub width: Width, }