From e0f308dc7ce0674612cee3ec8168bf9800d51ee5 Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Fri, 13 Dec 2024 15:23:39 -0600 Subject: [PATCH 01/19] Add failing test case demonstrating sprintf negative zero bug #15557 --- .../FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs index 187fa62c60e..622e5370cbc 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs +++ b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs @@ -75,6 +75,16 @@ type PrintfTests() = Assert.AreEqual(" 7B", sprintf "%*X" 8 123 ) Assert.AreEqual("7B ", sprintf "%-*X" 8 123 ) + + [] + member this.``positive and negative zero``() = + test "%f" +0.0 "0.000000" + test "%f" -0.0 "-0.000000" + test "%f" -0.0000001 "-0.000000" + test "%+f" +0.0 "+0.000000" + test "%+f" -0.0 "-0.000000" + test "%+f" -0.0000001 "-0.000000" + [] member _.``union case formatting`` () = Assert.AreEqual("CaseOne", sprintf "%A" CaseOne) From 543dd71a6c9efd965ade80530c39a745510bb96a Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Fri, 13 Dec 2024 16:41:04 -0600 Subject: [PATCH 02/19] Fix negative zero behavior with printf "+" flag --- src/FSharp.Core/printf.fs | 14 ++++++++++---- .../Microsoft.FSharp.Core/PrintfTests.fs | 19 +++++++++++++------ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/FSharp.Core/printf.fs b/src/FSharp.Core/printf.fs index 8dfaf52b680..96324368a4d 100644 --- a/src/FSharp.Core/printf.fs +++ b/src/FSharp.Core/printf.fs @@ -657,8 +657,14 @@ module internal PrintfImpl = /// Contains functions to handle left/right and no justification case for numbers module GenericNumber = - - let isPositive (n: obj) = + + let inline singleIsNotNegativeZero (n: single) = + BitConverter.DoubleToInt64Bits (float n) <> BitConverter.DoubleToInt64Bits -0.0 + + let inline doubleIsNotNegativeZero (n: double) = + BitConverter.DoubleToInt64Bits n <> BitConverter.DoubleToInt64Bits -0.0 + + let isPositive (n: obj) = match n with | :? int8 as n -> n >= 0y | :? uint8 -> true @@ -670,8 +676,8 @@ module internal PrintfImpl = | :? uint64 -> true | :? nativeint as n -> n >= 0n | :? unativeint -> true - | :? single as n -> n >= 0.0f - | :? double as n -> n >= 0.0 + | :? single as n -> n >= 0.0f && singleIsNotNegativeZero n + | :? double as n -> n >= 0.0 && doubleIsNotNegativeZero n | :? decimal as n -> n >= 0.0M | _ -> failwith "isPositive: unreachable" diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs index 622e5370cbc..2fa590e23ec 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs +++ b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs @@ -78,12 +78,19 @@ type PrintfTests() = [] member this.``positive and negative zero``() = - test "%f" +0.0 "0.000000" - test "%f" -0.0 "-0.000000" - test "%f" -0.0000001 "-0.000000" - test "%+f" +0.0 "+0.000000" - test "%+f" -0.0 "-0.000000" - test "%+f" -0.0000001 "-0.000000" + test "%f" +0.0 "0.000000" + test "%f" -0.0 "-0.000000" + test "%f" -0.0000001 "-0.000000" + test "%+f" +0.0 "+0.000000" + test "%+f" -0.0 "-0.000000" + test "%+f" -0.0000001 "-0.000000" + + test "%f" +0.0f "0.000000" + test "%f" -0.0f "-0.000000" + test "%f" -0.0000001f "-0.000000" + test "%+f" +0.0f "+0.000000" + test "%+f" -0.0f "-0.000000" + test "%+f" -0.0000001f "-0.000000" [] member _.``union case formatting`` () = From 9e916f99ac1e4b82f7fb1ecfbc02e6442c00be1a Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Sat, 14 Dec 2024 12:33:56 -0600 Subject: [PATCH 03/19] Add printf tests for infinity and nan --- src/FSharp.Core/printf.fs | 6 +-- .../Microsoft.FSharp.Core/PrintfTests.fs | 38 ++++++++++++++++++- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/FSharp.Core/printf.fs b/src/FSharp.Core/printf.fs index 96324368a4d..fa04259730d 100644 --- a/src/FSharp.Core/printf.fs +++ b/src/FSharp.Core/printf.fs @@ -658,12 +658,12 @@ module internal PrintfImpl = /// Contains functions to handle left/right and no justification case for numbers module GenericNumber = - let inline singleIsNotNegativeZero (n: single) = - BitConverter.DoubleToInt64Bits (float n) <> BitConverter.DoubleToInt64Bits -0.0 - let inline doubleIsNotNegativeZero (n: double) = BitConverter.DoubleToInt64Bits n <> BitConverter.DoubleToInt64Bits -0.0 + let inline singleIsNotNegativeZero (n: single) = + doubleIsNotNegativeZero (float n) + let isPositive (n: obj) = match n with | :? int8 as n -> n >= 0y diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs index 2fa590e23ec..eb78d529199 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs +++ b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs @@ -75,9 +75,21 @@ type PrintfTests() = Assert.AreEqual(" 7B", sprintf "%*X" 8 123 ) Assert.AreEqual("7B ", sprintf "%-*X" 8 123 ) + + [] + member this.``sign flag - positive and negative one``() = + test "%f" +1.0 "1.000000" + test "%f" -1.0 "-1.000000" + test "%+f" +1.0 "+1.000000" + test "%+f" -1.0 "-1.000000" + test "%f" +1.0f "1.000000" + test "%f" -1.0f "-1.000000" + test "%+f" +1.0f "+1.000000" + test "%+f" -1.0f "-1.000000" + [] - member this.``positive and negative zero``() = + member this.``sign flag - positive and negative zero``() = test "%f" +0.0 "0.000000" test "%f" -0.0 "-0.000000" test "%f" -0.0000001 "-0.000000" @@ -91,7 +103,31 @@ type PrintfTests() = test "%+f" +0.0f "+0.000000" test "%+f" -0.0f "-0.000000" test "%+f" -0.0000001f "-0.000000" + + [] + member this.``sign flag - infinity``() = + test "%f" +infinity "Infinity" + test "%f" -infinity "-Infinity" + test "%+f" +infinity "Infinity" + test "%+f" -infinity "-Infinity" + test "%f" +infinityf "Infinity" + test "%f" -infinityf "-Infinity" + test "%+f" +infinityf "Infinity" + test "%+f" -infinityf "-Infinity" + + [] + member this.``sign flag - NaN``() = + test "%f" +nan "NaN" + test "%f" -nan "NaN" + test "%+f" +nan "NaN" + test "%+f" -nan "NaN" + + test "%f" +nanf "NaN" + test "%f" -nanf "NaN" + test "%+f" +nanf "NaN" + test "%+f" -nanf "NaN" + [] member _.``union case formatting`` () = Assert.AreEqual("CaseOne", sprintf "%A" CaseOne) From f3daa8f16eaebedef1d1f69c38063f6d2ab4064a Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Sat, 14 Dec 2024 12:48:11 -0600 Subject: [PATCH 04/19] Refactor --- src/FSharp.Core/printf.fs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/FSharp.Core/printf.fs b/src/FSharp.Core/printf.fs index fa04259730d..cdc6956191b 100644 --- a/src/FSharp.Core/printf.fs +++ b/src/FSharp.Core/printf.fs @@ -658,11 +658,14 @@ module internal PrintfImpl = /// Contains functions to handle left/right and no justification case for numbers module GenericNumber = - let inline doubleIsNotNegativeZero (n: double) = - BitConverter.DoubleToInt64Bits n <> BitConverter.DoubleToInt64Bits -0.0 + let inline doubleIsPositive (n: double) = + n >= 0.0 + // Ensure -0.0 is treated as negative (see https://github.com/dotnet/fsharp/issues/15557) + // and use bitwise comparison because floating point comparison treats +0.0 as equal to -0.0 + && (BitConverter.DoubleToInt64Bits n <> BitConverter.DoubleToInt64Bits -0.0) - let inline singleIsNotNegativeZero (n: single) = - doubleIsNotNegativeZero (float n) + let inline singleIsPositive (n: single) = + doubleIsPositive (float n) let isPositive (n: obj) = match n with @@ -676,8 +679,8 @@ module internal PrintfImpl = | :? uint64 -> true | :? nativeint as n -> n >= 0n | :? unativeint -> true - | :? single as n -> n >= 0.0f && singleIsNotNegativeZero n - | :? double as n -> n >= 0.0 && doubleIsNotNegativeZero n + | :? single as n -> singleIsPositive n + | :? double as n -> doubleIsPositive n | :? decimal as n -> n >= 0.0M | _ -> failwith "isPositive: unreachable" From 1781debf4cb739e7fad02793657a1acd5138fee4 Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Sat, 14 Dec 2024 15:47:50 -0600 Subject: [PATCH 05/19] Fix printf sign handling for -0 decimal and work around a .NET bug --- src/FSharp.Core/printf.fs | 22 +++++++++++++++++-- .../Microsoft.FSharp.Core/PrintfTests.fs | 14 +++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/FSharp.Core/printf.fs b/src/FSharp.Core/printf.fs index cdc6956191b..46e077342f7 100644 --- a/src/FSharp.Core/printf.fs +++ b/src/FSharp.Core/printf.fs @@ -667,6 +667,17 @@ module internal PrintfImpl = let inline singleIsPositive (n: single) = doubleIsPositive (float n) + let decimalSignBit (n: decimal) = + // Unfortunately it's impossible to avoid this array allocation without either targeting .NET 5+ or relying on knowledge about decimal's internal representation + let bits = Decimal.GetBits n + bits[3] >>> 31 + + let inline decimalIsNegativeZero (n: decimal) = + decimalSignBit n <> 0 + + let inline decimalIsPositive (n: decimal) = + n > 0.0M || (n = 0.0M && decimalSignBit n = 0) + let isPositive (n: obj) = match n with | :? int8 as n -> n >= 0y @@ -681,7 +692,7 @@ module internal PrintfImpl = | :? unativeint -> true | :? single as n -> singleIsPositive n | :? double as n -> doubleIsPositive n - | :? decimal as n -> n >= 0.0M + | :? decimal as n -> decimalIsPositive n | _ -> failwith "isPositive: unreachable" /// handles right justification when pad char = '0' @@ -860,11 +871,18 @@ module internal PrintfImpl = module FloatAndDecimal = + let fixupDecimalSign (n: decimal) (nStr: string) = + // Forward-compatible workaround for a .NET bug which causes -0.0m (negative zero) to be missing its sign (see: https://github.com/dotnet/runtime/issues/110712) + if n = 0.0m && not (nStr.StartsWith "-") && GenericNumber.decimalIsNegativeZero n then + "-" + nStr + else + nStr + let rec toFormattedString fmt (v: obj) = match v with | :? single as n -> n.ToString(fmt, CultureInfo.InvariantCulture) | :? double as n -> n.ToString(fmt, CultureInfo.InvariantCulture) - | :? decimal as n -> n.ToString(fmt, CultureInfo.InvariantCulture) + | :? decimal as n -> n.ToString(fmt, CultureInfo.InvariantCulture) |> fixupDecimalSign n | _ -> failwith "toFormattedString: unreachable" let isNumber (x: obj) = diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs index eb78d529199..fc7bfae125a 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs +++ b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs @@ -87,6 +87,11 @@ type PrintfTests() = test "%f" -1.0f "-1.000000" test "%+f" +1.0f "+1.000000" test "%+f" -1.0f "-1.000000" + + test "%f" +1.0M "1.000000" + test "%f" -1.0M "-1.000000" + test "%+f" +1.0M "+1.000000" + test "%+f" -1.0M "-1.000000" [] member this.``sign flag - positive and negative zero``() = @@ -103,7 +108,14 @@ type PrintfTests() = test "%+f" +0.0f "+0.000000" test "%+f" -0.0f "-0.000000" test "%+f" -0.0000001f "-0.000000" - + + test "%f" +0.0M "0.000000" + test "%f" -0.0M "-0.000000" + test "%f" -0.0000001M "-0.000000" + test "%+f" +0.0M "+0.000000" + test "%+f" -0.0M "-0.000000" + test "%+f" -0.0000001M "-0.000000" + [] member this.``sign flag - infinity``() = test "%f" +infinity "Infinity" From f1df5556bccaea85d46ca4bb46c9d5dba465fd1a Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Sat, 14 Dec 2024 16:14:41 -0600 Subject: [PATCH 06/19] Work around another corner case consequence of .NET negative decimal formatting bug --- src/FSharp.Core/printf.fs | 3 ++- .../FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/FSharp.Core/printf.fs b/src/FSharp.Core/printf.fs index 46e077342f7..749d0f75236 100644 --- a/src/FSharp.Core/printf.fs +++ b/src/FSharp.Core/printf.fs @@ -873,7 +873,8 @@ module internal PrintfImpl = let fixupDecimalSign (n: decimal) (nStr: string) = // Forward-compatible workaround for a .NET bug which causes -0.0m (negative zero) to be missing its sign (see: https://github.com/dotnet/runtime/issues/110712) - if n = 0.0m && not (nStr.StartsWith "-") && GenericNumber.decimalIsNegativeZero n then + // This also affects numbers which round/truncate to negative zero (i.e. very small negative numbers) + if (n = 0.0m && not (nStr.StartsWith "-") && GenericNumber.decimalIsNegativeZero n) || (n < 0.0m && not (nStr.StartsWith "-")) then "-" + nStr else nStr diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs index fc7bfae125a..da1ece3f0c0 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs +++ b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs @@ -111,7 +111,7 @@ type PrintfTests() = test "%f" +0.0M "0.000000" test "%f" -0.0M "-0.000000" - test "%f" -0.0000001M "-0.000000" + test "%f" -0.0000001M "0.000000" test "%+f" +0.0M "+0.000000" test "%+f" -0.0M "-0.000000" test "%+f" -0.0000001M "-0.000000" From d38630bae85480811896dc6ec531b25a12ed8c16 Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Sat, 14 Dec 2024 16:16:34 -0600 Subject: [PATCH 07/19] Add test cases --- .../Microsoft.FSharp.Core/PrintfTests.fs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs index da1ece3f0c0..5632eb78e4f 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs +++ b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs @@ -75,7 +75,7 @@ type PrintfTests() = Assert.AreEqual(" 7B", sprintf "%*X" 8 123 ) Assert.AreEqual("7B ", sprintf "%-*X" 8 123 ) - + // test cases for https://github.com/dotnet/fsharp/issues/15557 [] member this.``sign flag - positive and negative one``() = test "%f" +1.0 "1.000000" @@ -111,7 +111,7 @@ type PrintfTests() = test "%f" +0.0M "0.000000" test "%f" -0.0M "-0.000000" - test "%f" -0.0000001M "0.000000" + test "%f" -0.0000001M "-0.000000" test "%+f" +0.0M "+0.000000" test "%+f" -0.0M "-0.000000" test "%+f" -0.0000001M "-0.000000" @@ -140,6 +140,29 @@ type PrintfTests() = test "%+f" +nanf "NaN" test "%+f" -nanf "NaN" + // test cases for https://github.com/dotnet/runtime/issues/110712 (same root cause as #15557; listing for completeness) + [] + member this.``zero padding - positive and negative one`` () = + test "%010.3f" +1.0 "000001.000" + test "%010.3f" -1.0 "-00001.000" + + test "%010.3f" +1.0f "000001.000" + test "%010.3f" -1.0f "-00001.000" + + test "%010.3f" +1.0M "000001.000" + test "%010.3f" -1.0M "-00001.000" + + [] + member this.``zero padding - positive and negative zero`` () = + test "%010.3f" +0.0 "000000.000" + test "%010.3f" -0.0 "-00000.000" + + test "%010.3f" +0.0f "000000.000" + test "%010.3f" -0.0f "-00000.000" + + test "%010.3f" +0.0M "000000.000" + test "%010.3f" -0.0M "-00000.000" + [] member _.``union case formatting`` () = Assert.AreEqual("CaseOne", sprintf "%A" CaseOne) From 9cf4569a4477bad0cb10738927575c1729b5e443 Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Sat, 14 Dec 2024 16:26:39 -0600 Subject: [PATCH 08/19] Add release notes --- docs/release-notes/.FSharp.Core/11.0.100.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.FSharp.Core/11.0.100.md b/docs/release-notes/.FSharp.Core/11.0.100.md index d17942708c9..fcc5c0a07d9 100644 --- a/docs/release-notes/.FSharp.Core/11.0.100.md +++ b/docs/release-notes/.FSharp.Core/11.0.100.md @@ -4,6 +4,7 @@ * Fix `Array.exists2` documentation examples to use equal-length arrays; the previous examples would throw `ArgumentException` at runtime instead of returning the documented `false`/`true` values. ([PR #19672](https://github.com/dotnet/fsharp/pull/19672)) * Move `Async.StartChild` to the "Starting Async Computations" docs category alongside `Async.StartChildAsTask`. ([Issue #19667](https://github.com/dotnet/fsharp/issues/19667)) * Add `InlineIfLambda` to `Array.init` ([PR #19869](https://github.com/dotnet/fsharp/pull/19869)) +* Fix printf handling of -0.0 (negative zero) values for float, float32, and decimal values ([Issue #15557](https://github.com/dotnet/fsharp/issues/15557) and [Issue #15558](https://github.com/dotnet/fsharp/issues/15558), [PR #18147](https://github.com/dotnet/fsharp/pull/18147)) ### Added From 016dda4c5dbe07256aba0b6e5983df8d76762c1c Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Sun, 15 Dec 2024 11:36:36 -0600 Subject: [PATCH 09/19] Correct wrong issue link --- .../FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs index 5632eb78e4f..a0d10bb4f9e 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs +++ b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs @@ -140,7 +140,7 @@ type PrintfTests() = test "%+f" +nanf "NaN" test "%+f" -nanf "NaN" - // test cases for https://github.com/dotnet/runtime/issues/110712 (same root cause as #15557; listing for completeness) + // test cases for https://github.com/dotnet/fsharp/issues/15558 (same root cause as #15557; listing for completeness) [] member this.``zero padding - positive and negative one`` () = test "%010.3f" +1.0 "000001.000" From d84c7db22a190d1832f15f3d258e6e5a575407ad Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Mon, 16 Dec 2024 11:28:39 -0600 Subject: [PATCH 10/19] Change test case expected outputs in light of reviews --- .../Microsoft.FSharp.Core/PrintfTests.fs | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs index a0d10bb4f9e..6fd148aef7e 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs +++ b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs @@ -96,25 +96,28 @@ type PrintfTests() = [] member this.``sign flag - positive and negative zero``() = test "%f" +0.0 "0.000000" - test "%f" -0.0 "-0.000000" - test "%f" -0.0000001 "-0.000000" + test "%f" -0.0 "0.000000" + test "%f" -0.0000001 "0.000000" test "%+f" +0.0 "+0.000000" - test "%+f" -0.0 "-0.000000" - test "%+f" -0.0000001 "-0.000000" + test "%+f" -0.0 "+0.000000" + // TODO: should this output -0.000000 or +0.000000? See https://github.com/dotnet/fsharp/pull/18147#issuecomment-2546220183 + test "%+f" -0.0000001 "+0.000000" test "%f" +0.0f "0.000000" - test "%f" -0.0f "-0.000000" - test "%f" -0.0000001f "-0.000000" + test "%f" -0.0f "0.000000" + test "%f" -0.0000001f "0.000000" test "%+f" +0.0f "+0.000000" - test "%+f" -0.0f "-0.000000" - test "%+f" -0.0000001f "-0.000000" + test "%+f" -0.0f "+0.000000" + // see previous comment + test "%+f" -0.0000001f "+0.000000" test "%f" +0.0M "0.000000" - test "%f" -0.0M "-0.000000" - test "%f" -0.0000001M "-0.000000" + test "%f" -0.0M "0.000000" + test "%f" -0.0000001M "0.000000" test "%+f" +0.0M "+0.000000" - test "%+f" -0.0M "-0.000000" - test "%+f" -0.0000001M "-0.000000" + test "%+f" -0.0M "+0.000000" + // see previous comment + test "%+f" -0.0000001M "+0.000000" [] member this.``sign flag - infinity``() = @@ -155,13 +158,13 @@ type PrintfTests() = [] member this.``zero padding - positive and negative zero`` () = test "%010.3f" +0.0 "000000.000" - test "%010.3f" -0.0 "-00000.000" + test "%010.3f" -0.0 "000000.000" test "%010.3f" +0.0f "000000.000" - test "%010.3f" -0.0f "-00000.000" + test "%010.3f" -0.0f "000000.000" test "%010.3f" +0.0M "000000.000" - test "%010.3f" -0.0M "-00000.000" + test "%010.3f" -0.0M "000000.000" [] member _.``union case formatting`` () = From 131d7cd0ec5c969a5a4fe4bc07f7cd13abfafa2a Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Mon, 16 Dec 2024 12:59:25 -0600 Subject: [PATCH 11/19] Comment out some test cases for now, and add some others --- .../Microsoft.FSharp.Core/PrintfTests.fs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs index 6fd148aef7e..d7e5b388844 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs +++ b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs @@ -101,7 +101,7 @@ type PrintfTests() = test "%+f" +0.0 "+0.000000" test "%+f" -0.0 "+0.000000" // TODO: should this output -0.000000 or +0.000000? See https://github.com/dotnet/fsharp/pull/18147#issuecomment-2546220183 - test "%+f" -0.0000001 "+0.000000" + // test "%+f" -0.0000001 "+0.000000" test "%f" +0.0f "0.000000" test "%f" -0.0f "0.000000" @@ -109,7 +109,7 @@ type PrintfTests() = test "%+f" +0.0f "+0.000000" test "%+f" -0.0f "+0.000000" // see previous comment - test "%+f" -0.0000001f "+0.000000" + // test "%+f" -0.0000001f "+0.000000" test "%f" +0.0M "0.000000" test "%f" -0.0M "0.000000" @@ -117,7 +117,7 @@ type PrintfTests() = test "%+f" +0.0M "+0.000000" test "%+f" -0.0M "+0.000000" // see previous comment - test "%+f" -0.0000001M "+0.000000" + // test "%+f" -0.0000001M "+0.000000" [] member this.``sign flag - infinity``() = @@ -157,14 +157,14 @@ type PrintfTests() = [] member this.``zero padding - positive and negative zero`` () = - test "%010.3f" +0.0 "000000.000" - test "%010.3f" -0.0 "000000.000" + test "%010.3f" +0.0 "000000.000" + test "%010.3f" -0.0 "000000.000" - test "%010.3f" +0.0f "000000.000" - test "%010.3f" -0.0f "000000.000" + test "%010.3f" +0.0f "000000.000" + test "%010.3f" -0.0f "000000.000" - test "%010.3f" +0.0M "000000.000" - test "%010.3f" -0.0M "000000.000" + test "%010.3f" +0.0M "000000.000" + test "%010.3f" -0.0M "000000.000" [] member _.``union case formatting`` () = From 306bcaee0497fc664d5d5cae8267d19ac78b2c47 Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Mon, 16 Dec 2024 14:38:23 -0600 Subject: [PATCH 12/19] Fix sign handling to not be a breaking change with respect to sprintf behavior on .NET Framework --- src/FSharp.Core/printf.fs | 44 +++++++------------ .../Microsoft.FSharp.Core/PrintfTests.fs | 19 +++++--- 2 files changed, 28 insertions(+), 35 deletions(-) diff --git a/src/FSharp.Core/printf.fs b/src/FSharp.Core/printf.fs index 749d0f75236..1b6b8302657 100644 --- a/src/FSharp.Core/printf.fs +++ b/src/FSharp.Core/printf.fs @@ -658,25 +658,9 @@ module internal PrintfImpl = /// Contains functions to handle left/right and no justification case for numbers module GenericNumber = - let inline doubleIsPositive (n: double) = - n >= 0.0 - // Ensure -0.0 is treated as negative (see https://github.com/dotnet/fsharp/issues/15557) - // and use bitwise comparison because floating point comparison treats +0.0 as equal to -0.0 - && (BitConverter.DoubleToInt64Bits n <> BitConverter.DoubleToInt64Bits -0.0) - - let inline singleIsPositive (n: single) = - doubleIsPositive (float n) - - let decimalSignBit (n: decimal) = - // Unfortunately it's impossible to avoid this array allocation without either targeting .NET 5+ or relying on knowledge about decimal's internal representation - let bits = Decimal.GetBits n - bits[3] >>> 31 - - let inline decimalIsNegativeZero (n: decimal) = - decimalSignBit n <> 0 - - let inline decimalIsPositive (n: decimal) = - n > 0.0M || (n = 0.0M && decimalSignBit n = 0) + let inline singleIsPositive n = n >= 0.0f + let inline doubleIsPositive n = n >= 0.0 + let inline decimalIsPositive n = n >= 0.0M let isPositive (n: obj) = match n with @@ -870,20 +854,24 @@ module internal PrintfImpl = | _ -> invalidArg (nameof spec) "Invalid integer format" module FloatAndDecimal = - - let fixupDecimalSign (n: decimal) (nStr: string) = - // Forward-compatible workaround for a .NET bug which causes -0.0m (negative zero) to be missing its sign (see: https://github.com/dotnet/runtime/issues/110712) - // This also affects numbers which round/truncate to negative zero (i.e. very small negative numbers) - if (n = 0.0m && not (nStr.StartsWith "-") && GenericNumber.decimalIsNegativeZero n) || (n < 0.0m && not (nStr.StartsWith "-")) then - "-" + nStr + + let fixupSign isPositive (nStr: string) = + // .NET Core and .NET Framework differ in how ToString and other formatting methods handle certain negative floating-point values (namely, -0.0 and values which round to -0.0 upon display). + // (see: https://devblogs.microsoft.com/dotnet/floating-point-parsing-and-formatting-improvements-in-net-core-3-0/) + // So in order for F#'s sprintf to behave consistently across platforms, we essentially "polyfill" (normalize) the output to ToString across the two runtimes. Specifically we do this by + // removing the '-' character in situations where the rest of the sprintf logic treats the number as positive, but .NET Core treats it as negative (i.e. -0.0, or -0.0000000001 when + // displaying with only a few decimal places) + // TODO: make this work for numbers like -0.0000000001 + if isPositive && nStr.StartsWith "-" then + nStr.Substring 1 else nStr let rec toFormattedString fmt (v: obj) = match v with - | :? single as n -> n.ToString(fmt, CultureInfo.InvariantCulture) - | :? double as n -> n.ToString(fmt, CultureInfo.InvariantCulture) - | :? decimal as n -> n.ToString(fmt, CultureInfo.InvariantCulture) |> fixupDecimalSign n + | :? single as n -> n.ToString(fmt, CultureInfo.InvariantCulture) |> fixupSign (GenericNumber.singleIsPositive n) + | :? double as n -> n.ToString(fmt, CultureInfo.InvariantCulture) |> fixupSign (GenericNumber.doubleIsPositive n) + | :? decimal as n -> n.ToString(fmt, CultureInfo.InvariantCulture) |> fixupSign (GenericNumber.decimalIsPositive n) | _ -> failwith "toFormattedString: unreachable" let isNumber (x: obj) = diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs index d7e5b388844..6ba6181588d 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs +++ b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs @@ -97,25 +97,30 @@ type PrintfTests() = member this.``sign flag - positive and negative zero``() = test "%f" +0.0 "0.000000" test "%f" -0.0 "0.000000" - test "%f" -0.0000001 "0.000000" test "%+f" +0.0 "+0.000000" test "%+f" -0.0 "+0.000000" - // TODO: should this output -0.000000 or +0.000000? See https://github.com/dotnet/fsharp/pull/18147#issuecomment-2546220183 - // test "%+f" -0.0000001 "+0.000000" test "%f" +0.0f "0.000000" test "%f" -0.0f "0.000000" - test "%f" -0.0000001f "0.000000" test "%+f" +0.0f "+0.000000" test "%+f" -0.0f "+0.000000" - // see previous comment - // test "%+f" -0.0000001f "+0.000000" test "%f" +0.0M "0.000000" test "%f" -0.0M "0.000000" - test "%f" -0.0000001M "0.000000" test "%+f" +0.0M "+0.000000" test "%+f" -0.0M "+0.000000" + + [] + member this.``sign flag - very small positive and negative numbers``() = + test "%f" -0.0000001 "0.000000" + // TODO: should this output -0.000000 or +0.000000? See https://github.com/dotnet/fsharp/pull/18147#issuecomment-2546220183 + // test "%+f" -0.0000001 "+0.000000" + + test "%f" -0.0000001f "0.000000" + // see previous comment + // test "%+f" -0.0000001f "+0.000000" + + test "%f" -0.0000001M "0.000000" // see previous comment // test "%+f" -0.0000001M "+0.000000" From 9385d8d80996dde4d24355d608262d21e8d1fb86 Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Thu, 20 Aug 2026 01:41:49 -0500 Subject: [PATCH 13/19] Adjust printf test expectations to match runtime behavior (net framework on net framework, net core on net core), and reimplement the logic All tests passing now (locally). --- src/FSharp.Core/printf.fs | 38 +++++---- .../Microsoft.FSharp.Core/PrintfTests.fs | 79 +++++++++++++++---- 2 files changed, 82 insertions(+), 35 deletions(-) diff --git a/src/FSharp.Core/printf.fs b/src/FSharp.Core/printf.fs index 1b6b8302657..069320566a4 100644 --- a/src/FSharp.Core/printf.fs +++ b/src/FSharp.Core/printf.fs @@ -658,11 +658,9 @@ module internal PrintfImpl = /// Contains functions to handle left/right and no justification case for numbers module GenericNumber = - let inline singleIsPositive n = n >= 0.0f - let inline doubleIsPositive n = n >= 0.0 - let inline decimalIsPositive n = n >= 0.0M + let inline strIsNegative (str: string) = str.Length > 0 && str[0] = '-' - let isPositive (n: obj) = + let isPositive (n: obj) (nStr: string) = match n with | :? int8 as n -> n >= 0y | :? uint8 -> true @@ -674,9 +672,9 @@ module internal PrintfImpl = | :? uint64 -> true | :? nativeint as n -> n >= 0n | :? unativeint -> true - | :? single as n -> singleIsPositive n - | :? double as n -> doubleIsPositive n - | :? decimal as n -> decimalIsPositive n + | :? single -> not (strIsNegative nStr) + | :? double -> not (strIsNegative nStr) + | :? decimal -> not (strIsNegative nStr) | _ -> failwith "isPositive: unreachable" /// handles right justification when pad char = '0' @@ -686,7 +684,7 @@ module internal PrintfImpl = let rightJustifyWithZeroAsPadChar (str: string) isNumber isPositive w (prefixForPositives: string) = Debug.Assert(prefixForPositives.Length = 0 || prefixForPositives.Length = 1) if isNumber then - if isPositive then + if isPositive str then prefixForPositives + (if w = 0 then str else str.PadLeft(w - prefixForPositives.Length, '0')) // save space to else if str.[0] = '-' then @@ -700,12 +698,12 @@ module internal PrintfImpl = /// handler right justification when pad char = ' ' let rightJustifyWithSpaceAsPadChar (str: string) isNumber isPositive w (prefixForPositives: string) = Debug.Assert(prefixForPositives.Length = 0 || prefixForPositives.Length = 1) - (if isNumber && isPositive then prefixForPositives + str else str).PadLeft(w, ' ') + (if isNumber && isPositive str then prefixForPositives + str else str).PadLeft(w, ' ') /// handles left justification with formatting with 'G'\'g' - either for decimals or with 'g'\'G' is explicitly set let leftJustifyWithGFormat (str: string) isNumber isInteger isPositive w (prefixForPositives: string) padChar = if isNumber then - let str = if isPositive then prefixForPositives + str else str + let str = if isPositive str then prefixForPositives + str else str // NOTE: difference - for 'g' format we use isInt check to detect situations when '5.0' is printed as '5' // in this case we need to override padding and always use ' ', otherwise we'll produce incorrect results if isInteger then @@ -717,20 +715,20 @@ module internal PrintfImpl = let leftJustifyWithNonGFormat (str: string) isNumber isPositive w (prefixForPositives: string) padChar = if isNumber then - let str = if isPositive then prefixForPositives + str else str + let str = if isPositive str then prefixForPositives + str else str str.PadRight(w, padChar) else str.PadRight(w, ' ') // pad NaNs with ' ' /// processes given string based depending on values isNumber\isPositive let noJustificationCore (str: string) isNumber isPositive prefixForPositives = - if isNumber && isPositive then prefixForPositives + str + if isNumber && isPositive str then prefixForPositives + str else str /// noJustification handler for f: 'T -> string - basic integer types let noJustification (f: objnull -> string) (prefix: string) isUnsigned = if isUnsigned then - fun (v: objnull) -> noJustificationCore (f v) true true prefix + fun (v: objnull) -> noJustificationCore (f v) true (fun _ -> true) prefix else fun (v: objnull) -> noJustificationCore (f v) true (isPositive v) prefix @@ -787,10 +785,10 @@ module internal PrintfImpl = if isUnsigned then if isGFormat then fun (w: int) (v: objnull) -> - GenericNumber.leftJustifyWithGFormat (f v) true true true w prefix padChar + GenericNumber.leftJustifyWithGFormat (f v) true true (fun _ -> true) w prefix padChar else fun (w: int) (v: objnull) -> - GenericNumber.leftJustifyWithNonGFormat (f v) true true w prefix padChar + GenericNumber.leftJustifyWithNonGFormat (f v) true (fun _ -> true) w prefix padChar else if isGFormat then fun (w: int) (v: objnull) -> @@ -804,11 +802,11 @@ module internal PrintfImpl = if isUnsigned then if padChar = '0' then fun (w: int) (v: objnull) -> - GenericNumber.rightJustifyWithZeroAsPadChar (f v) true true w prefixForPositives + GenericNumber.rightJustifyWithZeroAsPadChar (f v) true (fun _ -> true) w prefixForPositives else Debug.Assert((padChar = ' ')) fun (w: int) (v: objnull) -> - GenericNumber.rightJustifyWithSpaceAsPadChar (f v) true true w prefixForPositives + GenericNumber.rightJustifyWithSpaceAsPadChar (f v) true (fun _ -> true) w prefixForPositives else if padChar = '0' then fun (w: int) (v: objnull) -> @@ -869,9 +867,9 @@ module internal PrintfImpl = let rec toFormattedString fmt (v: obj) = match v with - | :? single as n -> n.ToString(fmt, CultureInfo.InvariantCulture) |> fixupSign (GenericNumber.singleIsPositive n) - | :? double as n -> n.ToString(fmt, CultureInfo.InvariantCulture) |> fixupSign (GenericNumber.doubleIsPositive n) - | :? decimal as n -> n.ToString(fmt, CultureInfo.InvariantCulture) |> fixupSign (GenericNumber.decimalIsPositive n) + | :? single as n -> n.ToString(fmt, CultureInfo.InvariantCulture) //|> fixupSign (GenericNumber.singleIsPositive n) + | :? double as n -> n.ToString(fmt, CultureInfo.InvariantCulture) //|> fixupSign (GenericNumber.doubleIsPositive n) + | :? decimal as n -> n.ToString(fmt, CultureInfo.InvariantCulture) //|> fixupSign (GenericNumber.decimalIsPositive n) | _ -> failwith "toFormattedString: unreachable" let isNumber (x: obj) = diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs index 6ba6181588d..d07591ff9b4 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs +++ b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs @@ -96,14 +96,34 @@ type PrintfTests() = [] member this.``sign flag - positive and negative zero``() = test "%f" +0.0 "0.000000" - test "%f" -0.0 "0.000000" + test "%f" -0.0 +#if NETCOREAPP + "-0.000000" +#else + "0.000000" +#endif test "%+f" +0.0 "+0.000000" - test "%+f" -0.0 "+0.000000" + test "%+f" -0.0 +#if NETCOREAPP + "-0.000000" +#else + "+0.000000" +#endif test "%f" +0.0f "0.000000" - test "%f" -0.0f "0.000000" + test "%f" -0.0f +#if NETCOREAPP + "-0.000000" +#else + "0.000000" +#endif test "%+f" +0.0f "+0.000000" - test "%+f" -0.0f "+0.000000" + test "%+f" -0.0f +#if NETCOREAPP + "-0.000000" +#else + "+0.000000" +#endif test "%f" +0.0M "0.000000" test "%f" -0.0M "0.000000" @@ -112,17 +132,36 @@ type PrintfTests() = [] member this.``sign flag - very small positive and negative numbers``() = - test "%f" -0.0000001 "0.000000" - // TODO: should this output -0.000000 or +0.000000? See https://github.com/dotnet/fsharp/pull/18147#issuecomment-2546220183 - // test "%+f" -0.0000001 "+0.000000" - - test "%f" -0.0000001f "0.000000" - // see previous comment - // test "%+f" -0.0000001f "+0.000000" + test "%f" -0.0000001 +#if NETCOREAPP + "-0.000000" +#else + "0.000000" +#endif + test "%+f" -0.0000001 +#if NETCOREAPP + "-0.000000" +#else + "+0.000000" +#endif + + test "%f" -0.0000001f +#if NETCOREAPP + "-0.000000" +#else + "0.000000" +#endif + test "%+f" -0.0000001f +#if NETCOREAPP + "-0.000000" +#else + "+0.000000" +#endif + test "%f" -0.0000001M "0.000000" - // see previous comment - // test "%+f" -0.0000001M "+0.000000" + + test "%+f" -0.0000001M "+0.000000" [] member this.``sign flag - infinity``() = @@ -163,10 +202,20 @@ type PrintfTests() = [] member this.``zero padding - positive and negative zero`` () = test "%010.3f" +0.0 "000000.000" - test "%010.3f" -0.0 "000000.000" + test "%010.3f" -0.0 +#if NETCOREAPP + "-00000.000" +#else + "000000.000" +#endif test "%010.3f" +0.0f "000000.000" - test "%010.3f" -0.0f "000000.000" + test "%010.3f" -0.0f +#if NETCOREAPP + "-00000.000" +#else + "000000.000" +#endif test "%010.3f" +0.0M "000000.000" test "%010.3f" -0.0M "000000.000" From f797dac459e68e0b38a36569e5569d00778c30f3 Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Thu, 20 Aug 2026 02:23:36 -0500 Subject: [PATCH 14/19] Eliminate isPositive closures and remove dead code --- src/FSharp.Core/printf.fs | 70 +++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/src/FSharp.Core/printf.fs b/src/FSharp.Core/printf.fs index 069320566a4..15dbd203eaa 100644 --- a/src/FSharp.Core/printf.fs +++ b/src/FSharp.Core/printf.fs @@ -684,7 +684,7 @@ module internal PrintfImpl = let rightJustifyWithZeroAsPadChar (str: string) isNumber isPositive w (prefixForPositives: string) = Debug.Assert(prefixForPositives.Length = 0 || prefixForPositives.Length = 1) if isNumber then - if isPositive str then + if isPositive then prefixForPositives + (if w = 0 then str else str.PadLeft(w - prefixForPositives.Length, '0')) // save space to else if str.[0] = '-' then @@ -698,12 +698,12 @@ module internal PrintfImpl = /// handler right justification when pad char = ' ' let rightJustifyWithSpaceAsPadChar (str: string) isNumber isPositive w (prefixForPositives: string) = Debug.Assert(prefixForPositives.Length = 0 || prefixForPositives.Length = 1) - (if isNumber && isPositive str then prefixForPositives + str else str).PadLeft(w, ' ') + (if isNumber && isPositive then prefixForPositives + str else str).PadLeft(w, ' ') /// handles left justification with formatting with 'G'\'g' - either for decimals or with 'g'\'G' is explicitly set let leftJustifyWithGFormat (str: string) isNumber isInteger isPositive w (prefixForPositives: string) padChar = if isNumber then - let str = if isPositive str then prefixForPositives + str else str + let str = if isPositive then prefixForPositives + str else str // NOTE: difference - for 'g' format we use isInt check to detect situations when '5.0' is printed as '5' // in this case we need to override padding and always use ' ', otherwise we'll produce incorrect results if isInteger then @@ -715,22 +715,24 @@ module internal PrintfImpl = let leftJustifyWithNonGFormat (str: string) isNumber isPositive w (prefixForPositives: string) padChar = if isNumber then - let str = if isPositive str then prefixForPositives + str else str + let str = if isPositive then prefixForPositives + str else str str.PadRight(w, padChar) else str.PadRight(w, ' ') // pad NaNs with ' ' /// processes given string based depending on values isNumber\isPositive let noJustificationCore (str: string) isNumber isPositive prefixForPositives = - if isNumber && isPositive str then prefixForPositives + str + if isNumber && isPositive then prefixForPositives + str else str /// noJustification handler for f: 'T -> string - basic integer types let noJustification (f: objnull -> string) (prefix: string) isUnsigned = if isUnsigned then - fun (v: objnull) -> noJustificationCore (f v) true (fun _ -> true) prefix + fun (v: objnull) -> noJustificationCore (f v) true true prefix else - fun (v: objnull) -> noJustificationCore (f v) true (isPositive v) prefix + fun (v: objnull) -> + let vStr = f v + noJustificationCore vStr true (isPositive v vStr) prefix /// contains functions to handle left/right and no justification case for numbers module Integer = @@ -785,37 +787,42 @@ module internal PrintfImpl = if isUnsigned then if isGFormat then fun (w: int) (v: objnull) -> - GenericNumber.leftJustifyWithGFormat (f v) true true (fun _ -> true) w prefix padChar + GenericNumber.leftJustifyWithGFormat (f v) true true true w prefix padChar else fun (w: int) (v: objnull) -> - GenericNumber.leftJustifyWithNonGFormat (f v) true (fun _ -> true) w prefix padChar + GenericNumber.leftJustifyWithNonGFormat (f v) true true w prefix padChar else if isGFormat then fun (w: int) (v: objnull) -> - GenericNumber.leftJustifyWithGFormat (f v) true true (GenericNumber.isPositive v) w prefix padChar + let vStr = f v + GenericNumber.leftJustifyWithGFormat vStr true true (GenericNumber.isPositive v vStr) w prefix padChar else fun (w: int) (v: objnull) -> - GenericNumber.leftJustifyWithNonGFormat (f v) true (GenericNumber.isPositive v) w prefix padChar + let vStr = f v + GenericNumber.leftJustifyWithNonGFormat vStr true (GenericNumber.isPositive v vStr) w prefix padChar /// Right justification handler for f: 'T -> string - basic integer types let rightJustify f (prefixForPositives: string) padChar isUnsigned = if isUnsigned then if padChar = '0' then fun (w: int) (v: objnull) -> - GenericNumber.rightJustifyWithZeroAsPadChar (f v) true (fun _ -> true) w prefixForPositives + let vStr = f v + GenericNumber.rightJustifyWithZeroAsPadChar vStr true true w prefixForPositives else Debug.Assert((padChar = ' ')) fun (w: int) (v: objnull) -> - GenericNumber.rightJustifyWithSpaceAsPadChar (f v) true (fun _ -> true) w prefixForPositives + GenericNumber.rightJustifyWithSpaceAsPadChar (f v) true true w prefixForPositives else if padChar = '0' then fun (w: int) (v: objnull) -> - GenericNumber.rightJustifyWithZeroAsPadChar (f v) true (GenericNumber.isPositive v) w prefixForPositives + let vStr = f v + GenericNumber.rightJustifyWithZeroAsPadChar vStr true (GenericNumber.isPositive v vStr) w prefixForPositives else Debug.Assert((padChar = ' ')) fun (w: int) v -> - GenericNumber.rightJustifyWithSpaceAsPadChar (f v) true (GenericNumber.isPositive v) w prefixForPositives + let vStr = f v + GenericNumber.rightJustifyWithSpaceAsPadChar vStr true (GenericNumber.isPositive v vStr) w prefixForPositives /// Computes a new function from 'f' that wraps the basic conversion given /// by 'f' with padding for 0, spacing and justification, if the flags specify @@ -853,23 +860,11 @@ module internal PrintfImpl = module FloatAndDecimal = - let fixupSign isPositive (nStr: string) = - // .NET Core and .NET Framework differ in how ToString and other formatting methods handle certain negative floating-point values (namely, -0.0 and values which round to -0.0 upon display). - // (see: https://devblogs.microsoft.com/dotnet/floating-point-parsing-and-formatting-improvements-in-net-core-3-0/) - // So in order for F#'s sprintf to behave consistently across platforms, we essentially "polyfill" (normalize) the output to ToString across the two runtimes. Specifically we do this by - // removing the '-' character in situations where the rest of the sprintf logic treats the number as positive, but .NET Core treats it as negative (i.e. -0.0, or -0.0000000001 when - // displaying with only a few decimal places) - // TODO: make this work for numbers like -0.0000000001 - if isPositive && nStr.StartsWith "-" then - nStr.Substring 1 - else - nStr - let rec toFormattedString fmt (v: obj) = match v with - | :? single as n -> n.ToString(fmt, CultureInfo.InvariantCulture) //|> fixupSign (GenericNumber.singleIsPositive n) - | :? double as n -> n.ToString(fmt, CultureInfo.InvariantCulture) //|> fixupSign (GenericNumber.doubleIsPositive n) - | :? decimal as n -> n.ToString(fmt, CultureInfo.InvariantCulture) //|> fixupSign (GenericNumber.decimalIsPositive n) + | :? single as n -> n.ToString(fmt, CultureInfo.InvariantCulture) + | :? double as n -> n.ToString(fmt, CultureInfo.InvariantCulture) + | :? decimal as n -> n.ToString(fmt, CultureInfo.InvariantCulture) | _ -> failwith "toFormattedString: unreachable" let isNumber (x: obj) = @@ -894,24 +889,29 @@ module internal PrintfImpl = let noJustification (prefixForPositives: string) = fun (fmt: string) (v: obj) -> - GenericNumber.noJustificationCore (toFormattedString fmt v) (isNumber v) (GenericNumber.isPositive v) prefixForPositives + let vStr = toFormattedString fmt v + GenericNumber.noJustificationCore vStr (isNumber v) (GenericNumber.isPositive v vStr) prefixForPositives let leftJustify isGFormat (prefix: string) padChar = if isGFormat then fun (fmt: string) (w: int) (v: obj) -> - GenericNumber.leftJustifyWithGFormat (toFormattedString fmt v) (isNumber v) (isInteger v) (GenericNumber.isPositive v) w prefix padChar + let vStr = toFormattedString fmt v + GenericNumber.leftJustifyWithGFormat vStr (isNumber v) (isInteger v) (GenericNumber.isPositive v vStr) w prefix padChar else fun (fmt: string) (w: int) (v: obj) -> - GenericNumber.leftJustifyWithNonGFormat (toFormattedString fmt v) (isNumber v) (GenericNumber.isPositive v) w prefix padChar + let vStr = toFormattedString fmt v + GenericNumber.leftJustifyWithNonGFormat vStr (isNumber v) (GenericNumber.isPositive v vStr) w prefix padChar let rightJustify (prefixForPositives: string) padChar = if padChar = '0' then fun (fmt: string) (w: int) (v: obj) -> - GenericNumber.rightJustifyWithZeroAsPadChar (toFormattedString fmt v) (isNumber v) (GenericNumber.isPositive v) w prefixForPositives + let vStr = toFormattedString fmt v + GenericNumber.rightJustifyWithZeroAsPadChar vStr (isNumber v) (GenericNumber.isPositive v vStr) w prefixForPositives else Debug.Assert((padChar = ' ')) fun (fmt: string) (w: int) (v: obj) -> - GenericNumber.rightJustifyWithSpaceAsPadChar (toFormattedString fmt v) (isNumber v) (GenericNumber.isPositive v) w prefixForPositives + let vStr = toFormattedString fmt v + GenericNumber.rightJustifyWithSpaceAsPadChar vStr (isNumber v) (GenericNumber.isPositive v vStr) w prefixForPositives let withPadding (spec: FormatSpecifier) getFormat defaultFormat = let padChar, prefix = spec.GetPadAndPrefix true From 6aa68402aaf03bd6f1e885a456db360b48d749a0 Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Thu, 20 Aug 2026 13:48:12 -0500 Subject: [PATCH 15/19] Fix migrated printf/printf-interpolated tests (and others) not actually running due to partial application It seems these tests have not been running for quite a while, if ever --- .../Miscellaneous/FsharpSuiteMigrated.fs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/FSharp.Compiler.ComponentTests/Miscellaneous/FsharpSuiteMigrated.fs b/tests/FSharp.Compiler.ComponentTests/Miscellaneous/FsharpSuiteMigrated.fs index b976f60d438..7fb9a614885 100644 --- a/tests/FSharp.Compiler.ComponentTests/Miscellaneous/FsharpSuiteMigrated.fs +++ b/tests/FSharp.Compiler.ComponentTests/Miscellaneous/FsharpSuiteMigrated.fs @@ -91,7 +91,7 @@ module TestFrameworkAdapter = | LangVersion.Latest -> "latest", bonusArgs - let singleTestBuildAndRunAuxVersion (folder:string) bonusArgs mode langVersion sessionIsolation = + let singleTestBuildAndRunAuxVersion (folder:string) bonusArgs mode langVersion sessionIsolation = let absFolder = Path.Combine(baseFolder,folder) let supportedNames, files = match mode with @@ -162,9 +162,10 @@ module TestFrameworkAdapter = let singleTestBuildAndRunAux folder bonusArgs mode = singleTestBuildAndRunAuxVersion folder bonusArgs mode LangVersion.Latest - let singleTestBuildAndRunVersion folder mode version = singleTestBuildAndRunAuxVersion folder [] mode version - let singleTestBuildAndRun folder mode = singleTestBuildAndRunVersion folder mode LangVersion.Latest ScriptSessionIsolation.Shared - let singleTestBuildAndRunIsolated folder mode = singleTestBuildAndRunVersion folder mode LangVersion.Latest ScriptSessionIsolation.Isolated + let singleTestBuildAndRunVersion folder mode version : unit = singleTestBuildAndRunAuxVersion folder [] mode version ScriptSessionIsolation.Shared + let singleTestBuildAndRunVersionIsolated folder mode version : unit = singleTestBuildAndRunAuxVersion folder [] mode version ScriptSessionIsolation.Isolated + let singleTestBuildAndRun folder mode : unit = singleTestBuildAndRunVersion folder mode LangVersion.Latest + let singleTestBuildAndRunIsolated folder mode : unit = singleTestBuildAndRunVersionIsolated folder mode LangVersion.Latest let singleVersionedNegTestAux folder bonusArgs version testName = singleTestBuildAndRunAuxVersion folder bonusArgs (NEG_TEST_BUILD testName) version ScriptSessionIsolation.Shared From 0651f1f3871dc780907b23a7c49e176937f12f2c Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Thu, 20 Aug 2026 14:00:02 -0500 Subject: [PATCH 16/19] Eliminate type test branching by using strIsNegative for all number types, not just single/double/decimal --- src/FSharp.Core/printf.fs | 39 ++++++++++++--------------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/src/FSharp.Core/printf.fs b/src/FSharp.Core/printf.fs index 15dbd203eaa..fdc20e0a270 100644 --- a/src/FSharp.Core/printf.fs +++ b/src/FSharp.Core/printf.fs @@ -658,24 +658,9 @@ module internal PrintfImpl = /// Contains functions to handle left/right and no justification case for numbers module GenericNumber = - let inline strIsNegative (str: string) = str.Length > 0 && str[0] = '-' + let inline isStrNegative (str: string) = str.Length > 0 && str[0] = '-' - let isPositive (n: obj) (nStr: string) = - match n with - | :? int8 as n -> n >= 0y - | :? uint8 -> true - | :? int16 as n -> n >= 0s - | :? uint16 -> true - | :? int32 as n -> n >= 0 - | :? uint32 -> true - | :? int64 as n -> n >= 0L - | :? uint64 -> true - | :? nativeint as n -> n >= 0n - | :? unativeint -> true - | :? single -> not (strIsNegative nStr) - | :? double -> not (strIsNegative nStr) - | :? decimal -> not (strIsNegative nStr) - | _ -> failwith "isPositive: unreachable" + let isPositive (nStr: string) = not (isStrNegative nStr) /// handles right justification when pad char = '0' /// this case can be tricky: @@ -732,7 +717,7 @@ module internal PrintfImpl = else fun (v: objnull) -> let vStr = f v - noJustificationCore vStr true (isPositive v vStr) prefix + noJustificationCore vStr true (isPositive vStr) prefix /// contains functions to handle left/right and no justification case for numbers module Integer = @@ -795,11 +780,11 @@ module internal PrintfImpl = if isGFormat then fun (w: int) (v: objnull) -> let vStr = f v - GenericNumber.leftJustifyWithGFormat vStr true true (GenericNumber.isPositive v vStr) w prefix padChar + GenericNumber.leftJustifyWithGFormat vStr true true (GenericNumber.isPositive vStr) w prefix padChar else fun (w: int) (v: objnull) -> let vStr = f v - GenericNumber.leftJustifyWithNonGFormat vStr true (GenericNumber.isPositive v vStr) w prefix padChar + GenericNumber.leftJustifyWithNonGFormat vStr true (GenericNumber.isPositive vStr) w prefix padChar /// Right justification handler for f: 'T -> string - basic integer types let rightJustify f (prefixForPositives: string) padChar isUnsigned = @@ -816,13 +801,13 @@ module internal PrintfImpl = if padChar = '0' then fun (w: int) (v: objnull) -> let vStr = f v - GenericNumber.rightJustifyWithZeroAsPadChar vStr true (GenericNumber.isPositive v vStr) w prefixForPositives + GenericNumber.rightJustifyWithZeroAsPadChar vStr true (GenericNumber.isPositive vStr) w prefixForPositives else Debug.Assert((padChar = ' ')) fun (w: int) v -> let vStr = f v - GenericNumber.rightJustifyWithSpaceAsPadChar vStr true (GenericNumber.isPositive v vStr) w prefixForPositives + GenericNumber.rightJustifyWithSpaceAsPadChar vStr true (GenericNumber.isPositive vStr) w prefixForPositives /// Computes a new function from 'f' that wraps the basic conversion given /// by 'f' with padding for 0, spacing and justification, if the flags specify @@ -890,28 +875,28 @@ module internal PrintfImpl = let noJustification (prefixForPositives: string) = fun (fmt: string) (v: obj) -> let vStr = toFormattedString fmt v - GenericNumber.noJustificationCore vStr (isNumber v) (GenericNumber.isPositive v vStr) prefixForPositives + GenericNumber.noJustificationCore vStr (isNumber v) (GenericNumber.isPositive vStr) prefixForPositives let leftJustify isGFormat (prefix: string) padChar = if isGFormat then fun (fmt: string) (w: int) (v: obj) -> let vStr = toFormattedString fmt v - GenericNumber.leftJustifyWithGFormat vStr (isNumber v) (isInteger v) (GenericNumber.isPositive v vStr) w prefix padChar + GenericNumber.leftJustifyWithGFormat vStr (isNumber v) (isInteger v) (GenericNumber.isPositive vStr) w prefix padChar else fun (fmt: string) (w: int) (v: obj) -> let vStr = toFormattedString fmt v - GenericNumber.leftJustifyWithNonGFormat vStr (isNumber v) (GenericNumber.isPositive v vStr) w prefix padChar + GenericNumber.leftJustifyWithNonGFormat vStr (isNumber v) (GenericNumber.isPositive vStr) w prefix padChar let rightJustify (prefixForPositives: string) padChar = if padChar = '0' then fun (fmt: string) (w: int) (v: obj) -> let vStr = toFormattedString fmt v - GenericNumber.rightJustifyWithZeroAsPadChar vStr (isNumber v) (GenericNumber.isPositive v vStr) w prefixForPositives + GenericNumber.rightJustifyWithZeroAsPadChar vStr (isNumber v) (GenericNumber.isPositive vStr) w prefixForPositives else Debug.Assert((padChar = ' ')) fun (fmt: string) (w: int) (v: obj) -> let vStr = toFormattedString fmt v - GenericNumber.rightJustifyWithSpaceAsPadChar vStr (isNumber v) (GenericNumber.isPositive v vStr) w prefixForPositives + GenericNumber.rightJustifyWithSpaceAsPadChar vStr (isNumber v) (GenericNumber.isPositive vStr) w prefixForPositives let withPadding (spec: FormatSpecifier) getFormat defaultFormat = let padChar, prefix = spec.GetPadAndPrefix true From 18589d678b4e48e65d97e77b1176105371444812 Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Thu, 20 Aug 2026 16:50:40 -0500 Subject: [PATCH 17/19] Add some more corner case tests --- .../Microsoft.FSharp.Core/PrintfTests.fs | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs index d07591ff9b4..ab3c17d9d20 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs +++ b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Core/PrintfTests.fs @@ -130,6 +130,53 @@ type PrintfTests() = test "%+f" +0.0M "+0.000000" test "%+f" -0.0M "+0.000000" + [] + member this.``sign flag - positive and negative zero - corner cases`` () = + test "%-10.3f" +0.0f "0.000 " + test "%-10.3f" -0.0f +#if NETCOREAPP + "-0.000 " +#else + "0.000 " +#endif + test "%-+10.3f" +0.0f "+0.000 " + test "%-+10.3f" -0.0f +#if NETCOREAPP + "-0.000 " +#else + "+0.000 " +#endif + test "%+020e" +0.0f "+0000000.000000e+000" + test "%+020e" -0.0f +#if NETCOREAPP + "-0000000.000000e+000" +#else + "+0000000.000000e+000" +#endif + + test "%-10.3f" +0.0 "0.000 " + test "%-10.3f" -0.0 +#if NETCOREAPP + "-0.000 " +#else + "0.000 " +#endif + test "%-+10.3f" +0.0 "+0.000 " + test "%-+10.3f" -0.0 +#if NETCOREAPP + "-0.000 " +#else + "+0.000 " +#endif + test "%+020e" +0.0 "+0000000.000000e+000" + test "%+020e" -0.0 +#if NETCOREAPP + "-0000000.000000e+000" +#else + "+0000000.000000e+000" +#endif + + [] member this.``sign flag - very small positive and negative numbers``() = test "%f" -0.0000001 @@ -144,7 +191,7 @@ type PrintfTests() = #else "+0.000000" #endif - + test "%f" -0.0000001f #if NETCOREAPP @@ -158,7 +205,7 @@ type PrintfTests() = #else "+0.000000" #endif - + test "%f" -0.0000001M "0.000000" test "%+f" -0.0000001M "+0.000000" From 2a05123a39fac28f60ae305407640afb5010dd35 Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Fri, 21 Aug 2026 09:26:59 -0500 Subject: [PATCH 18/19] Enable running of all printf tests by default instead of randomized subset --- tests/fsharp/core/printf/test.fsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fsharp/core/printf/test.fsx b/tests/fsharp/core/printf/test.fsx index 3ebe2c9ba33..973e26a28ec 100644 --- a/tests/fsharp/core/printf/test.fsx +++ b/tests/fsharp/core/printf/test.fsx @@ -17,7 +17,7 @@ let report_failure (s : string) = // change this to true to run every test case // leave as false to randomly execute a subset of cases (this is a very expensive test area) -let runEveryTest = false +let runEveryTest = true if runEveryTest then stdout.WriteLine "Running every test. Set \"runEveryTest\" to false to reduce runtime" else stdout.WriteLine "Running a random subset of tests. Set \"runEveryTest\" to true to run all tests" From d0efb0071c5492789cb6bf9e5ce35b7f949f01c7 Mon Sep 17 00:00:00 2001 From: John Wostenberg Date: Fri, 21 Aug 2026 09:49:01 -0500 Subject: [PATCH 19/19] Add doc comment explaining new isPositive approach --- src/FSharp.Core/printf.fs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/FSharp.Core/printf.fs b/src/FSharp.Core/printf.fs index fdc20e0a270..3d503139815 100644 --- a/src/FSharp.Core/printf.fs +++ b/src/FSharp.Core/printf.fs @@ -660,6 +660,15 @@ module internal PrintfImpl = let inline isStrNegative (str: string) = str.Length > 0 && str[0] = '-' + /// Check if the number is negative -- not with the number itself, but with the formatted ToString representation -- + /// to ensure that we always agree with the runtime (.NET and .NET Framework differ at -0.0 for floats). + /// Single.IsPositive (and similar) would be the right choice, but it is not available in .NET Framework / + /// .NET Standard 2.0. + /// Additionally: behaviorally speaking, this is really only needed for single and double, at which point it's + /// just simpler to uniformly use the same approach for all number types, which also has the added bonus of + /// avoiding type tests (micro perf win! https://github.com/dotnet/fsharp/pull/18147#discussion_r3821830744) + /// See: https://github.com/dotnet/fsharp/issues/15557 + /// and: https://github.com/dotnet/fsharp/issues/15558 let isPositive (nStr: string) = not (isStrNegative nStr) /// handles right justification when pad char = '0'