Skip to main content

dashu_ratio/fmt/
expanded.rs

1//! Positional-expansion formatting for rationals: render the infinite decimal (or other-base)
2//! expansion of an [`RBig`]/[`Relaxed`], exposing the non-repeating and repeating parts.
3//!
4//! The [`InExpanded`] helper drives this output and supports precision control, scientific
5//! notation, and repetend display.
6
7use alloc::collections::BTreeMap;
8use alloc::vec;
9use alloc::vec::Vec;
10use core::fmt::{self, Display, Formatter, LowerExp, UpperExp, Write};
11use dashu_base::{DivRem, Sign, UnsignedAbs};
12use dashu_int::{fast_div::ConstDivisor, UBig, Word};
13
14use crate::rbig::{RBig, Relaxed};
15
16/// Returned by [`RBig::in_expanded`] and [`Relaxed::in_expanded`].
17///
18/// Implements [`Display`], [`LowerExp`], and [`UpperExp`] for printing a
19/// rational number in expanded positional notation (e.g., `0.3333` for 1/3).
20///
21/// # Format options
22///
23/// - `{}` — default expansion with a fixed number of fractional digits.
24/// - `{:.N}` — exactly N digits after the radix point.
25/// - `{:#}` — detect and display the repetend in parentheses (e.g., `0.(3)`).
26/// - `{:e}` / `{:E}` — scientific notation. `#` has no effect in this mode.
27/// - `{:.Ne}` / `{:.NE}` — scientific notation with N digits of precision.
28pub struct InExpanded<'a> {
29    sign: Sign,
30    num_abs: UBig,
31    denominator: &'a UBig,
32    radix: u8,
33}
34
35/// Returned by `expand`.
36struct Expanded {
37    int_digits: Vec<u8>,
38    frac_prefix: Vec<u8>, // non-repeating fractional digits
39    repetend: Vec<u8>,    // repeating part (empty = terminating)
40}
41
42/// Perform long division and record digits.
43///
44/// If `track_repetend` is true, the expansion is produced one digit at a time so a
45/// cycle can be detected and displayed exactly (e.g. `1/6 -> 0.1(6)`). Otherwise a
46/// batched fast path is used: each iteration emits [`digits_per_word`] digits via a
47/// single division by a precomputed [`ConstDivisor`], trading `k` big-int divisions
48/// for one. Stops once `max_digits` fractional digits have been produced or the
49/// expansion terminates (or a cycle is found, in the repetend path).
50fn expand(num: &UBig, den: &UBig, radix: u8, max_digits: usize, track_repetend: bool) -> Expanded {
51    let (int_part, mut rem) = num.div_rem(den);
52    let int_digits: Vec<u8> = int_part
53        .to_digits(radix as Word)
54        .into_iter()
55        .map(|d| d as u8)
56        .collect();
57
58    if track_repetend {
59        // Per-digit long division so the cycle can be detected at single-digit granularity.
60        let mut frac_digits: Vec<u8> = Vec::with_capacity(max_digits);
61        let mut seen: BTreeMap<UBig, usize> = BTreeMap::new();
62        let mut repetend_start: Option<usize> = None;
63
64        while frac_digits.len() < max_digits {
65            if rem.is_zero() {
66                break;
67            }
68            if let Some(&pos) = seen.get(&rem) {
69                repetend_start = Some(pos);
70                break;
71            }
72            seen.insert(rem.clone(), frac_digits.len());
73
74            let scaled = &rem * radix;
75            let (digit, new_rem) = scaled.div_rem(den);
76            rem = new_rem;
77            // digit is guaranteed 0..radix-1 (radix <= 36), so fits in u8
78            frac_digits.push(u8::try_from(&digit).unwrap());
79        }
80
81        let (frac_prefix, repetend) = if let Some(start) = repetend_start {
82            let repetend = frac_digits.split_off(start);
83            (frac_digits, repetend)
84        } else {
85            (frac_digits, Vec::new())
86        };
87        Expanded {
88            int_digits,
89            frac_prefix,
90            repetend,
91        }
92    } else {
93        // Batched fast path: emit k digits per big-int division.
94        let radix_word = radix as Word;
95        let (k, radix_k) = if radix_word == 10 {
96            DECIMAL_DIGITS_PER_WORD
97        } else {
98            digits_per_word(radix_word)
99        };
100        let den_div = ConstDivisor::new(den.clone());
101        let frac_digits = expand_frac_fast(rem, &den_div, radix_word, radix_k, k, max_digits);
102        Expanded {
103            int_digits,
104            frac_prefix: frac_digits,
105            repetend: Vec::new(),
106        }
107    }
108}
109
110/// Largest `k` such that `radix^k <= Word::MAX`, together with the value `radix^k`, used to batch
111/// fractional digit extraction.
112const fn digits_per_word(radix: Word) -> (usize, Word) {
113    let mut k = 0usize;
114    let mut power: Word = 1;
115    while power <= Word::MAX / radix {
116        power *= radix;
117        k += 1;
118    }
119    (k, power)
120}
121
122/// Precomputed value for the common decimal (radix 10) case.
123///
124/// This is a separate constant rather than a `radix == 10` branch inside `digits_per_word`:
125/// the const initializer calls `digits_per_word(10)`, so a self-referential branch there would
126/// form a const-evaluation cycle. Callers read this constant directly on the radix-10 path.
127const DECIMAL_DIGITS_PER_WORD: (usize, Word) = digits_per_word(10);
128
129/// Extract up to `limit` fractional base-`radix` digits of the value `rem / den`
130/// (with `rem < den`, i.e. the value is in `[0, 1)`), stopping early if the expansion
131/// terminates. Each iteration produces `k` digits: multiply the remainder by `radix^k`,
132/// divide once by the precomputed `den_div`, then split the word-sized quotient chunk
133/// into `k` digits (most-significant first).
134fn expand_frac_fast(
135    mut rem: UBig,
136    den_div: &ConstDivisor,
137    radix: Word,
138    radix_k: Word,
139    k: usize,
140    limit: usize,
141) -> Vec<u8> {
142    let mut digits: Vec<u8> = Vec::with_capacity(limit);
143    let mut chunk: Vec<u8> = Vec::with_capacity(k); // reused; one chunk, LSB-first
144    while digits.len() < limit && !rem.is_zero() {
145        let scaled = &rem * radix_k;
146        let (quot, new_rem) = scaled.div_rem(den_div);
147        rem = new_rem;
148
149        // quot < radix^k <= Word::MAX, so it always fits in a single Word.
150        let mut word: Word = quot.try_into().unwrap();
151
152        // split `word` into k base-radix digits, LSB-first
153        chunk.clear();
154        for _ in 0..k {
155            chunk.push((word % radix) as u8);
156            word /= radix;
157        }
158        // append up to `limit - len` digits, most-significant first
159        let take = (limit - digits.len()).min(k);
160        for i in 0..take {
161            digits.push(chunk[k - 1 - i]);
162        }
163    }
164    digits
165}
166
167/// Default number of fractional digits for a given radix.
168///
169/// Returns `Word::BITS` digits for all radices. Use `{:.N}` to control
170/// precision explicitly.
171fn default_precision(_radix: u8) -> usize {
172    Word::BITS as usize
173}
174
175/// Propagate a carry of 1 through a mutable slice of radix digits.
176/// Returns `true` if the carry overflowed all digits (all wrapped to 0).
177fn propagate_carry(digits: &mut [u8], radix: u8) -> bool {
178    let carry = 1u8;
179    for d in digits.iter_mut().rev() {
180        *d += carry;
181        if *d >= radix {
182            *d -= radix;
183        } else {
184            return false;
185        }
186    }
187    true
188}
189
190/// Check the guard digit at position `keep`, truncate, and apply half-up rounding.
191/// Returns `true` if the carry propagated through all `keep` digits (rollover).
192/// Returns `false` immediately if there are not enough digits for a guard.
193fn round_and_carry(digits: &mut Vec<u8>, radix: u8, keep: usize) -> bool {
194    if digits.len() <= keep {
195        return false;
196    }
197    let extra = digits[keep];
198    digits.truncate(keep);
199    extra * 2 >= radix && propagate_carry(digits, radix)
200}
201
202impl Display for InExpanded<'_> {
203    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
204        f.write_str(self.sign.as_sign_str(f.sign_plus()))?;
205
206        // Zero shortcut
207        if self.num_abs.is_zero() {
208            f.write_char('0')?;
209            if let Some(prec) = f.precision() {
210                if prec > 0 {
211                    f.write_char('.')?;
212                    for _ in 0..prec {
213                        f.write_char('0')?;
214                    }
215                }
216            }
217            return Ok(());
218        }
219
220        let prec = f
221            .precision()
222            .unwrap_or_else(|| default_precision(self.radix));
223        let show_repetend = f.alternate();
224
225        // When detecting repetends we need extra digits to find the cycle.
226        let max_digits = if show_repetend {
227            (prec + 1).max(128)
228        } else {
229            prec + 1
230        };
231
232        let expanded =
233            expand(&self.num_abs, self.denominator, self.radix, max_digits, show_repetend);
234
235        let mut int_digits = expanded.int_digits;
236
237        // When repetend display is active, show the exact pattern without rounding.
238        if show_repetend && !expanded.repetend.is_empty() {
239            write_digits(f, &int_digits, self.radix, false)?;
240            if !expanded.frac_prefix.is_empty() || !expanded.repetend.is_empty() {
241                f.write_char('.')?;
242            }
243            write_digits(f, &expanded.frac_prefix, self.radix, false)?;
244            f.write_char('(')?;
245            write_digits(f, &expanded.repetend, self.radix, false)?;
246            f.write_char(')')?;
247        } else {
248            let total_frac: Vec<u8> = if !expanded.repetend.is_empty() {
249                [&expanded.frac_prefix[..], &expanded.repetend[..]].concat()
250            } else {
251                expanded.frac_prefix.clone()
252            };
253
254            let mut frac_digits = total_frac;
255
256            if round_and_carry(&mut frac_digits, self.radix, prec)
257                && propagate_carry(&mut int_digits, self.radix)
258            {
259                int_digits.insert(0, 1);
260            }
261
262            // Print integer part
263            write_digits(f, &int_digits, self.radix, false)?;
264
265            // Print fractional part if needed
266            if !frac_digits.is_empty() || prec > 0 {
267                f.write_char('.')?;
268                let printed = frac_digits.len().min(prec);
269                write_digits(f, &frac_digits[..printed], self.radix, false)?;
270                for _ in printed..prec {
271                    f.write_char('0')?;
272                }
273            }
274        }
275
276        Ok(())
277    }
278}
279
280impl LowerExp for InExpanded<'_> {
281    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
282        self.fmt_scientific(f, 'e')
283    }
284}
285
286impl UpperExp for InExpanded<'_> {
287    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
288        self.fmt_scientific(f, 'E')
289    }
290}
291
292impl InExpanded<'_> {
293    fn fmt_scientific(&self, f: &mut Formatter<'_>, exp_char: char) -> fmt::Result {
294        f.write_str(self.sign.as_sign_str(f.sign_plus()))?;
295
296        if self.num_abs.is_zero() {
297            f.write_char('0')?;
298            let prec = f.precision().unwrap_or(0);
299            if prec > 0 {
300                f.write_char('.')?;
301                for _ in 0..prec {
302                    f.write_char('0')?;
303                }
304            }
305            return write!(f, "{}0", exp_char);
306        }
307
308        let prec = f
309            .precision()
310            .unwrap_or_else(|| default_precision(self.radix));
311        let exp_marker = if self.radix == 10 { exp_char } else { '@' };
312
313        // Compute integer part and remainder
314        let (int_part, rem) = (&self.num_abs).div_rem(self.denominator);
315
316        let exp: isize;
317        let mut significand_digits: Vec<u8>;
318
319        if !int_part.is_zero() {
320            // Integer part >= 1: exponent = number of int digits - 1
321            let int_digits: Vec<u8> = int_part
322                .to_digits(self.radix as Word)
323                .into_iter()
324                .map(|d| d as u8)
325                .collect();
326            exp = int_digits.len() as isize - 1;
327            significand_digits = int_digits;
328            // Compute fractional digits to reach prec + 2 total (1 before point, prec+1 after)
329            let need_frac = (prec + 2).saturating_sub(significand_digits.len());
330            let more = expand_fraction(rem, self.denominator, self.radix, need_frac);
331            significand_digits.extend_from_slice(&more);
332        } else {
333            // Integer part == 0: find first non-zero fractional digit
334            let mut cur_rem = rem.clone();
335            let mut leading_zeros: isize = 0;
336            loop {
337                if cur_rem.is_zero() {
338                    // The number is exactly zero — should have been caught above
339                    exp = 0;
340                    significand_digits = vec![0];
341                    break;
342                }
343                let scaled = &cur_rem * self.radix;
344                let (d, new_rem) = scaled.div_rem(self.denominator);
345                cur_rem = new_rem;
346                if !d.is_zero() {
347                    leading_zeros += 1;
348                    exp = -leading_zeros;
349                    significand_digits = vec![u8::try_from(&d).unwrap()];
350                    // Compute remaining significand digits
351                    let more = expand_fraction(cur_rem, self.denominator, self.radix, prec + 1);
352                    significand_digits.extend_from_slice(&more);
353                    break;
354                }
355                leading_zeros += 1;
356            }
357        };
358
359        // Round the significand
360        if round_and_carry(&mut significand_digits, self.radix, prec + 1) {
361            significand_digits.insert(0, 1);
362        }
363
364        // Re-check for rollover
365        let actual_exp = if significand_digits.len() > prec + 1 {
366            // Rollover happened
367            significand_digits.truncate(prec + 1);
368            exp + 1
369        } else {
370            exp
371        };
372
373        // Print: first digit, '.', remaining digits, exp marker, exponent
374        let upper = exp_char == 'E';
375        let first = significand_digits.first().copied().unwrap_or(0);
376        write_digit_char(f, first, self.radix, upper)?;
377
378        let rest = &significand_digits[1..];
379        if !rest.is_empty() || prec > 0 {
380            f.write_char('.')?;
381            let end = prec.min(rest.len());
382            write_digits(f, &rest[..end], self.radix, upper)?;
383            // Pad with zeros if needed
384            for _ in end..prec {
385                f.write_char('0')?;
386            }
387        }
388
389        write!(f, "{}{}", exp_marker, actual_exp)
390    }
391}
392
393/// Compute `n` fractional digits via long division starting from `rem`.
394/// Does not track repetends.
395fn expand_fraction(rem: UBig, den: &UBig, radix: u8, n: usize) -> Vec<u8> {
396    let radix_word = radix as Word;
397    let (k, radix_k) = if radix_word == 10 {
398        DECIMAL_DIGITS_PER_WORD
399    } else {
400        digits_per_word(radix_word)
401    };
402    let den_div = ConstDivisor::new(den.clone());
403    let mut digits = expand_frac_fast(rem, &den_div, radix_word, radix_k, k, n);
404    // the expansion may terminate before `n` digits; pad with zeros
405    digits.resize(n, 0);
406    digits
407}
408
409/// Write a slice of digit values in the given radix to the formatter.
410fn write_digits(f: &mut Formatter<'_>, digits: &[u8], radix: u8, upper: bool) -> fmt::Result {
411    for &d in digits {
412        write_digit_char(f, d, radix, upper)?;
413    }
414    Ok(())
415}
416
417/// Write a single digit value (0..radix-1) as a character.
418fn write_digit_char(f: &mut Formatter<'_>, digit: u8, _radix: u8, upper: bool) -> fmt::Result {
419    let ch = if digit < 10 {
420        (b'0' + digit) as char
421    } else if upper {
422        (b'A' + (digit - 10)) as char
423    } else {
424        (b'a' + (digit - 10)) as char
425    };
426    f.write_char(ch)
427}
428
429impl RBig {
430    /// Representation in expanded positional notation.
431    ///
432    /// Returns a wrapper that implements [`Display`], [`LowerExp`], and
433    /// [`UpperExp`] for printing the rational number in fractional form.
434    ///
435    /// The `radix` parameter is `u8`. Valid radices are 2 through 36.
436    ///
437    /// # Examples
438    ///
439    /// ```
440    /// # use dashu_ratio::RBig;
441    /// let one_third = RBig::from_parts(1.into(), 3u8.into());
442    /// assert_eq!(format!("{:.4}", one_third.in_expanded(10)), "0.3333");
443    /// assert_eq!(format!("{:#.4}", one_third.in_expanded(10)), "0.(3)");
444    /// assert_eq!(format!("{:.4e}", one_third.in_expanded(10)), "3.3333e-1");
445    /// ```
446    #[inline]
447    pub fn in_expanded(&self, radix: u8) -> InExpanded<'_> {
448        assert!((2..=36).contains(&radix), "radix must be between 2 and 36");
449        InExpanded {
450            sign: self.0.numerator.sign(),
451            num_abs: self.0.numerator.clone().unsigned_abs(),
452            denominator: self.denominator(),
453            radix,
454        }
455    }
456}
457
458impl Relaxed {
459    /// Representation in expanded positional notation.
460    ///
461    /// The `radix` parameter is `u8`. See [`RBig::in_expanded`] for details.
462    #[inline]
463    pub fn in_expanded(&self, radix: u8) -> InExpanded<'_> {
464        assert!((2..=36).contains(&radix), "radix must be between 2 and 36");
465        InExpanded {
466            sign: self.0.numerator.sign(),
467            num_abs: self.0.numerator.clone().unsigned_abs(),
468            denominator: self.denominator(),
469            radix,
470        }
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use crate::RBig;
477    use alloc::format;
478    use core::str::FromStr;
479
480    #[test]
481    fn test_expanded_terminating() {
482        // terminating fractions: the batched path must not change the digits
483        let r = RBig::from_str("1/8").unwrap(); // 0.125
484        assert_eq!(format!("{:.4}", r.in_expanded(10)), "0.1250");
485        assert_eq!(format!("{:.10}", r.in_expanded(10)), "0.1250000000");
486        let r = RBig::from_str("1/4").unwrap(); // 0.25
487        assert_eq!(format!("{:.3}", r.in_expanded(10)), "0.250");
488    }
489
490    #[test]
491    fn test_expanded_repeating() {
492        let third = RBig::from_str("1/3").unwrap();
493        assert_eq!(format!("{:.6}", third.in_expanded(10)), "0.333333");
494        assert_eq!(format!("{:#}", third.in_expanded(10)), "0.(3)");
495        assert_eq!(format!("{:#}", RBig::from_str("1/7").unwrap().in_expanded(10)), "0.(142857)");
496        assert_eq!(format!("{:#}", RBig::from_str("1/6").unwrap().in_expanded(10)), "0.1(6)");
497    }
498
499    #[test]
500    fn test_expanded_scientific() {
501        let third = RBig::from_str("1/3").unwrap();
502        assert_eq!(format!("{:.4e}", third.in_expanded(10)), "3.3333e-1");
503        let eighth = RBig::from_str("1/8").unwrap();
504        assert_eq!(format!("{:.2e}", eighth.in_expanded(10)), "1.25e-1");
505    }
506
507    #[test]
508    fn test_expanded_binary() {
509        // a non-decimal radix exercises batching with a different digits_per_word
510        let eighth = RBig::from_str("1/8").unwrap(); // 0.001 binary
511        assert_eq!(format!("{:.5}", eighth.in_expanded(2)), "0.00100");
512        assert_eq!(format!("{:#}", RBig::from_str("1/3").unwrap().in_expanded(2)), "0.(01)");
513    }
514}