rust_icu_ecma402/
numberformat.rs

1// Copyright 2020 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Implements the traits found in [ecma402_traits::numberformat].
16
17use {
18    ecma402_traits, rust_icu_common as common, rust_icu_unumberformatter as unumf,
19    std::convert::TryInto, std::fmt,
20};
21
22#[derive(Debug)]
23pub struct NumberFormat {
24    // The internal representation of number formatting.
25    rep: unumf::UNumberFormatter,
26}
27
28pub(crate) mod internal {
29    use {
30        ecma402_traits::numberformat, ecma402_traits::numberformat::options,
31        rust_icu_common as common,
32    };
33
34    /// Produces a [skeleton][skel] that corresponds to the given option.
35    ///
36    /// The conversion may fail if the options are malformed, for example request currency
37    /// formatting but do not have a currency defined.
38    ///
39    /// [skel]: https://github.com/unicode-org/icu/blob/%6d%61%73%74%65%72/docs/userguide/format_parse/numbers/skeletons.md
40    pub fn skeleton_from(opts: &numberformat::Options) -> Result<String, common::Error> {
41        let mut skel: Vec<String> = vec![];
42        if let Some(ref c) = opts.compact_display {
43            match c {
44                options::CompactDisplay::Long => skel.push("compact-long".into()),
45                options::CompactDisplay::Short => skel.push("compact-short".into()),
46            }
47        }
48        match opts.style {
49            options::Style::Currency => {
50                match opts.currency {
51                    None => {
52                        return Err(common::Error::Wrapper(anyhow::anyhow!(
53                            "currency not specified"
54                        )));
55                    }
56                    Some(ref c) => {
57                        skel.push(format!("currency/{}", &c.0));
58                    }
59                }
60                match opts.currency_display {
61                    options::CurrencyDisplay::Symbol => {
62                        skel.push(format!("unit-width-short"));
63                    }
64                    options::CurrencyDisplay::NarrowSymbol => {
65                        skel.push(format!("unit-width-narrow"));
66                    }
67                    options::CurrencyDisplay::Code => {
68                        skel.push(format!("unit-width-iso-code"));
69                    }
70                    options::CurrencyDisplay::Name => {
71                        skel.push(format!("unit-width-full-name"));
72                    }
73                }
74                match opts.currency_sign {
75                    options::CurrencySign::Accounting => {
76                        skel.push(format!("sign-accounting"));
77                    }
78                    options::CurrencySign::Standard => {
79                        // No special setup here.
80                    }
81                }
82            }
83            options::Style::Unit => match opts.unit {
84                None => {
85                    return Err(common::Error::Wrapper(anyhow::anyhow!(
86                        "unit not specified"
87                    )));
88                }
89                Some(ref u) => {
90                    skel.push(format!("measure-unit/{}", &u.0));
91                }
92            },
93            options::Style::Percent => {
94                skel.push(format!("percent"));
95            }
96            options::Style::Decimal => {
97                // Default, no special setup needed, apparently.
98            }
99        }
100        match opts.notation {
101            options::Notation::Standard => {
102                // Nothing is needed here.
103            }
104            options::Notation::Engineering => match opts.sign_display {
105                options::SignDisplay::Auto => {
106                    skel.push(format!("scientific/*ee"));
107                }
108                options::SignDisplay::Always => {
109                    skel.push(format!("scientific/*ee/sign-always"));
110                }
111                options::SignDisplay::Never => {
112                    skel.push(format!("scientific/*ee/sign-never"));
113                }
114                options::SignDisplay::ExceptZero => {
115                    skel.push(format!("scientific/*ee/sign-expect-zero"));
116                }
117            },
118            options::Notation::Scientific => {
119                skel.push(format!("scientific"));
120            }
121            options::Notation::Compact => {
122                // ?? Is this true?
123                skel.push(format!("compact-short"));
124            }
125        }
126        if let Some(ref n) = opts.numbering_system {
127            skel.push(format!("numbering-system/{}", &n.0));
128        }
129
130        if opts.notation != options::Notation::Engineering {
131            match opts.sign_display {
132                options::SignDisplay::Auto => {
133                    skel.push("sign-auto".into());
134                }
135                options::SignDisplay::Never => {
136                    skel.push("sign-never".into());
137                }
138                options::SignDisplay::Always => {
139                    skel.push("sign-always".into());
140                }
141                options::SignDisplay::ExceptZero => {
142                    skel.push("sign-always".into());
143                }
144            }
145        }
146
147        let minimum_integer_digits = opts.minimum_integer_digits.unwrap_or(1);
148        // TODO: this should match the list at:
149        // https://www.currency-iso.org/en/home/tables/table-a1.html
150        let minimum_fraction_digits = opts.minimum_fraction_digits.unwrap_or(match opts.style {
151            options::Style::Currency => 2,
152            _ => 0,
153        });
154        let maximum_fraction_digits = opts.maximum_fraction_digits.unwrap_or(match opts.style {
155            options::Style::Currency => std::cmp::max(2, minimum_fraction_digits),
156            _ => 3,
157        });
158        let minimum_significant_digits = opts.minimum_significant_digits.unwrap_or(1);
159        let maximum_significant_digits = opts.maximum_significant_digits.unwrap_or(21);
160
161        // TODO: add skeleton items for min and max integer, fraction and significant digits.
162        skel.push(integer_digits(minimum_integer_digits as usize));
163        skel.push(fraction_digits(
164            minimum_fraction_digits as usize,
165            maximum_fraction_digits as usize,
166            minimum_significant_digits as usize,
167            maximum_significant_digits as usize,
168        ));
169
170        Ok(skel.iter().map(|s| format!("{} ", s)).collect())
171    }
172
173    // Returns the skeleton annotation for integer width
174    // 1 -> "integer-width/*0"
175    // 3 -> "integer-width/*000"
176    fn integer_digits(digits: usize) -> String {
177        let zeroes: String = std::iter::repeat("0").take(digits).collect();
178        #[cfg(feature = "icu_version_67_plus")]
179        return format!("integer-width/*{}", zeroes);
180        #[cfg(not(feature = "icu_version_67_plus"))]
181        return format!("integer-width/+{}", zeroes);
182    }
183
184    fn fraction_digits(min: usize, max: usize, min_sig: usize, max_sig: usize) -> String {
185        eprintln!(
186            "fraction_digits: min: {}, max: {} min_sig: {}, max_sig: {}",
187            min, max, min_sig, max_sig
188        );
189        assert!(min <= max, "fraction_digits: min: {}, max: {}", min, max);
190        let zeroes: String = std::iter::repeat("0").take(min).collect();
191        let hashes: String = std::iter::repeat("#").take(max - min).collect();
192
193        assert!(
194            min_sig <= max_sig,
195            "significant_digits: min: {}, max: {}",
196            min_sig,
197            max_sig
198        );
199        let ats: String = std::iter::repeat("@").take(min_sig).collect();
200        let hashes_sig: String = std::iter::repeat("#").take(max_sig - min_sig).collect();
201
202        return format!(".{}{}/{}{}", zeroes, hashes, ats, hashes_sig,);
203    }
204
205    #[cfg(test)]
206    mod testing {
207        use super::*;
208
209        #[test]
210        fn fraction_digits_skeleton_fragment() {
211            assert_eq!(fraction_digits(0, 3, 1, 21), ".###/@####################");
212            assert_eq!(fraction_digits(2, 2, 1, 21), ".00/@####################");
213            assert_eq!(fraction_digits(0, 0, 0, 0), "./");
214            assert_eq!(fraction_digits(0, 3, 3, 3), ".###/@@@");
215        }
216    }
217}
218
219impl ecma402_traits::numberformat::NumberFormat for NumberFormat {
220    type Error = common::Error;
221
222    /// Creates a new [NumberFormat].
223    ///
224    /// Creation may fail, for example, if the locale-specific data is not loaded, or if
225    /// the supplied options are inconsistent.
226    fn try_new<L>(l: L, opts: ecma402_traits::numberformat::Options) -> Result<Self, Self::Error>
227    where
228        L: ecma402_traits::Locale,
229        Self: Sized,
230    {
231        let locale = format!("{}", l);
232        let skeleton: String = internal::skeleton_from(&opts)?;
233        let rep = unumf::UNumberFormatter::try_new(&skeleton, &locale)?;
234        Ok(NumberFormat { rep })
235    }
236
237    /// Formats the plural class of `number` into the supplied `writer`.
238    ///
239    /// The function implements [`Intl.NumberFormat`][plr] from [ECMA 402][ecma].
240    ///
241    ///    [plr]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat
242    ///    [ecma]: https://www.ecma-international.org/publications/standards/Ecma-402.htm
243    fn format<W>(&self, number: f64, writer: &mut W) -> fmt::Result
244    where
245        W: fmt::Write,
246    {
247        let result = self.rep.format_double(number).map_err(|e| e.into())?;
248        let result_str: String = result.try_into().map_err(|e: common::Error| e.into())?;
249        write!(writer, "{}", result_str)
250    }
251}
252
253#[cfg(test)]
254mod testing {
255
256    use super::*;
257    use ecma402_traits::numberformat;
258    use ecma402_traits::numberformat::NumberFormat;
259    use rust_icu_uloc as uloc;
260    use std::convert::TryFrom;
261
262    #[test]
263    fn formatting() {
264        #[derive(Debug, Clone)]
265        struct TestCase {
266            locale: &'static str,
267            opts: numberformat::Options,
268            numbers: Vec<f64>,
269            expected: Vec<&'static str>,
270        }
271        let tests = vec![
272            TestCase {
273                locale: "sr-RS",
274                opts: Default::default(),
275                numbers: vec![
276                    0.0, 1.0, -1.0, 1.5, -1.5, 100.0, 1000.0, 10000.0, 123456.789,
277                ],
278                expected: vec![
279                    "0",
280                    "1",
281                    "-1",
282                    "1,5",
283                    "-1,5",
284                    "100",
285                    "1.000",
286                    "10.000",
287                    "123.456,789",
288                ],
289            },
290            TestCase {
291                locale: "de-DE",
292                opts: numberformat::Options {
293                    style: numberformat::options::Style::Currency,
294                    currency: Some("EUR".into()),
295                    ..Default::default()
296                },
297                numbers: vec![123456.789],
298                expected: vec!["123.456,79\u{a0}€"],
299            },
300            TestCase {
301                locale: "ja-JP",
302                opts: numberformat::Options {
303                    style: numberformat::options::Style::Currency,
304                    currency: Some("JPY".into()),
305                    // This is the default for JPY, but we don't consult the
306                    // currency list.
307                    minimum_fraction_digits: Some(0),
308                    maximum_fraction_digits: Some(0),
309                    ..Default::default()
310                },
311                numbers: vec![123456.789],
312                expected: vec!["¥123,457"],
313            },
314            // TODO: This ends up being a syntax error, why?
315            //TestCase {
316            //locale: "en-IN",
317            //opts: numberformat::Options {
318            //maximum_significant_digits: Some(3),
319            //..Default::default()
320            //},
321            //numbers: vec![123456.789],
322            //expected: vec!["1,23,000"],
323            //},
324        ];
325        for test in tests {
326            let locale = crate::Locale::FromULoc(
327                uloc::ULoc::try_from(test.locale).expect(&format!("locale exists: {:?}", &test)),
328            );
329            let format = crate::numberformat::NumberFormat::try_new(locale, test.clone().opts)
330                .expect(&format!("try_from should succeed: {:?}", &test));
331            let actual = test
332                .numbers
333                .iter()
334                .map(|n| {
335                    let mut result = String::new();
336                    format
337                        .format(*n, &mut result)
338                        .expect(&format!("formatting succeeded for: {:?}", &test));
339                    result
340                })
341                .collect::<Vec<String>>();
342            assert_eq!(
343                test.expected, actual,
344                "\n\tfor test case: {:?},\n\tformat: {:?}",
345                &test, &format
346            );
347        }
348    }
349}