french_numbers/
lib.rs

1//! This crate transforms a number into its French representation
2
3#![deny(missing_docs)]
4#![allow(clippy::non_ascii_literal)]
5#![doc = include_str!("../README.md")]
6
7use num_integer::Integer;
8use num_traits::{CheckedMul, FromPrimitive, ToPrimitive};
9use std::fmt::Display;
10
11/// Options for French number representation
12#[derive(Debug)]
13pub struct Options {
14    /// Set to `true` to get a feminine declination (default `false`).
15    /// This only affects numbers ending in 1.
16    pub feminine: bool,
17    /// Set to `false` to prevent hyphens from being inserted between
18    /// literals greater than 100 (default `true`). This corresponds
19    /// to the way of writing predating the 1990 orthographic reform.
20    pub reformed: bool,
21}
22
23/// Pre 1990 reform masculine variant.
24pub static PRE_REFORM_MASCULINE: Options = Options {
25    feminine: false,
26    reformed: false,
27};
28
29/// Pre 1990 reform feminine variant.
30pub static PRE_REFORM_FEMININE: Options = Options {
31    feminine: true,
32    reformed: false,
33};
34
35/// Post 1990 reform masculine variant. This is the default.
36pub static POST_REFORM_MASCULINE: Options = Options {
37    feminine: false,
38    reformed: true,
39};
40
41/// Post 1990 reform feminine variant.
42pub static POST_REFORM_FEMININE: Options = Options {
43    feminine: true,
44    reformed: true,
45};
46
47#[allow(clippy::derivable_impls)] // Clippy wrongly suggest that this Default trait can be derived
48impl Default for Options {
49    fn default() -> Options {
50        Options {
51            ..POST_REFORM_MASCULINE
52        }
53    }
54}
55
56impl Options {
57    fn masculinize(&self) -> Self {
58        Options {
59            feminine: false,
60            ..*self
61        }
62    }
63}
64
65fn literal_for(value: usize, options: &Options) -> Option<String> {
66    static SMALLS: [&str; 21] = [
67        "zéro", "un", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf", "dix",
68        "onze", "douze", "treize", "quatorze", "quinze", "seize", "dix-sept", "dix-huit",
69        "dix-neuf", "vingt",
70    ];
71    let literal = if value == 1 && options.feminine {
72        Some("une")
73    } else if value <= 20 {
74        Some(SMALLS[value])
75    } else if value == 30 {
76        Some("trente")
77    } else if value == 40 {
78        Some("quarante")
79    } else if value == 50 {
80        Some("cinquante")
81    } else if value == 60 {
82        Some("soixante")
83    } else if value == 71 {
84        Some(if options.reformed {
85            "soixante-et-onze"
86        } else {
87            "soixante et onze"
88        })
89    } else if value == 80 {
90        Some("quatre-vingts")
91    } else if value == 81 {
92        Some(if options.feminine {
93            "quatre-vingt-une"
94        } else {
95            "quatre-vingt-un"
96        })
97    } else if value == 100 {
98        Some("cent")
99    } else if value == 1000 {
100        Some("mille")
101    } else {
102        None
103    };
104    literal.map(String::from)
105}
106
107fn add_unit_for(str: &mut String, prefix_count: usize, log1000: usize) -> bool {
108    static PREFIXES: [&str; 16] = [
109        "m",
110        "b",
111        "tr",
112        "quadr",
113        "quint",
114        "sext",
115        "sept",
116        "oct",
117        "non",
118        "déc",
119        "unodéc",
120        "duodéc",
121        "trédéc",
122        "quattuordéc",
123        "quindéc",
124        "sexdéc",
125    ];
126    PREFIXES.get(log1000 / 2).map_or(false, |prefix| {
127        str.push_str(prefix);
128        if log1000 % 2 == 0 {
129            str.push_str("illion");
130        } else {
131            str.push_str("illiard");
132        }
133        if prefix_count > 1 {
134            str.push('s');
135        }
136        true
137    })
138}
139
140fn unpluralize(str: &mut String) {
141    if str.ends_with("ts") {
142        str.truncate(str.len() - 1);
143    }
144}
145
146fn complete(mut str: String, n: usize, prefix_under_100: bool, options: &Options) -> String {
147    if n > 0 {
148        unpluralize(&mut str);
149        if n == 1 {
150            if prefix_under_100 && options.reformed {
151                str.push_str("-et-un");
152            } else if prefix_under_100 {
153                str.push_str(" et un");
154            } else if options.reformed {
155                str.push_str("-un");
156            } else {
157                str.push_str(" un");
158            }
159            if options.feminine {
160                str.push('e');
161            }
162        } else {
163            str.push(if options.reformed || (prefix_under_100 && n < 100) {
164                '-'
165            } else {
166                ' '
167            });
168            str.push_str(&basic(&n, options, false));
169        }
170    }
171    str
172}
173
174fn basic<N: Integer + FromPrimitive + ToPrimitive + Display>(
175    n: &N,
176    options: &Options,
177    negative: bool,
178) -> String {
179    n.to_usize()
180        .and_then(|n| {
181            literal_for(n, options).or_else(|| match n {
182                n if n < 60 => Some(smaller_than_60(n, options)),
183                n if n < 80 => Some(base_onto(60, n, options)),
184                n if n < 100 => Some(base_onto(80, n, options)),
185                n if n < 1000 => Some(smaller_than_1000(n, options)),
186                n if n < 2000 => Some(smaller_than_2000(n, options)),
187                n if n < 1_000_000 => Some(smaller_than_1000000(n, options)),
188                _ => None,
189            })
190        })
191        .map_or_else(
192            || over_1000000(n, options, negative),
193            |s| add_minus(s, negative),
194        )
195}
196
197fn smaller_than_60(n: usize, options: &Options) -> String {
198    let unit = n % 10;
199    complete(
200        basic(&(n - unit), &Options::default(), false),
201        unit,
202        true,
203        options,
204    )
205}
206
207fn base_onto(b: usize, n: usize, options: &Options) -> String {
208    complete(literal_for(b, options).unwrap(), n - b, true, options)
209}
210
211fn smaller_than_1000(n: usize, options: &Options) -> String {
212    let (hundredths, rest) = n.div_rem(&100);
213    let result = if hundredths > 1 {
214        let mut prefix = literal_for(hundredths, options).unwrap();
215        push_space_or_dash(&mut prefix, options);
216        prefix.push_str("cents");
217        prefix
218    } else {
219        String::from("cent")
220    };
221    complete(result, rest, false, options)
222}
223
224fn smaller_than_2000(n: usize, options: &Options) -> String {
225    complete(String::from("mille"), n - 1000, false, options)
226}
227
228fn push_space_or_dash(str: &mut String, options: &Options) {
229    str.push(if options.reformed { '-' } else { ' ' });
230}
231
232fn smaller_than_1000000(n: usize, options: &Options) -> String {
233    let (thousands, rest) = n.div_rem(&1000);
234    let prefix = if thousands > 1 {
235        let mut thousands = basic(&thousands, &options.masculinize(), false);
236        unpluralize(&mut thousands);
237        push_space_or_dash(&mut thousands, options);
238        thousands.push_str("mille");
239        thousands
240    } else {
241        String::from("mille")
242    };
243    complete(prefix, rest, false, options)
244}
245
246fn over_1000000<N: Integer + FromPrimitive + ToPrimitive + Display>(
247    n: &N,
248    options: &Options,
249    negative: bool,
250) -> String {
251    let thousand = N::from_u32(1000).unwrap();
252    let (mut num, small) = n.div_rem(&N::from_u32(1_000_000).unwrap());
253    let mut base = if small == N::zero() {
254        None
255    } else {
256        Some(basic(&small, options, false))
257    };
258    let mut log1000 = 0;
259    while num != N::zero() {
260        let (rest, prefix) = num.div_rem(&thousand);
261        let prefix = prefix.to_usize().unwrap();
262        if prefix > 0 {
263            let mut str = basic(&prefix, &options.masculinize(), false);
264            push_space_or_dash(&mut str, options);
265            if !add_unit_for(&mut str, prefix, log1000) {
266                return add_minus_digits(n, negative);
267            }
268            if let Some(base) = base {
269                push_space_or_dash(&mut str, options);
270                str.push_str(&base);
271            }
272            base = Some(str);
273        }
274        log1000 += 1;
275        num = rest;
276    }
277    base.map_or_else(|| add_minus_digits(n, negative), |s| add_minus(s, negative))
278}
279
280fn add_minus(s: String, negative: bool) -> String {
281    if negative {
282        format!("moins {s}")
283    } else {
284        s
285    }
286}
287
288fn add_minus_digits<N>(n: N, negative: bool) -> String
289where
290    N: Display,
291{
292    if negative {
293        format!("-{n}")
294    } else {
295        n.to_string()
296    }
297}
298
299/// Compute the French language representation of the given number.
300///
301/// If the number is too large (greater than 10^103), then its numerical
302/// representation is returned with a leading minus sign if needed.
303///
304/// Also, the smallest number of a bounded signed numerical type will be
305/// returned as a numerical representation because the opposite value
306/// cannot be computed. For example, `-128u8` will be shown as `-128`.
307///
308/// By default, the masculine declination is used, as well as the preferred
309/// orthographic form introduced in the 1990 reform (use hyphens everywhere).
310/// See `french_number_options` if you wish to change either of those options.
311///
312/// # Example
313///
314/// ```
315/// use french_numbers::french_number;
316///
317/// assert_eq!(french_number(&71), "soixante-et-onze");
318/// assert_eq!(french_number(&1001), "mille-un");
319/// assert_eq!(french_number(&-200001), "moins deux-cent-mille-un");
320/// assert_eq!(french_number(&-200000001), "moins deux-cents-millions-un");
321/// assert_eq!(french_number(&-204000001), "moins deux-cent-quatre-millions-un");
322/// ```
323pub fn french_number<N: Integer + FromPrimitive + ToPrimitive + Display + CheckedMul>(
324    n: &N,
325) -> String {
326    french_number_options(n, &Options::default())
327}
328
329/// Compute the French language representation of the given number with
330/// the given formatting options.
331///
332/// If the number is too large (greater than 10^103), then its numerical
333/// representation is returned with a leading minus sign if needed.
334///
335/// Also, the smallest number of a bounded signed numerical type will be
336/// returned as a numerical representation because the opposite value
337/// cannot be computed. For example, `-128u8` will be shown as `-128`.
338///
339/// # Example
340///
341/// ```
342/// use french_numbers::*;
343///
344/// assert_eq!(french_number_options(&37251061, &POST_REFORM_MASCULINE),
345///            "trente-sept-millions-deux-cent-cinquante-et-un-mille-soixante-et-un");
346/// assert_eq!(french_number_options(&37251061, &POST_REFORM_FEMININE),
347///            "trente-sept-millions-deux-cent-cinquante-et-un-mille-soixante-et-une");
348/// assert_eq!(french_number_options(&37251061, &PRE_REFORM_FEMININE),
349///            "trente-sept millions deux cent cinquante et un mille soixante et une");
350/// assert_eq!(french_number_options(&37251061, &PRE_REFORM_MASCULINE),
351///            "trente-sept millions deux cent cinquante et un mille soixante et un")
352/// ```
353pub fn french_number_options<N: Integer + FromPrimitive + ToPrimitive + Display + CheckedMul>(
354    n: &N,
355    options: &Options,
356) -> String {
357    if *n < N::zero() {
358        // Take the absolute value of n without consuming it. Since n is negative, we know that
359        // we can build the -1 constant. However, the positive value may not be properly
360        // representable with this type.
361        N::from_i8(-1)
362            .and_then(|m1| m1.checked_mul(n))
363            .map_or_else(|| n.to_string(), |n| basic(&n, options, true))
364    } else {
365        basic(n, options, false)
366    }
367}
368
369#[cfg(test)]
370mod tests {
371
372    use crate::{add_unit_for, basic, literal_for, unpluralize};
373
374    #[test]
375    fn test_literal_for() {
376        assert_eq!(
377            literal_for(30, &Default::default()),
378            Some(String::from("trente"))
379        );
380        assert_eq!(literal_for(31, &Default::default()), None);
381    }
382
383    #[test]
384    fn test_add_unit_for() {
385        let mut str = String::new();
386        assert!(add_unit_for(&mut str, 1, 0));
387        assert_eq!(str, "million");
388        str.clear();
389        assert!(add_unit_for(&mut str, 2, 0));
390        assert_eq!(str, "millions");
391        str.clear();
392        assert!(add_unit_for(&mut str, 1, 3));
393        assert_eq!(str, "billiard");
394        assert!(!add_unit_for(&mut str, 1, 97));
395    }
396
397    #[test]
398    fn test_unpluralize() {
399        let mut s = String::from("quatre-cents");
400        unpluralize(&mut s);
401        assert_eq!(s, "quatre-cent");
402        let mut s = String::from("cent");
403        unpluralize(&mut s);
404        assert_eq!(s, "cent");
405    }
406
407    #[test]
408    fn test_basic() {
409        assert_eq!(basic(&0, &Default::default(), false), "zéro");
410        assert_eq!(basic(&21, &Default::default(), false), "vingt-et-un");
411        assert_eq!(basic(&54, &Default::default(), false), "cinquante-quatre");
412        assert_eq!(basic(&64, &Default::default(), false), "soixante-quatre");
413        assert_eq!(basic(&71, &Default::default(), false), "soixante-et-onze");
414        assert_eq!(basic(&72, &Default::default(), false), "soixante-douze");
415        assert_eq!(basic(&80, &Default::default(), false), "quatre-vingts");
416        assert_eq!(basic(&81, &Default::default(), false), "quatre-vingt-un");
417        assert_eq!(basic(&91, &Default::default(), false), "quatre-vingt-onze");
418        assert_eq!(basic(&101, &Default::default(), false), "cent-un");
419        assert_eq!(basic(&800, &Default::default(), false), "huit-cents");
420        assert_eq!(basic(&803, &Default::default(), false), "huit-cent-trois");
421        assert_eq!(
422            basic(&872, &Default::default(), false),
423            "huit-cent-soixante-douze"
424        );
425        assert_eq!(
426            basic(&880, &Default::default(), false),
427            "huit-cent-quatre-vingts"
428        );
429        assert_eq!(
430            basic(&882, &Default::default(), false),
431            "huit-cent-quatre-vingt-deux"
432        );
433        assert_eq!(basic(&1001, &Default::default(), false), "mille-un");
434        assert_eq!(
435            basic(&1882, &Default::default(), false),
436            "mille-huit-cent-quatre-vingt-deux"
437        );
438        assert_eq!(basic(&2001, &Default::default(), false), "deux-mille-un");
439        assert_eq!(
440            basic(&300_001, &Default::default(), false),
441            "trois-cent-mille-un"
442        );
443        assert_eq!(
444            basic(&180_203, &Default::default(), false),
445            "cent-quatre-vingt-mille-deux-cent-trois"
446        );
447        assert_eq!(
448            basic(&180_203, &Default::default(), false),
449            "cent-quatre-vingt-mille-deux-cent-trois"
450        );
451        assert_eq!(
452            basic(&17_180_203, &Default::default(), false),
453            "dix-sept-millions-cent-quatre-vingt-mille-deux-cent-trois"
454        );
455    }
456}