From 93b00872cb4315e3378d61986c19d4f657042a0a Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 13 Oct 2026 11:08:24 +0200 Subject: [PATCH 1/4] Restore captured printf interpolation for NativeAOT Reuse the existing captured PrintfFormat interpolation pipeline for per-hole printf fallbacks, preserving concat fast paths, original hole/specifier ranges, and evaluation timing. Avoid synthetic format-location notifications so foreground checking retains real arity and source ranges. Verify #20757 under fixed SDK/Core/ILC options: first parent runs natively, regression merge/current fail with CaptureFinal1, corrected native timestamp emits exactly 01:02:03 with exit 0. Add shared native/JIT formatting, generic/evaluation and separate-library fixtures, captured IL and FCS diagnostic tests. Keep the original native harness warning-strict; only the compatibility executable allows eight proven baseline warning IDs, all visible, with no new warning signatures. Validation: Release build/pack and Debug/Release compiler builds pass; 67 focused component/IL/diagnostic cases, 15 focused FCS cases, and strict plus compatibility NativeAOT harness pass. Full editor checks pass 74 with 3 unchanged skips. Direct parse/typecheck and non-Claude local expert review pass. Update the 11.0.200 issue-linked release note. No push or remote posting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../.FSharp.Compiler.Service/11.0.200.md | 1 + .../Checking/Expressions/CheckExpressions.fs | 15 ++- .../PrintfInterpolation/Directory.Build.props | 18 +++ .../PrintfInterpolation/Formatting.fs | 89 +++++++++++++++ .../PrintfInterpolation/Library/Formatting.fs | 4 + .../Library/PrintfInterpolationLibrary.fsproj | 5 + .../PrintfInterpolation_Test.fsproj | 15 +++ .../NativeAOT/PrintfInterpolation/Program.fs | 10 ++ tests/AheadOfTime/NativeAOT/Program.fs | 5 +- tests/AheadOfTime/NativeAOT/check.ps1 | 104 +++++++++--------- .../EmittedIL/StringFormatAndInterpolation.fs | 31 ++++++ .../Language/InterpolatedStringsTests.fs | 48 ++++++++ .../EditorTests.fs | 38 +++++++ 13 files changed, 321 insertions(+), 62 deletions(-) create mode 100644 tests/AheadOfTime/NativeAOT/PrintfInterpolation/Directory.Build.props create mode 100644 tests/AheadOfTime/NativeAOT/PrintfInterpolation/Formatting.fs create mode 100644 tests/AheadOfTime/NativeAOT/PrintfInterpolation/Library/Formatting.fs create mode 100644 tests/AheadOfTime/NativeAOT/PrintfInterpolation/Library/PrintfInterpolationLibrary.fsproj create mode 100644 tests/AheadOfTime/NativeAOT/PrintfInterpolation/PrintfInterpolation_Test.fsproj create mode 100644 tests/AheadOfTime/NativeAOT/PrintfInterpolation/Program.fs diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 6c8714cc66e..b4d0db992f7 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -12,6 +12,7 @@ * Prevent `InvalidProgramException` when runtime async awaits a `Task` upcast to `Task`, including through `ConfigureAwait`. ([Issue #20686](https://github.com/dotnet/fsharp/issues/20686), [PR #20726](https://github.com/dotnet/fsharp/pull/20726)) * Struct/reference anonymous-record mismatches now name anonymous records rather than tuples. ([Issue #6110](https://github.com/dotnet/fsharp/issues/6110), [PR #20681](https://github.com/dotnet/fsharp/pull/20681)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) +* Restore NativeAOT execution of printf-formatted interpolated strings such as zero-padded timestamps, while preserving reflection-free interpolation fast paths. ([Issue #20757](https://github.com/dotnet/fsharp/issues/20757)) * Build parser diagnostic messages without runtime printf specialization or reflective function construction, so Native AOT hosts report indentation and invalid-character diagnostics instead of failing during formatting. Preserve `#r` reference tooltips while script project options remain alive, including after garbage collection or cache eviction. ([PR #20658](https://github.com/dotnet/fsharp/pull/20658)) * Preserve tupled lambda argument groups when reducing state-machine applications, avoiding invalid argument substitutions and runtime failures with inline formatters. ([Issue #20675](https://github.com/dotnet/fsharp/issues/20675), [PR #20700](https://github.com/dotnet/fsharp/pull/20700)) * Preserve SRTP extension scopes across source files and competing witnesses through imported inline calls. Compile generic extension forwarding, including quotations, in unoptimized builds. Preserve applicable built-in operator priority in imported inline quotations. Do not reuse SRTP specializations across different selected witnesses. ([Issue #20683](https://github.com/dotnet/fsharp/issues/20683), [Issue #20684](https://github.com/dotnet/fsharp/issues/20684), [Issue #20685](https://github.com/dotnet/fsharp/issues/20685), [PR #20717](https://github.com/dotnet/fsharp/pull/20717)) diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index ceb7a4da0aa..f4792d4e305 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -7712,7 +7712,7 @@ and TcFormatStringExpr cenv (overallTy: OverallTy) env m tpenv (fmtString: strin mkString g m fmtString, tpenv ) -/// Lower a string-typed interpolated string to a reflection-free System.String.Concat of its parts, +/// Lower a string-typed interpolated string to System.String.Concat of its rendered parts, /// type-checking each part in place. and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, m: range, tpenv: UnscopedTyparEnv, parts: SynInterpolatedStringPart list) = let g = cenv.g @@ -7720,10 +7720,9 @@ and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, let strLit (s: string) = SynExpr.Const(SynConst.String(s, SynStringKind.Regular, mSynth), mSynth) let paren (e: SynExpr) = SynExpr.Paren(e, range0, None, mSynth) - // '(sprintf spec e : string)': format a printf-specifier hole (still reflection-based). - let sprintfOp (spec: string, e: SynExpr) = - let f = mkSynApp1 (mkSynLidGet mSynth [ "Microsoft"; "FSharp"; "Core"; "ExtraTopLevelOperators" ] "sprintf") (strLit spec) mSynth - let call = mkSynApp1 f (paren e) mSynth + let sprintfOp (formatting: SynInterpolationFormatting, e: SynExpr) = + let format = SynExpr.InterpolatedString([ SynInterpolatedStringPart.FillExpr(e, formatting) ], SynStringKind.Regular, mSynth) + let call = mkSynApp1 (mkSynLidGet mSynth [ "Microsoft"; "FSharp"; "Core"; "ExtraTopLevelOperators" ] "sprintf") format mSynth SynExpr.Typed(call, SynType.LongIdent(SynLongIdent([ mkSynId mSynth "string" ], [], [ None ])), mSynth) // 'String.Format(InvariantCulture, "{0,align:format}", e)': format an aligned or '{e:fmt}' hole. @@ -7756,7 +7755,7 @@ and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, | "%d" | "%i" -> convertViaString (CheckFormatStrings.mkFlexibleIntFormatTypar g m) | "%M" -> convertViaString (CheckFormatStrings.mkFlexibleDecimalFormatTypar g m) | _ -> - let arg, tpenv = TcExpr cenv (MustEqual g.string_ty) env tpenv (sprintfOp (spec, synFill)) + let arg, tpenv = TcExpr cenv (MustEqual g.string_ty) env tpenv (sprintfOp (formatting, synFill)) (arg, false), tpenv | SynInterpolationFormatting.DotNet (alignment, format) -> // Type-checking the hole here is also where a function value gets warned about. @@ -7910,7 +7909,7 @@ and TcInterpolatedStringExpr cenv (overallTy: OverallTy) env m tpenv (parts: Syn // original source and this may include some additional characters, // and also doesn't raise all necessary errors match cenv.tcSink.CurrentSink with - | Some sink when sink.FormatStringCheckContext.IsSome -> + | Some sink when not m.IsSynthetic && sink.FormatStringCheckContext.IsSome -> try let _argTys, _printerTy, _printerTupleTyRequired, _percentATys, specifierLocations, _dotnetFormatString = CheckFormatStrings.ParseFormatString m stringFragmentRanges g true isFormattableString sink.FormatStringCheckContext printfFormatString printerArgTy printerResidueTy printerResultTy @@ -7950,7 +7949,7 @@ and TcInterpolatedStringExpr cenv (overallTy: OverallTy) env m tpenv (parts: Syn let str = mkString g m printfFormatString mkCallNewFormat g m printerTy printerArgTy printerResidueTy printerResultTy printerTupleTy str, tpenv elif isString then - // String-typed interpolation: lower to a reflection-free System.String.Concat of the parts, + // String-typed interpolation: concatenate rendered parts, // type-checking each hole in place (no separate batch, no flat fill-expression list). TcInterpolatedStringViaConcat (cenv, overallTy, env, m, tpenv, parts) else diff --git a/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Directory.Build.props b/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Directory.Build.props new file mode 100644 index 00000000000..3acfa74f222 --- /dev/null +++ b/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Directory.Build.props @@ -0,0 +1,18 @@ + + + + + $(FSharpCoreShippedNetTargetFramework) + preview + true + true + $(LocalFSharpBuildBinPath)\FSharp.Build.dll + $(LocalFSharpBuildBinPath)\fsc.dll + $(LocalFSharpBuildBinPath)\fsc.dll + False + True + + + + + diff --git a/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Formatting.fs b/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Formatting.fs new file mode 100644 index 00000000000..1a3d4d1ab36 --- /dev/null +++ b/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Formatting.fs @@ -0,0 +1,89 @@ +module Formatting + +open System +open System.Globalization + +let check (expected: string) (actual: string) = + if actual <> expected then + raise (InvalidOperationException("Expected '" + expected + "', got '" + actual + "'")) + +let timestamp (now: DateTime) = + $"%02d{now.Hour}:%02d{now.Minute}:%02d{now.Second}" + +let padded n = $"%02d{n}" +let inline paddedInteger (n: ^T) = $"%02d{n}" + +type Rendered(trace: ResizeArray) = + override _.ToString() = + trace.Add 11 + "x" + +let runChecks () = + let now = DateTime(2026, 10, 13, 1, 2, 3) + check "01" $"%02d{now.Hour}" + check "0102" $"%02d{now.Hour}%02d{now.Minute}" + check "010203" $"%02d{now.Hour}%02d{now.Minute}%02d{now.Second}" + check "01:02:03" (timestamp now) + check "01:02:03" (PrintfInterpolationLibrary.timestamp now) + for n, expected in + [ 0, "00"; 9, "09"; 10, "10"; -1, "-1"; -9, "-9"; -10, "-10" + Int32.MinValue, "-2147483648"; Int32.MaxValue, "2147483647" ] do + check expected (padded n) + check "01" (paddedInteger 1y) + check "01" (paddedInteger 1uy) + check "01" (paddedInteger 1s) + check "01" (paddedInteger 1us) + check "01" (paddedInteger 1L) + check "01" (paddedInteger 1u) + check "01" (paddedInteger 1UL) + check " 1" $"%5d{1}" + check "001" $"%03d{1}" + check "-01" $"%03d{-1}" + check "3.14" $"%.2f{3.14159}" + check "true false" $"%b{true} %b{false}" + check "4294967295" $"%u{-1}" + + let originalCulture = CultureInfo.CurrentCulture + try + CultureInfo.CurrentCulture <- CultureInfo("fr-FR") + let n, text, initial, amount, flag = 2, "s", 'c', 1.5M, true + let missing: string = null + for actual in + [ $"%02d{1}%d{n}|%d{n}%02d{1}%d{n}|%d{n}%02d{1}" + $"%02d{1}%i{n}|%i{n}%02d{1}%i{n}|%i{n}%02d{1}" + $"%02d{1}%s{string n}|%s{string n}%02d{1}%s{string n}|%s{string n}%02d{1}" + $"%02d{1}%c{'2'}|%c{'2'}%02d{1}%c{'2'}|%c{'2'}%02d{1}" + $"%02d{1}%M{2M}|%M{2M}%02d{1}%M{2M}|%M{2M}%02d{1}" + $"%02d{1}{n}|{n}%02d{1}{n}|{n}%02d{1}" ] do + check "012|2012|201" actual + check "s01c" $"%s{text}%02d{1}%c{initial}" + check "011.5" $"%02d{1}%M{amount}" + check "1.501" $"%M{amount}%02d{1}" + check "True01true" $"{flag}%02d{1}%b{flag}" + check "01" $"%s{missing}%02d{1}{missing}" + check "01:02:03" $"{now.Hour:D2}:{now.Minute:D2}:{now.Second:D2}" + check " 42" $"{42,6}" + check "011.50" $"%02d{1}{amount:F2}" + check "1.5001" $"{1.5:F2}%02d{1}" + finally + CultureInfo.CurrentCulture <- originalCulture + + let trace = ResizeArray() + let hole id value = + trace.Add id + value + check "01203" $"%02d{hole 1 1}{hole 2 2}%02d{hole 3 3}" + if Seq.toList trace <> [ 1; 2; 3 ] then failwith "Hole evaluation order" + trace.Clear() + check "x02" $"{hole 1 (Rendered trace)}%02d{hole 2 2}" + if Seq.toList trace <> [ 1; 11; 2 ] then failwith "Formatting moved past a later hole" + trace.Clear() + let throwing () = + trace.Add 2 + raise (InvalidOperationException("hole")) + try + let _ = $"%02d{hole 1 1}%02d{throwing ()}%02d{hole 3 3}" + failwith "Expected the second hole to throw" + with + | :? InvalidOperationException as error when error.Message = "hole" -> () + if Seq.toList trace <> [ 1; 2 ] then failwith "Evaluated a hole after an exception" diff --git a/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Library/Formatting.fs b/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Library/Formatting.fs new file mode 100644 index 00000000000..50cd0164d06 --- /dev/null +++ b/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Library/Formatting.fs @@ -0,0 +1,4 @@ +module PrintfInterpolationLibrary + +let timestamp (now: System.DateTime) = + $"%02d{now.Hour}:%02d{now.Minute}:%02d{now.Second}" diff --git a/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Library/PrintfInterpolationLibrary.fsproj b/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Library/PrintfInterpolationLibrary.fsproj new file mode 100644 index 00000000000..de479d0a714 --- /dev/null +++ b/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Library/PrintfInterpolationLibrary.fsproj @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/AheadOfTime/NativeAOT/PrintfInterpolation/PrintfInterpolation_Test.fsproj b/tests/AheadOfTime/NativeAOT/PrintfInterpolation/PrintfInterpolation_Test.fsproj new file mode 100644 index 00000000000..000ac37e941 --- /dev/null +++ b/tests/AheadOfTime/NativeAOT/PrintfInterpolation/PrintfInterpolation_Test.fsproj @@ -0,0 +1,15 @@ + + + Exe + true + win-x64 + + false + IL2055;IL2060;IL2067;IL2070;IL2072;IL2075;IL2080;IL3050 + + + + + + + diff --git a/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Program.fs b/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Program.fs new file mode 100644 index 00000000000..f5825083c24 --- /dev/null +++ b/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Program.fs @@ -0,0 +1,10 @@ +module Program + +[] +let main _ = + Formatting.runChecks () + let now = System.DateTime(2026, 10, 13, 1, 2, 3) + let startAt = Formatting.timestamp now + Formatting.check "01:02:03" startAt + System.Console.WriteLine(startAt) + 0 diff --git a/tests/AheadOfTime/NativeAOT/Program.fs b/tests/AheadOfTime/NativeAOT/Program.fs index 40ec41ba92c..46d008d670f 100644 --- a/tests/AheadOfTime/NativeAOT/Program.fs +++ b/tests/AheadOfTime/NativeAOT/Program.fs @@ -21,10 +21,7 @@ let runChecks () = check ($"answer = %d{x}", "answer = 42") check ($"initial = %c{initial}", "initial = F") - // The following use printf specifiers that still route through 'sprintf', so they would make the - // NativeAOT publish fail with IL2026/IL2070/IL3050. - // check ($"pi ~ %.2f{pi}", "pi ~ 3.14") - // check ($"value = %A{x}", "value = 42") + // Captured printf compatibility is tested separately: its Core analysis warnings are not reflection-free. [] let main argv = diff --git a/tests/AheadOfTime/NativeAOT/check.ps1 b/tests/AheadOfTime/NativeAOT/check.ps1 index 32d6fe532d8..b91faf5b73d 100644 --- a/tests/AheadOfTime/NativeAOT/check.ps1 +++ b/tests/AheadOfTime/NativeAOT/check.ps1 @@ -1,63 +1,67 @@ -# Publish the test project with NativeAOT and check that it runs. -# -# The point of this check is that the publish succeeds: a string-typed interpolated string -# must lower to a reflection-free form (System.String.Concat), not the reflection-based -# printf engine. If it regresses to printf, FSharp.Reflection becomes statically reachable, -# NativeAOT analysis emits IL2026/IL2070/IL3050, TreatWarningsAsErrors turns them into errors, -# and this publish fails. +# Keep reflection-free paths warning-strict and test captured printf compatibility separately. $ErrorActionPreference = "Stop" -$root = "NativeAOT_Test" - $cwd = Get-Location -Set-Location $PSScriptRoot - -dotnet publish -restore -c release "$root.fsproj" -bl:"$PSScriptRoot/../../../artifacts/log/Release/AheadOfTime/NativeAOT/$root.binlog" -if (-not ($LASTEXITCODE -eq 0)) { - Set-Location $cwd - Write-Error "NativeAOT publish failed with exit code $LASTEXITCODE" -ErrorAction Stop -} +try { + foreach ($fixture in @( + @{ Directory = $PSScriptRoot; Name = "NativeAOT_Test"; Expected = "Finished" }, + @{ Directory = (Join-Path $PSScriptRoot "PrintfInterpolation"); Name = "PrintfInterpolation_Test"; Expected = "01:02:03" } + )) { + Set-Location $fixture.Directory + $root = $fixture.Name + $logDirectory = Join-Path $PSScriptRoot "..\..\..\artifacts\log\Release\AheadOfTime\NativeAOT" + dotnet publish -restore -c release "$root.fsproj" "-bl:$logDirectory\$root.binlog" + $publishExit = $LASTEXITCODE + if ($publishExit -ne 0) { + throw "$root publish failed with exit code $publishExit" + } -# Read the frameworks from the project; passing -f above would override TargetFramework globally. -$props = dotnet msbuild "$root.fsproj" -getProperty:TargetFramework -getProperty:FSharpCoreShippedNetTargetFramework -nologo | ConvertFrom-Json -$tfm = $props.Properties.TargetFramework -$coreAsset = "lib/$($props.Properties.FSharpCoreShippedNetTargetFramework)/FSharp.Core.dll" + # Passing -f would override TargetFramework globally, including referenced projects. + $propertiesOutput = dotnet msbuild "$root.fsproj" -getProperty:TargetFramework -getProperty:FSharpCoreShippedNetTargetFramework -nologo + $propertiesExit = $LASTEXITCODE + if ($propertiesExit -ne 0) { throw "$root framework query failed with exit code $propertiesExit" } + $props = $propertiesOutput | ConvertFrom-Json + $tfm = $props.Properties.TargetFramework + $coreAsset = "lib/$($props.Properties.FSharpCoreShippedNetTargetFramework)/FSharp.Core.dll" -# Assert on the selected compile/runtime asset, not the "libraries" manifest, which lists every -# lib folder and would false-pass on the netstandard2.1 fallback. -$assets = Join-Path $PSScriptRoot "obj/project.assets.json" -$assetsJson = Get-Content $assets -Raw | ConvertFrom-Json -$netAssetSelected = $false -foreach ($target in $assetsJson.targets.PSObject.Properties) { - foreach ($package in $target.Value.PSObject.Properties) { - if ($package.Name -like "FSharp.Core/*") { - $compileKeys = if ($package.Value.compile) { @($package.Value.compile.PSObject.Properties.Name) } else { @() } - $runtimeKeys = if ($package.Value.runtime) { @($package.Value.runtime.PSObject.Properties.Name) } else { @() } - # FSharp.Core packs only lib/{tfm}, so both must resolve there; anything else is a fallback. - if (($compileKeys -contains $coreAsset) -and ($runtimeKeys -contains $coreAsset)) { - $netAssetSelected = $true + $assetDirectories = @($fixture.Directory) + if ($root -eq "PrintfInterpolation_Test") { + $assetDirectories += Join-Path $fixture.Directory "Library" + } + foreach ($directory in $assetDirectories) { + # Check selected compile/runtime assets, not the manifest listing every packaged lib folder. + $assets = Join-Path $directory "obj\project.assets.json" + $assetsJson = Get-Content $assets -Raw | ConvertFrom-Json + $netAssetSelected = $false + foreach ($target in $assetsJson.targets.PSObject.Properties) { + foreach ($package in $target.Value.PSObject.Properties) { + if ($package.Name -like "FSharp.Core/*") { + $compileKeys = if ($package.Value.compile) { @($package.Value.compile.PSObject.Properties.Name) } else { @() } + $runtimeKeys = if ($package.Value.runtime) { @($package.Value.runtime.PSObject.Properties.Name) } else { @() } + if (($compileKeys -contains $coreAsset) -and ($runtimeKeys -contains $coreAsset)) { + $netAssetSelected = $true + } + } + } + } + if (-not $netAssetSelected) { + throw "The $tfm consumer did not resolve the $coreAsset FSharp.Core compile/runtime asset. See $assets." } } + + $exe = Join-Path $fixture.Directory "bin\release\$tfm\win-x64\publish\$root.exe" + $stderrPath = Join-Path $logDirectory "$root.stderr" + $lines = @(& $exe 2> $stderrPath) + $exitCode = $LASTEXITCODE + $output = $lines -join "`n" + $stderr = Get-Content $stderrPath -Raw + if ($exitCode -ne 0 -or $output -cne $fixture.Expected -or $stderr) { + throw "$root behavior checks failed (exit $exitCode).`nStdout:`n$output`nStderr:`n$stderr" + } } -} -if (-not $netAssetSelected) { +} finally { Set-Location $cwd - Write-Error "The $tfm consumer did not resolve the $coreAsset FSharp.Core asset (it fell back to a lower target framework). The shipped net FSharp.Core target framework is not being dogfooded. See $assets." -ErrorAction Stop -} - -$exe = Join-Path $PSScriptRoot "bin/release/$tfm/win-x64/publish/$root.exe" -$output = (& $exe) -join "`n" -$exitCode = $LASTEXITCODE -Set-Location $cwd - -# The app prints a "FAILED" line per mismatch and "Finished" last, so its output is exactly "Finished" only if all checks passed. -if (-not ($exitCode -eq 0)) { - Write-Error "NativeAOT app crashed with exit code $exitCode.`nOutput:`n$output" -ErrorAction Stop -} - -if ($output.Trim() -ne "Finished") { - Write-Error "NativeAOT behavior checks failed.`nOutput:`n$output" -ErrorAction Stop } Write-Host "NativeAOT test passed." diff --git a/tests/FSharp.Compiler.ComponentTests/EmittedIL/StringFormatAndInterpolation.fs b/tests/FSharp.Compiler.ComponentTests/EmittedIL/StringFormatAndInterpolation.fs index 04fce5fbee1..07ca81a6930 100644 --- a/tests/FSharp.Compiler.ComponentTests/EmittedIL/StringFormatAndInterpolation.fs +++ b/tests/FSharp.Compiler.ComponentTests/EmittedIL/StringFormatAndInterpolation.fs @@ -6,6 +6,37 @@ open Xunit open FSharp.Test.Compiler module ``StringFormatAndInterpolation`` = + [] + let ``Issue 20757 - padded timestamp uses captured printf formats`` () = + let result = + FSharp """ +module Timestamp +let format (now: System.DateTime) = $"%02d{now.Hour}:%02d{now.Minute}:%02d{now.Second}" +""" + |> compile + |> shouldSucceed + result |> verifyILPresent [ + "ldstr \"%02d%P()\"" + "::.ctor(string," + "object[]," + "System.Type[])" + "PrintFormatToStringThen" + "System.String::Concat" + ] + result |> verifyILNotPresent ["FSharpFunc`2"; "::Invoke(int32)"] + + [] + [] + [] + [] + let ``Issue 20757 - fast paths do not construct printf formats`` (expression: string, call: string) = + let result = + FSharp ("module FastPath\nlet format (n: int) = " + expression) + |> compile + |> shouldSucceed + result |> verifyILPresent [call] + result |> verifyILNotPresent ["PrintfFormat"; "PrintFormatToStringThen"] + #if !DEBUG // sensitive to debug-level code coming across from debug FSharp.Core [] let ``Interpolated string with no holes is reduced to a string or simple format when used in printf``() = diff --git a/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs index 270a3a51a09..7db41c5314d 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs @@ -8,6 +8,54 @@ open FSharp.Test.Compiler module InterpolatedStringsTests = + [] + let ``Issue 20757 - printf interpolation matrix across files and assemblies`` () = + let fixture = System.IO.Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "AheadOfTime", "NativeAOT", "PrintfInterpolation") + let library = + FsFromPath(System.IO.Path.Combine(fixture, "Library", "Formatting.fs")) + |> withName "PrintfInterpolationLibrary" + |> asLibrary + |> withLangVersionPreview + FsFromPath(System.IO.Path.Combine(fixture, "Formatting.fs")) + |> withAdditionalSourceFiles [SourceFromPath(System.IO.Path.Combine(fixture, "Program.fs"))] + |> withReferences [library] + |> withLangVersionPreview + |> compileExeAndRun + |> shouldSucceed + |> withStdOutContains "01:02:03" + + [] + let ``Issue 20757 - non-string printf surfaces remain unchanged`` () = + Fsx """ +let check expected actual = if actual <> expected then failwith "Unexpected formatting" +let format: Printf.StringFormat = $"%02d{1}:%02d{2}" +check "01:02" (sprintf format) +check "01" (sprintf "%02d" 1) +check "[1; 2]" $"%A{[1; 2]}" +let formattable: System.FormattableString = $"{1,3:D2}" +check " 01" (formattable.ToString(System.Globalization.CultureInfo.InvariantCulture)) +let asInterface: System.IFormattable = $"{1:D2}" +check "01" (asInterface.ToString(null, System.Globalization.CultureInfo.InvariantCulture)) +""" + |> withLangVersionPreview + |> compileExeAndRun + |> shouldSucceed + + [] + [] + [] + let ``Issue 20757 - fallback hole diagnostics retain user ranges`` (specifier: string, value: string, expected: string, actual: string, endColumn: int) = + Fsx ("let s = $\"" + specifier + "{" + value + "}\"") + |> withLangVersionPreview + |> typecheck + |> shouldFail + |> withSingleDiagnostic (Error 1, Line 1, Col (endColumn - value.Length), Line 1, Col endColumn, + if specifier = "%02d" then + "The type 'bool' is not compatible with any of the types byte,int16,int32,int64,sbyte,uint16,uint32,uint64,nativeint,unativeint, arising from the use of a printf-style format string" + else + "This expression was expected to have type" + Environment.NewLine + " '" + expected + "' " + Environment.NewLine + + "but here has type" + Environment.NewLine + " '" + actual + "' ") + [] let ``Regression: Empty Interpolated String properly typechecks with explicit type on binding`` () = Fsx """ let a:byte = $"string" """ diff --git a/tests/FSharp.Compiler.Service.Tests/EditorTests.fs b/tests/FSharp.Compiler.Service.Tests/EditorTests.fs index 0f4a47c440b..923d7ff4b16 100644 --- a/tests/FSharp.Compiler.Service.Tests/EditorTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/EditorTests.fs @@ -598,6 +598,44 @@ let ``Implicit-yield seq body diagnostics are reported once`` (source: string) = |> Array.filter (fun (_, n) -> n > 1) Assert.Empty duplicated +[] +[] +[] +[] +let ``Issue 20757 - foreground fallback formats preserve locations and arity`` (expression: string, firstStart: int, firstEnd: int, secondStart: int, secondEnd: int) = + let _, results = parseAndCheckScriptWithOptions(testFile, "let s = " + expression, [| "--langversion:preview" |]) + results.Diagnostics |> shouldEqual [||] + results.GetFormatSpecifierLocationsAndArity() + |> Array.map (fun (range, arity) -> range.StartLine, range.StartColumn, range.EndLine, range.EndColumn, arity) + |> shouldEqual [| (1, firstStart, 1, firstEnd, 1); (1, secondStart, 1, secondEnd, 1) |] + +[] +[] +[] +[] +[] +[] +[ string = padded\nlet s = f 1")>] +let ``Issue 20757 - foreground checks fallback boundaries and generic integers`` (source: string) = + let _, results = parseAndCheckScriptWithOptions(testFile, source, [| "--langversion:preview" |]) + results.Diagnostics |> shouldEqual [||] + +[] +[] +[] +let ``Issue 20757 - foreground wrong-type holes report one real range`` (source: string, startColumn: int, endColumn: int) = + let _, results = parseAndCheckScriptWithOptions(testFile, source, [| "--langversion:preview" |]) + results.Diagnostics + |> Array.map (fun d -> d.ErrorNumber, d.StartLine, d.StartColumn, d.EndLine, d.EndColumn, d.Range.IsSynthetic) + |> shouldEqual [| (1, 1, startColumn, 1, endColumn, false) |] + +[] +let ``Issue 20757 - mixed invalid fallback holes are checked once`` () = + let _, results = parseAndCheckScriptWithOptions(testFile, "let s = $\"%b{1}%02d{true}\"", [| "--langversion:preview" |]) + results.Diagnostics + |> Array.map (fun d -> d.ErrorNumber, d.StartLine, d.StartColumn, d.EndLine, d.EndColumn, d.Range.IsSynthetic) + |> shouldEqual [| (1, 1, 13, 1, 14, false); (1, 1, 20, 1, 24, false) |] + #if ASSUME_PREVIEW_FSHARP_CORE [] let ``Printf specifiers for regular and verbatim interpolated strings`` () = From 3e0a4b0954b46f70dc23a1ad53f25821ac4e4968 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 13 Oct 2026 12:50:20 +0200 Subject: [PATCH 2/4] Fix printf interpolation warnings and compiler allocations Build captured fallback formats directly from the already parsed hole types. Reuse the existing captured constructor pipeline rather than rechecking synthesized sprintf syntax, preserving NativeAOT execution without false FS3884 warnings or redundant per-hole typechecking. Retain function-format and structured-capture regressions and consolidate foreground diagnostic scenarios. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- .../Checking/Expressions/CheckExpressions.fs | 52 +++++++++++-------- .../Language/InterpolatedStringsTests.fs | 27 ++++++++++ .../EditorTests.fs | 21 ++++---- 4 files changed, 68 insertions(+), 34 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index b4d0db992f7..c170ee07df4 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -12,7 +12,7 @@ * Prevent `InvalidProgramException` when runtime async awaits a `Task` upcast to `Task`, including through `ConfigureAwait`. ([Issue #20686](https://github.com/dotnet/fsharp/issues/20686), [PR #20726](https://github.com/dotnet/fsharp/pull/20726)) * Struct/reference anonymous-record mismatches now name anonymous records rather than tuples. ([Issue #6110](https://github.com/dotnet/fsharp/issues/6110), [PR #20681](https://github.com/dotnet/fsharp/pull/20681)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) -* Restore NativeAOT execution of printf-formatted interpolated strings such as zero-padded timestamps, while preserving reflection-free interpolation fast paths. ([Issue #20757](https://github.com/dotnet/fsharp/issues/20757)) +* Restore NativeAOT execution of printf-formatted interpolated strings such as zero-padded timestamps, while preserving reflection-free interpolation fast paths and avoiding spurious function-hole warnings or repeated hole typechecking. ([Issue #20757](https://github.com/dotnet/fsharp/issues/20757)) * Build parser diagnostic messages without runtime printf specialization or reflective function construction, so Native AOT hosts report indentation and invalid-character diagnostics instead of failing during formatting. Preserve `#r` reference tooltips while script project options remain alive, including after garbage collection or cache eviction. ([PR #20658](https://github.com/dotnet/fsharp/pull/20658)) * Preserve tupled lambda argument groups when reducing state-machine applications, avoiding invalid argument substitutions and runtime failures with inline formatters. ([Issue #20675](https://github.com/dotnet/fsharp/issues/20675), [PR #20700](https://github.com/dotnet/fsharp/pull/20700)) * Preserve SRTP extension scopes across source files and competing witnesses through imported inline calls. Compile generic extension forwarding, including quotations, in unoptimized builds. Preserve applicable built-in operator priority in imported inline quotations. Do not reuse SRTP specializations across different selected witnesses. ([Issue #20683](https://github.com/dotnet/fsharp/issues/20683), [Issue #20684](https://github.com/dotnet/fsharp/issues/20684), [Issue #20685](https://github.com/dotnet/fsharp/issues/20685), [PR #20717](https://github.com/dotnet/fsharp/pull/20717)) diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index f4792d4e305..0ae6092357f 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -7712,19 +7712,26 @@ and TcFormatStringExpr cenv (overallTy: OverallTy) env m tpenv (fmtString: strin mkString g m fmtString, tpenv ) +and MakeCapturedPrintfFormat (cenv: cenv) m newFormatMethod printfFormatString argTys fillExprs percentATys = + let g = cenv.g + let fillExprsBoxed = (argTys, fillExprs) ||> List.map2 (mkCallBox g m) + let argsExpr = mkArray (g.obj_ty_withNulls, fillExprsBoxed, m) + let percentATysExpr = + if Array.isEmpty percentATys then + mkNull m (mkArrayType g g.system_Type_ty) + else + let tyExprs = percentATys |> Array.map (mkCallTypeOf g m) |> Array.toList + mkArray (g.system_Type_ty, tyExprs, m) + MakeMethInfoCall cenv.amap m newFormatMethod [] [mkString g m printfFormatString; argsExpr; percentATysExpr] None + /// Lower a string-typed interpolated string to System.String.Concat of its rendered parts, /// type-checking each part in place. -and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, m: range, tpenv: UnscopedTyparEnv, parts: SynInterpolatedStringPart list) = +and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, m: range, tpenv: UnscopedTyparEnv, parts: SynInterpolatedStringPart list, argTys: TType list) = let g = cenv.g let mSynth = m.MakeSynthetic() let strLit (s: string) = SynExpr.Const(SynConst.String(s, SynStringKind.Regular, mSynth), mSynth) let paren (e: SynExpr) = SynExpr.Paren(e, range0, None, mSynth) - let sprintfOp (formatting: SynInterpolationFormatting, e: SynExpr) = - let format = SynExpr.InterpolatedString([ SynInterpolatedStringPart.FillExpr(e, formatting) ], SynStringKind.Regular, mSynth) - let call = mkSynApp1 (mkSynLidGet mSynth [ "Microsoft"; "FSharp"; "Core"; "ExtraTopLevelOperators" ] "sprintf") format mSynth - SynExpr.Typed(call, SynType.LongIdent(SynLongIdent([ mkSynId mSynth "string" ], [], [ None ])), mSynth) - // 'String.Format(InvariantCulture, "{0,align:format}", e)': format an aligned or '{e:fmt}' hole. let stringFormatOp (alignment: SynExpr option, format: Ident option, e: SynExpr) = let alignText = match alignment with Some (SynExpr.Const (SynConst.Int32 n, _)) -> "," + string n | _ -> "" @@ -7735,7 +7742,7 @@ and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, mkSynApp1 (mkSynLidGet mSynth [ "System"; "String" ] "Format") args mSynth // Type-check one hole and convert it to a (string expression, may-be-null) pair. - let convertHole (synFill: SynExpr, formatting: SynInterpolationFormatting, tpenv: UnscopedTyparEnv) = + let convertHole (synFill: SynExpr, formatting: SynInterpolationFormatting, argTy: TType, tpenv: UnscopedTyparEnv) = // Constrain the hole to 'constraintTy', then render it with 'string' as for a plain '{x}' hole. Used for // bare specifiers (no flags/width/precision) that act only as a type annotation: the value renders the // same through 'string' as through the specifier. ('%u' is not one of these: it reinterprets a signed @@ -7755,7 +7762,16 @@ and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, | "%d" | "%i" -> convertViaString (CheckFormatStrings.mkFlexibleIntFormatTypar g m) | "%M" -> convertViaString (CheckFormatStrings.mkFlexibleDecimalFormatTypar g m) | _ -> - let arg, tpenv = TcExpr cenv (MustEqual g.string_ty) env tpenv (sprintfOp (formatting, synFill)) + let fill, tpenv = TcExprFlex2 cenv argTy env false tpenv synFill + let formatTy = mkPrintfFormatTy g g.string_ty g.unit_ty g.string_ty g.string_ty (mkRefTupledTy g [argTy]) + let newFormatMethod = + match GetIntrinsicConstructorInfosOfType cenv.infoReader mSynth formatTy |> List.filter (fun minfo -> minfo.NumArgs = [3]) with + | [ctorInfo] -> ctorInfo + | _ -> error (Error(FSComp.SR.csMethodNotFound(RichText.mkMethod "Microsoft.FSharp.Core.PrintfFormat.ctor"), m)) + let percentATys = if spec.EndsWith("A", StringComparison.Ordinal) then [| argTy |] else [||] + let format = MakeCapturedPrintfFormat cenv mSynth newFormatMethod (spec + "%P()") [argTy] [fill] percentATys + let sprintf = g.sprintf_vref + let arg = mkApps g ((exprForValRef mSynth sprintf, sprintf.Type), [[g.string_ty]], [format], mSynth) (arg, false), tpenv | SynInterpolationFormatting.DotNet (alignment, format) -> // Type-checking the hole here is also where a function value gets warned about. @@ -7782,13 +7798,15 @@ and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, let argExprs, tpenv = let ra = ResizeArray() let mutable tpenvAcc = tpenv + let mutable remainingArgTys = argTys for part in parts do match part with | SynInterpolatedStringPart.String (s, _) -> if s <> "" then ra.Add((mkString g m (s.Replace("%%", "%")), false)) | SynInterpolatedStringPart.FillExpr (synFill, formatting) -> - let argExpr, tpenvAfter = convertHole (synFill, formatting, tpenvAcc) + let argExpr, tpenvAfter = convertHole (synFill, formatting, remainingArgTys.Head, tpenvAcc) + remainingArgTys <- remainingArgTys.Tail ra.Add argExpr tpenvAcc <- tpenvAfter List.ofSeq ra, tpenvAcc @@ -7909,7 +7927,7 @@ and TcInterpolatedStringExpr cenv (overallTy: OverallTy) env m tpenv (parts: Syn // original source and this may include some additional characters, // and also doesn't raise all necessary errors match cenv.tcSink.CurrentSink with - | Some sink when not m.IsSynthetic && sink.FormatStringCheckContext.IsSome -> + | Some sink when sink.FormatStringCheckContext.IsSome -> try let _argTys, _printerTy, _printerTupleTyRequired, _percentATys, specifierLocations, _dotnetFormatString = CheckFormatStrings.ParseFormatString m stringFragmentRanges g true isFormattableString sink.FormatStringCheckContext printfFormatString printerArgTy printerResidueTy printerResultTy @@ -7951,7 +7969,7 @@ and TcInterpolatedStringExpr cenv (overallTy: OverallTy) env m tpenv (parts: Syn elif isString then // String-typed interpolation: concatenate rendered parts, // type-checking each hole in place (no separate batch, no flat fill-expression list). - TcInterpolatedStringViaConcat (cenv, overallTy, env, m, tpenv, parts) + TcInterpolatedStringViaConcat (cenv, overallTy, env, m, tpenv, parts, argTys) else // $"...{x}..." used as a PrintfFormat value: build a PrintfFormat that captures the args. let fillExprs, tpenv = TcExprsNoFlexes cenv env m tpenv argTys synFillExprs @@ -7959,17 +7977,7 @@ and TcInterpolatedStringExpr cenv (overallTy: OverallTy) env m tpenv (parts: Syn if g.langVersion.SupportsFeature LanguageFeature.WarnWhenFunctionValueUsedAsInterpolatedStringArg then warnForFunctionValuesInFillExprs g argTys synFillExprs - let fillExprsBoxed = (argTys, fillExprs) ||> List.map2 (mkCallBox g m) - - let argsExpr = mkArray (g.obj_ty_withNulls, fillExprsBoxed, m) - let percentATysExpr = - if percentATys.Length = 0 then - mkNull m (mkArrayType g g.system_Type_ty) - else - let tyExprs = percentATys |> Array.map (mkCallTypeOf g m) |> Array.toList - mkArray (g.system_Type_ty, tyExprs, m) - - MakeMethInfoCall cenv.amap m newFormatMethod [] [mkString g m printfFormatString; argsExpr; percentATysExpr] None, tpenv + MakeCapturedPrintfFormat cenv m newFormatMethod printfFormatString argTys fillExprs percentATys, tpenv // The case for $"..." used as type FormattableString or IFormattable | Choice2Of2 createFormattableStringMethod -> diff --git a/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs index 7db41c5314d..b28f3d8eed8 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs @@ -8,6 +8,28 @@ open FSharp.Test.Compiler module InterpolatedStringsTests = + [] + [ printed}\"", "printed")>] + [ printed}\"", "printed")>] + [ printed}\"\"\"", "printed")>] + [ x}%02d{1}%t{fun () -> y}\"", "x01y")>] + let ``Issue 20757 - printf thunk holes do not warn about ToString`` (expression: string, expected: string) = + Fsx ("let printed, x, y = \"printed\", \"x\", \"y\"\nlet s = " + expression + "\nif s <> \"" + expected + "\" then failwith \"Unexpected thunk formatting\"") + |> withLangVersionPreview + |> withOptions ["--warnaserror:3884"] + |> compileExeAndRun + |> shouldSucceed + + [] + [] + [] + let ``Issue 20757 - explicit printf function formatting retains diagnostics`` (specifier: string) = + Fsx ("let s = $\"" + specifier + "{fun () -> 1}\"") + |> withLangVersionPreview + |> withOptions ["--warnaserror:3884"] + |> typecheck + |> shouldSucceed + [] let ``Issue 20757 - printf interpolation matrix across files and assemblies`` () = let fixture = System.IO.Path.Combine(__SOURCE_DIRECTORY__, "..", "..", "AheadOfTime", "NativeAOT", "PrintfInterpolation") @@ -32,6 +54,11 @@ let format: Printf.StringFormat = $"%02d{1}:%02d{2}" check "01:02" (sprintf format) check "01" (sprintf "%02d" 1) check "[1; 2]" $"%A{[1; 2]}" +let inline structured value = $"%02d{1}|%A{value}|%02d{2}" +check "01|[1; 2]|02" (structured [1; 2]) +check "01|Some \"x\"|02" (structured (Some "x")) +let optional = Some "x" +check "[1; 2]|Some \"x\"" $"%A{[1; 2]}|%A{optional}" let formattable: System.FormattableString = $"{1,3:D2}" check " 01" (formattable.ToString(System.Globalization.CultureInfo.InvariantCulture)) let asInterface: System.IFormattable = $"{1:D2}" diff --git a/tests/FSharp.Compiler.Service.Tests/EditorTests.fs b/tests/FSharp.Compiler.Service.Tests/EditorTests.fs index 923d7ff4b16..56a27486a32 100644 --- a/tests/FSharp.Compiler.Service.Tests/EditorTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/EditorTests.fs @@ -616,25 +616,24 @@ let ``Issue 20757 - foreground fallback formats preserve locations and arity`` ( [] [] [ string = padded\nlet s = f 1")>] +[ string 1}%02d{1}%t{fun () -> string 2}\"")>] +[ 1}%O{fun () -> 2}\"")>] let ``Issue 20757 - foreground checks fallback boundaries and generic integers`` (source: string) = let _, results = parseAndCheckScriptWithOptions(testFile, source, [| "--langversion:preview" |]) results.Diagnostics |> shouldEqual [||] [] -[] -[] -let ``Issue 20757 - foreground wrong-type holes report one real range`` (source: string, startColumn: int, endColumn: int) = +[] +[] +[] +let ``Issue 20757 - foreground wrong-type holes report real ranges once`` (source: string, firstStart: int, firstEnd: int, secondStart: int, secondEnd: int) = let _, results = parseAndCheckScriptWithOptions(testFile, source, [| "--langversion:preview" |]) results.Diagnostics |> Array.map (fun d -> d.ErrorNumber, d.StartLine, d.StartColumn, d.EndLine, d.EndColumn, d.Range.IsSynthetic) - |> shouldEqual [| (1, 1, startColumn, 1, endColumn, false) |] - -[] -let ``Issue 20757 - mixed invalid fallback holes are checked once`` () = - let _, results = parseAndCheckScriptWithOptions(testFile, "let s = $\"%b{1}%02d{true}\"", [| "--langversion:preview" |]) - results.Diagnostics - |> Array.map (fun d -> d.ErrorNumber, d.StartLine, d.StartColumn, d.EndLine, d.EndColumn, d.Range.IsSynthetic) - |> shouldEqual [| (1, 1, 13, 1, 14, false); (1, 1, 20, 1, 24, false) |] + |> shouldEqual [| + (1, 1, firstStart, 1, firstEnd, false) + if secondStart >= 0 then (1, 1, secondStart, 1, secondEnd, false) + |] #if ASSUME_PREVIEW_FSHARP_CORE [] From 387ada0071947ad594e4da05d0062ef3d94e631d Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 13 Oct 2026 16:06:50 +0200 Subject: [PATCH 3/4] Restore interpolation printer constraints for object and interface targets Normalize printer state, residue and result for every string-rendering dispatch before parsing interpolation holes. Reject invalid printf thunks with FS0001 instead of capturing them with incompatible unit/string format types. Preserve custom PrintfFormat and FormattableString dispatch. Add state/residue diagnostic regressions, valid upcast rendering checks, custom-format controls, foreground diagnostic checks and NativeAOT coverage. Clean Release and Debug solution builds pass; Release interpolation/IL tests pass 80, Debug interpolation tests pass 67, editor tests pass 80 with 3 existing skips in each configuration. Fresh native fixtures return exactly Finished and 01:02:03; expert review reports LGTM. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- .../Checking/Expressions/CheckExpressions.fs | 17 +++++---- .../PrintfInterpolation/Formatting.fs | 5 +++ .../Language/InterpolatedStringsTests.fs | 36 +++++++++++++++++++ .../EditorTests.fs | 13 +++++++ 5 files changed, 63 insertions(+), 10 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index c170ee07df4..50e484fd911 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -12,7 +12,7 @@ * Prevent `InvalidProgramException` when runtime async awaits a `Task` upcast to `Task`, including through `ConfigureAwait`. ([Issue #20686](https://github.com/dotnet/fsharp/issues/20686), [PR #20726](https://github.com/dotnet/fsharp/pull/20726)) * Struct/reference anonymous-record mismatches now name anonymous records rather than tuples. ([Issue #6110](https://github.com/dotnet/fsharp/issues/6110), [PR #20681](https://github.com/dotnet/fsharp/pull/20681)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) -* Restore NativeAOT execution of printf-formatted interpolated strings such as zero-padded timestamps, while preserving reflection-free interpolation fast paths and avoiding spurious function-hole warnings or repeated hole typechecking. ([Issue #20757](https://github.com/dotnet/fsharp/issues/20757)) +* Restore NativeAOT execution of printf-formatted interpolated strings such as zero-padded timestamps, while preserving reflection-free interpolation fast paths and avoiding spurious function-hole warnings or repeated hole typechecking. Reject incorrectly typed printf thunks at compile time, including when the interpolated string is used as an object or interface. ([Issue #20757](https://github.com/dotnet/fsharp/issues/20757)) * Build parser diagnostic messages without runtime printf specialization or reflective function construction, so Native AOT hosts report indentation and invalid-character diagnostics instead of failing during formatting. Preserve `#r` reference tooltips while script project options remain alive, including after garbage collection or cache eviction. ([PR #20658](https://github.com/dotnet/fsharp/pull/20658)) * Preserve tupled lambda argument groups when reducing state-machine applications, avoiding invalid argument substitutions and runtime failures with inline formatters. ([Issue #20675](https://github.com/dotnet/fsharp/issues/20675), [PR #20700](https://github.com/dotnet/fsharp/pull/20700)) * Preserve SRTP extension scopes across source files and competing witnesses through imported inline calls. Compile generic extension forwarding, including quotations, in unoptimized builds. Preserve applicable built-in operator priority in imported inline quotations. Do not reuse SRTP specializations across different selected witnesses. ([Issue #20683](https://github.com/dotnet/fsharp/issues/20683), [Issue #20684](https://github.com/dotnet/fsharp/issues/20684), [Issue #20685](https://github.com/dotnet/fsharp/issues/20685), [PR #20717](https://github.com/dotnet/fsharp/pull/20717)) diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index 0ae6092357f..8c324e4d918 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -7860,15 +7860,6 @@ and TcInterpolatedStringExpr cenv (overallTy: OverallTy) env m tpenv (parts: Syn let stringKind = // If this is an interpolated string then try to force the result to be a string if (AddCxTypeEqualsTypeUndoIfFailed env.DisplayEnv cenv.css m overallTy.Commit g.string_ty) then - - // And if that succeeds, the result of printing is a string - UnifyTypes cenv env m printerArgTy g.unit_ty - UnifyTypes cenv env m printerResidueTy g.string_ty - UnifyTypes cenv env m printerResultTy overallTy.Commit - - // And if that succeeds, the printerTy and printerResultTy must be the same (there are no curried arguments) - UnifyTypes cenv env m printerTy printerResultTy - Choice1Of2 (true, newFormatMethod) // ... or if that fails then may be a FormattableString by a type-directed rule.... @@ -7896,6 +7887,14 @@ and TcInterpolatedStringExpr cenv (overallTy: OverallTy) env m tpenv (parts: Syn else Choice1Of2 (true, newFormatMethod) + match stringKind with + | Choice1Of2 (true, _) -> + UnifyTypes cenv env m printerArgTy g.unit_ty + UnifyTypes cenv env m printerResidueTy g.string_ty + UnifyTypes cenv env m printerResultTy g.string_ty + UnifyTypes cenv env m printerTy printerResultTy + | Choice1Of2 (false, _) | Choice2Of2 _ -> () + let isFormattableString = (match stringKind with Choice2Of2 _ -> true | _ -> false) // The format string used for checking in CheckFormatStrings, reconstructed from the parts: each diff --git a/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Formatting.fs b/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Formatting.fs index 1a3d4d1ab36..b5ec458306f 100644 --- a/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Formatting.fs +++ b/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Formatting.fs @@ -25,6 +25,11 @@ let runChecks () = check "010203" $"%02d{now.Hour}%02d{now.Minute}%02d{now.Second}" check "01:02:03" (timestamp now) check "01:02:03" (PrintfInterpolationLibrary.timestamp now) + let asObject: obj = $"""%t{fun () -> "ok"}%02d{1}""" + let asComparable: IComparable = $"""%t{fun () -> "ok"}%02d{1}""" + let asCloneable: ICloneable = $"""%t{fun () -> "ok"}%02d{1}""" + for value in [asObject; asComparable; asCloneable] do + check "ok01" (string value) for n, expected in [ 0, "00"; 9, "09"; 10, "10"; -1, "-1"; -9, "-9"; -10, "-10" Int32.MinValue, "-2147483648"; Int32.MaxValue, "2147483647" ] do diff --git a/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs index b28f3d8eed8..b125ff0a8c6 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs @@ -20,6 +20,42 @@ module InterpolatedStringsTests = |> compileExeAndRun |> shouldSucceed + [] + [] + [] + [] + [] + let ``Issue 20757 - string-rendering thunk holes enforce state and residue types`` (targetType: string) = + for thunk in ["fun (_: int) -> \"bad\""; "fun () -> 42"] do + Fsx ("let value: " + targetType + " = $\"\"\"%t{" + thunk + "}\"\"\"") + |> withLangVersionPreview + |> typecheck + |> shouldFail + |> withErrorCode 1 + |> ignore + + [] + [] + [] + [] + [] + let ``Issue 20757 - string-rendering thunk holes render before upcasting`` (targetType: string) = + Fsx ("let value: " + targetType + " = $\"\"\"%t{fun () -> \"ok\"}%02d{1}\"\"\"\nif string value <> \"ok01\" then failwith \"Unexpected thunk formatting\"") + |> withLangVersionPreview + |> withOptions ["--warnaserror:3884"] + |> compileExeAndRun + |> shouldSucceed + + [] + [", "fun (state: int) -> string state")>] + [", "fun () -> 42")>] + let ``Issue 20757 - captured format thunk holes retain custom state and residue types`` (targetType: string, thunk: string) = + Fsx ("let format: " + targetType + " = $\"\"\"%t{" + thunk + "}\"\"\"") + |> withLangVersionPreview + |> withOptions ["--nowarn:3884"] + |> typecheck + |> shouldSucceed + [] [] [] diff --git a/tests/FSharp.Compiler.Service.Tests/EditorTests.fs b/tests/FSharp.Compiler.Service.Tests/EditorTests.fs index 56a27486a32..5269cbdf8ea 100644 --- a/tests/FSharp.Compiler.Service.Tests/EditorTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/EditorTests.fs @@ -622,6 +622,19 @@ let ``Issue 20757 - foreground checks fallback boundaries and generic integers`` let _, results = parseAndCheckScriptWithOptions(testFile, source, [| "--langversion:preview" |]) results.Diagnostics |> shouldEqual [||] +[] +[] +[] +[] +[] +let ``Issue 20757 - foreground string-rendering thunks enforce state and residue types`` (targetType: string) = + for thunk in ["fun (_: int) -> \"bad\""; "fun () -> 42"] do + let source = "let value: " + targetType + " = $\"\"\"%t{" + thunk + "}\"\"\"" + let _, results = parseAndCheckScriptWithOptions(testFile, source, [| "--langversion:preview" |]) + results.Diagnostics + |> Array.map (fun d -> d.ErrorNumber, d.Range.IsSynthetic) + |> shouldEqual [| (1, false) |] + [] [] [] From ee5bd00bd555cb2b59be2e11083c0d38220e174e Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 13 Oct 2026 18:33:47 +0200 Subject: [PATCH 4/4] Add release notes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/release-notes/.FSharp.Compiler.Service/11.0.200.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 50e484fd911..c4f0ad7efea 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -12,7 +12,7 @@ * Prevent `InvalidProgramException` when runtime async awaits a `Task` upcast to `Task`, including through `ConfigureAwait`. ([Issue #20686](https://github.com/dotnet/fsharp/issues/20686), [PR #20726](https://github.com/dotnet/fsharp/pull/20726)) * Struct/reference anonymous-record mismatches now name anonymous records rather than tuples. ([Issue #6110](https://github.com/dotnet/fsharp/issues/6110), [PR #20681](https://github.com/dotnet/fsharp/pull/20681)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) -* Restore NativeAOT execution of printf-formatted interpolated strings such as zero-padded timestamps, while preserving reflection-free interpolation fast paths and avoiding spurious function-hole warnings or repeated hole typechecking. Reject incorrectly typed printf thunks at compile time, including when the interpolated string is used as an object or interface. ([Issue #20757](https://github.com/dotnet/fsharp/issues/20757)) +* Restore NativeAOT execution of printf-formatted interpolated strings such as zero-padded timestamps, while preserving reflection-free interpolation fast paths and avoiding spurious function-hole warnings or repeated hole typechecking. Reject incorrectly typed printf thunks at compile time, including when the interpolated string is used as an object or interface. ([Issue #20757](https://github.com/dotnet/fsharp/issues/20757), [PR #20778](https://github.com/dotnet/fsharp/pull/20778)) * Build parser diagnostic messages without runtime printf specialization or reflective function construction, so Native AOT hosts report indentation and invalid-character diagnostics instead of failing during formatting. Preserve `#r` reference tooltips while script project options remain alive, including after garbage collection or cache eviction. ([PR #20658](https://github.com/dotnet/fsharp/pull/20658)) * Preserve tupled lambda argument groups when reducing state-machine applications, avoiding invalid argument substitutions and runtime failures with inline formatters. ([Issue #20675](https://github.com/dotnet/fsharp/issues/20675), [PR #20700](https://github.com/dotnet/fsharp/pull/20700)) * Preserve SRTP extension scopes across source files and competing witnesses through imported inline calls. Compile generic extension forwarding, including quotations, in unoptimized builds. Preserve applicable built-in operator priority in imported inline quotations. Do not reuse SRTP specializations across different selected witnesses. ([Issue #20683](https://github.com/dotnet/fsharp/issues/20683), [Issue #20684](https://github.com/dotnet/fsharp/issues/20684), [Issue #20685](https://github.com/dotnet/fsharp/issues/20685), [PR #20717](https://github.com/dotnet/fsharp/pull/20717))