Skip to main content

rich/
pyformat.rs

1//! Python `str.format` for progress text columns.
2//!
3//! Port of the subset of Python's format mini-language that upstream
4//! `TextColumn` reaches through `text_format.format(task=task)`: replacement
5//! fields that name a value (`{task.completed}`, `{task.fields[name]}`), `{{`
6//! and `}}` escapes, and format specs `[[fill]align][sign][z][#][0][width]
7//! [grouping][.precision][type]` over strings, integers, floats, booleans and
8//! `None`. Values print as Python's `str()` would, so `10.0` stays `10.0` and
9//! `1e16` prints `1e+16`.
10//!
11//! The spec handling follows CPython's `Python/formatter_unicode.c` and
12//! `PyOS_double_to_string` (Python 3.11+, for the `z` flag). The `n` type uses
13//! the C locale, which is what a Python process that never calls `setlocale`
14//! formats with. `tests/golden/pyformat.tsv` pins a few thousand cases.
15
16/// A value a replacement field resolves to.
17#[derive(Debug, Clone, PartialEq)]
18pub enum FormatValue {
19    /// Python `None`.
20    None,
21    Bool(bool),
22    Int(i64),
23    Float(f64),
24    Str(String),
25}
26
27impl From<&str> for FormatValue {
28    fn from(value: &str) -> Self {
29        FormatValue::Str(value.to_string())
30    }
31}
32
33impl From<String> for FormatValue {
34    fn from(value: String) -> Self {
35        FormatValue::Str(value)
36    }
37}
38
39impl From<i64> for FormatValue {
40    fn from(value: i64) -> Self {
41        FormatValue::Int(value)
42    }
43}
44
45impl From<i32> for FormatValue {
46    fn from(value: i32) -> Self {
47        FormatValue::Int(i64::from(value))
48    }
49}
50
51impl From<usize> for FormatValue {
52    fn from(value: usize) -> Self {
53        FormatValue::Int(value as i64)
54    }
55}
56
57impl From<f64> for FormatValue {
58    fn from(value: f64) -> Self {
59        FormatValue::Float(value)
60    }
61}
62
63impl From<bool> for FormatValue {
64    fn from(value: bool) -> Self {
65        FormatValue::Bool(value)
66    }
67}
68
69impl<T: Into<FormatValue>> From<Option<T>> for FormatValue {
70    fn from(value: Option<T>) -> Self {
71        value.map_or(FormatValue::None, Into::into)
72    }
73}
74
75/// Expand `template`, resolving each field name through `lookup`.
76///
77/// A field that `lookup` does not know, or a spec the value rejects, is left
78/// in place verbatim: upstream would raise from `str.format`, which in a
79/// progress display means a crash mid-render.
80pub fn format(template: &str, lookup: impl Fn(&str) -> Option<FormatValue>) -> String {
81    let mut out = String::with_capacity(template.len());
82    let mut chars = template.char_indices().peekable();
83    while let Some((index, c)) = chars.next() {
84        match c {
85            '{' if chars.peek().map(|&(_, n)| n) == Some('{') => {
86                chars.next();
87                out.push('{');
88            }
89            '}' if chars.peek().map(|&(_, n)| n) == Some('}') => {
90                chars.next();
91                out.push('}');
92            }
93            '{' => {
94                let Some(close) = template[index..].find('}') else {
95                    out.push_str(&template[index..]);
96                    break;
97                };
98                let field = &template[index + 1..index + close];
99                // Skip past the closing brace.
100                while let Some(&(at, _)) = chars.peek() {
101                    if at > index + close {
102                        break;
103                    }
104                    chars.next();
105                }
106                let (name, spec) = match field.split_once(':') {
107                    Some((name, spec)) => (name, spec),
108                    None => (field, ""),
109                };
110                // `!s` / `!r` conversions: `str()` is the default rendering.
111                let name = name.split_once('!').map_or(name, |(name, _)| name);
112                match lookup(name).and_then(|value| format_value(&value, spec)) {
113                    Some(text) => out.push_str(&text),
114                    None => out.push_str(&template[index..=index + close]),
115                }
116            }
117            other => out.push(other),
118        }
119    }
120    out
121}
122
123/// Python's `repr(float)` (also its `str()`): the shortest round-tripping
124/// digits, in scientific notation when the exponent is below -4 or at least 16.
125pub fn float_repr(value: f64) -> String {
126    if value.is_nan() {
127        return "nan".to_string();
128    }
129    if value.is_infinite() {
130        return if value > 0.0 { "inf" } else { "-inf" }.to_string();
131    }
132    let sci = format!("{value:e}");
133    let (mantissa, exponent) = sci.split_once('e').expect("LowerExp has an exponent");
134    let exponent: i32 = exponent.parse().expect("integer exponent");
135    let negative = mantissa.starts_with('-');
136    let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect();
137    let sign = if negative { "-" } else { "" };
138    if !(-4..16).contains(&exponent) {
139        let (head, tail) = digits.split_at(1);
140        let fraction = if tail.is_empty() {
141            String::new()
142        } else {
143            format!(".{tail}")
144        };
145        return format!(
146            "{sign}{head}{fraction}e{}{:02}",
147            exp_sign(exponent),
148            exponent.abs()
149        );
150    }
151    let point = exponent + 1;
152    let text = if point <= 0 {
153        format!("0.{}{digits}", "0".repeat((-point) as usize))
154    } else if point as usize >= digits.len() {
155        format!("{digits}{}.0", "0".repeat(point as usize - digits.len()))
156    } else {
157        let (integer, fraction) = digits.split_at(point as usize);
158        format!("{integer}.{fraction}")
159    };
160    format!("{sign}{text}")
161}
162
163fn exp_sign(exponent: i32) -> char {
164    if exponent < 0 { '-' } else { '+' }
165}
166
167/// Which thousands separator a spec asked for (CPython's `LT_*` locale kinds).
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169enum Thousands {
170    None,
171    /// `,`: every three digits.
172    Comma,
173    /// `_`: every three digits.
174    Underscore,
175    /// `_` on a binary, octal or hex integer: every four digits.
176    UnderscoreFour,
177}
178
179/// A parsed format spec. Port of CPython's `InternalFormatSpec`
180/// (`Python/formatter_unicode.c`).
181#[derive(Debug)]
182struct Spec {
183    fill: char,
184    align: char,
185    sign: Option<char>,
186    no_neg_0: bool,
187    alternate: bool,
188    width: Option<usize>,
189    thousands: Thousands,
190    precision: Option<usize>,
191    /// The presentation type; `'\0'` when omitted from a float spec.
192    kind: char,
193}
194
195/// `parse_internal_render_format_spec`: `None` wherever Python raises
196/// `ValueError` while parsing or validating the spec.
197fn parse_spec(spec: &str, default_type: char, default_align: char) -> Option<Spec> {
198    let chars: Vec<char> = spec.chars().collect();
199    let mut at = 0;
200    let mut parsed = Spec {
201        fill: ' ',
202        align: default_align,
203        sign: None,
204        no_neg_0: false,
205        alternate: false,
206        width: None,
207        thousands: Thousands::None,
208        precision: None,
209        kind: default_type,
210    };
211    let is_align = |c: char| matches!(c, '<' | '>' | '^' | '=');
212    let mut fill_specified = false;
213    let mut align_specified = false;
214    if chars.len() >= 2 && is_align(chars[1]) {
215        parsed.fill = chars[0];
216        parsed.align = chars[1];
217        fill_specified = true;
218        align_specified = true;
219        at = 2;
220    } else if !chars.is_empty() && is_align(chars[0]) {
221        parsed.align = chars[0];
222        align_specified = true;
223        at = 1;
224    }
225    if let Some(&c) = chars.get(at).filter(|c| matches!(c, '+' | '-' | ' ')) {
226        parsed.sign = Some(c);
227        at += 1;
228    }
229    if chars.get(at) == Some(&'z') {
230        parsed.no_neg_0 = true;
231        at += 1;
232    }
233    if chars.get(at) == Some(&'#') {
234        parsed.alternate = true;
235        at += 1;
236    }
237    // The `0` flag: zero fill, and `=` alignment for a right-aligned type.
238    if !fill_specified && chars.get(at) == Some(&'0') {
239        parsed.fill = '0';
240        if !align_specified && default_align == '>' {
241            parsed.align = '=';
242        }
243        at += 1;
244    }
245    let start = at;
246    while chars.get(at).is_some_and(char::is_ascii_digit) {
247        at += 1;
248    }
249    if at > start {
250        parsed.width = Some(chars[start..at].iter().collect::<String>().parse().ok()?);
251    }
252    if chars.get(at) == Some(&',') {
253        parsed.thousands = Thousands::Comma;
254        at += 1;
255    }
256    if chars.get(at) == Some(&'_') {
257        if parsed.thousands != Thousands::None {
258            return None; // "Cannot specify both ',' and '_'."
259        }
260        parsed.thousands = Thousands::Underscore;
261        at += 1;
262    }
263    if chars.get(at) == Some(&',') {
264        return None; // "Cannot specify both ',' and '_'."
265    }
266    if chars.get(at) == Some(&'.') {
267        at += 1;
268        let start = at;
269        while chars.get(at).is_some_and(char::is_ascii_digit) {
270            at += 1;
271        }
272        if at == start {
273            return None; // "Format specifier missing precision"
274        }
275        parsed.precision = Some(chars[start..at].iter().collect::<String>().parse().ok()?);
276    }
277    match chars.len() - at {
278        0 => {}
279        1 => parsed.kind = chars[at],
280        _ => return None, // "Invalid format specifier"
281    }
282    if parsed.thousands != Thousands::None {
283        match parsed.kind {
284            'd' | 'e' | 'f' | 'g' | 'E' | 'G' | '%' | 'F' | '\0' => {}
285            'b' | 'o' | 'x' | 'X' if parsed.thousands == Thousands::Underscore => {
286                parsed.thousands = Thousands::UnderscoreFour;
287            }
288            _ => return None, // "Cannot specify ',' with '…'."
289        }
290    }
291    Some(parsed)
292}
293
294/// `format(value, spec)`, or `None` where Python would raise.
295pub fn format_value(value: &FormatValue, spec: &str) -> Option<String> {
296    // `format(x, "")` is `str(x)` for every type.
297    if spec.is_empty() {
298        return Some(match value {
299            FormatValue::None => "None".to_string(),
300            FormatValue::Bool(flag) => if *flag { "True" } else { "False" }.to_string(),
301            FormatValue::Int(number) => number.to_string(),
302            FormatValue::Float(number) => float_repr(*number),
303            FormatValue::Str(text) => text.clone(),
304        });
305    }
306    match value {
307        // `object.__format__` rejects any non-empty spec.
308        FormatValue::None => None,
309        FormatValue::Str(text) => format_str(text, &parse_spec(spec, 's', '<')?),
310        // `bool` formats through `int.__format__`.
311        FormatValue::Bool(flag) => format_int(i64::from(*flag), &parse_spec(spec, 'd', '>')?),
312        FormatValue::Int(number) => format_int(*number, &parse_spec(spec, 'd', '>')?),
313        FormatValue::Float(number) => format_float(*number, &parse_spec(spec, '\0', '>')?),
314    }
315}
316
317/// `format_string_internal`.
318fn format_str(text: &str, spec: &Spec) -> Option<String> {
319    if spec.kind != 's'
320        || spec.sign.is_some()
321        || spec.no_neg_0
322        || spec.alternate
323        || spec.align == '='
324    {
325        return None;
326    }
327    let body: String = match spec.precision {
328        Some(precision) => text.chars().take(precision).collect(),
329        None => text.to_string(),
330    };
331    let length = body.chars().count();
332    let gap = spec.width.map_or(0, |width| width.saturating_sub(length));
333    let left = match spec.align {
334        '>' => gap,
335        '^' => gap / 2,
336        _ => 0,
337    };
338    let fill = |count: usize| spec.fill.to_string().repeat(count);
339    Some(format!("{}{body}{}", fill(left), fill(gap - left)))
340}
341
342/// `format_long_internal`, handing the float presentation types to
343/// [`format_float`] as `int.__format__` does.
344fn format_int(number: i64, spec: &Spec) -> Option<String> {
345    if matches!(spec.kind, 'e' | 'E' | 'f' | 'F' | 'g' | 'G' | '%') {
346        return format_float(number as f64, spec);
347    }
348    if spec.precision.is_some() || spec.no_neg_0 {
349        return None;
350    }
351    let sign_char = number < 0;
352    let magnitude = number.unsigned_abs();
353    let (prefix, digits, remainder) = match spec.kind {
354        'c' => {
355            if spec.sign.is_some() || spec.alternate {
356                return None;
357            }
358            // `%c arg not in range(0x110000)`; a surrogate, which Python
359            // allows, cannot be a Rust `char`.
360            let c = u32::try_from(number).ok().and_then(char::from_u32)?;
361            // CPython formats the character as "remainder" so it is copied,
362            // never grouped or zero-padded as digits.
363            return Some(fill_number(
364                false,
365                "",
366                "",
367                false,
368                &c.to_string(),
369                spec,
370                None,
371            ));
372        }
373        'd' | 'n' => ("", magnitude.to_string(), ""),
374        'b' => ("0b", format!("{magnitude:b}"), ""),
375        'o' => ("0o", format!("{magnitude:o}"), ""),
376        'x' => ("0x", format!("{magnitude:x}"), ""),
377        'X' => ("0X", format!("{magnitude:X}"), ""),
378        _ => return None,
379    };
380    let prefix = if spec.alternate { prefix } else { "" };
381    Some(fill_number(
382        sign_char,
383        prefix,
384        &digits,
385        false,
386        remainder,
387        spec,
388        grouping(spec),
389    ))
390}
391
392/// The separator and group size for a spec's digits. `n` uses the current
393/// locale, which for a Python process that never calls `setlocale` is the
394/// C locale: no grouping at all.
395fn grouping(spec: &Spec) -> Option<(char, usize)> {
396    if spec.kind == 'n' {
397        return None;
398    }
399    match spec.thousands {
400        Thousands::None => None,
401        Thousands::Comma => Some((',', 3)),
402        Thousands::Underscore => Some(('_', 3)),
403        Thousands::UnderscoreFour => Some(('_', 4)),
404    }
405}
406
407/// `format_float_internal`.
408fn format_float(number: f64, spec: &Spec) -> Option<String> {
409    let mut kind = spec.kind;
410    let mut add_dot_0 = false;
411    let mut default_precision = 6;
412    if kind == '\0' {
413        // No type: `repr()` without a precision, else `g` keeping a digit
414        // after the point.
415        add_dot_0 = true;
416        kind = 'r';
417        default_precision = 0;
418    }
419    if kind == 'n' {
420        kind = 'g';
421    }
422    if !matches!(kind, 'e' | 'E' | 'f' | 'F' | 'g' | 'G' | '%' | 'r') {
423        return None; // "Unknown format code … for object of type 'float'"
424    }
425    let mut value = number;
426    let mut add_pct = false;
427    if kind == '%' {
428        kind = 'f';
429        value *= 100.0;
430        add_pct = true;
431    }
432    let precision = match spec.precision {
433        None => default_precision,
434        Some(precision) => {
435            if kind == 'r' {
436                kind = 'g';
437            }
438            precision
439        }
440    };
441    let mut buffer = double_to_string(
442        value,
443        kind,
444        precision,
445        spec.alternate,
446        add_dot_0,
447        spec.no_neg_0,
448    );
449    if add_pct {
450        buffer.push('%');
451    }
452    let (negative, body) = match buffer.strip_prefix('-') {
453        Some(body) => (true, body),
454        None => (false, buffer.as_str()),
455    };
456    // `parse_number`: the leading digits, an optional decimal point, and
457    // everything after it copied as-is.
458    let digit_count = body.bytes().take_while(u8::is_ascii_digit).count();
459    let (digits, rest) = body.split_at(digit_count);
460    let (has_decimal, remainder) = match rest.strip_prefix('.') {
461        Some(remainder) => (true, remainder),
462        None => (false, rest),
463    };
464    Some(fill_number(
465        negative,
466        "",
467        digits,
468        has_decimal,
469        remainder,
470        spec,
471        grouping(spec),
472    ))
473}
474
475/// `PyOS_double_to_string` / `format_float_short` for the `e`, `f`, `g` and
476/// `r` (repr) codes, upper-case variants included.
477fn double_to_string(
478    value: f64,
479    code: char,
480    precision: usize,
481    alternate: bool,
482    add_dot_0: bool,
483    no_neg_0: bool,
484) -> String {
485    let upper = code.is_ascii_uppercase();
486    let code = code.to_ascii_lowercase();
487    let case = |text: String| if upper { text.to_uppercase() } else { text };
488    if value.is_nan() {
489        // "we *never* add a sign for a nan".
490        return case("nan".to_string());
491    }
492    if value.is_infinite() {
493        return case(if value < 0.0 { "-inf" } else { "inf" }.to_string());
494    }
495    // `_Py_dg_dtoa`: the significant digits (no trailing zeros) and the
496    // decimal point's position relative to them.
497    let magnitude = value.abs();
498    let (digits, mut decpt) = match code {
499        'e' => dtoa_significant(magnitude, precision + 1),
500        'g' => dtoa_significant(magnitude, precision.max(1)),
501        'f' => dtoa_fixed(magnitude, precision),
502        _ => dtoa_shortest(magnitude),
503    };
504    let precision = match code {
505        'e' => precision + 1,
506        'g' => precision.max(1),
507        _ => precision,
508    } as i64;
509    let digits_len = digits.len() as i64;
510    let mut use_exp = false;
511    let mut vdigits_end = digits_len;
512    match code {
513        'e' => {
514            use_exp = true;
515            vdigits_end = precision;
516        }
517        'f' => vdigits_end = decpt + precision,
518        'g' => {
519            let limit = if add_dot_0 { precision - 1 } else { precision };
520            if decpt <= -4 || decpt > limit {
521                use_exp = true;
522            }
523            if alternate {
524                vdigits_end = precision;
525            }
526        }
527        _ => {
528            if decpt <= -4 || decpt > 16 {
529                use_exp = true;
530            }
531        }
532    }
533    let mut exponent = 0;
534    if use_exp {
535        exponent = decpt - 1;
536        decpt = 1;
537    }
538    let vdigits_start = if decpt <= 0 { decpt - 1 } else { 0 };
539    vdigits_end = if !use_exp && add_dot_0 {
540        vdigits_end.max(decpt + 1)
541    } else {
542        vdigits_end.max(decpt)
543    };
544
545    let zero_value = digits.bytes().all(|b| b == b'0');
546    let negative = value.is_sign_negative() && !(no_neg_0 && zero_value);
547    let zeros = |count: i64| "0".repeat(count.max(0) as usize);
548    let mut out = String::new();
549    if negative {
550        out.push('-');
551    }
552    if decpt <= 0 {
553        out.push_str(&zeros(decpt - vdigits_start));
554        out.push('.');
555        out.push_str(&zeros(-decpt));
556    } else {
557        out.push_str(&zeros(-vdigits_start));
558    }
559    if 0 < decpt && decpt <= digits_len {
560        out.push_str(&digits[..decpt as usize]);
561        out.push('.');
562        out.push_str(&digits[decpt as usize..]);
563    } else {
564        out.push_str(&digits);
565    }
566    if digits_len < decpt {
567        out.push_str(&zeros(decpt - digits_len));
568        out.push('.');
569        out.push_str(&zeros(vdigits_end - decpt));
570    } else {
571        out.push_str(&zeros(vdigits_end - digits_len));
572    }
573    if out.ends_with('.') && !alternate {
574        out.pop();
575    }
576    if use_exp {
577        out.push_str(&format!(
578            "e{}{:02}",
579            exp_sign(exponent as i32),
580            exponent.abs()
581        ));
582    }
583    case(out)
584}
585
586/// Split Rust's `{:e}` rendering into dtoa's `(digits, decpt)`, trailing
587/// zeros dropped. Zero is `("0", 1)`, as dtoa returns it.
588fn split_scientific(text: &str) -> (String, i64) {
589    let (mantissa, exponent) = text.split_once('e').expect("LowerExp has an exponent");
590    let exponent: i64 = exponent.parse().expect("integer exponent");
591    let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect();
592    let digits = digits.trim_end_matches('0');
593    if digits.is_empty() {
594        return ("0".to_string(), 1);
595    }
596    (digits.to_string(), exponent + 1)
597}
598
599/// dtoa mode 0: the shortest digits that round-trip.
600fn dtoa_shortest(value: f64) -> (String, i64) {
601    split_scientific(&format!("{value:e}"))
602}
603
604/// dtoa mode 2: `count` significant digits, correctly rounded.
605fn dtoa_significant(value: f64, count: usize) -> (String, i64) {
606    split_scientific(&format!("{value:.*e}", count - 1))
607}
608
609/// dtoa mode 3: rounded to `precision` digits after the point. A value that
610/// rounds to nothing is dtoa's empty digit string at `decpt = -precision`.
611fn dtoa_fixed(value: f64, precision: usize) -> (String, i64) {
612    if value == 0.0 {
613        return ("0".to_string(), 1);
614    }
615    let text = format!("{value:.precision$}");
616    let (integer, fraction) = text.split_once('.').unwrap_or((&text, ""));
617    let all = format!("{integer}{fraction}");
618    let leading = all.bytes().take_while(|b| *b == b'0').count();
619    let digits = all[leading..].trim_end_matches('0');
620    if digits.is_empty() {
621        return (String::new(), -(precision as i64));
622    }
623    (digits.to_string(), integer.len() as i64 - leading as i64)
624}
625
626/// `calc_number_widths` + `fill_number`: lay out
627/// `<lpad><sign><prefix><spad><grouped digits><.><remainder><rpad>`.
628fn fill_number(
629    negative: bool,
630    prefix: &str,
631    digits: &str,
632    has_decimal: bool,
633    remainder: &str,
634    spec: &Spec,
635    grouping: Option<(char, usize)>,
636) -> String {
637    let sign = match (spec.sign, negative) {
638        (_, true) => Some('-'),
639        (Some('+'), false) => Some('+'),
640        (Some(' '), false) => Some(' '),
641        _ => None,
642    };
643    let width = spec.width.map_or(-1, |width| width as i64);
644    let non_digit = i64::from(sign.is_some())
645        + prefix.chars().count() as i64
646        + i64::from(has_decimal)
647        + remainder.chars().count() as i64;
648    let min_width = if spec.fill == '0' && spec.align == '=' {
649        width - non_digit
650    } else {
651        0
652    };
653    let grouped = if digits.is_empty() {
654        String::new()
655    } else {
656        insert_thousands_grouping(digits, min_width, grouping)
657    };
658    let padding = width - (non_digit + grouped.chars().count() as i64);
659    let (mut left, mut middle, mut right) = (0, 0, 0);
660    if padding > 0 {
661        match spec.align {
662            '<' => right = padding,
663            '^' => {
664                left = padding / 2;
665                right = padding - left;
666            }
667            '=' => middle = padding,
668            _ => left = padding,
669        }
670    }
671    let fill = |count: i64| spec.fill.to_string().repeat(count as usize);
672    let mut out = fill(left);
673    out.extend(sign);
674    out.push_str(prefix);
675    out.push_str(&fill(middle));
676    out.push_str(&grouped);
677    if has_decimal {
678        out.push('.');
679    }
680    out.push_str(remainder);
681    out.push_str(&fill(right));
682    out
683}
684
685/// `_PyUnicode_InsertThousandsGrouping`: group `digits` from the right,
686/// zero-padding (and grouping the zeros too) up to `min_width` characters.
687fn insert_thousands_grouping(
688    digits: &str,
689    min_width: i64,
690    grouping: Option<(char, usize)>,
691) -> String {
692    let chars: Vec<char> = digits.chars().collect();
693    let mut remaining = chars.len() as i64;
694    let mut min_width = min_width;
695    // Groups from the right; each is (zeros, digit count, separator after).
696    let mut pieces: Vec<String> = Vec::new();
697    let mut use_separator = false;
698    let mut piece = |length: i64, remaining: i64, use_separator: bool| {
699        let zeros = (length - remaining).max(0);
700        let count = remaining.min(length).max(0);
701        let mut text = "0".repeat(zeros as usize);
702        text.extend(&chars[(remaining - count) as usize..remaining as usize]);
703        if use_separator {
704            text.push(grouping.map_or(',', |(separator, _)| separator));
705        }
706        pieces.push(text);
707        count
708    };
709    let mut finished = false;
710    if let Some((_, size)) = grouping {
711        loop {
712            let length = (size as i64).min(remaining.max(min_width).max(1));
713            remaining -= piece(length, remaining, use_separator);
714            use_separator = true;
715            min_width -= length;
716            if remaining <= 0 && min_width <= 0 {
717                finished = true;
718                break;
719            }
720            min_width -= 1;
721        }
722    }
723    if !finished {
724        let length = remaining.max(min_width).max(1);
725        piece(length, remaining, use_separator);
726    }
727    pieces.iter().rev().map(String::as_str).collect()
728}
729
730#[cfg(test)]
731mod tests {
732    use super::*;
733
734    fn fmt(value: impl Into<FormatValue>, spec: &str) -> String {
735        format_value(&value.into(), spec).expect("valid spec")
736    }
737
738    #[test]
739    fn floats_print_like_python_str() {
740        // Each expected value is Python 3.11's `str(float)`.
741        for (value, expected) in [
742            (10.0, "10.0"),
743            (0.1, "0.1"),
744            (1e16, "1e+16"),
745            (1.5e16, "1.5e+16"),
746            (1e15, "1000000000000000.0"),
747            (0.0001, "0.0001"),
748            (0.00001, "1e-05"),
749            (-2.5, "-2.5"),
750            (123456.789, "123456.789"),
751            (f64::INFINITY, "inf"),
752        ] {
753            assert_eq!(float_repr(value), expected, "{value}");
754        }
755    }
756
757    #[test]
758    fn specs_match_python_format() {
759        // Each expected value is Python 3.11's `format(value, spec)`.
760        assert_eq!(fmt(42.0, ">3.0f"), " 42");
761        assert_eq!(fmt(99.95, ">3.0f"), "100");
762        assert_eq!(fmt(2.5, ".0f"), "2");
763        assert_eq!(fmt(3.5, ".0f"), "4");
764        assert_eq!(fmt(0.425, ".1%"), "42.5%");
765        assert_eq!(fmt(1234567.891, ",.2f"), "1,234,567.89");
766        assert_eq!(fmt(1234567i64, ","), "1,234,567");
767        assert_eq!(fmt(42i64, "05d"), "00042");
768        assert_eq!(fmt(-42i64, "05d"), "-0042");
769        assert_eq!(fmt(42i64, "+d"), "+42");
770        assert_eq!(fmt(255i64, "#x"), "0xff");
771        assert_eq!(fmt("ab", "*^6"), "**ab**");
772        assert_eq!(fmt("abcdef", ".3"), "abc");
773        assert_eq!(fmt("ab", "5"), "ab   ");
774        assert_eq!(fmt(7i64, "5"), "    7");
775        assert_eq!(fmt(1234.5, ".2"), "1.2e+03");
776        assert_eq!(fmt(10.0, ".3"), "10.0");
777        assert_eq!(fmt(10.0, ".3g"), "10");
778        assert_eq!(fmt(0.000012345, "g"), "1.2345e-05");
779        assert_eq!(fmt(1500.0, "e"), "1.500000e+03");
780        assert_eq!(fmt(true, ""), "True");
781        assert_eq!(fmt(true, "d"), "1");
782        assert_eq!(fmt(FormatValue::None, ""), "None");
783        assert_eq!(fmt(3i64, ".1f"), "3.0");
784        // No type with a precision: exponent once `exp >= precision - 1`.
785        assert_eq!(fmt(12.5, ".2"), "1.2e+01");
786        assert_eq!(fmt(2.5, ".0"), "2e+00");
787        // Grouping touches only the leading digits, never an exponent.
788        assert_eq!(fmt(1e16, ","), "1e+16");
789        assert_eq!(fmt(f64::INFINITY, "%"), "inf%");
790        assert_eq!(fmt(123.0, "#g"), "123.000");
791        assert_eq!(fmt(123.0, "#.3g"), "123.");
792        assert_eq!(fmt(-0.0001, "z.2f"), "0.00");
793        assert_eq!(fmt(1234.5, "n"), "1234.5");
794        assert_eq!(fmt("h\u{e9}llo", "08"), "h\u{e9}llo000");
795        // Zero padding is grouped along with the digits.
796        assert_eq!(fmt(1234i64, "010,"), "00,001,234");
797        assert_eq!(fmt(65i64, "c"), "A");
798        assert_eq!(format_value(&FormatValue::from("ab"), "=5"), None);
799        assert_eq!(format_value(&FormatValue::Int(1234), ",n"), None);
800    }
801
802    #[test]
803    fn templates_expand_fields_and_escapes() {
804        let lookup = |name: &str| match name {
805            "task.completed" => Some(FormatValue::Float(3.0)),
806            "task.fields[name]" => Some(FormatValue::from("disk")),
807            _ => None,
808        };
809        assert_eq!(
810            format("{{x}} {task.fields[name]}: {task.completed:>5.1f}", lookup),
811            "{x} disk:   3.0"
812        );
813        // Unknown fields and rejected specs stay verbatim.
814        assert_eq!(
815            format("{task.nope} {task.completed:q}", lookup),
816            "{task.nope} {task.completed:q}"
817        );
818        assert_eq!(format("{task.completed!s}", lookup), "3.0");
819        assert_eq!(format("open {", lookup), "open {");
820    }
821}