Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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))
182 changes: 78 additions & 104 deletions src/Compiler/Checking/Expressions/CheckExpressions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ module internal FSharp.Compiler.CheckExpressions

open System
open System.Collections.Generic
open System.Text.RegularExpressions

open Internal.Utilities.Collections
open Internal.Utilities.Library
Expand Down Expand Up @@ -146,43 +145,6 @@ exception InvalidInternalsVisibleToAssemblyName of badName: string * fileName: s

exception InvalidAttributeTargetForLanguageElement of elementTargets: string array * allowedTargets: string array * range: range

//----------------------------------------------------------------------------------------------
// Helpers for determining if/what specifiers a string has.
// Used to decide if interpolated string can be lowered to a concat call.
// We don't care about single- vs multi-$ strings here, because lexer took care of that already.
//----------------------------------------------------------------------------------------------
[<return: Struct>]
let (|HasFormatSpecifier|_|) (s: string) =
if
Regex.IsMatch(
s,
// Regex pattern for something like: %[flags][width][.precision][type]
"""
(^|[^%]) # Start with beginning of string or any char other than '%'
(%%)*% # followed by an odd number of '%' chars
[+-0 ]{0,3} # optionally followed by flags
(\d+)? # optionally followed by width
(\.\d+)? # optionally followed by .precision
[bscdiuxXoBeEfFgGMOAat] # and then a char that determines specifier's type
""",
RegexOptions.Compiled ||| RegexOptions.IgnorePatternWhitespace)
then
ValueSome HasFormatSpecifier
else
ValueNone

// Removes trailing "%s" unless it was escaped by another '%' (checks for odd sequence of '%' before final "%s")
let (|WithTrailingStringSpecifierRemoved|) (s: string) =
if s.EndsWith "%s" then
let i = s.AsSpan(0, s.Length - 2).LastIndexOfAnyExcept '%'
let diff = s.Length - 2 - i
if diff &&& 1 <> 0 then
s[..s.Length - 3]
else
s
else
s

/// Compute the available access rights from a particular location in code
let ComputeAccessRights eAccessPath eInternalsVisibleCompPaths eFamilyType =
AccessibleFrom (eAccessPath :: eInternalsVisibleCompPaths, eFamilyType)
Expand Down Expand Up @@ -7598,6 +7560,65 @@ 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.
/// 'holeIsString' flags, in order, the fill expressions that are already of type string.
and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, m: range, tpenv: UnscopedTyparEnv, parts: SynInterpolatedStringPart list, holeIsString: bool list) =
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)

// '(string e)': convert any value to a string using invariant culture.
let stringOp (e: SynExpr) =
mkSynApp1 (mkSynLidGet mSynth [ "Microsoft"; "FSharp"; "Core"; "Operators" ] "string") (paren e) 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 | _ -> ""
let formatText = match format with Some n -> ":" + n.idText | None -> ""
let netFormat = "{0" + alignText + formatText + "}"
let invariant = mkSynLidGet mSynth [ "System"; "Globalization"; "CultureInfo" ] "InvariantCulture"
let args = paren (SynExpr.Tuple(false, [ invariant; strLit netFormat; e ], [ range0; range0 ], mSynth))
mkSynApp1 (mkSynLidGet mSynth [ "System"; "String" ] "Format") args mSynth

// Build one string expression per part, consuming one 'holeIsString' flag per fill expression.
let rec build acc parts (holeIsString: bool list) =
match parts with
| [] -> List.rev acc
| SynInterpolatedStringPart.String ("", _) :: rest -> build acc rest holeIsString
| SynInterpolatedStringPart.String (s, _) :: rest -> build (strLit (s.Replace("%%", "%")) :: acc) rest holeIsString
| SynInterpolatedStringPart.FillExpr (e, formatting) :: rest ->
let isStr, rest' = match holeIsString with b :: bs -> b, bs | [] -> false, []
let argExpr =
match formatting with
// A string hole is already a string (Concat maps null to ""); convert anything else.
| SynInterpolationFormatting.DotNet (None, None) -> if isStr then e else stringOp e
| SynInterpolationFormatting.DotNet (alignment, format) -> stringFormatOp (alignment, format, e)
| SynInterpolationFormatting.Printf (spec, _) -> sprintfOp (spec, e)
build (argExpr :: acc) rest rest'

let argExprs = build [] parts holeIsString

let concatLid = mkSynLidGet mSynth [ "System"; "String" ] "Concat"

let resultExpr =

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This can use mkStringConcat defined in #19976

match argExprs with
| [] -> strLit ""
| [ single ] -> single
| _ when List.length argExprs <= 4 ->
let commas = List.replicate (List.length argExprs - 1) range0
mkSynApp1 concatLid (paren (SynExpr.Tuple(false, argExprs, commas, mSynth))) mSynth
| _ ->
mkSynApp1 concatLid (paren (SynExpr.ArrayOrList(true, argExprs, mSynth))) mSynth

TcPropagatingExprLeafThenConvert cenv overallTy cenv.g.string_ty env m (fun () ->
TcExpr cenv (MustEqual cenv.g.string_ty) env tpenv resultExpr)

/// Check an interpolated string expression
and [<TailCall>] warnForFunctionValuesInFillExprs (g: TcGlobals) argTys synFillExprs =
match argTys, synFillExprs with
Expand All @@ -7615,11 +7636,7 @@ and TcInterpolatedStringExpr cenv (overallTy: OverallTy) env m tpenv (parts: Syn
parts
|> List.choose (function
| SynInterpolatedStringPart.String _ -> None
| SynInterpolatedStringPart.FillExpr (fillExpr, _) ->
match fillExpr with
// Detect "x" part of "...{x,3}..."
| SynExpr.Tuple (false, [e; SynExpr.Const (SynConst.Int32 _align, _)], _, _) -> Some e
| e -> Some e)
| SynInterpolatedStringPart.FillExpr (fillExpr, _) -> Some fillExpr)

let stringFragmentRanges =
parts
Expand Down Expand Up @@ -7687,19 +7704,21 @@ and TcInterpolatedStringExpr cenv (overallTy: OverallTy) env m tpenv (parts: Syn

let isFormattableString = (match stringKind with Choice2Of2 _ -> true | _ -> false)

// The format string used for checking in CheckFormatStrings. This replaces interpolation holes with %P
// The format string used for checking in CheckFormatStrings, reconstructed from the parts: each
// hole becomes a '%P(...)' marker, prefixed by its printf specifier or alignment.
let printfFormatString =
parts
|> List.map (function
| SynInterpolatedStringPart.String (s, _) -> s
| SynInterpolatedStringPart.FillExpr (fillExpr, format) ->
| SynInterpolatedStringPart.FillExpr (_, SynInterpolationFormatting.Printf (spec, _)) ->
spec + "%P()"
| SynInterpolatedStringPart.FillExpr (fillExpr, SynInterpolationFormatting.DotNet (alignment, format)) ->
match fillExpr with
| SynExpr.Tuple (false, _, _, _) -> errorR(Error(FSComp.SR.tcInvalidAlignmentInInterpolatedString(), m))
| _ -> ()
let alignText =
match fillExpr with
// Validate and detect ",3" part of "...{x,3}..."
| SynExpr.Tuple (false, args, _, _) ->
match args with
| [_; SynExpr.Const (SynConst.Int32 align, _)] -> string align
| _ -> errorR(Error(FSComp.SR.tcInvalidAlignmentInInterpolatedString(), m)); ""
match alignment with
| Some (SynExpr.Const (SynConst.Int32 align, _)) -> string align
| _ -> ""
let formatText = match format with None -> "()" | Some n -> "(" + n.idText + ")"
"%" + alignText + "P" + formatText )
Expand Down Expand Up @@ -7754,55 +7773,18 @@ 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
else
// Type check the expressions filling the holes
let fillExprs, tpenv = TcExprsNoFlexes cenv env m tpenv argTys synFillExprs

if g.langVersion.SupportsFeature LanguageFeature.WarnWhenFunctionValueUsedAsInterpolatedStringArg then
warnForFunctionValuesInFillExprs g argTys synFillExprs

// Take all interpolated string parts and typed fill expressions
// and convert them to typed expressions that can be used as args to System.String.Concat
// return an empty list if there are some format specifiers that make lowering to not applicable
let rec concatenable acc fillExprs parts =
match fillExprs, parts with
| [], [] ->
List.rev acc
| [], SynInterpolatedStringPart.FillExpr _ :: _
| _, [] ->
// This should never happen, there will always be as many typed fill expressions
// as there are FillExprs in the interpolated string parts
error(InternalError("Mismatch in interpolation expression count", m))
| _, SynInterpolatedStringPart.String (WithTrailingStringSpecifierRemoved "", _) :: parts ->
// If the string is empty (after trimming %s of the end), we skip it
concatenable acc fillExprs parts

| _, SynInterpolatedStringPart.String (WithTrailingStringSpecifierRemoved HasFormatSpecifier, _) :: _
| _, SynInterpolatedStringPart.FillExpr (_, Some _) :: _
| _, SynInterpolatedStringPart.FillExpr (SynExpr.Tuple (isStruct = false; exprs = [_; SynExpr.Const (SynConst.Int32 _, _)]), _) :: _ ->
// There was a format specifier like %20s{..} or {..,20} or {x:hh}, which means we cannot simply concat
[]

| _, SynInterpolatedStringPart.String (s & WithTrailingStringSpecifierRemoved trimmed, m) :: parts ->
let finalStr = trimmed.Replace("%%", "%")
concatenable (mkString g (shiftEnd 0 (finalStr.Length - s.Length) m) finalStr :: acc) fillExprs parts

| fillExpr :: fillExprs, SynInterpolatedStringPart.FillExpr _ :: parts ->
concatenable (fillExpr :: acc) fillExprs parts

let canLower =
g.langVersion.SupportsFeature LanguageFeature.LowerInterpolatedStringToConcat
Comment thread
T-Gro marked this conversation as resolved.
&& isString
&& argTys |> List.forall (isStringTy g)

let concatenableExprs = if canLower then concatenable [] fillExprs parts else []

match concatenableExprs with
| [p1; p2; p3; p4] -> TcPropagatingExprLeafThenConvert cenv overallTy g.string_ty env m (fun () -> mkStaticCall_String_Concat4 g m p1 p2 p3 p4, tpenv)
| [p1; p2; p3] -> TcPropagatingExprLeafThenConvert cenv overallTy g.string_ty env m (fun () -> mkStaticCall_String_Concat3 g m p1 p2 p3, tpenv)
| [p1; p2] -> TcPropagatingExprLeafThenConvert cenv overallTy g.string_ty env m (fun () -> mkStaticCall_String_Concat2 g m p1 p2, tpenv)
| [p1] -> p1, tpenv
| _ ->

if isString then
// String-typed interpolation: lower to a reflection-free System.String.Concat of the parts.
// A hole whose value is already a string is passed straight through.
let holeIsString = fillExprs |> List.map (fun fillExpr -> isStringTy g (tyOfExpr g fillExpr))
TcInterpolatedStringViaConcat (cenv, overallTy, env, m, tpenv, parts, holeIsString)
else
// $"...{x}..." used as a PrintfFormat value: build a PrintfFormat that captures the args.
let fillExprsBoxed = (argTys, fillExprs) ||> List.map2 (mkCallBox g m)

let argsExpr = mkArray (g.obj_ty_withNulls, fillExprsBoxed, m)
Expand All @@ -7813,15 +7795,7 @@ and TcInterpolatedStringExpr cenv (overallTy: OverallTy) env m tpenv (parts: Syn
let tyExprs = percentATys |> Array.map (mkCallTypeOf g m) |> Array.toList
mkArray (g.system_Type_ty, tyExprs, m)

let fmtExpr = MakeMethInfoCall cenv.amap m newFormatMethod [] [mkString g m printfFormatString; argsExpr; percentATysExpr] None

if isString then
TcPropagatingExprLeafThenConvert cenv overallTy g.string_ty env (* true *) m (fun () ->
// Make the call to sprintf
mkCall_sprintf g m printerTy fmtExpr [], tpenv
)
else
fmtExpr, tpenv
MakeMethInfoCall cenv.amap m newFormatMethod [] [mkString g m printfFormatString; argsExpr; percentATysExpr] None, tpenv

// The case for $"..." used as type FormattableString or IFormattable
| Choice2Of2 createFormattableStringMethod ->
Expand Down
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
Loading
Loading