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 99464a277dd..0aec7626400 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -14,6 +14,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), [PR #20778](https://github.com/dotnet/fsharp/pull/20778)) * `GetProjectSnapshotFromScript` uses the document source of the checker when none is passed, also for the TransparentCompiler's `GetProjectOptionsFromScript`. A file from a `DocumentSource.Custom` keeps its snapshot version until its text changes, and a file for which the source returns `None` is read from disk. `FSharpFileSnapshot.CreateFromDocumentSource` now returns `Async`. `SourceTextNew.ofISourceText` returns an `ISourceTextNew` unchanged, and otherwise computes the checksum from the text instead of from `ToString()`. ([Issue #20750](https://github.com/dotnet/fsharp/issues/20750), [PR #20751](https://github.com/dotnet/fsharp/pull/20751)) * 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)) diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index ceb7a4da0aa..8c324e4d918 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -7712,20 +7712,26 @@ 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, +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) - // '(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 - 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 | _ -> "" @@ -7736,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 @@ -7756,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 (spec, 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. @@ -7783,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 @@ -7843,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.... @@ -7879,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 @@ -7950,9 +7966,9 @@ 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) + 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 @@ -7960,17 +7976,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/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..b5ec458306f --- /dev/null +++ b/tests/AheadOfTime/NativeAOT/PrintfInterpolation/Formatting.fs @@ -0,0 +1,94 @@ +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) + 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 + 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..b125ff0a8c6 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs @@ -8,6 +8,117 @@ 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 - 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 + + [] + [] + [] + 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") + 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 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}" +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..5269cbdf8ea 100644 --- a/tests/FSharp.Compiler.Service.Tests/EditorTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/EditorTests.fs @@ -598,6 +598,56 @@ 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")>] +[ 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 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) |] + +[] +[] +[] +[] +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, firstStart, 1, firstEnd, false) + if secondStart >= 0 then (1, 1, secondStart, 1, secondEnd, false) + |] + #if ASSUME_PREVIEW_FSHARP_CORE [] let ``Printf specifiers for regular and verbatim interpolated strings`` () =