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}