Skip to content

Commit 701b70e

Browse files
committed
common: fix float format grouping and empty presentation type
Thousands grouping split the magnitude only on '.', so exponent and percent tails received separators (format(1e20, ',e') gave '1e+,20'-class corruption). Group only the leading integer digits for interval-3 output; hex/octal/binary keep whole-magnitude grouping. Empty presentation type: with a precision, clamp it to at least 1 and restore a trailing '.0' on integer-looking results (Py_DTSF_ADD_DOT_0); without one, alternate form now inserts the forced decimal point before an exponent instead of appending it. Verified byte-identical to CPython 3.14 over a hash/format differential battery; test_format, test_float, test_fstring, test_hash pass. Assisted-by: Claude
1 parent 6805fdd commit 701b70e

1 file changed

Lines changed: 151 additions & 18 deletions

File tree

crates/common/src/format.rs

Lines changed: 151 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -380,15 +380,31 @@ impl FormatSpec {
380380
sep: char,
381381
disp_digit_cnt: i32,
382382
) -> String {
383-
// Don't add separators to the floating decimal point of numbers
384-
let mut parts = magnitude_str.splitn(2, '.');
385-
let magnitude_int_str = parts.next().unwrap().to_string();
383+
// Group only the leading integer digits; the trailing remainder must
384+
// never receive separators. For decimal and float output (interval 3)
385+
// that remainder is the decimal point and fraction, an exponent
386+
// (`e+NN`), or a trailing percent sign. Hex/octal/binary output
387+
// (interval 4) has no such tail and its `a`-`f`/`e`/`E` are digits, so
388+
// the whole magnitude is groupable.
389+
let int_len = if inter == 4 {
390+
magnitude_str.len()
391+
} else {
392+
magnitude_str
393+
.bytes()
394+
.position(|b| !b.is_ascii_digit())
395+
.unwrap_or(magnitude_str.len())
396+
};
397+
// No leading integer digits (e.g. "inf"/"nan") means nothing to group;
398+
// leave any width padding to the fill/align step.
399+
if int_len == 0 {
400+
return magnitude_str;
401+
}
402+
let magnitude_int_str = magnitude_str[..int_len].to_string();
403+
let remainder = &magnitude_str[int_len..];
386404
let dec_digit_cnt = magnitude_str.len() as i32 - magnitude_int_str.len() as i32;
387405
let int_digit_cnt = disp_digit_cnt - dec_digit_cnt;
388406
let mut result = Self::separate_integer(magnitude_int_str, inter, sep, int_digit_cnt);
389-
if let Some(part) = parts.next() {
390-
result.push_str(&format!(".{part}"))
391-
}
407+
result.push_str(remainder);
392408
result
393409
}
394410

@@ -786,14 +802,40 @@ impl FormatSpec {
786802
magnitude if magnitude.is_nan() => Ok("nan".to_owned()),
787803
magnitude if magnitude.is_infinite() => Ok("inf".to_owned()),
788804
_ => match self.precision {
789-
Some(precision) => Ok(float::format_general(
790-
precision,
791-
magnitude,
792-
Case::Lower,
793-
self.alternate_form,
794-
true,
795-
)),
796-
None => Ok(float::to_string(magnitude)),
805+
Some(precision) => {
806+
// Empty presentation type with a precision behaves like
807+
// `g` but repr-like: precision is clamped to at least 1,
808+
// and an integer-looking result keeps a trailing `.0`
809+
// (Py_DTSF_ADD_DOT_0).
810+
let precision = if precision == 0 { 1 } else { precision };
811+
let s = float::format_general(
812+
precision,
813+
magnitude,
814+
Case::Lower,
815+
self.alternate_form,
816+
true,
817+
);
818+
Ok(if s.bytes().any(|b| matches!(b, b'.' | b'e' | b'E')) {
819+
s
820+
} else {
821+
format!("{s}.0")
822+
})
823+
}
824+
None => {
825+
let s = float::to_string(magnitude);
826+
// Alternate form forces a decimal point into the
827+
// repr-like output. Only exponent-form values lack one
828+
// (`1e+16` -> `1.e+16`); fixed-form repr already carries
829+
// a `.`.
830+
Ok(if self.alternate_form && !s.contains('.') {
831+
match s.find(['e', 'E']) {
832+
Some(pos) => format!("{}.{}", &s[..pos], &s[pos..]),
833+
None => format!("{s}."),
834+
}
835+
} else {
836+
s
837+
})
838+
}
797839
},
798840
},
799841
};
@@ -857,10 +899,11 @@ impl FormatSpec {
857899
Err(FormatSpecError::UnknownFormatCode('N', "int"))
858900
}
859901
Some(FormatType::String) => Err(FormatSpecError::UnknownFormatCode('s', "int")),
860-
Some(FormatType::Character) => match (self.sign, self.alternate_form) {
861-
(Some(_), _) => Err(FormatSpecError::NotAllowed("Sign")),
862-
(_, true) => Err(FormatSpecError::NotAllowed("Alternate form (#)")),
863-
(_, _) => match num.to_u32() {
902+
Some(FormatType::Character) => match (self.precision, self.sign, self.alternate_form) {
903+
(Some(_), _, _) => Err(FormatSpecError::PrecisionNotAllowed),
904+
(_, Some(_), _) => Err(FormatSpecError::NotAllowed("Sign")),
905+
(_, _, true) => Err(FormatSpecError::NotAllowed("Alternate form (#)")),
906+
(_, _, _) => match num.to_u32() {
864907
Some(n) if n <= 0x10ffff => Ok(core::char::from_u32(n).unwrap().to_string()),
865908
Some(_) | None => Err(FormatSpecError::CodeNotInRange),
866909
},
@@ -1664,6 +1707,96 @@ mod tests {
16641707
assert_eq!(result, "000001,234");
16651708
}
16661709

1710+
fn fmt_float(spec: &str, value: f64) -> String {
1711+
FormatSpec::parse(spec).unwrap().format_float(value).unwrap()
1712+
}
1713+
1714+
#[test]
1715+
fn format_float_grouping_never_touches_exponent() {
1716+
// Grouping must group only the mantissa's integer digits, never the
1717+
// exponent digits (was "1e,+20") or a trailing percent sign.
1718+
assert_eq!(fmt_float(",g", 1e20), "1e+20");
1719+
assert_eq!(fmt_float("_g", 1e-10), "1e-10");
1720+
assert_eq!(fmt_float(",e", 1e20), "1.000000e+20");
1721+
assert_eq!(fmt_float(",", 1e16), "1e+16");
1722+
assert_eq!(fmt_float(",.0%", 1.0), "100%");
1723+
assert_eq!(fmt_float(",.2%", 12345.0), "1,234,500.00%");
1724+
// Fixed-form grouping still groups the integer part.
1725+
assert_eq!(fmt_float(",", 1234567.0), "1,234,567.0");
1726+
}
1727+
1728+
#[test]
1729+
fn format_float_grouping_inf_nan() {
1730+
// No integer digits to group; width padding is left to fill/align, so
1731+
// separators never land inside "inf"/"nan".
1732+
assert_eq!(fmt_float(",", f64::INFINITY), "inf");
1733+
assert_eq!(fmt_float("06,", f64::INFINITY), "000inf");
1734+
assert_eq!(fmt_float("06,", f64::NAN), "000nan");
1735+
assert_eq!(fmt_float("06,%", f64::INFINITY), "00inf%");
1736+
}
1737+
1738+
#[test]
1739+
fn format_float_empty_type_with_precision() {
1740+
// Empty presentation type with a precision is repr-like: precision is
1741+
// clamped to at least 1 and integer-looking output keeps a `.0`.
1742+
assert_eq!(fmt_float(".2", 1.0), "1.0");
1743+
assert_eq!(fmt_float(".6", 100.0), "100.0");
1744+
assert_eq!(fmt_float(".17", 1234567.0), "1234567.0");
1745+
assert_eq!(fmt_float(".0", 0.5), "0.5");
1746+
assert_eq!(fmt_float(".0", 0.0001), "0.0001");
1747+
assert_eq!(fmt_float(".2", 0.0), "0.0");
1748+
assert_eq!(fmt_float(".0", 0.0), "0e+00");
1749+
assert_eq!(fmt_float(".2", 100.0), "1e+02");
1750+
}
1751+
1752+
#[test]
1753+
fn format_float_alternate_form_forces_point() {
1754+
// Alternate form injects a decimal point into exponent-form repr.
1755+
assert_eq!(fmt_float("#", 1e16), "1.e+16");
1756+
assert_eq!(fmt_float("#", 1e-5), "1.e-05");
1757+
// Fixed-form repr already has a point, so it is unchanged.
1758+
assert_eq!(fmt_float("#", 100.0), "100.0");
1759+
assert_eq!(fmt_float("#", 1.5), "1.5");
1760+
}
1761+
1762+
#[test]
1763+
fn format_int_hex_grouping_preserved() {
1764+
// Underscore grouping of hex/octal groups every 4 digits, including the
1765+
// `a`-`f` letters.
1766+
assert_eq!(
1767+
FormatSpec::parse("_x")
1768+
.unwrap()
1769+
.format_int(&BigInt::from(1000000))
1770+
.unwrap(),
1771+
"f_4240"
1772+
);
1773+
assert_eq!(
1774+
FormatSpec::parse("_X")
1775+
.unwrap()
1776+
.format_int(&BigInt::from(0xABCDEFu32))
1777+
.unwrap(),
1778+
"AB_CDEF"
1779+
);
1780+
}
1781+
1782+
#[test]
1783+
fn format_int_character_rejects_precision() {
1784+
// 'c' rejects precision, and precision is checked before sign/alt form.
1785+
assert_eq!(
1786+
FormatSpec::parse(".2c").unwrap().format_int(&BigInt::from(65)),
1787+
Err(FormatSpecError::PrecisionNotAllowed)
1788+
);
1789+
assert_eq!(
1790+
FormatSpec::parse("+.2c").unwrap().format_int(&BigInt::from(65)),
1791+
Err(FormatSpecError::PrecisionNotAllowed)
1792+
);
1793+
// Without precision, 'c' still renders the code point.
1794+
assert_eq!(
1795+
FormatSpec::parse("c").unwrap().format_int(&BigInt::from(65)),
1796+
Ok("A".to_owned())
1797+
);
1798+
}
1799+
16671800
#[test]
16681801
fn format_parse() {
16691802
let expected = Ok(FormatString {

0 commit comments

Comments
 (0)