diff --git a/crates/literal/src/complex.rs b/crates/literal/src/complex.rs index c91d1c143..bbfc88cb3 100644 --- a/crates/literal/src/complex.rs +++ b/crates/literal/src/complex.rs @@ -2,14 +2,39 @@ use crate::float; use alloc::borrow::ToOwned; use alloc::string::{String, ToString}; +/// Format a single complex component (real or imag) for `repr`. +/// Uses scientific notation when `|value| < 1e-4` or `|value| >= 1e16` +/// (matching CPython's `PyOS_double_to_string(format='r')`), otherwise +/// Rust's default `Display`, which drops the trailing `.0` for +/// integer-valued floats. +/// +/// This differs from `float::to_string` only in that integer values in +/// the normal range render as `"1"` rather than `"1.0"` — complex repr +/// formats `1+2j` as `"(1+2j)"`, not `"(1.0+2.0j)"`. +fn component_to_string(value: f64) -> String { + let lit = alloc::format!("{value:e}"); + if let Some(position) = lit.find('e') { + let significand = &lit[..position]; + let exponent = lit[position + 1..].parse::().unwrap(); + if exponent < 16 && exponent > -5 { + // Normal magnitude — Rust's default Display emits "1" for 1.0, + // "1.5" for 1.5, "1000000000000000" for 1e15, etc. + value.to_string() + } else { + alloc::format!("{significand}e{exponent:+#03}") + } + } else { + // nan / inf / -inf — `format!("{x:e}")` produces e.g. "NaN" with no + // exponent marker; lowercase to match Python. + let mut s = value.to_string(); + s.make_ascii_lowercase(); + s + } +} + /// Convert a complex number to a string. pub fn to_string(re: f64, im: f64) -> String { - // integer => drop ., fractional => float_ops - let mut im_part = if im.fract() == 0.0 { - im.to_string() - } else { - float::to_string(im) - }; + let mut im_part = component_to_string(im); im_part.push('j'); // positive empty => return im_part, integer => drop ., fractional => float_ops @@ -19,10 +44,8 @@ pub fn to_string(re: f64, im: f64) -> String { } else { "-0".to_owned() } - } else if re.fract() == 0.0 { - re.to_string() } else { - float::to_string(re) + component_to_string(re) }; let mut result = String::with_capacity(re_part.len() + im_part.len() + 2 + im.is_sign_positive() as usize); diff --git a/extra_tests/snippets/builtin_complex.py b/extra_tests/snippets/builtin_complex.py index 2a2c2d375..136f26ef0 100644 --- a/extra_tests/snippets/builtin_complex.py +++ b/extra_tests/snippets/builtin_complex.py @@ -236,3 +236,35 @@ class complex_subclass(complex): z = complex_subclass(3 + 4j) assert z.__complex__() == 3 + 4j assert type(z.__complex__()) == complex + + +# repr must use scientific notation for |value| >= 1e16 or < 1e-4, matching +# CPython. Previously integer-valued large magnitudes (e.g. 1e16, 1e100) hit +# a `fract() == 0.0` branch in rustpython_literal::complex::to_string that +# used Rust's default Display — which emits the full decimal expansion +# (`10000...000`) instead of `1e+16`. +assert repr(1e16 + 1j) == "(1e+16+1j)" +assert repr(1e17 + 1j) == "(1e+17+1j)" +assert repr(1e100 + 1e100j) == "(1e+100+1e+100j)" +assert repr(-1e100 - 1e100j) == "(-1e+100-1e+100j)" +assert repr(1e-100 + 1e100j) == "(1e-100+1e+100j)" +assert repr(1 + 1e100j) == "(1+1e+100j)" +assert repr(1e100 + 1j) == "(1e+100+1j)" + +# Threshold boundary: |x| in [1e-4, 1e16) renders in decimal form; values +# outside that range use scientific notation. These three assertions pin +# the exact transition points. +assert repr(1e15 + 1j) == "(1000000000000000+1j)" # below 1e16 -> decimal +assert repr(1e-4 + 1j) == "(0.0001+1j)" # at 1e-4 (inclusive) -> decimal +assert repr(1e-5 + 1j) == "(1e-05+1j)" # below 1e-4 -> scientific + +# Integer-valued components render without trailing ".0". +assert repr(1 + 2j) == "(1+2j)" +assert repr(1.0 + 2.0j) == "(1+2j)" + +# Special values still round-trip correctly. +assert repr(float("nan") + 1j) == "(nan+1j)" +assert repr(float("inf") + 1j) == "(inf+1j)" +assert repr(float("-inf") + 1j) == "(-inf+1j)" +assert repr(complex(1, float("nan"))) == "(1+nanj)" +assert repr(complex(1, float("inf"))) == "(1+infj)"