Skip to main content

mf2_runtime/number/
decimal.rs

1//! The digit backend of the numeric semantics — the seam of owner decision 1
2//! (`plans/10-phase-3-work-order.md`, A5 and A5b).
3//!
4//! The default is this crate's own buffer ([`own`]): an exact decimal of up
5//! to [`INPUT_DIGITS`] significant digits, stored inline — `Copy`, no
6//! allocation, no panic path. Feature `fixed-decimal` swaps in `fixed_decimal`
7//! behind the same interface; it exists only as the A/B baseline.
8//!
9//! Magnitudes are powers of ten: the digit at magnitude `m` is worth `d ×
10//! 10^m`. A value is canonical: no leading or trailing zero digit is stored,
11//! so `low`/`high` are the least and most significant *nonzero* magnitudes.
12
13#[cfg(not(feature = "fixed-decimal"))]
14mod own;
15#[cfg(not(feature = "fixed-decimal"))]
16pub(crate) use own::Decimal;
17
18#[cfg(feature = "fixed-decimal")]
19mod fixed;
20#[cfg(feature = "fixed-decimal")]
21pub(crate) use fixed::Decimal;
22
23/// The most significant digits an operand may have (an implementation limit,
24/// `number.md`: past it, Unsupported Operation). i64 needs 19, f64 17.
25pub(crate) const INPUT_DIGITS: usize = 40;
26
27/// The largest exponent a `number-literal` may carry (an implementation
28/// limit; it bounds the plain output of a value to about 10,000 digits).
29pub(crate) const MAX_EXPONENT: i32 = 9999;
30
31/// The largest magnitude a value may reach (exponent + digits + slack).
32pub(crate) const MAX_MAGNITUDE: i32 = MAX_EXPONENT + 2 * 64;
33
34/// Why a `number-literal` did not become a [`Decimal`].
35#[derive(Clone, Copy, PartialEq, Eq, Debug)]
36pub(crate) enum ParseError {
37    /// Not `number-literal`: Bad Operand.
38    Syntax,
39    /// Valid, but past [`INPUT_DIGITS`] or [`MAX_EXPONENT`]: Unsupported
40    /// Operation.
41    Limit,
42}
43
44/// `roundingMode` (ECMA-402's names and meanings).
45#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
46#[non_exhaustive]
47pub enum RoundingMode {
48    /// `ceil`: towards +∞.
49    Ceil,
50    /// `floor`: towards −∞.
51    Floor,
52    /// `expand`: away from 0.
53    Expand,
54    /// `trunc`: towards 0.
55    Trunc,
56    /// `halfCeil`: to the nearest, ties towards +∞.
57    HalfCeil,
58    /// `halfFloor`: to the nearest, ties towards −∞.
59    HalfFloor,
60    /// `halfExpand` (the default): to the nearest, ties away from 0.
61    HalfExpand,
62    /// `halfTrunc`: to the nearest, ties towards 0.
63    HalfTrunc,
64    /// `halfEven`: to the nearest, ties to even.
65    HalfEven,
66}
67
68/// The mantissa of a rounding increment: every `roundingIncrement` value is
69/// `1`, `2`, `5` or `25` times a power of ten.
70#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
71pub(crate) enum Increment {
72    One,
73    Two,
74    Five,
75    TwentyFive,
76}
77
78impl Increment {
79    #[cfg_attr(feature = "fixed-decimal", allow(dead_code))]
80    pub(crate) const fn value(self) -> u8 {
81        match self {
82            Increment::One => 1,
83            Increment::Two => 2,
84            Increment::Five => 5,
85            Increment::TwentyFive => 25,
86        }
87    }
88}
89
90/// A `number-literal`, split: `["-"] int ["." frac] [e exp]`.
91pub(crate) struct Literal<'s> {
92    pub(crate) neg: bool,
93    /// The integer digits (ASCII), never empty.
94    pub(crate) int: &'s [u8],
95    /// The fraction digits (ASCII), possibly empty.
96    pub(crate) frac: &'s [u8],
97    pub(crate) exp: i32,
98}
99
100/// Checks `number-literal` (`number.md`, "Numeric Operands") and splits it.
101pub(crate) fn split_literal(b: &[u8]) -> Result<Literal<'_>, ParseError> {
102    let neg = b.first() == Some(&b'-');
103    let rest = if neg { b.get(1..).unwrap_or(&[]) } else { b };
104    let mut i = 0;
105    match rest.first() {
106        Some(b'0') => i = 1,
107        Some(b'1'..=b'9') => {
108            while rest.get(i).is_some_and(u8::is_ascii_digit) {
109                i += 1;
110            }
111        }
112        _ => return Err(ParseError::Syntax),
113    }
114    let int = rest.get(..i).unwrap_or(&[]);
115    let mut frac: &[u8] = &[];
116    if rest.get(i) == Some(&b'.') {
117        i += 1;
118        let start = i;
119        while rest.get(i).is_some_and(u8::is_ascii_digit) {
120            i += 1;
121        }
122        if i == start {
123            return Err(ParseError::Syntax);
124        }
125        frac = rest.get(start..i).unwrap_or(&[]);
126    }
127    let mut exp: i32 = 0;
128    let mut limit = false;
129    if matches!(rest.get(i), Some(b'e' | b'E')) {
130        i += 1;
131        let neg_exp = match rest.get(i) {
132            Some(b'-') => {
133                i += 1;
134                true
135            }
136            Some(b'+') => {
137                i += 1;
138                false
139            }
140            _ => false,
141        };
142        let start = i;
143        while let Some(&d) = rest.get(i).filter(|d| d.is_ascii_digit()) {
144            if !limit {
145                exp = exp * 10 + i32::from(d - b'0');
146                limit = exp > MAX_EXPONENT;
147            }
148            i += 1;
149        }
150        if i == start {
151            return Err(ParseError::Syntax);
152        }
153        if neg_exp {
154            exp = -exp;
155        }
156    }
157    if i != rest.len() {
158        return Err(ParseError::Syntax);
159    }
160    if limit {
161        return Err(ParseError::Limit);
162    }
163    Ok(Literal {
164        neg,
165        int,
166        frac,
167        exp,
168    })
169}
170
171#[cfg(test)]
172#[allow(clippy::panic)]
173mod tests {
174    use super::{Decimal, Increment, ParseError, RoundingMode};
175    use alloc::string::String;
176
177    fn plain(d: &Decimal) -> String {
178        let mut s = String::new();
179        d.write_plain(&mut s);
180        s
181    }
182
183    fn p(s: &str) -> Decimal {
184        match Decimal::parse(s.as_bytes()) {
185            Ok(d) => d,
186            Err(e) => panic!("{s}: {e:?}"),
187        }
188    }
189
190    #[test]
191    fn parse_and_print() {
192        for (src, want) in [
193            ("0", "0"),
194            ("-0", "-0"),
195            ("4.2", "4.2"),
196            ("-4.20", "-4.2"),
197            ("0.42e+1", "4.2"),
198            ("1E3", "1000"),
199            ("1e-2", "0.01"),
200            ("10", "10"),
201            ("0.000", "0"),
202            ("120.0500", "120.05"),
203            ("1e21", "1000000000000000000000"),
204            ("1.5e-7", "0.00000015"),
205        ] {
206            assert_eq!(plain(&p(src)), want, "{src}");
207        }
208        for bad in [
209            "00", "042", "1.", "1e", "1E", "1.e", "1.2e", "1.e3", "1e+", "1e-", "1.0e2.0", "foo",
210            ".1", "+1", "0x1", "", "-", "1 ", " 1",
211        ] {
212            assert_eq!(
213                Decimal::parse(bad.as_bytes()).err(),
214                Some(ParseError::Syntax),
215                "{bad}"
216            );
217        }
218        assert_eq!(Decimal::parse(b"1e10000").err(), Some(ParseError::Limit));
219        assert_eq!(plain(&p("1e0000000009")), "1000000000");
220        let long = "1234567890123456789012345678901234567890";
221        assert_eq!(plain(&p(long)), long);
222        let longer = "12345678901234567890123456789012345678901";
223        assert_eq!(
224            Decimal::parse(longer.as_bytes()).err(),
225            Some(ParseError::Limit)
226        );
227        // Leading and trailing zeros do not count.
228        assert_eq!(
229            plain(&p(
230                "0.000000000000000000001234567890123456789012345678901234567890000"
231            )),
232            "0.00000000000000000000123456789012345678901234567890123456789"
233        );
234    }
235
236    fn rounded(s: &str, pos: i16, mode: RoundingMode, inc: Increment) -> String {
237        let mut d = p(s);
238        d.round(pos, mode, inc);
239        plain(&d)
240    }
241
242    #[test]
243    fn rounding_modes() {
244        use RoundingMode::*;
245        let one = Increment::One;
246        // ECMA-402's table for roundingMode, at 0 fraction digits.
247        let table: &[(&str, [&str; 9])] = &[
248            // value   ceil  floor expand trunc hCeil hFloor hExpand hTrunc hEven
249            (
250                "-1.5",
251                ["-1", "-2", "-2", "-1", "-1", "-2", "-2", "-1", "-2"],
252            ),
253            (
254                "-0.5",
255                ["-0", "-1", "-1", "-0", "-0", "-1", "-1", "-0", "-0"],
256            ),
257            ("0.4", ["1", "0", "1", "0", "0", "0", "0", "0", "0"]),
258            ("0.5", ["1", "0", "1", "0", "1", "0", "1", "0", "0"]),
259            ("0.6", ["1", "0", "1", "0", "1", "1", "1", "1", "1"]),
260            ("1.5", ["2", "1", "2", "1", "2", "1", "2", "1", "2"]),
261            ("2.5", ["3", "2", "3", "2", "3", "2", "3", "2", "2"]),
262        ];
263        let modes = [
264            Ceil, Floor, Expand, Trunc, HalfCeil, HalfFloor, HalfExpand, HalfTrunc, HalfEven,
265        ];
266        for (v, wants) in table {
267            for (m, want) in modes.iter().zip(wants) {
268                assert_eq!(rounded(v, 0, *m, one), *want, "{v} {m:?}");
269            }
270        }
271        assert_eq!(rounded("9.995", -2, HalfExpand, one), "10");
272        assert_eq!(rounded("999.5", 0, HalfExpand, one), "1000");
273        assert_eq!(rounded("0.004", 0, Ceil, one), "1");
274        assert_eq!(rounded("0.004", 0, HalfExpand, one), "0");
275        assert_eq!(rounded("1234", 2, HalfExpand, one), "1200");
276        assert_eq!(rounded("1.25000000000000000001", -1, HalfEven, one), "1.3");
277        assert_eq!(rounded("1.25", -1, HalfEven, one), "1.2");
278        assert_eq!(rounded("1.35", -1, HalfEven, one), "1.4");
279    }
280
281    #[test]
282    fn increments() {
283        use RoundingMode::*;
284        // Multiples of 0.05, 0.25, 2, …
285        assert_eq!(rounded("1.23", -2, HalfExpand, Increment::Five), "1.25");
286        assert_eq!(rounded("1.22", -2, HalfExpand, Increment::Five), "1.2");
287        assert_eq!(rounded("1.225", -2, HalfExpand, Increment::Five), "1.25");
288        assert_eq!(rounded("1.225", -2, HalfEven, Increment::Five), "1.2");
289        assert_eq!(rounded("1.275", -2, HalfEven, Increment::Five), "1.3");
290        assert_eq!(rounded("1.3", -1, HalfExpand, Increment::TwentyFive), "2.5");
291        assert_eq!(rounded("1.1", -1, HalfExpand, Increment::TwentyFive), "0");
292        assert_eq!(rounded("1.125", -2, HalfEven, Increment::TwentyFive), "1");
293        assert_eq!(rounded("1.375", -2, HalfEven, Increment::TwentyFive), "1.5");
294        assert_eq!(rounded("3", 0, HalfEven, Increment::Two), "4");
295        assert_eq!(rounded("5", 0, HalfEven, Increment::Two), "4");
296        assert_eq!(rounded("7", 0, HalfEven, Increment::Two), "8");
297        assert_eq!(rounded("7", 0, Trunc, Increment::Two), "6");
298        assert_eq!(rounded("-7", 0, Floor, Increment::Two), "-8");
299        assert_eq!(rounded("-7", 0, Ceil, Increment::Two), "-6");
300        assert_eq!(rounded("99.9", 1, HalfExpand, Increment::Five), "100");
301        assert_eq!(rounded("0.1", 2, Ceil, Increment::TwentyFive), "2500");
302        assert_eq!(rounded("12", -3, HalfExpand, Increment::Five), "12");
303    }
304
305    #[test]
306    fn addition() {
307        let add = |a: &str, b: &str| {
308            let d = p(a).add(&p(b)).map(|d| plain(&d));
309            d.unwrap_or_default()
310        };
311        assert_eq!(add("41", "1"), "42");
312        assert_eq!(add("52", "-10"), "42");
313        assert_eq!(add("1", "-3"), "-2");
314        assert_eq!(add("-1.5", "2"), "0.5");
315        assert_eq!(add("0.25", "-1"), "-0.75");
316        assert_eq!(add("999", "99"), "1098");
317        assert_eq!(add("-0", "0"), "0");
318        assert_eq!(add("1", "-1"), "0");
319        // The own buffer adds exactly up to 40 digits; `fixed_decimal`'s
320        // baseline (P0.5's `add_small`) only through `i64`.
321        #[cfg(not(feature = "fixed-decimal"))]
322        assert_eq!(add("1e30", "1"), "1000000000000000000000000000001");
323        assert!(p("1e45").add(&p("1")).is_none());
324    }
325}