Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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 @@ -157,6 +157,10 @@

* 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))
* Optimizer: don't inline named functions in debug builds ([PR #19548](https://github.com/dotnet/fsharp/pull/19548)
6 changes: 4 additions & 2 deletions src/Compiler/Checking/CheckFormatStrings.fs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ let mkFlexibleDecimalFormatTypar (g: TcGlobals) m =
let mkFlexibleFloatFormatTypar (g: TcGlobals) m =
mkFlexibleFormatTypar g m [ g.float_ty; g.float32_ty; g.decimal_ty ] g.float_ty

let stringFormatTy (g: TcGlobals) =
if g.checkNullness && g.langFeatureNullness then g.string_ty_ambivalent else g.string_ty

type FormatInfoRegister =
{ mutable leftJustify : bool
mutable numPrefixIfPos : char option
Expand Down Expand Up @@ -448,8 +451,7 @@ let parseFormatStringInternal
checkOtherFlags ch
collectSpecifierLocation fragLine fragCol 1
let i = skipPossibleInterpolationHole (i+1)
let stringTy = if g.checkNullness && g.langFeatureNullness then g.string_ty_ambivalent else g.string_ty
parseLoop ((posi, stringTy) :: acc) (i, fragLine, fragCol+1) fragments
parseLoop ((posi, stringFormatTy g) :: acc) (i, fragLine, fragCol+1) fragments

| 'O' ->
checkOtherFlags ch
Expand Down
9 changes: 9 additions & 0 deletions src/Compiler/Checking/CheckFormatStrings.fsi
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ 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

/// The type accepted by the '%s' specifier: ambivalent about nullness when nullness is checked.
val stringFormatTy: g: TcGlobals -> TType

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

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/Compiler/Service/SynExpr.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1087,10 +1087,13 @@ module SynExpr =
| SynExpr.InterpolatedString _, SynExpr.Sequential _
| SynExpr.InterpolatedString _, SynExpr.Tuple(isStruct = false) -> true

// Removing the parens would let a trailing alignment or format be parsed as part of the hole,
// e.g. the ',-3' in '$"{(if b then 1 else 0),-3}"' becoming a tuple in the else branch.
| SynExpr.InterpolatedString(contents = contents), Dangling.Problematic _ ->
contents
|> List.exists (function
| SynInterpolatedStringPart.FillExpr(qualifiers = Some _) -> true
| SynInterpolatedStringPart.FillExpr(formatting = SynInterpolationFormatting.DotNet(alignment = Some _))
| SynInterpolatedStringPart.FillExpr(formatting = SynInterpolationFormatting.DotNet(format = Some _)) -> true
Comment thread
abonie marked this conversation as resolved.
| _ -> false)

// {| A = (1; 2) |}
Expand Down
46 changes: 46 additions & 0 deletions src/Compiler/SyntaxTree/ParseHelpers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,52 @@ 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 thread
T-Gro marked this conversation as resolved.

/// 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
1 change: 0 additions & 1 deletion src/Compiler/TypedTree/TcGlobals.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1725,7 +1725,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
2 changes: 0 additions & 2 deletions src/Compiler/TypedTree/TcGlobals.fsi
Original file line number Diff line number Diff line change
Expand Up @@ -1007,8 +1007,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
3 changes: 0 additions & 3 deletions src/Compiler/TypedTree/TypedTreeOps.ExprOps.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1454,9 +1454,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
3 changes: 0 additions & 3 deletions src/Compiler/TypedTree/TypedTreeOps.ExprOps.fsi
Original file line number Diff line number Diff line change
Expand Up @@ -404,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."
4 changes: 2 additions & 2 deletions tests/AheadOfTime/Trimming/check.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ $allErrors += CheckTrim -root "SelfContained_Trimming_Test" -tfm "net9.0" -outpu
# Check net9.0 trimmed assemblies with static linked FSharpCore.
# Statically links FSharp.Compiler.Service; the size is stable now that its codegen is
# deterministic (#19928/#19929). Update if compiler/trimming output intentionally changes.
$allErrors += CheckTrim -root "StaticLinkedFSharpCore_Trimming_Test" -tfm "net9.0" -outputfile "StaticLinkedFSharpCore_Trimming_Test.dll" -expected_len 9173504 -callerLineNumber 71
$allErrors += CheckTrim -root "StaticLinkedFSharpCore_Trimming_Test" -tfm "net9.0" -outputfile "StaticLinkedFSharpCore_Trimming_Test.dll" -expected_len 9174016 -callerLineNumber 71

# Check net9.0 trimmed assemblies with F# metadata resources removed
$allErrors += CheckTrim -root "FSharpMetadataResource_Trimming_Test" -tfm "net9.0" -outputfile "FSharpMetadataResource_Trimming_Test.dll" -expected_len 7612928 -callerLineNumber 74
$allErrors += CheckTrim -root "FSharpMetadataResource_Trimming_Test" -tfm "net9.0" -outputfile "FSharpMetadataResource_Trimming_Test.dll" -expected_len 7613440 -callerLineNumber 74

# Report all errors and exit with failure if any occurred
if ($allErrors.Count -gt 0) {
Expand Down
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