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}