Skip to main content

decimal_scaled/support/
display.rs

1// SPDX-FileCopyrightText: 2026 John Moxley
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! [`core::fmt`] formatters and [`core::str::FromStr`] for the decimal
5//! widths. `Display` / `Debug` and `FromStr` are emitted per width via
6//! [`crate::macros::display::decl_decimal_display!`] and
7//! [`crate::macros::from_str::decl_decimal_from_str!`]; the
8//! scientific-notation formatters ([`fmt::LowerExp`] / [`fmt::UpperExp`])
9//! and the shared [`parse_components`] front-end live here as single
10//! generic implementations covering every width.
11//!
12//! # Parser factoring
13//!
14//! [`parse_components`] is the shared string-parsing front-end (sign /
15//! dot / digit-character validation, plus the overlong-fractional and
16//! leading-zero checks). The arithmetic accumulator that turns the
17//! integer / fractional digit slices into a storage value is emitted
18//! per-storage by [`crate::macros::from_str::decl_decimal_from_str!`]'s
19//! `wide` arm: the base-10 accumulation happens directly in the storage
20//! type so the `10^SCALE` multiplier never overflows even at SCALE = 1230.
21//!
22//! # Display format
23//!
24//! [`fmt::Display`] formats as a base-10 decimal literal: integer digits,
25//! a `.`, then exactly `SCALE` fractional digits (trailing zeros are always
26//! emitted). At `SCALE = 12`, `1.5` displays as `1.500000000000`. The output
27//! is bit-faithful: parsing it back through [`core::str::FromStr`] returns
28//! the identical storage value.
29//!
30//! # Debug format
31//!
32//! [`fmt::Debug`] wraps the [`fmt::Display`] output with a scale annotation:
33//! `D38<SCALE>(...)`. This replaces the default derived format, which would
34//! show only the raw `i128` storage.
35//!
36//! # Scientific notation
37//!
38//! [`fmt::LowerExp`] and [`fmt::UpperExp`] emit scientific notation (`1.5e0`
39//! / `1.5E0`). Trailing zeros in the mantissa are stripped. Unlike
40//! `Display` / `Debug` (emitted per width by the macro so `Debug` can name
41//! its concrete type), these are a SINGLE generic `impl` over
42//! `D<Int<N>, SCALE>` covering every width: scientific notation reshapes the
43//! magnitude's decimal digit string and places the point per the const
44//! `SCALE`, independent of limb count, so one generic kernel serves all
45//! tiers.
46//!
47//! # Storage-level radix formats
48//!
49//! [`fmt::LowerHex`], [`fmt::UpperHex`], [`fmt::Octal`], and [`fmt::Binary`]
50//! format the **raw `i128` storage** (= `value * 10^SCALE`), not the decimal
51//! value. For example, `D38s12::ONE` (storage `10^12`) prints in lower-hex
52//! as `e8d4a51000`.
53//!
54//! # `FromStr`
55//!
56//! Parses canonical decimal literals. Accepted forms:
57//! - Integer-only: `42` parses as `42 * 10^SCALE`.
58//! - Decimal with up to `SCALE` fractional digits: `1.5`, `1.500`.
59//! - Excess fractional digits that are all zeros, since they carry no
60//! value: `1.00` and `1.000` parse at any scale — including `SCALE = 0`,
61//! where both are exactly `1`.
62//! - Optional sign prefix: `-` or `+`.
63//! - Bare zero: `0` or `0.0`.
64//!
65//! Rejected forms (with the corresponding [`ParseError`] variant):
66//! - Empty string: [`ParseError::Empty`].
67//! - Sign with no digits: [`ParseError::SignOnly`].
68//! - Redundant leading zeros (`01`, `00`): [`ParseError::LeadingZero`].
69//! - More than `SCALE` *significant* fractional digits — i.e. digits past
70//! `SCALE` that are not all zeros, such as `1.05` at `SCALE = 0`:
71//! [`ParseError::OverlongFractional`].
72//! - Scientific notation (`1e3`): [`ParseError::ScientificNotation`].
73//! - Missing digits on either side of the point (`.5`, `5.`):
74//! [`ParseError::MissingDigits`].
75//! - Non-digit, non-sign, non-dot characters: [`ParseError::InvalidChar`].
76//! - Magnitudes outside `[D38::MIN, D38::MAX]`: [`ParseError::OutOfRange`].
77
78use core::fmt;
79
80use crate::int::types::Int;
81use crate::int::types::compute_limbs::{ComputeLimbs, Limbs};
82use crate::support::int_fmt::fmt_into;
83use crate::types::widths::ParseError;
84
85#[cfg(feature = "alloc")]
86extern crate alloc;
87
88// ──────────────────────────────────────────────────────────────────────
89// Display and Debug are emitted by the `decl_decimal_display!` macro
90// invoked from `types/widths.rs`; the macro itself lives in
91// `src/macros/display.rs` and handles all widths uniformly.
92// ──────────────────────────────────────────────────────────────────────
93
94// ──────────────────────────────────────────────────────────────────────
95// LowerExp / UpperExp -- scientific notation
96//
97// A SINGLE generic blanket over `D<Int<N>, SCALE>` (the same shape as the
98// `PartialEq` / `Ord` blankets in `types/unified.rs`), so every width gets
99// `{:e}` / `{:E}` from one source. Scientific notation reshapes the
100// magnitude's decimal digit string and places the point per the const
101// `SCALE`; it is independent of limb count, so no per-tier impl is needed.
102// `Debug` stays macro-emitted because it must name its concrete type; these
103// formatters name nothing, so the generic impl is sound and collision-free.
104// ──────────────────────────────────────────────────────────────────────
105
106impl<const N: usize, const SCALE: u32> fmt::LowerExp for crate::D<Int<N>, SCALE>
107where
108    Limbs<N>: ComputeLimbs,
109{
110    /// Formats the value in scientific notation with a lowercase `e`.
111    ///
112    /// Trailing zeros in the mantissa are stripped, so `1.500000000000`
113    /// formats as `1.5e0`. Zero formats as `0e0`.
114    ///
115    /// # Precision
116    ///
117    /// Strict: all arithmetic is integer-only; result is bit-exact.
118    ///
119    /// # Examples
120    ///
121    /// ```
122    /// use decimal_scaled::D38s12;
123    ///
124    /// let v = D38s12::from_bits(decimal_scaled::Int::<2>::try_from(1_500_000_000_000_i128).unwrap());
125    /// assert_eq!(format!("{v:e}"), "1.5e0");
126    ///
127    /// let sub = D38s12::from_bits(decimal_scaled::Int::<2>::try_from(1_500_000_000_i128).unwrap());
128    /// assert_eq!(format!("{sub:e}"), "1.5e-3");
129    /// ```
130    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
131        format_exp::<N>(self.0, SCALE, false, formatter)
132    }
133}
134
135impl<const N: usize, const SCALE: u32> fmt::UpperExp for crate::D<Int<N>, SCALE>
136where
137    Limbs<N>: ComputeLimbs,
138{
139    /// Formats the value in scientific notation with an uppercase `E`.
140    ///
141    /// Identical to [`fmt::LowerExp`] except the exponent separator is `E`.
142    ///
143    /// # Precision
144    ///
145    /// Strict: all arithmetic is integer-only; result is bit-exact.
146    ///
147    /// # Examples
148    ///
149    /// ```
150    /// use decimal_scaled::D38s12;
151    ///
152    /// let v = D38s12::from_bits(decimal_scaled::Int::<2>::try_from(1_500_000_000_000_i128).unwrap());
153    /// assert_eq!(format!("{v:E}"), "1.5E0");
154    /// ```
155    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
156        format_exp::<N>(self.0, SCALE, true, formatter)
157    }
158}
159
160/// Shared scientific-notation kernel for `LowerExp` and `UpperExp`, generic
161/// over the storage width `N`.
162///
163/// Extracts the magnitude's decimal digit string via the same per-width
164/// [`fmt_into`] path that `Int<N>` / `Uint<N>`'s `Display` use — writing into
165/// the per-`N` `digit_formatting_limbs_u8` stack buffer, so no heap is
166/// touched — then places the decimal point after the leading digit and emits
167/// the exponent `(digits − 1) − SCALE`. Width-agnostic: the reshaping only
168/// sees the digit string and the scale.
169///
170/// # Precision
171///
172/// Strict: all arithmetic is integer-only; result is bit-exact.
173fn format_exp<const N: usize>(
174    value: Int<N>,
175    scale: u32,
176    uppercase: bool,
177    formatter: &mut fmt::Formatter<'_>,
178) -> fmt::Result
179where
180    Limbs<N>: ComputeLimbs,
181{
182    let exp_char = if uppercase { 'E' } else { 'e' };
183    if value.is_zero() {
184        return write!(formatter, "0{exp_char}0");
185    }
186    let negative = value.is_negative();
187
188    // Decimal digits of the magnitude, MSB-first with no leading zeros,
189    // written into the per-`N` formatting buffer (the identical extraction
190    // path the integer `Display` impls take — pure stack, no heap).
191    let magnitude = *value.unsigned_abs().as_limbs();
192    let mut buf = Limbs::<N>::digit_formatting_limbs_u8();
193    let digits = fmt_into::<N>(&magnitude, 10, true, buf.as_mut()).as_bytes();
194    let digit_count = digits.len();
195
196    // The decimal exponent for the leading digit is `(len - 1) - scale`.
197    let exp: i32 = (digit_count as i32 - 1) - scale as i32;
198
199    // Strip trailing zeros from the mantissa digit string.
200    let mut frac_end = digit_count;
201    while frac_end > 1 && digits[frac_end - 1] == b'0' {
202        frac_end -= 1;
203    }
204    let mantissa_int = digits[0] as char;
205    let mantissa_frac = &digits[1..frac_end];
206
207    if negative {
208        formatter.write_str("-")?;
209    }
210    if mantissa_frac.is_empty() {
211        // Single-digit mantissa: emit without a decimal point.
212        write!(formatter, "{mantissa_int}{exp_char}{exp}")
213    } else {
214        // mantissa_frac contains only ASCII digit bytes; from_utf8 cannot fail.
215        let frac_str = core::str::from_utf8(mantissa_frac).map_err(|_| fmt::Error)?;
216        write!(formatter, "{mantissa_int}.{frac_str}{exp_char}{exp}")
217    }
218}
219
220// ──────────────────────────────────────────────────────────────────────
221// `ParseError`'s `Display` and `Error` impls live in `src/error.rs`.
222
223/// Outcome of the string-parsing front-end: sign and the integer / fractional
224/// digit slices. Both byte slices contain only ASCII digits.
225///
226/// Centralises the sign / dot / digit-character state machine so the
227/// per-storage accumulators (`i128` for the narrow tier; the wide signed
228/// integers emitted via the from-str macro for the wide tier) only need
229/// to do the base-10 arithmetic.
230pub(crate) struct ParseComponents<'a> {
231    pub negative: bool,
232    pub int_str: &'a [u8],
233    pub frac_str: &'a [u8],
234}
235
236/// String-parsing front-end shared by every width.
237///
238/// Validates and splits the input into sign / integer-digits / fractional-
239/// digits. The `SCALE` parameter is needed only to bound the fractional part:
240/// value-free trailing zeros past `SCALE` are trimmed off, and a fractional
241/// part still longer than `SCALE` after that is rejected as overlong. No
242/// arithmetic happens here, so wide-tier callers can drive their own
243/// storage-typed accumulator without overflow risk.
244///
245/// The returned `frac_str` is therefore never longer than `SCALE`, which the
246/// accumulators rely on when they zero-pad it back out to `SCALE` digits.
247///
248/// # Precision
249///
250/// Strict: integer-only string slicing; no arithmetic.
251pub(crate) fn parse_components<const SCALE: u32>(
252    input: &str,
253) -> Result<ParseComponents<'_>, ParseError> {
254    if input.is_empty() {
255        return Err(ParseError::Empty);
256    }
257
258    let bytes = input.as_bytes();
259    let mut idx = 0usize;
260
261    // Consume an optional leading sign byte.
262    let negative = match bytes[0] {
263        b'-' => {
264            idx += 1;
265            true
266        }
267        b'+' => {
268            idx += 1;
269            false
270        }
271        _ => false,
272    };
273    if idx == bytes.len() {
274        // Sign byte with nothing following it.
275        return Err(ParseError::SignOnly);
276    }
277
278    // Single forward pass: locate the decimal point; reject scientific
279    // notation and invalid characters immediately.
280    let mut dot_pos: Option<usize> = None;
281    {
282        let mut scan_pos = idx;
283        while scan_pos < bytes.len() {
284            let byte = bytes[scan_pos];
285            match byte {
286                b'0'..=b'9' => {}
287                b'.' => {
288                    if dot_pos.is_some() {
289                        // A second dot is an invalid character, not a
290                        // missing-digit case.
291                        return Err(ParseError::InvalidChar);
292                    }
293                    dot_pos = Some(scan_pos);
294                }
295                b'e' | b'E' => {
296                    return Err(ParseError::ScientificNotation);
297                }
298                _ => return Err(ParseError::InvalidChar),
299            }
300            scan_pos += 1;
301        }
302    }
303
304    let (int_str, mut frac_str) = match dot_pos {
305        Some(dot_index) => (&bytes[idx..dot_index], &bytes[dot_index + 1..]),
306        None => (&bytes[idx..], &[][..]),
307    };
308
309    if dot_pos.is_some() {
310        // Both sides of the dot must have at least one digit.
311        if int_str.is_empty() || frac_str.is_empty() {
312            return Err(ParseError::MissingDigits);
313        }
314    } else if int_str.is_empty() {
315        return Err(ParseError::SignOnly);
316    }
317
318    // Allow `0` and `0.x` but reject `00`, `01`, `01.5`.
319    if int_str.len() > 1 && int_str[0] == b'0' {
320        return Err(ParseError::LeadingZero);
321    }
322
323    // Fractional digits past SCALE are acceptable when every one of them is
324    // a zero: a trailing zero carries no value, so `1.00` at SCALE = 0 is
325    // exactly `1` and round-trips losing nothing. Trim that excess — the
326    // accumulator pads back to SCALE, so the parsed value is unchanged, and
327    // it relies on `frac_str` never exceeding SCALE. Whatever remains past
328    // SCALE is a significant digit, i.e. a genuine loss of precision.
329    while frac_str.len() > SCALE as usize && frac_str.last() == Some(&b'0') {
330        frac_str = &frac_str[..frac_str.len() - 1];
331    }
332    if frac_str.len() > SCALE as usize {
333        return Err(ParseError::OverlongFractional);
334    }
335
336    Ok(ParseComponents {
337        negative,
338        int_str,
339        frac_str,
340    })
341}