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