Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
4 changes: 4 additions & 0 deletions docs/release-notes/.FSharp.Compiler.Service/11.0.100.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,5 +100,9 @@

* Improvements in error and warning messages: new error FS3885 when `let!`/`use!` is the final expression in a computation expression; new warning FS3886 when a list literal contains a single tuple element (likely missing `;` separator); improved wording for FS0003, FS0025, FS0039, FS0072, FS0247, FS0597, FS0670, FS3082, and SRTP operator-not-in-scope hints. ([PR #19398](https://github.com/dotnet/fsharp/pull/19398))
* Exception field serialization (`GetObjectData` and field-restoring constructor) is now gated behind `langversion:11` (`LanguageFeature.ExceptionFieldSerializationSupport`). With langversion ≤10, exception codegen is unchanged from pre-#19342 behavior. ([PR #19746](https://github.com/dotnet/fsharp/pull/19746))
* Lower string-typed interpolated strings to `System.String.Concat` rather than the reflection-based `printf` engine, making them trim- and NativeAOT-compatible. This generalizes and ungates the previous all-string `String.Concat` optimization, so it now applies to every string-typed interpolation. ([Language suggestion #1108](https://github.com/fsharp/fslang-suggestions/issues/1108), [PR #19971](https://github.com/dotnet/fsharp/pull/19971))
* Interpolated string holes (e.g. `$"{x}"`) are now formatted with invariant culture (via the `string` operator) instead of the current thread culture. ([PR #19971](https://github.com/dotnet/fsharp/pull/19971))

### Breaking Changes

* `FSharp.Compiler.Syntax.SynInterpolatedStringPart.FillExpr` now carries a `SynInterpolationFormatting` value (separating .NET alignment/format from printf specifiers) instead of an `Ident option`. ([PR #19971](https://github.com/dotnet/fsharp/pull/19971))
6 changes: 6 additions & 0 deletions src/Compiler/Checking/CheckFormatStrings.fsi
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ open FSharp.Compiler.TcGlobals
open FSharp.Compiler.Text
open FSharp.Compiler.TypedTree

/// A flexible type variable constrained to the integer types accepted by the '%d'/'%i'/'%u' specifiers.
val mkFlexibleIntFormatTypar: g: TcGlobals -> m: range -> TType

/// A flexible type variable constrained to 'decimal', as accepted by the '%M' specifier.
val mkFlexibleDecimalFormatTypar: g: TcGlobals -> m: range -> TType

val ParseFormatString:
m: range ->
fragmentRanges: range list ->
Expand Down
234 changes: 122 additions & 112 deletions src/Compiler/Checking/Expressions/CheckExpressions.fs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Compiler/Service/SynExpr.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1090,7 +1090,7 @@ module SynExpr =
| SynExpr.InterpolatedString(contents = contents), Dangling.Problematic _ ->
contents
|> List.exists (function
| SynInterpolatedStringPart.FillExpr(qualifiers = Some _) -> true
| SynInterpolatedStringPart.FillExpr(formatting = SynInterpolationFormatting.DotNet(format = Some _)) -> true
Comment thread
T-Gro marked this conversation as resolved.
| _ -> false)

// { (!x) with … }
Expand Down
44 changes: 44 additions & 0 deletions src/Compiler/SyntaxTree/ParseHelpers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,50 @@ let rhs2 (parseState: IParseState) i j =
/// Get the range corresponding to one of the r.h.s. symbols of a grammar rule while it is being reduced
let rhs parseState i = rhs2 parseState i i

/// Split a trailing printf specifier (e.g. "%d") off an interpolated-string literal that precedes a
/// hole. '%%' is a literal escape, not a specifier.
let peelTrailingPrintfSpecifier (litText: string) : string * string option =
let n = litText.Length
let mutable i = 0
let mutable specStart = -1

while i < n && specStart < 0 do
if litText[i] = '%' then
if i + 1 < n && litText[i + 1] = '%' then
i <- i + 2 // '%%' escape, keep scanning
else
specStart <- i // start of a real specifier
else
i <- i + 1

// A real printf specifier ends, immediately before the hole, with a type character. Anything else
// (for example the explicit '%P(' placeholder syntax) is left in the literal untouched.
if specStart < 0 || "bscdiuxXoBeEfFgGMOAat".IndexOf litText[n - 1] < 0 then
litText, None
else
litText[.. specStart - 1], Some litText[specStart..]
Comment on lines +72 to +93

@T-Gro T-Gro Jun 22, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would love if we could have this logic inside the pars.fsy grammar instead of custom character scanning code, is that doable?
(WDYT @auduchinok ?)


/// Build the [String literal; FillExpr hole] pair for one interpolation hole, splitting the '{x,n}'
/// alignment out of its tuple encoding and peeling a trailing printf specifier onto the hole.
let mkInterpolatedStringFillParts (litText: string, litRange: range, fill: SynExpr * Ident option) =
let fillExpr, qualifier = fill

let holeExpr, alignment =
match fillExpr with
| SynExpr.Tuple(false, [ e; (SynExpr.Const(SynConst.Int32 _, _) as n) ], _, _) -> e, Some n
| _ -> fillExpr, None

let litValue, formatting =
match qualifier, alignment with
| None, None ->
match peelTrailingPrintfSpecifier litText with
| lit, Some spec -> lit, SynInterpolationFormatting.Printf(spec, litRange)
| _, None -> litText, SynInterpolationFormatting.DotNet(None, None)
| _ -> litText, SynInterpolationFormatting.DotNet(alignment, qualifier)

[ SynInterpolatedStringPart.String(litValue, litRange)
SynInterpolatedStringPart.FillExpr(holeExpr, formatting) ]

//------------------------------------------------------------------------
// Parsing/lexing: status of #if/#endif processing in lexing, used for continuations
// for whitespace tokens in parser specification.
Expand Down
10 changes: 10 additions & 0 deletions src/Compiler/SyntaxTree/ParseHelpers.fsi
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ val rhs2: parseState: IParseState -> i: int -> j: int -> range

val rhs: parseState: IParseState -> i: int -> range

/// Peel a trailing printf specifier (e.g. "%d") off an interpolated-string literal that precedes a
/// hole, returning the literal without it and the specifier text. '%%' is a literal escape.
val peelTrailingPrintfSpecifier: litText: string -> string * string option

/// Build the [String literal; FillExpr hole] pair for one interpolation hole, splitting the
/// '{x,n}' alignment out of its tuple encoding and peeling a trailing printf specifier off the
/// literal onto the hole.
val mkInterpolatedStringFillParts:
litText: string * litRange: range * fill: (SynExpr * Ident option) -> SynInterpolatedStringPart list

type LexerIfdefStackEntry =
| IfDefIf
| IfDefElse
Expand Down
7 changes: 6 additions & 1 deletion src/Compiler/SyntaxTree/SyntaxTree.fs
Original file line number Diff line number Diff line change
Expand Up @@ -875,7 +875,12 @@ type SynExprRecordField =
[<NoEquality; NoComparison; RequireQualifiedAccess>]
type SynInterpolatedStringPart =
| String of value: string * range: range
| FillExpr of fillExpr: SynExpr * qualifiers: Ident option
| FillExpr of fillExpr: SynExpr * formatting: SynInterpolationFormatting

[<NoEquality; NoComparison; RequireQualifiedAccess>]
type SynInterpolationFormatting =
| DotNet of alignment: SynExpr option * format: Ident option
| Printf of specifier: string * range: range

[<NoEquality; NoComparison; RequireQualifiedAccess>]
type SynSimplePat =
Expand Down
11 changes: 10 additions & 1 deletion src/Compiler/SyntaxTree/SyntaxTree.fsi
Original file line number Diff line number Diff line change
Expand Up @@ -999,7 +999,16 @@ type SynExprRecordField =
[<NoEquality; NoComparison; RequireQualifiedAccess>]
type SynInterpolatedStringPart =
| String of value: string * range: range
| FillExpr of fillExpr: SynExpr * qualifiers: Ident option
| FillExpr of fillExpr: SynExpr * formatting: SynInterpolationFormatting

/// Represents how an interpolation hole in an interpolated string is formatted.
[<NoEquality; NoComparison; RequireQualifiedAccess>]
type SynInterpolationFormatting =
/// .NET-style formatting: optional alignment '{x,n}' and optional format '{x:fmt}'.
| DotNet of alignment: SynExpr option * format: Ident option

/// printf-style formatting: a single specifier, the '%d' in '%d{x}'.
| Printf of specifier: string * range: range

/// Represents a syntax tree for simple F# patterns
[<NoEquality; NoComparison; RequireQualifiedAccess>]
Expand Down
3 changes: 2 additions & 1 deletion src/Compiler/TypedTree/TcGlobals.fs
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,7 @@ type TcGlobals(

let v_byte_operator_info = makeIntrinsicValRef(fslib_MFOperators_nleref, "byte" , None , Some "ToByte", [vara], ([[varaTy]], v_byte_ty))
let v_sbyte_operator_info = makeIntrinsicValRef(fslib_MFOperators_nleref, "sbyte" , None , Some "ToSByte", [vara], ([[varaTy]], v_sbyte_ty))
let v_string_operator_info = makeIntrinsicValRef(fslib_MFOperators_nleref, "string" , None , Some "ToString", [vara], ([[varaTy]], v_string_ty))
let v_int16_operator_info = makeIntrinsicValRef(fslib_MFOperators_nleref, "int16" , None , Some "ToInt16", [vara], ([[varaTy]], v_int16_ty))
let v_uint16_operator_info = makeIntrinsicValRef(fslib_MFOperators_nleref, "uint16" , None , Some "ToUInt16", [vara], ([[varaTy]], v_uint16_ty))
let v_int32_operator_info = makeIntrinsicValRef(fslib_MFOperators_nleref, "int32" , None , Some "ToInt32", [vara], ([[varaTy]], v_int32_ty))
Expand Down Expand Up @@ -1594,6 +1595,7 @@ type TcGlobals(

member _.byte_operator_info = v_byte_operator_info
member _.sbyte_operator_info = v_sbyte_operator_info
member _.string_operator_info = v_string_operator_info
member _.int16_operator_info = v_int16_operator_info
member _.uint16_operator_info = v_uint16_operator_info
member _.int32_operator_info = v_int32_operator_info
Expand Down Expand Up @@ -1707,7 +1709,6 @@ type TcGlobals(
member _.seq_map_info = v_seq_map_info
member _.seq_singleton_info = v_seq_singleton_info
member _.seq_empty_info = v_seq_empty_info
member _.sprintf_info = v_sprintf_info
member _.new_format_info = v_new_format_info
member _.unbox_info = v_unbox_info
member _.get_generic_comparer_info = v_get_generic_comparer_info
Expand Down
4 changes: 2 additions & 2 deletions src/Compiler/TypedTree/TcGlobals.fsi
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,8 @@ type internal TcGlobals =

member sbyte_operator_info: IntrinsicValRef

member string_operator_info: IntrinsicValRef

member sbyte_tcr: TypedTree.EntityRef

member sbyte_ty: TypedTree.TType
Expand Down Expand Up @@ -1003,8 +1005,6 @@ type internal TcGlobals =

member splice_raw_expr_vref: TypedTree.ValRef

member sprintf_info: IntrinsicValRef

member sprintf_vref: TypedTree.ValRef

member string_ty: TypedTree.TType
Expand Down
6 changes: 3 additions & 3 deletions src/Compiler/TypedTree/TypedTreeOps.ExprOps.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1366,6 +1366,9 @@ module internal Makers =
let mkCallNewFormat (g: TcGlobals) m aty bty cty dty ety formatStringExpr =
mkApps g (typedExprForIntrinsic g m g.new_format_info, [ [ aty; bty; cty; dty; ety ] ], [ formatStringExpr ], m)

let mkCallStringOperator (g: TcGlobals) m argTy e =
mkApps g (typedExprForIntrinsic g m g.string_operator_info, [ [ argTy ] ], [ e ], m)

let tryMkCallBuiltInWitness (g: TcGlobals) traitInfo argExprs m =
let info, tinst = g.MakeBuiltInWitnessInfo traitInfo
let vref = ValRefForIntrinsic info
Expand Down Expand Up @@ -1449,9 +1452,6 @@ module internal Makers =
let mkCallSeqEmpty g m ty1 =
mkApps g (typedExprForIntrinsic g m g.seq_empty_info, [ [ ty1 ] ], [], m)

let mkCall_sprintf (g: TcGlobals) m funcTy fmtExpr fillExprs =
mkApps g (typedExprForIntrinsic g m g.sprintf_info, [ [ funcTy ] ], fmtExpr :: fillExprs, m)

let mkCallDeserializeQuotationFSharp20Plus g m e1 e2 e3 e4 =
let args = [ e1; e2; e3; e4 ]
mkApps g (typedExprForIntrinsic g m g.deserialize_quoted_FSharp_20_plus_info, [], [ mkRefTupledNoTypes g m args ], m)
Expand Down
6 changes: 3 additions & 3 deletions src/Compiler/TypedTree/TypedTreeOps.ExprOps.fsi
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ module internal Makers =
val mkCallNewFormat:
TcGlobals -> range -> TType -> TType -> TType -> TType -> TType -> formatStringExpr: Expr -> Expr

/// Build a call to the 'string' operator (Operators.ToString) at the given argument type.
val mkCallStringOperator: TcGlobals -> range -> argTy: TType -> Expr -> Expr

val mkCallGetGenericComparer: TcGlobals -> range -> Expr

val mkCallGetGenericEREqualityComparer: TcGlobals -> range -> Expr
Expand Down Expand Up @@ -401,9 +404,6 @@ module internal Makers =

val mkCallSeqEmpty: TcGlobals -> range -> TType -> Expr

/// Make a call to the 'isprintf' function for string interpolation
val mkCall_sprintf: g: TcGlobals -> m: range -> funcTy: TType -> fmtExpr: Expr -> fillExprs: Expr list -> Expr

val mkCallDeserializeQuotationFSharp20Plus: TcGlobals -> range -> Expr -> Expr -> Expr -> Expr -> Expr

val mkCallDeserializeQuotationFSharp40Plus: TcGlobals -> range -> Expr -> Expr -> Expr -> Expr -> Expr -> Expr
Expand Down
4 changes: 2 additions & 2 deletions src/Compiler/pars.fsy
Original file line number Diff line number Diff line change
Expand Up @@ -7162,7 +7162,7 @@ interpolatedStringParts:
{ [ SynInterpolatedStringPart.String(fst $1, rhs parseState 1) ] }

| INTERP_STRING_PART interpolatedStringFill interpolatedStringParts
{ SynInterpolatedStringPart.String(fst $1, rhs parseState 1) :: SynInterpolatedStringPart.FillExpr $2 :: $3 }
{ mkInterpolatedStringFillParts (fst $1, rhs parseState 1, $2) @ $3 }

| INTERP_STRING_PART interpolatedStringParts
{ let rbrace = parseState.InputEndPosition 1
Expand All @@ -7176,7 +7176,7 @@ interpolatedStringParts:
interpolatedString:
| INTERP_STRING_BEGIN_PART interpolatedStringFill interpolatedStringParts
{ let s, synStringKind, _ = $1
SynInterpolatedStringPart.String(s, rhs parseState 1) :: SynInterpolatedStringPart.FillExpr $2 :: $3, synStringKind }
mkInterpolatedStringFillParts (s, rhs parseState 1, $2) @ $3, synStringKind }

| INTERP_STRING_BEGIN_END
{ let s, synStringKind, _ = $1
Expand Down
36 changes: 36 additions & 0 deletions tests/AheadOfTime/NativeAOT/NativeAOT_Test.fsproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net9.0</TargetFrameworks>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>

<PropertyGroup>
<DisableImplicitFSharpCoreReference>true</DisableImplicitFSharpCoreReference>
<DisableImplicitNuGetFallbackFolder>true</DisableImplicitNuGetFallbackFolder>
<DisableImplicitLibraryPacksFolder>true</DisableImplicitLibraryPacksFolder>
<PublishAot>true</PublishAot>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
</PropertyGroup>

<PropertyGroup>
<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>
<Compile Include="Program.fs" />
</ItemGroup>

<Import Project="$(MSBuildThisFileDirectory)../../../eng/Versions.props" />

<ItemGroup>
<PackageReference Include="FSharp.Core" Version="$(FSharpCorePreviewPackageVersionValue)" />
</ItemGroup>

</Project>
29 changes: 29 additions & 0 deletions tests/AheadOfTime/NativeAOT/Program.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
module Program

open System

let print (s: string) = Console.WriteLine(s)

let printInterpolated() =
let x = 42
let name = "world"
let pi = 3.14159
let initial = 'F'
print $"answer = {x}"
print $"hello {name}"
print $"pi ~ {pi:F2}"
print $"padded:{x,6}"
print $"greeting %s{name}"
// Bare '%d'/'%i'/'%c'/'%M' specifiers lower to the same reflection-free path as a plain hole.
print $"answer = %d{x}"
print $"initial = %c{initial}"

// The following use printf specifiers that still route through 'sprintf' and so generate
// AOT IL2026/IL2070/IL3050 warnings.
// print $"pi ~ %.2f{pi}"
// print $"value = %A{x}"

[<EntryPoint>]
let main _ =
printInterpolated()
0
2 changes: 2 additions & 0 deletions tests/AheadOfTime/NativeAOT/check.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@echo off
powershell -ExecutionPolicy ByPass -NoProfile -command "& """%~dp0check.ps1""""
32 changes: 32 additions & 0 deletions tests/AheadOfTime/NativeAOT/check.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 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.

$ErrorActionPreference = "Stop"

$root = "NativeAOT_Test"
$tfm = "net9.0"

$cwd = Get-Location
Set-Location $PSScriptRoot

dotnet publish -restore -c release -f:$tfm "$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
}

$exe = Join-Path $PSScriptRoot "bin/release/$tfm/win-x64/publish/$root.exe"
& $exe | Out-Null
$exitCode = $LASTEXITCODE
Set-Location $cwd

if (-not ($exitCode -eq 0)) {
Comment thread
T-Gro marked this conversation as resolved.
Write-Error "NativeAOT app failed with exit code $exitCode" -ErrorAction Stop
}

Write-Host "NativeAOT interpolated-string test passed."
1 change: 1 addition & 0 deletions tests/AheadOfTime/check.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ Write-Host "AheadOfTime: check1.ps1"

Equality\check.ps1
Trimming\check.ps1
NativeAOT\check.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ printfn \"%s\" s"
|> shouldSucceed
|> withStdOutContains "% 42"

[<Fact>]
let ``Interpolation holes are rendered with invariant culture`` () =
Fsx """
System.Threading.Thread.CurrentThread.CurrentCulture <- System.Globalization.CultureInfo "de-DE"
printf "%s" $"{1.5}"
"""
|> compileExeAndRun
|> shouldSucceed
|> withStdOutContains "1.5"

[<Fact>]
let ``Percent signs separated by format specifier's flags`` () =
Fsx """
Expand Down
Loading
Loading