Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/release-notes/.FSharp.Compiler.Service/11.0.200.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* Prevent `InvalidProgramException` when runtime async awaits a `Task<T>` 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<FSharpFileSnapshot>`. `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))
Expand Down
72 changes: 39 additions & 33 deletions src/Compiler/Checking/Expressions/CheckExpressions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 | _ -> ""
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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....
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -7950,27 +7966,17 @@ 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

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 ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project>
<Import Project="$(MSBuildThisFileDirectory)..\..\Directory.Build.props" />
<Import Project="$(MSBuildThisFileDirectory)..\..\..\..\eng\Versions.props" />
<PropertyGroup>
<TargetFramework>$(FSharpCoreShippedNetTargetFramework)</TargetFramework>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<DisableImplicitFSharpCoreReference>true</DisableImplicitFSharpCoreReference>
<FSharpBuildAssemblyFile>$(LocalFSharpBuildBinPath)\FSharp.Build.dll</FSharpBuildAssemblyFile>
<DotnetFscCompilerPath>$(LocalFSharpBuildBinPath)\fsc.dll</DotnetFscCompilerPath>
<Fsc_DotNET_DotnetFscCompilerPath>$(LocalFSharpBuildBinPath)\fsc.dll</Fsc_DotNET_DotnetFscCompilerPath>
<FSharpPreferNetFrameworkTools>False</FSharpPreferNetFrameworkTools>
<FSharpPrefer64BitTools>True</FSharpPrefer64BitTools>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FSharp.Core" Version="$(FSharpCorePreviewPackageVersionValue)" />
</ItemGroup>
</Project>
94 changes: 94 additions & 0 deletions tests/AheadOfTime/NativeAOT/PrintfInterpolation/Formatting.fs
Original file line number Diff line number Diff line change
@@ -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<int>) =
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<int>()
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"
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module PrintfInterpolationLibrary

let timestamp (now: System.DateTime) =
$"%02d{now.Hour}:%02d{now.Minute}:%02d{now.Second}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<Compile Include="Formatting.fs" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<PublishAot>true</PublishAot>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<!-- Captured printf already exposes these Core analysis warnings; keep them visible. -->
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<WarningsNotAsErrors>IL2055;IL2060;IL2067;IL2070;IL2072;IL2075;IL2080;IL3050</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
<Compile Include="Formatting.fs" />
<Compile Include="Program.fs" />
<ProjectReference Include="Library\PrintfInterpolationLibrary.fsproj" />
</ItemGroup>
</Project>
10 changes: 10 additions & 0 deletions tests/AheadOfTime/NativeAOT/PrintfInterpolation/Program.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module Program

[<EntryPoint>]
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
5 changes: 1 addition & 4 deletions tests/AheadOfTime/NativeAOT/Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

[<EntryPoint>]
let main argv =
Expand Down
Loading
Loading