From f571df97b148fdbc309429a0c0f39d669eb9a9de Mon Sep 17 00:00:00 2001 From: Charles Roddie Date: Thu, 18 Jun 2026 20:10:01 +0100 Subject: [PATCH 01/11] Lower string-typed interpolation to String.Concat (reflection-free) A string-typed interpolated string is lowered to System.String.Concat of its parts rather than the reflection-based printf engine: a string-typed hole is passed through directly, any other plain hole is converted with `string x`, an aligned/formatted hole with `String.Format(InvariantCulture, ...)`, and a printf-specifier hole with `sprintf`. This removes the reflection dependency on the common path, so these interpolations become trim- and NativeAOT-compatible. This generalizes and replaces the language-version-gated String.Concat optimization (#16556), which only handled all-string holes: the lowering now applies to every string-typed interpolation, ungated. The reflection path is used only for PrintfFormat/FormattableString-typed interpolation. The syntax tree now carries each hole's formatting explicitly, so a printf specifier no longer leaks into an adjacent literal and alignment is no longer a fake tuple: type SynInterpolatedStringPart = | String of value: string * range: range | FillExpr of fillExpr: SynExpr * formatting: SynInterpolationFormatting type SynInterpolationFormatting = | DotNet of alignment: SynExpr option * format: Ident option | Printf of specifier: string * range: range Behavioural change: plain `{x}` holes now render with invariant culture (the F# `string` operator) rather than the current thread culture, matching `string`. Adds a NativeAOT regression test under tests/AheadOfTime/NativeAOT. Co-Authored-By: Claude Opus 4.8 --- .../Checking/Expressions/CheckExpressions.fs | 182 ++++++++---------- src/Compiler/Service/SynExpr.fs | 2 +- src/Compiler/SyntaxTree/ParseHelpers.fs | 44 +++++ src/Compiler/SyntaxTree/ParseHelpers.fsi | 10 + src/Compiler/SyntaxTree/SyntaxTree.fs | 7 +- src/Compiler/SyntaxTree/SyntaxTree.fsi | 11 +- src/Compiler/TypedTree/TcGlobals.fs | 1 - src/Compiler/TypedTree/TcGlobals.fsi | 2 - .../TypedTree/TypedTreeOps.ExprOps.fs | 3 - .../TypedTree/TypedTreeOps.ExprOps.fsi | 3 - src/Compiler/pars.fsy | 4 +- .../NativeAOT/NativeAOT_Test.fsproj | 36 ++++ tests/AheadOfTime/NativeAOT/Program.fs | 25 +++ tests/AheadOfTime/NativeAOT/check.cmd | 2 + tests/AheadOfTime/NativeAOT/check.ps1 | 32 +++ tests/AheadOfTime/check.ps1 | 1 + .../Language/InterpolatedStringsTests.fs | 10 + ...iler.Service.SurfaceArea.netstandard20.bsl | 28 ++- tests/fsharp/core/quotes/test.fsx | 6 +- .../InterpolatedStringOffsideInModule.fs.bsl | 3 +- ...nterpolatedStringOffsideInNestedLet.fs.bsl | 7 +- ...polatedStringAdjacentEqualsWithHole.fs.bsl | 3 +- ...latedStringWithSynStringKindRegular.fs.bsl | 3 +- ...dStringWithSynStringKindTripleQuote.fs.bsl | 3 +- ...atedStringWithSynStringKindVerbatim.fs.bsl | 16 +- ...tringWithTripleQuoteMultipleDollars.fs.bsl | 6 +- ...ringWithTripleQuoteMultipleDollars2.fs.bsl | 2 +- 27 files changed, 309 insertions(+), 143 deletions(-) create mode 100644 tests/AheadOfTime/NativeAOT/NativeAOT_Test.fsproj create mode 100644 tests/AheadOfTime/NativeAOT/Program.fs create mode 100644 tests/AheadOfTime/NativeAOT/check.cmd create mode 100644 tests/AheadOfTime/NativeAOT/check.ps1 diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index 070ce6b3e8e..7e9fd36c7d4 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -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 @@ -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. -//---------------------------------------------------------------------------------------------- -[] -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) @@ -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 = + 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 [] warnForFunctionValuesInFillExprs (g: TcGlobals) argTys synFillExprs = match argTys, synFillExprs with @@ -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 @@ -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 ) @@ -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 - && 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) @@ -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 -> diff --git a/src/Compiler/Service/SynExpr.fs b/src/Compiler/Service/SynExpr.fs index d1d0c6eb4c3..9300855b240 100644 --- a/src/Compiler/Service/SynExpr.fs +++ b/src/Compiler/Service/SynExpr.fs @@ -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 | _ -> false) // { (!x) with … } diff --git a/src/Compiler/SyntaxTree/ParseHelpers.fs b/src/Compiler/SyntaxTree/ParseHelpers.fs index b329d48ee34..134f7029ea1 100644 --- a/src/Compiler/SyntaxTree/ParseHelpers.fs +++ b/src/Compiler/SyntaxTree/ParseHelpers.fs @@ -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..] + +/// 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. diff --git a/src/Compiler/SyntaxTree/ParseHelpers.fsi b/src/Compiler/SyntaxTree/ParseHelpers.fsi index aae952d210c..56b48e38e90 100644 --- a/src/Compiler/SyntaxTree/ParseHelpers.fsi +++ b/src/Compiler/SyntaxTree/ParseHelpers.fsi @@ -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 diff --git a/src/Compiler/SyntaxTree/SyntaxTree.fs b/src/Compiler/SyntaxTree/SyntaxTree.fs index f35bb3297de..0e92555a087 100644 --- a/src/Compiler/SyntaxTree/SyntaxTree.fs +++ b/src/Compiler/SyntaxTree/SyntaxTree.fs @@ -875,7 +875,12 @@ type SynExprRecordField = [] type SynInterpolatedStringPart = | String of value: string * range: range - | FillExpr of fillExpr: SynExpr * qualifiers: Ident option + | FillExpr of fillExpr: SynExpr * formatting: SynInterpolationFormatting + +[] +type SynInterpolationFormatting = + | DotNet of alignment: SynExpr option * format: Ident option + | Printf of specifier: string * range: range [] type SynSimplePat = diff --git a/src/Compiler/SyntaxTree/SyntaxTree.fsi b/src/Compiler/SyntaxTree/SyntaxTree.fsi index 8b152ba2d69..c100fa99089 100644 --- a/src/Compiler/SyntaxTree/SyntaxTree.fsi +++ b/src/Compiler/SyntaxTree/SyntaxTree.fsi @@ -999,7 +999,16 @@ type SynExprRecordField = [] 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. +[] +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 [] diff --git a/src/Compiler/TypedTree/TcGlobals.fs b/src/Compiler/TypedTree/TcGlobals.fs index 6dfbd42f98c..8fcb72dc619 100644 --- a/src/Compiler/TypedTree/TcGlobals.fs +++ b/src/Compiler/TypedTree/TcGlobals.fs @@ -1707,7 +1707,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 diff --git a/src/Compiler/TypedTree/TcGlobals.fsi b/src/Compiler/TypedTree/TcGlobals.fsi index e27bc1605a2..07e56f3e8b2 100644 --- a/src/Compiler/TypedTree/TcGlobals.fsi +++ b/src/Compiler/TypedTree/TcGlobals.fsi @@ -1003,8 +1003,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 diff --git a/src/Compiler/TypedTree/TypedTreeOps.ExprOps.fs b/src/Compiler/TypedTree/TypedTreeOps.ExprOps.fs index bdebaf40ff5..4e3bcbc45ad 100644 --- a/src/Compiler/TypedTree/TypedTreeOps.ExprOps.fs +++ b/src/Compiler/TypedTree/TypedTreeOps.ExprOps.fs @@ -1449,9 +1449,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) diff --git a/src/Compiler/TypedTree/TypedTreeOps.ExprOps.fsi b/src/Compiler/TypedTree/TypedTreeOps.ExprOps.fsi index ad90c5c818c..2b347a7d784 100644 --- a/src/Compiler/TypedTree/TypedTreeOps.ExprOps.fsi +++ b/src/Compiler/TypedTree/TypedTreeOps.ExprOps.fsi @@ -401,9 +401,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 diff --git a/src/Compiler/pars.fsy b/src/Compiler/pars.fsy index 01120123a36..6048b59f7c2 100644 --- a/src/Compiler/pars.fsy +++ b/src/Compiler/pars.fsy @@ -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 @@ -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 diff --git a/tests/AheadOfTime/NativeAOT/NativeAOT_Test.fsproj b/tests/AheadOfTime/NativeAOT/NativeAOT_Test.fsproj new file mode 100644 index 00000000000..1fa87907110 --- /dev/null +++ b/tests/AheadOfTime/NativeAOT/NativeAOT_Test.fsproj @@ -0,0 +1,36 @@ + + + + Exe + net9.0 + preview + true + + + + true + true + true + true + win-x64 + + + + $(LocalFSharpBuildBinPath)/FSharp.Build.dll + $(LocalFSharpBuildBinPath)/fsc.dll + $(LocalFSharpBuildBinPath)/fsc.dll + False + True + + + + + + + + + + + + + diff --git a/tests/AheadOfTime/NativeAOT/Program.fs b/tests/AheadOfTime/NativeAOT/Program.fs new file mode 100644 index 00000000000..f25e03867f7 --- /dev/null +++ b/tests/AheadOfTime/NativeAOT/Program.fs @@ -0,0 +1,25 @@ +module Program + +open System + +let print (s: string) = Console.WriteLine(s) + +let printInterpolated() = + let x = 42 + let name = "world" + let pi = 3.14159 + print $"answer = {x}" + print $"hello {name}" + print $"pi ~ {pi:F2}" + print $"padded:{x,6}" + + // The following use printf specifiers and will generate AOT IL2026/IL2070/IL3050 warnings. + // print $"answer = %d{x}" + // print $"hello %s{name}" + // print $"pi ~ %.2f{pi}" + // print $"value = %A{x}" + +[] +let main _ = + printInterpolated() + 0 diff --git a/tests/AheadOfTime/NativeAOT/check.cmd b/tests/AheadOfTime/NativeAOT/check.cmd new file mode 100644 index 00000000000..4eefff011c5 --- /dev/null +++ b/tests/AheadOfTime/NativeAOT/check.cmd @@ -0,0 +1,2 @@ +@echo off +powershell -ExecutionPolicy ByPass -NoProfile -command "& """%~dp0check.ps1"""" diff --git a/tests/AheadOfTime/NativeAOT/check.ps1 b/tests/AheadOfTime/NativeAOT/check.ps1 new file mode 100644 index 00000000000..3cc10abb993 --- /dev/null +++ b/tests/AheadOfTime/NativeAOT/check.ps1 @@ -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)) { + Write-Error "NativeAOT app failed with exit code $exitCode" -ErrorAction Stop +} + +Write-Host "NativeAOT interpolated-string test passed." diff --git a/tests/AheadOfTime/check.ps1 b/tests/AheadOfTime/check.ps1 index e8fd72b57e5..5c1de83b903 100644 --- a/tests/AheadOfTime/check.ps1 +++ b/tests/AheadOfTime/check.ps1 @@ -2,3 +2,4 @@ Write-Host "AheadOfTime: check1.ps1" Equality\check.ps1 Trimming\check.ps1 +NativeAOT\check.ps1 diff --git a/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs index 9894fa68126..b2d7a191fd9 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs @@ -102,6 +102,16 @@ printfn \"%s\" s" |> shouldSucceed |> withStdOutContains "% 42" + [] + 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" + [] let ``Percent signs separated by format specifier's flags`` () = Fsx """ diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl index 4034ba8064d..2292e107142 100644 --- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl +++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl @@ -8044,8 +8044,8 @@ FSharp.Compiler.Syntax.SynInterfaceImpl: Microsoft.FSharp.Core.FSharpOption`1[FS FSharp.Compiler.Syntax.SynInterfaceImpl: System.String ToString() FSharp.Compiler.Syntax.SynInterpolatedStringPart+FillExpr: FSharp.Compiler.Syntax.SynExpr fillExpr FSharp.Compiler.Syntax.SynInterpolatedStringPart+FillExpr: FSharp.Compiler.Syntax.SynExpr get_fillExpr() -FSharp.Compiler.Syntax.SynInterpolatedStringPart+FillExpr: Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.Syntax.Ident] get_qualifiers() -FSharp.Compiler.Syntax.SynInterpolatedStringPart+FillExpr: Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.Syntax.Ident] qualifiers +FSharp.Compiler.Syntax.SynInterpolatedStringPart+FillExpr: FSharp.Compiler.Syntax.SynInterpolationFormatting formatting +FSharp.Compiler.Syntax.SynInterpolatedStringPart+FillExpr: FSharp.Compiler.Syntax.SynInterpolationFormatting get_formatting() FSharp.Compiler.Syntax.SynInterpolatedStringPart+String: FSharp.Compiler.Text.Range get_range() FSharp.Compiler.Syntax.SynInterpolatedStringPart+String: FSharp.Compiler.Text.Range range FSharp.Compiler.Syntax.SynInterpolatedStringPart+String: System.String get_value() @@ -8056,7 +8056,7 @@ FSharp.Compiler.Syntax.SynInterpolatedStringPart: Boolean IsFillExpr FSharp.Compiler.Syntax.SynInterpolatedStringPart: Boolean IsString FSharp.Compiler.Syntax.SynInterpolatedStringPart: Boolean get_IsFillExpr() FSharp.Compiler.Syntax.SynInterpolatedStringPart: Boolean get_IsString() -FSharp.Compiler.Syntax.SynInterpolatedStringPart: FSharp.Compiler.Syntax.SynInterpolatedStringPart NewFillExpr(FSharp.Compiler.Syntax.SynExpr, Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.Syntax.Ident]) +FSharp.Compiler.Syntax.SynInterpolatedStringPart: FSharp.Compiler.Syntax.SynInterpolatedStringPart NewFillExpr(FSharp.Compiler.Syntax.SynExpr, FSharp.Compiler.Syntax.SynInterpolationFormatting) FSharp.Compiler.Syntax.SynInterpolatedStringPart: FSharp.Compiler.Syntax.SynInterpolatedStringPart NewString(System.String, FSharp.Compiler.Text.Range) FSharp.Compiler.Syntax.SynInterpolatedStringPart: FSharp.Compiler.Syntax.SynInterpolatedStringPart+FillExpr FSharp.Compiler.Syntax.SynInterpolatedStringPart: FSharp.Compiler.Syntax.SynInterpolatedStringPart+String @@ -8064,6 +8064,28 @@ FSharp.Compiler.Syntax.SynInterpolatedStringPart: FSharp.Compiler.Syntax.SynInte FSharp.Compiler.Syntax.SynInterpolatedStringPart: Int32 Tag FSharp.Compiler.Syntax.SynInterpolatedStringPart: Int32 get_Tag() FSharp.Compiler.Syntax.SynInterpolatedStringPart: System.String ToString() +FSharp.Compiler.Syntax.SynInterpolationFormatting+DotNet: Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.Syntax.Ident] format +FSharp.Compiler.Syntax.SynInterpolationFormatting+DotNet: Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.Syntax.Ident] get_format() +FSharp.Compiler.Syntax.SynInterpolationFormatting+DotNet: Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.Syntax.SynExpr] alignment +FSharp.Compiler.Syntax.SynInterpolationFormatting+DotNet: Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.Syntax.SynExpr] get_alignment() +FSharp.Compiler.Syntax.SynInterpolationFormatting+Printf: FSharp.Compiler.Text.Range get_range() +FSharp.Compiler.Syntax.SynInterpolationFormatting+Printf: FSharp.Compiler.Text.Range range +FSharp.Compiler.Syntax.SynInterpolationFormatting+Printf: System.String get_specifier() +FSharp.Compiler.Syntax.SynInterpolationFormatting+Printf: System.String specifier +FSharp.Compiler.Syntax.SynInterpolationFormatting+Tags: Int32 DotNet +FSharp.Compiler.Syntax.SynInterpolationFormatting+Tags: Int32 Printf +FSharp.Compiler.Syntax.SynInterpolationFormatting: Boolean IsDotNet +FSharp.Compiler.Syntax.SynInterpolationFormatting: Boolean IsPrintf +FSharp.Compiler.Syntax.SynInterpolationFormatting: Boolean get_IsDotNet() +FSharp.Compiler.Syntax.SynInterpolationFormatting: Boolean get_IsPrintf() +FSharp.Compiler.Syntax.SynInterpolationFormatting: FSharp.Compiler.Syntax.SynInterpolationFormatting NewDotNet(Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.Syntax.SynExpr], Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.Syntax.Ident]) +FSharp.Compiler.Syntax.SynInterpolationFormatting: FSharp.Compiler.Syntax.SynInterpolationFormatting NewPrintf(System.String, FSharp.Compiler.Text.Range) +FSharp.Compiler.Syntax.SynInterpolationFormatting: FSharp.Compiler.Syntax.SynInterpolationFormatting+DotNet +FSharp.Compiler.Syntax.SynInterpolationFormatting: FSharp.Compiler.Syntax.SynInterpolationFormatting+Printf +FSharp.Compiler.Syntax.SynInterpolationFormatting: FSharp.Compiler.Syntax.SynInterpolationFormatting+Tags +FSharp.Compiler.Syntax.SynInterpolationFormatting: Int32 Tag +FSharp.Compiler.Syntax.SynInterpolationFormatting: Int32 get_Tag() +FSharp.Compiler.Syntax.SynInterpolationFormatting: System.String ToString() FSharp.Compiler.Syntax.SynLetOrUse: Boolean IsBang FSharp.Compiler.Syntax.SynLetOrUse: Boolean IsFromSource FSharp.Compiler.Syntax.SynLetOrUse: Boolean IsRecursive diff --git a/tests/fsharp/core/quotes/test.fsx b/tests/fsharp/core/quotes/test.fsx index 30ed5ba331a..54364a56125 100644 --- a/tests/fsharp/core/quotes/test.fsx +++ b/tests/fsharp/core/quotes/test.fsx @@ -5884,10 +5884,8 @@ module Interpolation = let interpolatedWithLiteralQuoted = <@ $"abc {1} def" @> let actual2 = interpolatedWithLiteralQuoted.ToString() checkStrings "brewbreebrwhat2" actual2 - """Call (None, PrintFormatToString, - [NewObject (PrintfFormat`5, Value ("abc %P() def"), - NewArray (Object, Call (None, Box, [Value (1)])), - Value ())])""" + """Call (None, Concat, + [Value ("abc "), Call (None, ToString, [Value (1)]), Value (" def")])""" module TestQuotationWithIdenticalStaticInstanceMethods = type C() = diff --git a/tests/service/data/SyntaxTree/String/InterpolatedStringOffsideInModule.fs.bsl b/tests/service/data/SyntaxTree/String/InterpolatedStringOffsideInModule.fs.bsl index d7fb308c07b..1a16a38174d 100644 --- a/tests/service/data/SyntaxTree/String/InterpolatedStringOffsideInModule.fs.bsl +++ b/tests/service/data/SyntaxTree/String/InterpolatedStringOffsideInModule.fs.bsl @@ -21,7 +21,8 @@ ImplFile InterpolatedString ([String (" ", (3,8--4,1)); - FillExpr (Const (Int32 0, (4,1--4,2)), None); + FillExpr + (Const (Int32 0, (4,1--4,2)), DotNet (None, None)); String ("", (4,2--4,4))], Regular, (3,8--4,4)), (2,8--2,9), Yes (2,4--4,4), { LeadingKeyword = Let (2,4--2,7) diff --git a/tests/service/data/SyntaxTree/String/InterpolatedStringOffsideInNestedLet.fs.bsl b/tests/service/data/SyntaxTree/String/InterpolatedStringOffsideInNestedLet.fs.bsl index 1455d3f425e..0c57f36ed8a 100644 --- a/tests/service/data/SyntaxTree/String/InterpolatedStringOffsideInNestedLet.fs.bsl +++ b/tests/service/data/SyntaxTree/String/InterpolatedStringOffsideInNestedLet.fs.bsl @@ -27,9 +27,10 @@ ImplFile InterpolatedString ([String (" ", (3,8--4,1)); - FillExpr (Const (Int32 0, (4,1--4,2)), None); - String ("", (4,2--4,4))], Regular, (3,8--4,4)), - (2,8--2,9), Yes (2,4--4,4), + FillExpr + (Const (Int32 0, (4,1--4,2)), + DotNet (None, None)); String ("", (4,2--4,4))], + Regular, (3,8--4,4)), (2,8--2,9), Yes (2,4--4,4), { LeadingKeyword = Let (2,4--2,7) InlineKeyword = None EqualsRange = Some (2,10--2,11) })] diff --git a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsWithHole.fs.bsl b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsWithHole.fs.bsl index bcf55431e8f..9edcf8db177 100644 --- a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsWithHole.fs.bsl +++ b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsWithHole.fs.bsl @@ -26,7 +26,8 @@ ImplFile (None, SynValInfo ([], SynArgInfo ([], false, None)), None), Named (SynIdent (x, None), false, None, (2,4--2,5)), None, InterpolatedString - ([String ("", (2,7--2,10)); FillExpr (Ident n, None); + ([String ("", (2,7--2,10)); + FillExpr (Ident n, DotNet (None, None)); String ("", (2,11--2,13))], Regular, (2,7--2,13)), (2,4--2,5), Yes (2,0--2,13), { LeadingKeyword = Let (2,0--2,3) InlineKeyword = None diff --git a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithSynStringKindRegular.fs.bsl b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithSynStringKindRegular.fs.bsl index 7026b9a1034..46da672fdeb 100644 --- a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithSynStringKindRegular.fs.bsl +++ b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithSynStringKindRegular.fs.bsl @@ -14,7 +14,8 @@ ImplFile Named (SynIdent (s, None), false, None, (2,4--2,5)), None, InterpolatedString ([String ("yo ", (2,8--2,14)); - FillExpr (Const (Int32 42, (2,14--2,16)), None); + FillExpr + (Const (Int32 42, (2,14--2,16)), DotNet (None, None)); String ("", (2,16--2,18))], Regular, (2,8--2,18)), (2,4--2,5), Yes (2,0--2,18), { LeadingKeyword = Let (2,0--2,3) InlineKeyword = None diff --git a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithSynStringKindTripleQuote.fs.bsl b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithSynStringKindTripleQuote.fs.bsl index 9e42b6455ce..3945da38dce 100644 --- a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithSynStringKindTripleQuote.fs.bsl +++ b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithSynStringKindTripleQuote.fs.bsl @@ -17,7 +17,8 @@ ImplFile Named (SynIdent (s, None), false, None, (2,4--2,5)), None, InterpolatedString ([String ("yo ", (2,8--2,16)); - FillExpr (Const (Int32 42, (2,16--2,18)), None); + FillExpr + (Const (Int32 42, (2,16--2,18)), DotNet (None, None)); String ("", (2,18--2,22))], TripleQuote, (2,8--2,22)), (2,4--2,5), Yes (2,0--2,22), { LeadingKeyword = Let (2,0--2,3) InlineKeyword = None diff --git a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithSynStringKindVerbatim.fs.bsl b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithSynStringKindVerbatim.fs.bsl index 2fb03900ebf..6c84a3c2f0f 100644 --- a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithSynStringKindVerbatim.fs.bsl +++ b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithSynStringKindVerbatim.fs.bsl @@ -16,15 +16,15 @@ ImplFile Named (SynIdent (s, None), false, None, (2,4--2,5)), None, InterpolatedString ([String ("Migrate notes of file "", (2,8--2,36)); - FillExpr (Ident oldId, None); + FillExpr (Ident oldId, DotNet (None, None)); String ("" to new file "", (2,41--2,60)); - FillExpr (Ident newId, None); String ("".", (2,65--2,70))], - Verbatim, (2,8--2,70)), (2,4--2,5), Yes (2,0--2,70), - { LeadingKeyword = Let (2,0--2,3) - InlineKeyword = None - EqualsRange = Some (2,6--2,7) })], (2,0--2,70), - { InKeyword = None })], PreXmlDocEmpty, [], None, (2,0--3,0), - { LeadingKeyword = None })], (true, true), + FillExpr (Ident newId, DotNet (None, None)); + String ("".", (2,65--2,70))], Verbatim, (2,8--2,70)), + (2,4--2,5), Yes (2,0--2,70), { LeadingKeyword = Let (2,0--2,3) + InlineKeyword = None + EqualsRange = Some (2,6--2,7) })], + (2,0--2,70), { InKeyword = None })], PreXmlDocEmpty, [], None, + (2,0--3,0), { LeadingKeyword = None })], (true, true), { ConditionalDirectives = [] WarnDirectives = [] CodeComments = [] }, set [])) diff --git a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithTripleQuoteMultipleDollars.fs.bsl b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithTripleQuoteMultipleDollars.fs.bsl index 3bbd9b2ba46..3e12edd2257 100644 --- a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithTripleQuoteMultipleDollars.fs.bsl +++ b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithTripleQuoteMultipleDollars.fs.bsl @@ -17,9 +17,11 @@ ImplFile Named (SynIdent (s, None), false, None, (2,4--2,5)), None, InterpolatedString ([String ("1 + ", (2,8--2,21)); - FillExpr (Const (Int32 41, (2,21--2,23)), None); + FillExpr + (Const (Int32 41, (2,21--2,23)), DotNet (None, None)); String (" = ", (2,23--2,32)); - FillExpr (Const (Int32 6, (2,32--2,33)), None); + FillExpr + (Const (Int32 6, (2,32--2,33)), DotNet (None, None)); String (" * 7", (2,33--2,43))], TripleQuote, (2,8--2,43)), (2,4--2,5), Yes (2,0--2,43), { LeadingKeyword = Let (2,0--2,3) InlineKeyword = None diff --git a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithTripleQuoteMultipleDollars2.fs.bsl b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithTripleQuoteMultipleDollars2.fs.bsl index ca0ac31fffc..c280d8d831b 100644 --- a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithTripleQuoteMultipleDollars2.fs.bsl +++ b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringWithTripleQuoteMultipleDollars2.fs.bsl @@ -10,7 +10,7 @@ ImplFile [Expr (InterpolatedString ([String ("", (2,0--2,9)); - FillExpr (Const (Int32 5, (2,9--2,10)), None); + FillExpr (Const (Int32 5, (2,9--2,10)), DotNet (None, None)); String ("", (2,10--2,16))], TripleQuote, (2,0--2,16)), (2,0--2,16))], PreXmlDocEmpty, [], None, (2,0--2,16), { LeadingKeyword = None })], (true, true), From 423d00755b8dcaca8111b21f7399d6e034113e42 Mon Sep 17 00:00:00 2001 From: Charles Roddie Date: Thu, 18 Jun 2026 23:01:30 +0100 Subject: [PATCH 02/11] Add release notes for interpolated string String.Concat lowering Co-Authored-By: Claude Opus 4.8 --- docs/release-notes/.FSharp.Compiler.Service/11.0.100.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md index 5d4bd0fe6ac..41940c7f37d 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -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)) From c7beb02d6f1a2a1de197f0e43d346171f9273a5c Mon Sep 17 00:00:00 2001 From: Charles Roddie Date: Fri, 19 Jun 2026 17:27:12 +0100 Subject: [PATCH 03/11] tidy --- src/Compiler/Checking/Expressions/CheckExpressions.fs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index 7e9fd36c7d4..45fca0a4057 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -7587,11 +7587,11 @@ and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, 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) = + let rec build (acc: SynExpr list, parts: SynInterpolatedStringPart list, 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.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 = @@ -7600,9 +7600,9 @@ and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, | 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' + build (argExpr :: acc, rest, rest') - let argExprs = build [] parts holeIsString + let argExprs = build ([], parts, holeIsString) let concatLid = mkSynLidGet mSynth [ "System"; "String" ] "Concat" From 1951198008a63b7f0970ac5f7497f5addbe2cdbd Mon Sep 17 00:00:00 2001 From: Charles Roddie Date: Sat, 20 Jun 2026 08:15:21 +0100 Subject: [PATCH 04/11] Lower string interpolation by type-checking each part in place Rewrite TcInterpolatedStringViaConcat to type-check each interpolation part in place and convert it to a string expression, then String.Concat them. This removes the parallel 'holeIsString' bool list and the flat-fillExprs/dense-parts interleave entirely: 'build' now walks a single list (the parts), threading only tpenv. - Plain '{x}' holes are built directly in the typed tree: a string is passed through raw (matching #16556's lean IL), anything else is converted via the 'string' operator, emitted through a new string_operator_info intrinsic + mkCallStringOperator helper. - Aligned/formatted and printf holes are checked from a small synthesized String.Format/sprintf expression, so name resolution still does the BCL work. - The function-value warning is re-homed per-hole. Known follow-ups: ill-typed formatted holes currently report their error twice (the formatted arm type-checks the hole once for the warning and again inside String.Format); the warning wants to move to its own pass over hole types, which also removes that double check. Co-Authored-By: Claude Opus 4.8 --- .../Checking/Expressions/CheckExpressions.fs | 105 ++++++++++-------- src/Compiler/TypedTree/TcGlobals.fs | 2 + src/Compiler/TypedTree/TcGlobals.fsi | 2 + .../TypedTree/TypedTreeOps.ExprOps.fs | 3 + .../TypedTree/TypedTreeOps.ExprOps.fsi | 3 + 5 files changed, 66 insertions(+), 49 deletions(-) diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index 45fca0a4057..a0e528f35b9 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -7560,17 +7560,17 @@ 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) = +/// Lower a string-typed interpolated string to a reflection-free System.String.Concat of its parts, +/// type-checking each part in place. A literal becomes its text; a plain '{x}' hole is built straight in +/// the typed tree (passed through if already a string, else converted with the 'string' operator); an +/// aligned/formatted or printf hole is checked from a small synthesized 'String.Format'/'sprintf' so name +/// resolution applies. +and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, m: range, tpenv: UnscopedTyparEnv, parts: SynInterpolatedStringPart 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) - // '(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 @@ -7586,38 +7586,47 @@ and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, 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: SynExpr list, parts: SynInterpolatedStringPart list, holeIsString: bool list) = + // Type-check one hole and convert it to a typed string expression. A '%spec' hole is constrained by + // its specifier (a function there is a type error), so it goes straight to 'sprintf'. A plain or + // aligned/formatted hole is checked here first, so its value can be warned about if it is a function. + let convertHole (synFill: SynExpr, formatting: SynInterpolationFormatting, tpenv: UnscopedTyparEnv) = + match formatting with + | SynInterpolationFormatting.Printf (spec, _) -> TcExpr cenv (MustEqual g.string_ty) env tpenv (sprintfOp (spec, synFill)) + | SynInterpolationFormatting.DotNet (alignment, format) -> + let fill, tpenv = TcExprFlex2 cenv (NewInferenceType g) env false tpenv synFill + let fillTy = tyOfExpr g fill + if g.langVersion.SupportsFeature LanguageFeature.WarnWhenFunctionValueUsedAsInterpolatedStringArg && (isFunTy g fillTy || isDelegateTy g fillTy) then + warning (Error(FSComp.SR.tcFunctionValueUsedAsInterpolatedStringArg (), synFill.Range)) + match alignment, format with + // A plain hole reuses the checked expression; an aligned or formatted hole goes via String.Format. + | None, None -> (if isStringTy g fillTy then fill else mkCallStringOperator g m fillTy fill), tpenv + | _ -> TcExpr cenv (MustEqual g.string_ty) env tpenv (stringFormatOp (alignment, format, synFill)) + + // Build one string expression per part, type-checking each hole in place. + let rec build (parts: SynInterpolatedStringPart list, tpenv: UnscopedTyparEnv) = 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" + | [] -> [], tpenv + | SynInterpolatedStringPart.String ("", _) :: rest -> build (rest, tpenv) + | SynInterpolatedStringPart.String (s, _) :: rest -> + let args, tpenv = build (rest, tpenv) + mkString g m (s.Replace("%%", "%")) :: args, tpenv + | SynInterpolatedStringPart.FillExpr (synFill, formatting) :: rest -> + let argExpr, tpenv = convertHole (synFill, formatting, tpenv) + let args, tpenv = build (rest, tpenv) + argExpr :: args, tpenv + + let argExprs, tpenv = build (parts, tpenv) let resultExpr = match argExprs with - | [] -> strLit "" + | [] -> mkString g m "" | [ 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 + | [ a; b ] -> mkStaticCall_String_Concat2 g m a b + | [ a; b; c ] -> mkStaticCall_String_Concat3 g m a b c + | [ a; b; c; d ] -> mkStaticCall_String_Concat4 g m a b c d + | args -> mkStaticCall_String_Concat_Array g m (mkArray (g.string_ty, args, m)) - TcPropagatingExprLeafThenConvert cenv overallTy cenv.g.string_ty env m (fun () -> - TcExpr cenv (MustEqual cenv.g.string_ty) env tpenv resultExpr) + TcPropagatingExprLeafThenConvert cenv overallTy g.string_ty env m (fun () -> resultExpr, tpenv) /// Check an interpolated string expression and [] warnForFunctionValuesInFillExprs (g: TcGlobals) argTys synFillExprs = @@ -7772,30 +7781,28 @@ and TcInterpolatedStringExpr cenv (overallTy: OverallTy) env m tpenv (parts: Syn else 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, + // type-checking each hole in place (no separate batch, no flat fill-expression list). + TcInterpolatedStringViaConcat (cenv, overallTy, env, m, tpenv, parts) 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 - 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 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) + 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 + MakeMethInfoCall cenv.amap m newFormatMethod [] [mkString g m printfFormatString; argsExpr; percentATysExpr] None, tpenv // The case for $"..." used as type FormattableString or IFormattable | Choice2Of2 createFormattableStringMethod -> diff --git a/src/Compiler/TypedTree/TcGlobals.fs b/src/Compiler/TypedTree/TcGlobals.fs index 8fcb72dc619..4d5678b6c3a 100644 --- a/src/Compiler/TypedTree/TcGlobals.fs +++ b/src/Compiler/TypedTree/TcGlobals.fs @@ -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)) @@ -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 diff --git a/src/Compiler/TypedTree/TcGlobals.fsi b/src/Compiler/TypedTree/TcGlobals.fsi index 07e56f3e8b2..e525f8777ca 100644 --- a/src/Compiler/TypedTree/TcGlobals.fsi +++ b/src/Compiler/TypedTree/TcGlobals.fsi @@ -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 diff --git a/src/Compiler/TypedTree/TypedTreeOps.ExprOps.fs b/src/Compiler/TypedTree/TypedTreeOps.ExprOps.fs index 4e3bcbc45ad..bcb9ba6bd74 100644 --- a/src/Compiler/TypedTree/TypedTreeOps.ExprOps.fs +++ b/src/Compiler/TypedTree/TypedTreeOps.ExprOps.fs @@ -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 diff --git a/src/Compiler/TypedTree/TypedTreeOps.ExprOps.fsi b/src/Compiler/TypedTree/TypedTreeOps.ExprOps.fsi index 2b347a7d784..c9bbcdb0ddf 100644 --- a/src/Compiler/TypedTree/TypedTreeOps.ExprOps.fsi +++ b/src/Compiler/TypedTree/TypedTreeOps.ExprOps.fsi @@ -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 From 3d5bb3d890412e64d7c50fe6af468ede3a90b357 Mon Sep 17 00:00:00 2001 From: Charles Roddie Date: Sat, 20 Jun 2026 09:14:41 +0100 Subject: [PATCH 05/11] cleanup --- .../Checking/Expressions/CheckExpressions.fs | 39 ++++++++----------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index a0e528f35b9..491ef2e0937 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -7561,10 +7561,7 @@ and TcFormatStringExpr cenv (overallTy: OverallTy) env m tpenv (fmtString: strin ) /// Lower a string-typed interpolated string to a reflection-free System.String.Concat of its parts, -/// type-checking each part in place. A literal becomes its text; a plain '{x}' hole is built straight in -/// the typed tree (passed through if already a string, else converted with the 'string' operator); an -/// aligned/formatted or printf hole is checked from a small synthesized 'String.Format'/'sprintf' so name -/// resolution applies. +/// type-checking each part in place. and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, m: range, tpenv: UnscopedTyparEnv, parts: SynInterpolatedStringPart list) = let g = cenv.g let mSynth = m.MakeSynthetic() @@ -7586,9 +7583,8 @@ and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, let args = paren (SynExpr.Tuple(false, [ invariant; strLit netFormat; e ], [ range0; range0 ], mSynth)) mkSynApp1 (mkSynLidGet mSynth [ "System"; "String" ] "Format") args mSynth - // Type-check one hole and convert it to a typed string expression. A '%spec' hole is constrained by - // its specifier (a function there is a type error), so it goes straight to 'sprintf'. A plain or - // aligned/formatted hole is checked here first, so its value can be warned about if it is a function. + // Type-check one hole and convert it to a string. A printf hole goes straight to 'sprintf'; a plain + // or formatted hole is checked here first, so a function value can be warned about. let convertHole (synFill: SynExpr, formatting: SynInterpolationFormatting, tpenv: UnscopedTyparEnv) = match formatting with | SynInterpolationFormatting.Printf (spec, _) -> TcExpr cenv (MustEqual g.string_ty) env tpenv (sprintfOp (spec, synFill)) @@ -7598,24 +7594,23 @@ and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, if g.langVersion.SupportsFeature LanguageFeature.WarnWhenFunctionValueUsedAsInterpolatedStringArg && (isFunTy g fillTy || isDelegateTy g fillTy) then warning (Error(FSComp.SR.tcFunctionValueUsedAsInterpolatedStringArg (), synFill.Range)) match alignment, format with - // A plain hole reuses the checked expression; an aligned or formatted hole goes via String.Format. | None, None -> (if isStringTy g fillTy then fill else mkCallStringOperator g m fillTy fill), tpenv | _ -> TcExpr cenv (MustEqual g.string_ty) env tpenv (stringFormatOp (alignment, format, synFill)) - // Build one string expression per part, type-checking each hole in place. - let rec build (parts: SynInterpolatedStringPart list, tpenv: UnscopedTyparEnv) = - match parts with - | [] -> [], tpenv - | SynInterpolatedStringPart.String ("", _) :: rest -> build (rest, tpenv) - | SynInterpolatedStringPart.String (s, _) :: rest -> - let args, tpenv = build (rest, tpenv) - mkString g m (s.Replace("%%", "%")) :: args, tpenv - | SynInterpolatedStringPart.FillExpr (synFill, formatting) :: rest -> - let argExpr, tpenv = convertHole (synFill, formatting, tpenv) - let args, tpenv = build (rest, tpenv) - argExpr :: args, tpenv - - let argExprs, tpenv = build (parts, tpenv) + // One string expression per non-empty part; a builder (not map) since 'tpenv' threads through the holes. + let argExprs, tpenv = + let ra = ResizeArray() + let mutable tpenvAcc = tpenv + for part in parts do + match part with + | SynInterpolatedStringPart.String (s, _) -> + if s <> "" then + ra.Add(mkString g m (s.Replace("%%", "%"))) + | SynInterpolatedStringPart.FillExpr (synFill, formatting) -> + let argExpr, tpenvAfter = convertHole (synFill, formatting, tpenvAcc) + ra.Add argExpr + tpenvAcc <- tpenvAfter + List.ofSeq ra, tpenvAcc let resultExpr = match argExprs with From 5eae16cbde9d3a114a8a853ab4eaccfa976d6a48 Mon Sep 17 00:00:00 2001 From: Charles Roddie Date: Sat, 20 Jun 2026 12:28:52 +0100 Subject: [PATCH 06/11] Pass a bare %s through as a string instead of via sprintf A bare '%s' on a string hole now lowers to a String.Concat passthrough (reflection-free, AOT-clean), like a plain '{string}' hole, instead of routing through sprintf. This fixes the regression where '$"%s{name}"' went through the printf engine. Other specifiers (and '%5s' etc.) still format via sprintf, and the '%s' string constraint is still enforced. convertHole now returns a (string expression, may-be-null) pair: only a raw string passthrough can be null, so a lone such arg coalesces via the 'string' operator (e.g. $"%s{null}" and $"{(s: string)}" -> ""), while multi-arg cases rely on String.Concat mapping null to "". Co-Authored-By: Claude Opus 4.8 --- .../Checking/Expressions/CheckExpressions.fs | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index 491ef2e0937..76006f1dac9 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -7583,21 +7583,32 @@ and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, let args = paren (SynExpr.Tuple(false, [ invariant; strLit netFormat; e ], [ range0; range0 ], mSynth)) mkSynApp1 (mkSynLidGet mSynth [ "System"; "String" ] "Format") args mSynth - // Type-check one hole and convert it to a string. A printf hole goes straight to 'sprintf'; a plain - // or formatted hole is checked here first, so a function value can be warned about. + // Type-check one hole and convert it to a (string expression, may-be-null) pair. let convertHole (synFill: SynExpr, formatting: SynInterpolationFormatting, tpenv: UnscopedTyparEnv) = match formatting with - | SynInterpolationFormatting.Printf (spec, _) -> TcExpr cenv (MustEqual g.string_ty) env tpenv (sprintfOp (spec, synFill)) + | SynInterpolationFormatting.Printf (spec, _) -> + match spec with + // A bare '%s' requires a string; pass it through (it may be null) instead of formatting via 'sprintf'. + | "%s" -> + let fill, tpenv = TcExpr cenv (MustEqual g.string_ty) env tpenv synFill + (fill, true), tpenv + | _ -> + let arg, tpenv = TcExpr cenv (MustEqual g.string_ty) env tpenv (sprintfOp (spec, synFill)) + (arg, false), tpenv | SynInterpolationFormatting.DotNet (alignment, format) -> + // Type-checking the hole here is also where a function value gets warned about. let fill, tpenv = TcExprFlex2 cenv (NewInferenceType g) env false tpenv synFill let fillTy = tyOfExpr g fill if g.langVersion.SupportsFeature LanguageFeature.WarnWhenFunctionValueUsedAsInterpolatedStringArg && (isFunTy g fillTy || isDelegateTy g fillTy) then warning (Error(FSComp.SR.tcFunctionValueUsedAsInterpolatedStringArg (), synFill.Range)) match alignment, format with - | None, None -> (if isStringTy g fillTy then fill else mkCallStringOperator g m fillTy fill), tpenv - | _ -> TcExpr cenv (MustEqual g.string_ty) env tpenv (stringFormatOp (alignment, format, synFill)) + | None, None -> (if isStringTy g fillTy then (fill, true) else (mkCallStringOperator g m fillTy fill, false)), tpenv + | _ -> + let arg, tpenv = TcExpr cenv (MustEqual g.string_ty) env tpenv (stringFormatOp (alignment, format, synFill)) + (arg, false), tpenv - // One string expression per non-empty part; a builder (not map) since 'tpenv' threads through the holes. + // One (string expression, may-be-null) per non-empty part; a builder (not map) since 'tpenv' threads + // through the holes. Literals and conversions are never null; only a raw string passthrough may be. let argExprs, tpenv = let ra = ResizeArray() let mutable tpenvAcc = tpenv @@ -7605,7 +7616,7 @@ and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, match part with | SynInterpolatedStringPart.String (s, _) -> if s <> "" then - ra.Add(mkString g m (s.Replace("%%", "%"))) + ra.Add((mkString g m (s.Replace("%%", "%")), false)) | SynInterpolatedStringPart.FillExpr (synFill, formatting) -> let argExpr, tpenvAfter = convertHole (synFill, formatting, tpenvAcc) ra.Add argExpr @@ -7615,11 +7626,16 @@ and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, let resultExpr = match argExprs with | [] -> mkString g m "" - | [ single ] -> single - | [ a; b ] -> mkStaticCall_String_Concat2 g m a b - | [ a; b; c ] -> mkStaticCall_String_Concat3 g m a b c - | [ a; b; c; d ] -> mkStaticCall_String_Concat4 g m a b c d - | args -> mkStaticCall_String_Concat_Array g m (mkArray (g.string_ty, args, m)) + // A lone arg has no Concat to map its null to ""; a possibly-null one coalesces via 'string'. + | [ (single, mustCallStringOp) ]-> + if mustCallStringOp then mkCallStringOperator g m g.string_ty single + else single + | [ (a, _); (b, _) ] -> mkStaticCall_String_Concat2 g m a b + | [ (a, _); (b, _); (c, _) ] -> mkStaticCall_String_Concat3 g m a b c + | [ (a, _); (b, _); (c, _); (d, _) ] -> mkStaticCall_String_Concat4 g m a b c d + | _ -> + let exprs = argExprs |> List.map fst + mkStaticCall_String_Concat_Array g m (mkArray (g.string_ty, exprs, m)) TcPropagatingExprLeafThenConvert cenv overallTy g.string_ty env m (fun () -> resultExpr, tpenv) From b278961c5b30f1a5a0535a66bbd200422b7796ed Mon Sep 17 00:00:00 2001 From: Charles Roddie Date: Sat, 20 Jun 2026 12:33:48 +0100 Subject: [PATCH 07/11] %s is AOT compatible now --- tests/AheadOfTime/NativeAOT/Program.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/AheadOfTime/NativeAOT/Program.fs b/tests/AheadOfTime/NativeAOT/Program.fs index f25e03867f7..dc88da649e6 100644 --- a/tests/AheadOfTime/NativeAOT/Program.fs +++ b/tests/AheadOfTime/NativeAOT/Program.fs @@ -12,10 +12,10 @@ let printInterpolated() = print $"hello {name}" print $"pi ~ {pi:F2}" print $"padded:{x,6}" + print $"greeting %s{name}" // The following use printf specifiers and will generate AOT IL2026/IL2070/IL3050 warnings. // print $"answer = %d{x}" - // print $"hello %s{name}" // print $"pi ~ %.2f{pi}" // print $"value = %A{x}" From e83ddfb6f1636cedd5e49d813cd00687c4d32f29 Mon Sep 17 00:00:00 2001 From: Charles Roddie Date: Sat, 20 Jun 2026 13:18:14 +0100 Subject: [PATCH 08/11] Type-check formatted interpolation holes only once The formatted/aligned hole path lowered via a synthesized String.Format that re-type-checked the hole expression a second time, which could duplicate an error in the hole and leak a spurious 'Format' overload-resolution error. Bind the already-checked, boxed hole value to a temporary and reference that from the synthesized String.Format instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Checking/Expressions/CheckExpressions.fs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index 76006f1dac9..98381d5ae14 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -7604,8 +7604,16 @@ and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, match alignment, format with | None, None -> (if isStringTy g fillTy then (fill, true) else (mkCallStringOperator g m fillTy fill, false)), tpenv | _ -> - let arg, tpenv = TcExpr cenv (MustEqual g.string_ty) env tpenv (stringFormatOp (alignment, format, synFill)) - (arg, false), tpenv + // Format the already-checked hole via a synthesized 'String.Format', binding its boxed value + // to a temporary so the hole is not type-checked a second time. Re-checking 'synFill' would + // duplicate any error in it; boxing to 'obj' keeps the 'Format' overload unambiguous (so a + // hole that already failed to check doesn't also leak a confusing 'Format' overload error). + let boxedFill = mkCallBox g m fillTy fill + let tmpVal, _ = mkLocal mSynth "interpHole" (tyOfExpr g boxedFill) + let envInner = AddLocalVal g cenv.tcSink mSynth tmpVal env + let tmpRef = SynExpr.Ident(mkSynId mSynth tmpVal.LogicalName) + let arg, tpenv = TcExpr cenv (MustEqual g.string_ty) envInner tpenv (stringFormatOp (alignment, format, tmpRef)) + (mkCompGenLet mSynth tmpVal boxedFill arg, false), tpenv // One (string expression, may-be-null) per non-empty part; a builder (not map) since 'tpenv' threads // through the holes. Literals and conversions are never null; only a raw string passthrough may be. From 6112fd2ed82419e93c941d32b24f8f08b7814edf Mon Sep 17 00:00:00 2001 From: Charles Roddie Date: Sat, 20 Jun 2026 13:51:31 +0100 Subject: [PATCH 09/11] Lower bare %c/%d/%i/%M interpolation holes to String.Concat These printf specifiers act only as a type annotation - their value renders the same through the 'string' operator as through the specifier - so constrain the hole to the specifier's type and render it with 'string', as for a plain '{x}' hole, instead of routing through the reflection-based 'sprintf'. This makes them NativeAOT compatible. '%u' is excluded as it reinterprets a signed value as unsigned and so does not match 'string'. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Compiler/Checking/CheckFormatStrings.fsi | 6 ++++++ src/Compiler/Checking/Expressions/CheckExpressions.fs | 10 ++++++++++ tests/AheadOfTime/NativeAOT/Program.fs | 8 ++++++-- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/Compiler/Checking/CheckFormatStrings.fsi b/src/Compiler/Checking/CheckFormatStrings.fsi index eb8120f712d..7b22639f4dc 100644 --- a/src/Compiler/Checking/CheckFormatStrings.fsi +++ b/src/Compiler/Checking/CheckFormatStrings.fsi @@ -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 -> diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index 98381d5ae14..ae2aca5c255 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -7585,6 +7585,13 @@ and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, // Type-check one hole and convert it to a (string expression, may-be-null) pair. let convertHole (synFill: SynExpr, formatting: SynInterpolationFormatting, 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 + // value as unsigned, so it does not match 'string' - e.g. '%u' of -1 is "4294967295".) + let convertViaString constraintTy = + let fill, tpenv = TcExpr cenv (MustEqual constraintTy) env tpenv synFill + (mkCallStringOperator g m (tyOfExpr g fill) fill, false), tpenv match formatting with | SynInterpolationFormatting.Printf (spec, _) -> match spec with @@ -7592,6 +7599,9 @@ and TcInterpolatedStringViaConcat (cenv: cenv, overallTy: OverallTy, env: TcEnv, | "%s" -> let fill, tpenv = TcExpr cenv (MustEqual g.string_ty) env tpenv synFill (fill, true), tpenv + | "%c" -> convertViaString g.char_ty + | "%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)) (arg, false), tpenv diff --git a/tests/AheadOfTime/NativeAOT/Program.fs b/tests/AheadOfTime/NativeAOT/Program.fs index dc88da649e6..8dfc72824ce 100644 --- a/tests/AheadOfTime/NativeAOT/Program.fs +++ b/tests/AheadOfTime/NativeAOT/Program.fs @@ -8,14 +8,18 @@ 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 and will generate AOT IL2026/IL2070/IL3050 warnings. - // print $"answer = %d{x}" + // 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}" From 7d2a2460419129019b7a14ecc630a06bd65f596d Mon Sep 17 00:00:00 2001 From: Charles Roddie Date: Mon, 22 Jun 2026 17:39:51 +0100 Subject: [PATCH 10/11] Check NativeAOT interpolated-string output against expected values The NativeAOT check only asserted that publish and execution succeeded, so a change that silently altered interpolated-string rendering would pass unnoticed. The test program now checks each rendering against an expected string literal, printing a "FAILED" line on mismatch and "Finished" last; check.ps1 asserts the output is exactly "Finished". This follows the existing Trimming/check.ps1 pattern. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/AheadOfTime/NativeAOT/Program.fs | 33 +++++++++++++++----------- tests/AheadOfTime/NativeAOT/check.ps1 | 9 +++++-- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/tests/AheadOfTime/NativeAOT/Program.fs b/tests/AheadOfTime/NativeAOT/Program.fs index 8dfc72824ce..dce1bbaf53e 100644 --- a/tests/AheadOfTime/NativeAOT/Program.fs +++ b/tests/AheadOfTime/NativeAOT/Program.fs @@ -2,28 +2,33 @@ module Program open System -let print (s: string) = Console.WriteLine(s) +// 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 printInterpolated() = +let runChecks () = 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}" + 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. - print $"answer = %d{x}" - print $"initial = %c{initial}" + check ($"answer = %d{x}", "answer = 42") + check ($"initial = %c{initial}", "initial = F") - // 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}" + // 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") [] let main _ = - printInterpolated() + runChecks () + // Success sentinel; a failed check above printed a "FAILED" line first, so the output won't be just this. + Console.WriteLine "Finished" 0 diff --git a/tests/AheadOfTime/NativeAOT/check.ps1 b/tests/AheadOfTime/NativeAOT/check.ps1 index 3cc10abb993..dc69fb765df 100644 --- a/tests/AheadOfTime/NativeAOT/check.ps1 +++ b/tests/AheadOfTime/NativeAOT/check.ps1 @@ -21,12 +21,17 @@ if (-not ($LASTEXITCODE -eq 0)) { } $exe = Join-Path $PSScriptRoot "bin/release/$tfm/win-x64/publish/$root.exe" -& $exe | Out-Null +$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)) { - Write-Error "NativeAOT app failed with exit code $exitCode" -ErrorAction Stop + 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." From e87b12fdb7cdd05b7dec01adcf7f029d2b459636 Mon Sep 17 00:00:00 2001 From: Charles Roddie Date: Mon, 22 Jun 2026 18:24:15 +0100 Subject: [PATCH 11/11] Add EmittedIL tests for interpolation cases not optimized before Cover the lowerings the old (<=4 string parts) optimization never reached: - more than 4 parts -> System.String.Concat(string[]) array overload - string holes (incl. a string-returning method) concatenated directly, with no spurious string conversion or null check - a single float hole -> invariant-culture ToString (the rendering behaviour change), with no concat - a single bool hole -> ToString via the non-IFormattable path, with no concat Co-Authored-By: Claude Opus 4.8 (1M context) --- .../EmittedIL/StringFormatAndInterpolation.fs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/tests/FSharp.Compiler.ComponentTests/EmittedIL/StringFormatAndInterpolation.fs b/tests/FSharp.Compiler.ComponentTests/EmittedIL/StringFormatAndInterpolation.fs index 38729fc70be..57b3c0ae5ec 100644 --- a/tests/FSharp.Compiler.ComponentTests/EmittedIL/StringFormatAndInterpolation.fs +++ b/tests/FSharp.Compiler.ComponentTests/EmittedIL/StringFormatAndInterpolation.fs @@ -90,6 +90,90 @@ IL_0014: call string [runtime]System.String::Concat(string, string) IL_0019: ret"""] + [] + let ``Interpolated string with more than 4 parts is lowered to a System.String.Concat array`` () = + FSharp """ +module StringFormatAndInterpolation + +let f (a: string, b: string, c: string, d: string, e: string) = $"{a}{b}{c}{d}{e}" + """ + |> compile + |> shouldSucceed + |> verifyIL [""" +IL_0000: ldc.i4.5 +IL_0001: newarr [runtime]System.String +IL_0006: dup +IL_0007: ldc.i4.0 +IL_0008: ldarg.0 +IL_0009: stelem [runtime]System.String +IL_000e: dup +IL_000f: ldc.i4.1 +IL_0010: ldarg.1 +IL_0011: stelem [runtime]System.String +IL_0016: dup +IL_0017: ldc.i4.2 +IL_0018: ldarg.2 +IL_0019: stelem [runtime]System.String +IL_001e: dup +IL_001f: ldc.i4.3 +IL_0020: ldarg.3 +IL_0021: stelem [runtime]System.String +IL_0026: dup +IL_0027: ldc.i4.4 +IL_0028: ldarg.s e +IL_002a: stelem [runtime]System.String +IL_002f: call string [runtime]System.String::Concat(string[]) +IL_0034: ret"""] + + [] + let ``String-typed interpolation holes are concatenated directly, with no string conversion or null check`` () = + FSharp """ +module StringFormatAndInterpolation + +let f (a: string, b: string) = $"{a}{b.ToLower()}" + """ + |> compile + |> shouldSucceed + |> verifyIL [""" +IL_0000: ldarg.0 +IL_0001: ldarg.1 +IL_0002: callvirt instance string [runtime]System.String::ToLower() +IL_0007: call string [runtime]System.String::Concat(string, + string) +IL_000c: ret"""] + + [] + let ``Interpolated string with a single float hole is rendered via an invariant-culture ToString`` () = + FSharp """ +module StringFormatAndInterpolation + +let f (x: float) = $"{x}" + """ + |> compile + |> shouldSucceed + |> verifyIL [""" +IL_0000: ldarga.s x +IL_0002: ldnull +IL_0003: call class [netstandard]System.Globalization.CultureInfo [netstandard]System.Globalization.CultureInfo::get_InvariantCulture() +IL_0008: call instance string [netstandard]System.Double::ToString(string, + class [netstandard]System.IFormatProvider) +IL_000d: ret"""] + + [] + let ``Interpolated string with a single bool hole is rendered via ToString`` () = + FSharp """ +module StringFormatAndInterpolation + +let f (x: bool) = $"{x}" + """ + |> compile + |> shouldSucceed + |> verifyIL [""" +IL_0000: ldarga.s x +IL_0002: constrained. [runtime]System.Boolean +IL_0008: callvirt instance string [netstandard]System.Object::ToString() +IL_000d: ret"""] + [] let ``Interpolated string with concat converts to span implicitly`` () = let compilation =