diff --git a/modules/lagoon/validate_rules.go b/modules/lagoon/validate_rules.go index 63e02ec..79531ec 100644 --- a/modules/lagoon/validate_rules.go +++ b/modules/lagoon/validate_rules.go @@ -618,8 +618,11 @@ func phpScalarString(value any) (string, bool) { return "", false } -// phpFloatString formats a float as PHP 8 casts it to string: the shortest -// round-trip digits, in exponent form (1.0E+15) from 1e15 up and below 1e-4. +// phpFloatString formats a float as PHP 8 casts it to string: %.14G with +// the default precision ini of 14 (zend_gcvt). The value is correctly +// rounded to 14 significant digits and trailing zeros are dropped; it is +// written in exponent form (1.0E+15, 1.5E-5) when the decimal point would +// sit more than 14 digits right or more than 3 zeros left of the digits. func phpFloatString(f float64) string { switch { case math.IsNaN(f): @@ -634,21 +637,38 @@ func phpFloatString(f float64) string { } return "0" } - e := strconv.FormatFloat(f, 'e', -1, 64) + const precision = 14 + e := strconv.FormatFloat(math.Abs(f), 'e', precision-1, 64) mant, expStr, _ := strings.Cut(e, "e") exp, _ := strconv.Atoi(expStr) - if exp >= -4 && exp < 15 { - return strconv.FormatFloat(f, 'f', -1, 64) + digits := strings.TrimRight(strings.Replace(mant, ".", "", 1), "0") + if digits == "" { + digits = "0" } - if !strings.Contains(mant, ".") { - mant += ".0" + decpt := exp + 1 + var out string + switch { + case decpt < -3 || decpt > precision: + m := digits[:1] + ".0" + if len(digits) > 1 { + m = digits[:1] + "." + digits[1:] + } + sign := "+" + if exp < 0 { + sign, exp = "-", -exp + } + out = m + "E" + sign + strconv.Itoa(exp) + case decpt <= 0: + out = "0." + strings.Repeat("0", -decpt) + digits + case decpt >= len(digits): + out = digits + strings.Repeat("0", decpt-len(digits)) + default: + out = digits[:decpt] + "." + digits[decpt:] } - sign := "+" - if exp < 0 { - sign = "-" - exp = -exp + if f < 0 { + out = "-" + out } - return mant + "E" + sign + strconv.Itoa(exp) + return out } // phpTrim trims the characters PHP's trim() does. diff --git a/modules/lagoon/validate_rules_test.go b/modules/lagoon/validate_rules_test.go index 6af7ac1..f5b6e0f 100644 --- a/modules/lagoon/validate_rules_test.go +++ b/modules/lagoon/validate_rules_test.go @@ -2,6 +2,7 @@ package lagoon import ( "encoding/json" + "math" "reflect" "strings" "testing" @@ -209,3 +210,45 @@ func TestValidateRulesMatchLaravel(t *testing.T) { }) } } + +// TestPHPFloatStringMatchesPHPCast pins phpFloatString to PHP 8's (string) +// cast of a float, which formats with the precision ini (14 significant +// digits, %.14G): the wants are php -r '(string)$x' output. +func TestPHPFloatStringMatchesPHPCast(t *testing.T) { + for _, tc := range []struct { + in float64 + want string + }{ + {0.1 + 0.2, "0.3"}, + {1e14, "1.0E+14"}, + {1e15, "1.0E+15"}, + {1e20, "1.0E+20"}, + {123456789012345.0, "1.2345678901234E+14"}, + {12345678901234.0, "12345678901234"}, + {1.5e-5, "1.5E-5"}, + {0.0001, "0.0001"}, + {0.00012345678901234567, "0.00012345678901235"}, + {-2.5, "-2.5"}, + {3.0, "3"}, + {1.0 / 3, "0.33333333333333"}, + {2.0 / 3, "0.66666666666667"}, + {-1e-10, "-1.0E-10"}, + {99999999999999.9, "1.0E+14"}, + {0.1, "0.1"}, + {999999999999999.0, "1.0E+15"}, + {5e-324, "4.9406564584125E-324"}, + {1.7976931348623157e308, "1.7976931348623E+308"}, + {100.0, "100"}, + {1234.5678, "1234.5678"}, + {-0.000099999, "-9.9999E-5"}, + {0, "0"}, + {math.Copysign(0, -1), "-0"}, + {math.Inf(1), "INF"}, + {math.Inf(-1), "-INF"}, + {math.NaN(), "NAN"}, + } { + if got := phpFloatString(tc.in); got != tc.want { + t.Errorf("phpFloatString(%v) = %q, want %q", tc.in, got, tc.want) + } + } +}