This commit is contained in:
2026-08-30 12:22:00 +03:00
parent 8f607afb3c
commit b18dacec07
7 changed files with 403 additions and 53 deletions
+200 -38
View File
@@ -77,18 +77,27 @@ impl Rational {
Self { num: n, den: 1 }
}
/// Parse a decimal literal like `12`, `-3.5`, `0.001` into an exact fraction.
/// Parse a decimal literal like `12`, `-3.5`, `0.001`, `1.5e-9` into an
/// exact fraction (exponent shifts by exact powers of ten).
fn parse(s: &str) -> Result<Self, CalcError> {
let s = s.trim();
if s.is_empty() || s == "." || s == "-" || s == "-." {
return Err(CalcError::Invalid);
}
let neg = s.starts_with('-');
let body = if neg { &s[1..] } else { s };
// Split off an optional exponent: `1e-15`, `2.5E+3`.
let (body, exp) = match s.split_once(['e', 'E']) {
Some((b, e)) => {
let exp: i32 = e.parse().map_err(|_| CalcError::Invalid)?;
(b, exp)
}
None => (s, 0i32),
};
let neg = body.starts_with('-');
let b = if neg { &body[1..] } else { body };
let (int_part, frac_part) = match body.split_once('.') {
Some((a, b)) => (a, b),
None => (body, ""),
let (int_part, frac_part) = match b.split_once('.') {
Some((a, c)) => (a, c),
None => (b, ""),
};
if int_part.is_empty() && frac_part.is_empty() {
return Err(CalcError::Invalid);
@@ -107,6 +116,27 @@ impl Rational {
.and_then(|v| v.checked_add(frac_val))
.ok_or(CalcError::Overflow)?;
let num = if neg { -num } else { num };
// Apply the exponent with exact power-of-ten arithmetic:
// value·10^exp = num·10^exp / 10^frac_digits.
let (num, den) = if exp >= 0 {
let e = exp as usize;
if e >= frac_digits {
(
num.checked_mul(pow10(e - frac_digits)?)
.ok_or(CalcError::Overflow)?,
1,
)
} else {
(num, den / pow10(e)?)
}
} else {
(
num,
den.checked_mul(pow10((-exp) as usize)?)
.ok_or(CalcError::Overflow)?,
)
};
Self::new(num, den)
}
@@ -175,9 +205,14 @@ impl Value {
if !v.is_finite() {
return Err(CalcError::Overflow);
}
// Prefer exact integer when float is (almost) integral.
if v.fract().abs() < 1e-12 && v.abs() < 1e15 {
return Ok(Value::Rat(Rational::from_i128(v.round() as i128)));
if v == 0.0 {
return Ok(Value::Rat(Rational::from_i128(0)));
}
// Prefer exact integer when float is (almost) integral. Never snap a
// sub-unit magnitude to an integer: that turned 1e-16 into `0`.
let r = v.round();
if r != 0.0 && (v - r).abs() < 1e-12 && v.abs() < 1e15 {
return Ok(Value::Rat(Rational::from_i128(r as i128)));
}
Ok(Value::Float(v))
}
@@ -394,7 +429,7 @@ impl Calculator {
self.entry = d.to_string();
} else if self.entry == "-0" {
self.entry = format!("-{d}");
} else if digit_count(&self.entry) < 16 {
} else if digit_count(&self.entry) < 40 && significant_digits(&self.entry) < 16 {
self.entry.push(d);
}
self.entry_value = None;
@@ -911,6 +946,19 @@ fn tokenize(src: &str) -> Result<Vec<(Tok, Option<Value>)>, CalcError> {
while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
i += 1;
}
// Optional decimal exponent: `1e-9`, `2.5E+3`.
if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') {
let mut j = i + 1;
if j < chars.len() && (chars[j] == '+' || chars[j] == '-') {
j += 1;
}
if j < chars.len() && chars[j].is_ascii_digit() {
i = j;
while i < chars.len() && chars[i].is_ascii_digit() {
i += 1;
}
}
}
let lit: String = chars[start..i].iter().collect();
let v = Value::from_entry(&lit)?;
out.push((Tok::Num, Some(v)));
@@ -984,6 +1032,15 @@ fn digit_count(s: &str) -> usize {
s.chars().filter(|c| c.is_ascii_digit()).count()
}
/// Significant digits (leading zeros don't count) — caps precision, not
/// magnitude, so `0.0000000000000001` (1e-16) can be typed.
fn significant_digits(s: &str) -> usize {
s.chars()
.filter(|c| c.is_ascii_digit())
.skip_while(|&c| c == '0')
.count()
}
fn gcd(mut a: u128, mut b: u128) -> u128 {
while b != 0 {
let t = b;
@@ -1016,36 +1073,36 @@ fn format_rational(r: Rational) -> String {
}
fn terminating_decimal(r: Rational) -> Option<String> {
// Terminating iff den = 2^a · 5^b. Digit count after the point is
// k = max(a, b); scale num so the denominator becomes exactly 10^k.
let mut den = r.den;
let (mut a, mut b) = (0u32, 0u32);
while den % 2 == 0 {
den /= 2;
a += 1;
}
while den % 5 == 0 {
den /= 5;
b += 1;
}
if den != 1 {
return None;
}
// Scale denominator to a power of 10.
let mut d = r.den;
let mut n = r.num;
let neg = n < 0;
n = n.abs();
while d % 10 != 0 {
if d % 2 == 0 {
d *= 5;
n *= 5;
} else if d % 5 == 0 {
d *= 2;
n *= 2;
} else {
break;
}
let k = a.max(b);
let mut n = r.num.abs();
for _ in 0..(k - a) {
n = n.checked_mul(2)?;
}
for _ in 0..(k - b) {
n = n.checked_mul(5)?;
}
let mut d: i128 = 1;
for _ in 0..k {
d = d.checked_mul(10)?;
}
let int_part = n / d;
let mut frac = (n % d).to_string();
let width = d.to_string().len().saturating_sub(1);
while frac.len() < width {
while frac.len() < k as usize {
frac.insert(0, '0');
}
// trim trailing zeros
@@ -1057,24 +1114,26 @@ fn terminating_decimal(r: Rational) -> Option<String> {
} else {
format!("{int_part}.{frac}")
};
Some(if neg { format!("-{body}") } else { body })
Some(if r.num < 0 { format!("-{body}") } else { body })
}
fn format_float(v: f64) -> String {
if !v.is_finite() {
if !v.is_finite() || v == 0.0 {
return "0".into();
}
// Snap near-integers (guards any float path residue).
if (v - v.round()).abs() < 1e-10 && v.abs() < 1e15 {
return format!("{}", v.round() as i64);
// Snap near-integers (guards any float path residue); never for |v| < 1,
// otherwise tiny results like 1e-16 collapse to `0`.
let r = v.round();
if r != 0.0 && (v - r).abs() < 1e-10 && v.abs() < 1e15 {
return format!("{}", r as i64);
}
let s = format!("{:.12}", v);
let s = s.trim_end_matches('0').trim_end_matches('.').to_string();
if s.is_empty() || s == "-" {
"0".into()
} else {
s
// Very small / large magnitudes: scientific notation keeps the value
// visible and re-parseable (`Rational::parse` understands exponents).
if v.abs() < 1e-10 || v.abs() >= 1e16 {
return format!("{v:e}");
}
// Shortest representation that round-trips back to `v` exactly.
format!("{v}")
}
impl fmt::Display for Rational {
@@ -1173,4 +1232,107 @@ mod tests {
let r = Rational::parse("1.25").unwrap();
assert_eq!(r, Rational::new(5, 4).unwrap());
}
// --- regression tests (rounding / formatting bugs) ---
#[test]
fn quarter_and_eighth_display() {
// terminating_decimal scaled the fraction wrong: 1/4 displayed as "0.5".
let mut c = Calculator::new();
c.input_digit('1');
c.set_op(Op::Div);
c.input_digit('4');
c.equals();
assert_eq!(c.display(), "0.25");
c.clear_all();
c.input_digit('1');
c.set_op(Op::Div);
c.input_digit('8');
c.equals();
assert_eq!(c.display(), "0.125");
}
#[test]
fn five_hundredths_display() {
// 0.05 must survive exact formatting (was displayed as "0.1").
let mut c = Calculator::new();
c.input_digit('0');
c.input_dot();
c.input_digit('0');
c.input_digit('5'); // 0.05
c.set_op(Op::Mul);
c.input_digit('1');
c.equals();
assert_eq!(c.display(), "0.05");
}
#[test]
fn tiny_reciprocal_not_zero() {
// 1/9999999999999999 ≈ 1e-16 was snapped to "0" by format_float.
let mut c = Calculator::new();
for d in "9999999999999999".chars() {
c.input_digit(d);
}
c.reciprocal();
let disp = c.display();
assert!(disp != "0", "tiny reciprocal must not display as 0, got {disp}");
}
#[test]
fn tiny_result_reusable_standard() {
// Float result in scientific notation must be re-parsable as operand.
let mut c = Calculator::new();
for d in "9999999999999999".chars() {
c.input_digit(d);
}
c.reciprocal();
c.set_op(Op::Mul);
c.input_digit('2');
c.equals();
let disp = c.display();
assert!(disp != "0" && disp != "Invalid input", "got {disp}");
}
#[test]
fn sqrt_square_roundtrip_tiny() {
// √(1e-30) = 1e-15 was snapped to 0 by Value::from_f64.
let mut c = Calculator::new();
c.input_dot(); // "0."
for _ in 0..14 {
c.input_digit('0');
}
c.input_digit('1'); // 0.000000000000001 = 1e-15
c.square();
c.sqrt();
let disp = c.display();
assert!(disp != "0", "sqrt(1e-30) must not display as 0, got {disp}");
}
#[test]
fn rational_parse_exponent() {
// Scientific-notation entries must parse to exact fractions.
let r = Rational::parse("1e-15").unwrap();
assert_eq!(r, Rational::new(1, 1_000_000_000_000_000).unwrap());
let r = Rational::parse("1.5e3").unwrap();
assert_eq!(r, Rational::new(1500, 1).unwrap());
let r = Rational::parse("2.5e-2").unwrap();
assert_eq!(r, Rational::new(1, 40).unwrap());
let r = Rational::parse("-1E+3").unwrap();
assert_eq!(r, Rational::new(-1000, 1).unwrap());
assert!(Rational::parse("1e2e3").is_err());
assert!(Rational::parse("1e").is_err());
}
#[test]
fn can_type_one_e_minus_16() {
// Leading zeros must not eat the 16-significant-digit budget.
let mut c = Calculator::new();
c.input_dot();
for _ in 0..15 {
c.input_digit('0');
}
c.input_digit('1');
assert_eq!(c.display(), "0.0000000000000001");
}
}