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
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>
34 changes: 34 additions & 0 deletions tests/AheadOfTime/NativeAOT/Program.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module Program

open System

// Check a rendering against an expected string literal; a mismatch prints a "FAILED" line.
let check (actual: string, expected: string) =
if actual <> expected then
Console.WriteLine $"FAILED: expected '{expected}' but got '{actual}'"

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

[<EntryPoint>]
let main _ =
runChecks ()
// Success sentinel; a failed check above printed a "FAILED" line first, so the output won't be just this.
Console.WriteLine "Finished"
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""""
37 changes: 37 additions & 0 deletions tests/AheadOfTime/NativeAOT/check.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 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"
$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)) {
Comment thread
T-Gro marked this conversation as resolved.
Write-Error "NativeAOT app crashed with exit code $exitCode.`nOutput:`n$output" -ErrorAction Stop
}

if ($output.Trim() -ne "Finished") {
Write-Error "NativeAOT interpolation checks failed.`nOutput:`n$output" -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
Loading
Loading