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//! - Optional sign prefix: `-` or `+`.
60//! - Bare zero: `0` or `0.0`.
61//!
62//! Rejected forms (with the corresponding [`ParseError`] variant):
63//! - Empty string: [`ParseError::Empty`].
64//! - Sign with no digits: [`ParseError::SignOnly`].
65//! - Redundant leading zeros (`01`, `00`): [`ParseError::LeadingZero`].
66//! - More than `SCALE` fractional digits: [`ParseError::OverlongFractional`].
67//! - Scientific notation (`1e3`): [`ParseError::ScientificNotation`].
68//! - Missing digits on either side of the point (`.5`, `5.`):
69//! [`ParseError::MissingDigits`].
70//! - Non-digit, non-sign, non-dot characters: [`ParseError::InvalidChar`].
71//! - Magnitudes outside `[D38::MIN, D38::MAX]`: [`ParseError::OutOfRange`].
72
73use core::fmt;
74
75use crate::int::types::Int;
76use crate::int::types::compute_limbs::{ComputeLimbs, Limbs};
77use crate::support::int_fmt::fmt_into;
78use crate::types::widths::ParseError;
79
80#[cfg(feature = "alloc")]
81extern crate alloc;
82
83// ──────────────────────────────────────────────────────────────────────
84// Display and Debug are emitted by the `decl_decimal_display!` macro
85// invoked from `types/widths.rs`; the macro itself lives in
86// `src/macros/display.rs` and handles all widths uniformly.
87// ──────────────────────────────────────────────────────────────────────
88
89// ──────────────────────────────────────────────────────────────────────
90// LowerExp / UpperExp -- scientific notation
91//
92// A SINGLE generic blanket over `D<Int<N>, SCALE>` (the same shape as the
93// `PartialEq` / `Ord` blankets in `types/unified.rs`), so every width gets
94// `{:e}` / `{:E}` from one source. Scientific notation reshapes the
95// magnitude's decimal digit string and places the point per the const
96// `SCALE`; it is independent of limb count, so no per-tier impl is needed.
97// `Debug` stays macro-emitted because it must name its concrete type; these
98// formatters name nothing, so the generic impl is sound and collision-free.
99// ──────────────────────────────────────────────────────────────────────
100
101impl<const N: usize, const SCALE: u32> fmt::LowerExp for crate::D<Int<N>, SCALE>
102where
103    Limbs<N>: ComputeLimbs,
104{
105    /// Formats the value in scientific notation with a lowercase `e`.
106    ///
107    /// Trailing zeros in the mantissa are stripped, so `1.500000000000`
108    /// formats as `1.5e0`. Zero formats as `0e0`.
109    ///
110    /// # Precision
111    ///
112    /// Strict: all arithmetic is integer-only; result is bit-exact.
113    ///
114    /// # Examples
115    ///
116    /// ```
117    /// use decimal_scaled::D38s12;
118    ///
119    /// let v = D38s12::from_bits(decimal_scaled::Int::<2>::try_from(1_500_000_000_000_i128).unwrap());
120    /// assert_eq!(format!("{v:e}"), "1.5e0");
121    ///
122    /// let sub = D38s12::from_bits(decimal_scaled::Int::<2>::try_from(1_500_000_000_i128).unwrap());
123    /// assert_eq!(format!("{sub:e}"), "1.5e-3");
124    /// ```
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        format_exp::<N>(self.0, SCALE, false, f)
127    }
128}
129
130impl<const N: usize, const SCALE: u32> fmt::UpperExp for crate::D<Int<N>, SCALE>
131where
132    Limbs<N>: ComputeLimbs,
133{
134    /// Formats the value in scientific notation with an uppercase `E`.
135    ///
136    /// Identical to [`fmt::LowerExp`] except the exponent separator is `E`.
137    ///
138    /// # Precision
139    ///
140    /// Strict: all arithmetic is integer-only; result is bit-exact.
141    ///
142    /// # Examples
143    ///
144    /// ```
145    /// use decimal_scaled::D38s12;
146    ///
147    /// let v = D38s12::from_bits(decimal_scaled::Int::<2>::try_from(1_500_000_000_000_i128).unwrap());
148    /// assert_eq!(format!("{v:E}"), "1.5E0");
149    /// ```
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        format_exp::<N>(self.0, SCALE, true, f)
152    }
153}
154
155/// Shared scientific-notation kernel for `LowerExp` and `UpperExp`, generic
156/// over the storage width `N`.
157///
158/// Extracts the magnitude's decimal digit string via the same per-width
159/// [`fmt_into`] path that `Int<N>` / `Uint<N>`'s `Display` use — writing into
160/// the per-`N` `digit_formatting_limbs_u8` stack buffer, so no heap is
161/// touched — then places the decimal point after the leading digit and emits
162/// the exponent `(digits − 1) − SCALE`. Width-agnostic: the reshaping only
163/// sees the digit string and the scale.
164///
165/// # Precision
166///
167/// Strict: all arithmetic is integer-only; result is bit-exact.
168fn format_exp<const N: usize>(
169    value: Int<N>,
170    scale: u32,
171    upper: bool,
172    f: &mut fmt::Formatter<'_>,
173) -> fmt::Result
174where
175    Limbs<N>: ComputeLimbs,
176{
177    let exp_char = if upper { 'E' } else { 'e' };
178    if value.is_zero() {
179        return write!(f, "0{exp_char}0");
180    }
181    let negative = value.is_negative();
182
183    // Decimal digits of the magnitude, MSB-first with no leading zeros,
184    // written into the per-`N` formatting buffer (the identical extraction
185    // path the integer `Display` impls take — pure stack, no heap).
186    let mag = *value.unsigned_abs().as_limbs();
187    let mut buf = Limbs::<N>::digit_formatting_limbs_u8();
188    let digits = fmt_into::<N>(&mag, 10, true, buf.as_mut()).as_bytes();
189    let len = digits.len();
190
191    // The decimal exponent for the leading digit is `(len - 1) - scale`.
192    let exp: i32 = (len as i32 - 1) - scale as i32;
193
194    // Strip trailing zeros from the mantissa digit string.
195    let mut frac_end = len;
196    while frac_end > 1 && digits[frac_end - 1] == b'0' {
197        frac_end -= 1;
198    }
199    let mantissa_int = digits[0] as char;
200    let mantissa_frac = &digits[1..frac_end];
201
202    if negative {
203        f.write_str("-")?;
204    }
205    if mantissa_frac.is_empty() {
206        // Single-digit mantissa: emit without a decimal point.
207        write!(f, "{mantissa_int}{exp_char}{exp}")
208    } else {
209        // mantissa_frac contains only ASCII digit bytes; from_utf8 cannot fail.
210        let frac_str = core::str::from_utf8(mantissa_frac).map_err(|_| fmt::Error)?;
211        write!(f, "{mantissa_int}.{frac_str}{exp_char}{exp}")
212    }
213}
214
215// ──────────────────────────────────────────────────────────────────────
216// `ParseError`'s `Display` and `Error` impls live in `src/error.rs`.
217
218/// Outcome of the string-parsing front-end: sign and the integer / fractional
219/// digit slices. Both byte slices contain only ASCII digits.
220///
221/// Centralises the sign / dot / digit-character state machine so the
222/// per-storage accumulators (`i128` for the narrow tier; the wide signed
223/// integers emitted via the from-str macro for the wide tier) only need
224/// to do the base-10 arithmetic.
225pub(crate) struct ParseComponents<'a> {
226    pub negative: bool,
227    pub int_str: &'a [u8],
228    pub frac_str: &'a [u8],
229}
230
231/// String-parsing front-end shared by every width.
232///
233/// Validates and splits the input into sign / integer-digits / fractional-
234/// digits. The `SCALE` parameter is needed only to reject overlong fractional
235/// parts — no arithmetic happens here, so wide-tier callers can drive their
236/// own storage-typed accumulator without overflow risk.
237///
238/// # Precision
239///
240/// Strict: integer-only string slicing; no arithmetic.
241pub(crate) fn parse_components<const SCALE: u32>(
242    s: &str,
243) -> Result<ParseComponents<'_>, ParseError> {
244    if s.is_empty() {
245        return Err(ParseError::Empty);
246    }
247
248    let bytes = s.as_bytes();
249    let mut idx = 0usize;
250
251    // Consume an optional leading sign byte.
252    let negative = match bytes[0] {
253        b'-' => {
254            idx += 1;
255            true
256        }
257        b'+' => {
258            idx += 1;
259            false
260        }
261        _ => false,
262    };
263    if idx == bytes.len() {
264        // Sign byte with nothing following it.
265        return Err(ParseError::SignOnly);
266    }
267
268    // Single forward pass: locate the decimal point; reject scientific
269    // notation and invalid characters immediately.
270    let mut dot_pos: Option<usize> = None;
271    {
272        let mut i = idx;
273        while i < bytes.len() {
274            let c = bytes[i];
275            match c {
276                b'0'..=b'9' => {}
277                b'.' => {
278                    if dot_pos.is_some() {
279                        // A second dot is an invalid character, not a
280                        // missing-digit case.
281                        return Err(ParseError::InvalidChar);
282                    }
283                    dot_pos = Some(i);
284                }
285                b'e' | b'E' => {
286                    return Err(ParseError::ScientificNotation);
287                }
288                _ => return Err(ParseError::InvalidChar),
289            }
290            i += 1;
291        }
292    }
293
294    let (int_str, frac_str) = match dot_pos {
295        Some(p) => (&bytes[idx..p], &bytes[p + 1..]),
296        None => (&bytes[idx..], &[][..]),
297    };
298
299    if dot_pos.is_some() {
300        // Both sides of the dot must have at least one digit.
301        if int_str.is_empty() || frac_str.is_empty() {
302            return Err(ParseError::MissingDigits);
303        }
304    } else if int_str.is_empty() {
305        return Err(ParseError::SignOnly);
306    }
307
308    // Allow `0` and `0.x` but reject `00`, `01`, `01.5`.
309    if int_str.len() > 1 && int_str[0] == b'0' {
310        return Err(ParseError::LeadingZero);
311    }
312
313    // More than SCALE fractional digits would lose precision on round-trip.
314    if frac_str.len() > SCALE as usize {
315        return Err(ParseError::OverlongFractional);
316    }
317
318    Ok(ParseComponents {
319        negative,
320        int_str,
321        frac_str,
322    })
323}