Skip to content

Commit 41d8ecd

Browse files
PgBiellaurmaedje
andcommitted
Fix hashing of equal decimals with different scales (#5179)
Co-authored-by: Laurenz <[email protected]>
1 parent eea8f13 commit 41d8ecd

File tree

2 files changed

+49
-1
lines changed

2 files changed

+49
-1
lines changed

crates/typst/src/foundations/decimal.rs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::fmt::{self, Display, Formatter};
2+
use std::hash::{Hash, Hasher};
23
use std::ops::Neg;
34
use std::str::FromStr;
45

@@ -88,7 +89,7 @@ use crate::World;
8889
/// to rounding. When those two operations do not surpass the digit limits, they
8990
/// are fully precise.
9091
#[ty(scope, cast)]
91-
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
92+
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
9293
pub struct Decimal(rust_decimal::Decimal);
9394

9495
impl Decimal {
@@ -370,6 +371,22 @@ impl Neg for Decimal {
370371
}
371372
}
372373

374+
impl Hash for Decimal {
375+
fn hash<H: Hasher>(&self, state: &mut H) {
376+
// `rust_decimal`'s Hash implementation normalizes decimals before
377+
// hashing them. This means decimals with different scales but
378+
// equivalent value not only compare equal but also hash equally. Here,
379+
// we hash all bytes explicitly to ensure the scale is also considered.
380+
// This means that 123.314 == 123.31400, but 123.314.hash() !=
381+
// 123.31400.hash().
382+
//
383+
// Note that this implies that equal decimals can have different hashes,
384+
// which might generate problems with certain data structures, such as
385+
// HashSet and HashMap.
386+
self.0.serialize().hash(state);
387+
}
388+
}
389+
373390
/// A value that can be cast to a decimal.
374391
pub enum ToDecimal {
375392
/// A string with the decimal's representation.
@@ -386,3 +403,27 @@ cast! {
386403
v: f64 => Self::Float(v),
387404
v: Str => Self::Str(EcoString::from(v)),
388405
}
406+
407+
#[cfg(test)]
408+
mod tests {
409+
use std::str::FromStr;
410+
411+
use super::Decimal;
412+
use crate::utils::hash128;
413+
414+
#[test]
415+
fn test_decimals_with_equal_scales_hash_identically() {
416+
let a = Decimal::from_str("3.14").unwrap();
417+
let b = Decimal::from_str("3.14").unwrap();
418+
assert_eq!(a, b);
419+
assert_eq!(hash128(&a), hash128(&b));
420+
}
421+
422+
#[test]
423+
fn test_decimals_with_different_scales_hash_differently() {
424+
let a = Decimal::from_str("3.140").unwrap();
425+
let b = Decimal::from_str("3.14000").unwrap();
426+
assert_eq!(a, b);
427+
assert_ne!(hash128(&a), hash128(&b));
428+
}
429+
}

tests/suite/foundations/decimal.typ

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@
3131
// Error: 10-19 float is not a valid decimal: float.nan
3232
#decimal(float.nan)
3333

34+
--- decimal-scale-is-observable ---
35+
// Ensure equal decimals with different scales produce different strings.
36+
#let f1(x) = str(x)
37+
#let f2(x) = f1(x)
38+
#test(f2(decimal("3.140")), "3.140")
39+
#test(f2(decimal("3.14000")), "3.14000")
40+
3441
--- decimal-repr ---
3542
// Test the `repr` function with decimals.
3643
#test(repr(decimal("12.0")), "decimal(\"12.0\")")

0 commit comments

Comments
 (0)