diff --git a/internal/object/float_tos_test.go b/internal/object/float_tos_test.go new file mode 100644 index 00000000..b63d1ab7 --- /dev/null +++ b/internal/object/float_tos_test.go @@ -0,0 +1,61 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package object + +import ( + "math" + "testing" +) + +// TestFloatToSMatchesMRI pins Float#to_s against MRI's flo_to_s (numeric.c): +// the shortest round-tripping digits are laid out in fixed notation while the +// decimal point falls within -3..DBL_DIG (15), and in exponential notation +// outside that, where the mantissa always carries a fractional digit. +// +// Every expected value is what `ruby` 4.0.5 prints for the same literal. +func TestFloatToSMatchesMRI(t *testing.T) { + cases := []struct { + in float64 + want string + }{ + // Fixed notation, including the "1.0 not 1" rule. + {0, "0.0"}, + {math.Copysign(0, -1), "-0.0"}, + {1, "1.0"}, + {3.14, "3.14"}, + {-2.5, "-2.5"}, + {0.1, "0.1"}, + {100, "100.0"}, + {1e6, "1000000.0"}, // Go's 'g' verb gives "1e+06" here + // The upper boundary: decpt 15 stays fixed, 16 goes exponential. + {1e14, "100000000000000.0"}, + {123456789012345, "123456789012345.0"}, + {1e15, "1.0e+15"}, + {1234567890123456, "1.234567890123456e+15"}, + // The lower boundary: decpt -3 stays fixed, -4 goes exponential. + {1e-4, "0.0001"}, + {1e-5, "1.0e-05"}, + // Exponential, with and without a fraction of its own. + {1e-6, "1.0e-06"}, + {1.5e-6, "1.5e-06"}, + {-1e-6, "-1.0e-06"}, + {1e20, "1.0e+20"}, + {1e100, "1.0e+100"}, + {5e-324, "5.0e-324"}, + {math.MaxFloat64, "1.7976931348623157e+308"}, + // The non-finite values. + {math.Inf(1), "Infinity"}, + {math.Inf(-1), "-Infinity"}, + {math.NaN(), "NaN"}, + } + for _, c := range cases { + if got := Float(c.in).ToS(); got != c.want { + t.Errorf("Float(%v).ToS() = %q, want %q", c.in, got, c.want) + } + if got := Float(c.in).Inspect(); got != c.want { + t.Errorf("Float(%v).Inspect() = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/object/object.go b/internal/object/object.go index e5097323..9692dcbe 100644 --- a/internal/object/object.go +++ b/internal/object/object.go @@ -101,18 +101,39 @@ func BigOf(v Value) (*big.Int, bool) { type Float float64 func (f Float) ToS() string { - if math.IsInf(float64(f), 1) { + v := float64(f) + if math.IsInf(v, 1) { return "Infinity" } - if math.IsInf(float64(f), -1) { + if math.IsInf(v, -1) { return "-Infinity" } - if math.IsNaN(float64(f)) { + if math.IsNaN(v) { return "NaN" } - // Ruby always shows a decimal point for floats (1.0 not 1). - s := strconv.FormatFloat(float64(f), 'g', -1, 64) - if !strings.ContainsAny(s, ".eE") { + // MRI's flo_to_s (numeric.c) lays the shortest round-tripping digits out in + // fixed notation when the decimal point falls within -3..DBL_DIG (15), and in + // exponential notation otherwise. Go's 'g' verb decides differently — it + // compares the exponent against the number of significant digits — so 1e6 + // rendered as "1e+06" where Ruby prints "1000000.0", and 1e15 as + // "1e+15" where Ruby prints "1.0e+15". + // + // 'e' with precision -1 gives the shortest digits plus the exponent, which is + // what decides the layout. Atoi cannot fail on an exponent Go just produced. + e := strconv.FormatFloat(v, 'e', -1, 64) + at := strings.IndexByte(e, 'e') + exp, _ := strconv.Atoi(e[at+1:]) + if decpt := exp + 1; decpt < -3 || decpt > 15 { + // Ruby always shows a fractional digit in the mantissa: 1.0e-06, not 1e-06. + mant := e[:at] + if !strings.ContainsRune(mant, '.') { + mant += ".0" + } + return mant + e[at:] + } + s := strconv.FormatFloat(v, 'f', -1, 64) + // And a decimal point in fixed notation: 1.0, not 1. + if !strings.ContainsRune(s, '.') { s += ".0" } return s diff --git a/internal/vm/process_limits_internal_test.go b/internal/vm/process_limits_internal_test.go index 4cbb2c97..8c80ab4e 100644 --- a/internal/vm/process_limits_internal_test.go +++ b/internal/vm/process_limits_internal_test.go @@ -384,11 +384,10 @@ p Process.times.utime > u`, "true\n"}, {`p Process.clock_getres(:TIME_BASED_CLOCK_REALTIME, :nanosecond)`, "1000000000\n"}, {`p Process.clock_getres(:GETRUSAGE_BASED_CLOCK_PROCESS_CPUTIME_ID, :nanosecond)`, "1000\n"}, {`p Process.clock_getres(Process::CLOCK_REALTIME, :nanosecond)`, "1000\n"}, - // 1e-06 second, the same value MRI reports; the spelling differs only - // because this VM's Float#inspect drops the ".0" before the exponent - // (`p 0.000001` prints 1e-06 here and 1.0e-06 in MRI) — a core - // Float-formatting divergence, not a clock one. - {`p Process.clock_getres(Process::CLOCK_MONOTONIC)`, "1e-06\n"}, + // 1e-06 second, the same value and now the same spelling as MRI: the + // Float#inspect divergence this case used to document (1e-06 here, + // 1.0e-06 in MRI) is fixed in internal/object. + {`p Process.clock_getres(Process::CLOCK_MONOTONIC)`, "1.0e-06\n"}, // argv0 is frozen and is the same object every time. {`p [Process.argv0.is_a?(String), Process.argv0.frozen?, Process.argv0.equal?(Process.argv0)]`, "[true, true, true]\n"},