Skip to main content

icu_decimal/
decimal_formatter.rs

1// This file is part of ICU4X. For terms of use, please see the file
2// called LICENSE at the top level of the ICU4X source tree
3// (online at: https://github.com/unicode-org/icu4x/blob/main/LICENSE ).
4
5use super::Cow;
6use core::fmt::Write;
7#[cfg(feature = "unstable")]
8use icu_plurals::PluralOperands;
9use writeable::Part;
10
11use crate::DecimalFormatterPreferences;
12use crate::grouper;
13use crate::input::Decimal;
14use crate::options::*;
15use crate::parts;
16use crate::provider::*;
17use crate::size_test_macro::size_test;
18#[cfg(feature = "alloc")]
19use alloc::string::String;
20use fixed_decimal::{Sign, UnsignedDecimal};
21use icu_provider::prelude::*;
22use writeable::PartsWrite;
23use writeable::Writeable;
24
25size_test!(DecimalFormatter, decimal_formatter_size, 96);
26
27/// A formatter for [`Decimal`], rendering decimal digits in an i18n-friendly way.
28///
29/// [`DecimalFormatter`] supports:
30///
31/// 1. Rendering in the local numbering system
32/// 2. Locale-sensitive grouping separator positions
33/// 3. Locale-sensitive plus and minus signs
34///
35/// See the crate-level documentation for examples.
36#[doc = decimal_formatter_size!()]
37#[derive(Debug, Clone)]
38pub struct DecimalFormatter {
39    options: DecimalFormatterOptions,
40    symbols: DataPayload<DecimalSymbolsV1>,
41    digits: DataPayload<DecimalDigitsV1>,
42}
43
44impl AsRef<DecimalFormatter> for DecimalFormatter {
45    fn as_ref(&self) -> &DecimalFormatter {
46        self
47    }
48}
49
50impl DecimalFormatter {
51    icu_provider::gen_buffer_data_constructors!(
52        (prefs: DecimalFormatterPreferences, options: DecimalFormatterOptions) -> error: DataError,
53        /// Creates a new [`DecimalFormatter`] from compiled data and an options bag.
54    );
55
56    #[doc = icu_provider::gen_buffer_unstable_docs!(UNSTABLE, Self::try_new)]
57    pub fn try_new_unstable<
58        D: DataProvider<DecimalSymbolsV1> + DataProvider<DecimalDigitsV1> + ?Sized,
59    >(
60        provider: &D,
61        prefs: DecimalFormatterPreferences,
62        options: DecimalFormatterOptions,
63    ) -> Result<Self, DataError> {
64        // In case the user explicitly specified a numbering system, use digits from that numbering system. In case of explicitly specified numbering systems,
65        // the resolved one may end up being different due to a lack of data or fallback, e.g. attempting to resolve en-u-nu-thai will likely produce en-u-nu-Latn data.
66        //
67        // This correctly handles the following cases:
68        // - Explicitly specified numbering system that is the same as the resolved numbering system: This code effects no change
69        // - Explicitly specified numbering system that is different from the resolved one: This code overrides it, but the symbols are still correctly loaded for the locale
70        // - No explicitly specified numbering system: The default numbering system for the locale is used.
71        // - Explicitly specified numbering system without data for it: this falls back to the resolved numbering system
72        //
73        // Assuming the provider has symbols for en-u-nu-latn, th-u-nu-thai (default for th), and th-u-nu-latin, this produces the following behavior:
74        //
75        // | Input Locale | Symbols | Digits | Return value of `numbering_system()` |
76        // |--------------|---------|--------|--------------------------------------|
77        // | en           | latn    | latn   | latn                                 |
78        // | en-u-nu-thai | latn    | thai   | thai                                 |
79        // | th           | thai    | thai   | thai                                 |
80        // | th-u-nu-latn | latn    | latn   | latn                                 |
81        // | en-u-nu-wxyz | latn    | latn   | latn                                 |
82        // | th-u-nu-wxyz | thai    | thai   | thai                                 |
83
84        let locale = DecimalSymbolsV1::make_locale(prefs.locale_preferences);
85
86        // Load symbols for the locale/numsys pair provided
87        let symbols = load_with_fallback::<DecimalSymbolsV1>(
88            provider,
89            // fall back to the locale
90            prefs
91                .nu_id(&locale)
92                .into_iter()
93                .chain([DataIdentifierBorrowed::for_locale(&locale)]),
94        )?
95        .payload;
96
97        let resolved_nu_id = DataIdentifierBorrowed::for_marker_attributes(
98            DataMarkerAttributes::from_str_or_panic(symbols.get().numsys()),
99        );
100
101        let digits = load_with_fallback(
102            provider,
103            // fall back to the resolved numbering system
104            prefs.nu_id(&locale).into_iter().chain([resolved_nu_id]),
105        )?
106        .payload;
107
108        Ok(Self {
109            options,
110            symbols,
111            digits,
112        })
113    }
114
115    /// Formats a [`Decimal`], returning a [`FormattedDecimal`].
116    pub fn format<'l>(&'l self, value: &'l Decimal) -> FormattedDecimal<'l> {
117        FormattedDecimal(self.format_sign(
118            value.sign,
119            self.format_unsigned(Cow::Borrowed(&value.absolute)),
120        ))
121    }
122
123    pub(crate) fn format_unsigned<'l>(
124        &'l self,
125        value: Cow<'l, UnsignedDecimal>,
126    ) -> FormattedUnsignedDecimal<'l> {
127        FormattedUnsignedDecimal {
128            value,
129            options: &self.options,
130            symbols: self.symbols.get(),
131            digits: self.digits.get(),
132        }
133    }
134
135    pub(crate) fn format_sign<'l, T>(&'l self, sign: Sign, value: T) -> FormattedSign<'l, T> {
136        FormattedSign {
137            sign: match sign {
138                Sign::None => None,
139                Sign::Negative => Some((
140                    parts::MINUS_SIGN,
141                    self.symbols.get().strings.minus_sign_prefix(),
142                    self.symbols.get().strings.minus_sign_suffix(),
143                )),
144                Sign::Positive => Some((
145                    parts::PLUS_SIGN,
146                    self.symbols.get().strings.plus_sign_prefix(),
147                    self.symbols.get().strings.plus_sign_suffix(),
148                )),
149            },
150            value,
151        }
152    }
153
154    /// Formats a [`Decimal`], returning a [`String`].
155    ///
156    /// ✨ *Enabled with the `alloc` Cargo feature.*
157    #[cfg(feature = "alloc")]
158    pub fn format_to_string(&self, value: &Decimal) -> String {
159        use writeable::Writeable;
160        self.format(value).write_to_string().into_owned()
161    }
162}
163
164/// An intermediate structure returned by [`DecimalFormatter`](crate::DecimalFormatter).
165/// Use [`Writeable`][Writeable] to render the formatted decimal to a string or buffer.
166#[derive(Debug, PartialEq, Clone)]
167pub struct FormattedDecimal<'l>(pub(crate) FormattedSign<'l, FormattedUnsignedDecimal<'l>>);
168
169#[doc(hidden)] // TODO(#3647): should be private
170/// Building block for formatted numbers
171#[derive(Debug, PartialEq, Clone)]
172pub struct FormattedUnsignedDecimal<'l> {
173    pub(crate) value: Cow<'l, UnsignedDecimal>,
174    pub(crate) options: &'l DecimalFormatterOptions,
175    pub(crate) symbols: &'l DecimalSymbols<'l>,
176    pub(crate) digits: &'l [char; 10],
177}
178
179impl FormattedUnsignedDecimal<'_> {
180    #[cfg(feature = "unstable")]
181    pub(crate) fn plural_operands(&self) -> PluralOperands {
182        PluralOperands::from_significand_and_exponent(&self.value, 0)
183    }
184}
185
186#[doc(hidden)] // TODO(#3647): should be private
187/// Building block for formatted numbers
188#[derive(Debug, PartialEq, Clone)]
189pub struct FormattedSign<'l, T> {
190    pub(crate) value: T,
191    pub(crate) sign: Option<(Part, &'l str, &'l str)>,
192}
193
194impl<T: Writeable> Writeable for FormattedSign<'_, T> {
195    fn write_to_parts<S: PartsWrite + ?Sized>(&self, sink: &mut S) -> core::fmt::Result {
196        if let Some((part, prefix, _)) = self.sign {
197            sink.with_part(part, |w| w.write_str(prefix))?;
198        }
199        self.value.write_to_parts(sink)?;
200        if let Some((part, _, suffix)) = self.sign {
201            sink.with_part(part, |w| w.write_str(suffix))?;
202        }
203        Ok(())
204    }
205}
206
207impl Writeable for FormattedUnsignedDecimal<'_> {
208    fn write_to_parts<W>(&self, w: &mut W) -> Result<(), core::fmt::Error>
209    where
210        W: PartsWrite + ?Sized,
211    {
212        let range = self.value.magnitude_range();
213        let upper_magnitude = *range.end();
214        let mut range = range.rev().peekable();
215        w.with_part(parts::INTEGER, |w| {
216            while let Some(m) = range.next_if(|&m| m >= 0) {
217                #[expect(clippy::indexing_slicing)] // digit_at in 0..=9
218                w.write_char(self.digits[self.value.digit_at(m) as usize])?;
219                if grouper::check(
220                    upper_magnitude,
221                    m,
222                    self.options.grouping_strategy.unwrap_or_default(),
223                    self.symbols.grouping_sizes,
224                ) {
225                    w.with_part(parts::GROUP, |w| {
226                        w.write_str(self.symbols.grouping_separator())
227                    })?;
228                }
229            }
230            Ok(())
231        })?;
232        if range.peek().is_some() {
233            w.with_part(parts::DECIMAL, |w| {
234                w.write_str(self.symbols.decimal_separator())
235            })?;
236            w.with_part(parts::FRACTION, |w| {
237                for m in range.by_ref() {
238                    #[expect(clippy::indexing_slicing)] // digit_at in 0..=9
239                    w.write_char(self.digits[self.value.digit_at(m) as usize])?;
240                }
241                Ok(())
242            })?;
243        }
244        Ok(())
245    }
246}
247
248writeable::impl_writeable_delegate!(FormattedDecimal<'_>, |&self| &self.0, #[cfg(feature = "alloc")] fn write_to_string);
249writeable::impl_display_with_writeable!(FormattedDecimal<'_>, #[cfg(feature = "alloc")]);
250writeable::impl_display_with_writeable!(FormattedUnsignedDecimal<'_>, #[cfg(feature = "alloc")]);
251writeable::impl_display_with_writeable!(FormattedSign<'_, T>, #[cfg(feature = "alloc")], where T: Writeable);
252
253#[test]
254fn test_numbering_resolution_fallback() {
255    use icu_locale_core::locale;
256    fn test_locale(locale: icu_locale_core::Locale, expected_format: &str) {
257        let formatter =
258            DecimalFormatter::try_new((&locale).into(), Default::default()).expect("Must load");
259        let fd = 1234.into();
260        writeable::assert_writeable_eq!(
261            formatter.format(&fd),
262            expected_format,
263            "Correct format for {locale}"
264        );
265    }
266
267    // Loading en with default latn numsys
268    test_locale(locale!("en"), "1,234");
269    // Loading en with arab numsys not present in symbols data will mix en symbols with arab digits
270    test_locale(locale!("en-u-nu-arab"), "١,٢٣٤");
271    // Loading ar-EG with default (arab) numsys
272    test_locale(locale!("ar-EG"), "١٬٢٣٤");
273    // Loading ar-EG with overridden latn numsys, present in symbols data, uses ar-EG-u-nu-latn symbols data
274    test_locale(locale!("ar-EG-u-nu-latn"), "1,234");
275    // Loading ar-EG with overridden thai numsys, not present in symbols data, uses ar-EG symbols data + thai digits
276    test_locale(locale!("ar-EG-u-nu-thai"), "๑٬๒๓๔");
277    // Loading with nonexistant numbering systems falls back to default
278    test_locale(locale!("en-u-nu-wxyz"), "1,234");
279    test_locale(locale!("ar-EG-u-nu-wxyz"), "١٬٢٣٤");
280}
281
282#[test]
283pub fn test_es_mx() {
284    use icu_locale_core::locale;
285    use writeable::assert_writeable_eq;
286    let locale = locale!("es-MX").into();
287    let fmt = DecimalFormatter::try_new(locale, Default::default()).unwrap();
288    let fd = "12345.67".parse().unwrap();
289    assert_writeable_eq!(fmt.format(&fd), "12,345.67");
290}