From 98d38a43fbb87676c814b9833b8c9fd705ac348a Mon Sep 17 00:00:00 2001 From: edgargonzalez Date: Tue, 26 May 2026 15:47:22 -0500 Subject: [PATCH 1/2] Allow =$"..." (interpolated string adjacent to =) (#16696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lexer greedily matched '=$' as a single INFIX_COMPARE_OP, so 'C(Name=$"123")' and 'let x =$"123"' failed with FS0035 ('$' not permitted in operator names). Add a lexer rule that matches '=$"', consumes only the '=', and rewinds so the next scan begins at '$"' — letting the regular interpolated-string lexer handle the rest, including interpolation holes and triple-quoted forms. Fix is position-agnostic (works in let-bindings, named args, record creation/copy). Scoped to '=$"'; the '$@', '@$' and '$$' verbatim/extended forms still require a space, as before. --- .../.FSharp.Compiler.Service/11.0.100.md | 1 + src/Compiler/Facilities/prim-lexing.fsi | 5 + src/Compiler/lex.fsl | 11 +- .../Language/InterpolatedStringsTests.fs | 104 ++++++++++++++++++ ...SynExprInterpolatedStringAdjacentEquals.fs | 1 + ...xprInterpolatedStringAdjacentEquals.fs.bsl | 24 ++++ ...rpolatedStringAdjacentEqualsTripleQuote.fs | 1 + ...atedStringAdjacentEqualsTripleQuote.fs.bsl | 26 +++++ ...nterpolatedStringAdjacentEqualsWithHole.fs | 2 + ...polatedStringAdjacentEqualsWithHole.fs.bsl | 38 +++++++ 10 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEquals.fs create mode 100644 tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEquals.fs.bsl create mode 100644 tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsTripleQuote.fs create mode 100644 tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsTripleQuote.fs.bsl create mode 100644 tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsWithHole.fs create mode 100644 tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsWithHole.fs.bsl 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 7e9cd230a4c..9bebcda9cb1 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -1,6 +1,7 @@ ### Fixed * Fix `[]` prefix attributes being silently dropped on class members, and fix false-positive `AllowMultiple=false` errors when `[]` and `[]` are applied to the same binding. ([Issue #17904](https://github.com/dotnet/fsharp/issues/17904), [Issue #19020](https://github.com/dotnet/fsharp/issues/19020), [PR #19738](https://github.com/dotnet/fsharp/pull/19738)) +* Fix `=` adjacent to an interpolated string (e.g. `C(Name=$"value")`) being lexed as the invalid operator `=$` instead of an assignment followed by an interpolated string. ([Issue #16696](https://github.com/dotnet/fsharp/issues/16696)) * Fix attributes on return type of unparenthesized tuple methods being silently dropped from IL. ([Issue #462](https://github.com/dotnet/fsharp/issues/462), [PR #19714](https://github.com/dotnet/fsharp/pull/19714)) * Fix internal error FS0073 "Undefined or unsolved type variable" in IlxGen when nested inline SRTP functions with multiple overloads leave unsolved typars in the non-witness codegen path. ([Issue #19709](https://github.com/dotnet/fsharp/issues/19709), [PR #19710](https://github.com/dotnet/fsharp/pull/19710)) * Fix NRE when calling virtual Object methods on value types through inline SRTP functions. ([Issue #8098](https://github.com/dotnet/fsharp/issues/8098), [PR #19511](https://github.com/dotnet/fsharp/pull/19511)) diff --git a/src/Compiler/Facilities/prim-lexing.fsi b/src/Compiler/Facilities/prim-lexing.fsi index c95a97a8d42..bcb60fc4977 100644 --- a/src/Compiler/Facilities/prim-lexing.fsi +++ b/src/Compiler/Facilities/prim-lexing.fsi @@ -117,6 +117,11 @@ type internal LexBuffer<'Char> = /// The currently matched text as a Span, it is only valid until the lexer is advanced member LexemeView: System.ReadOnlySpan<'Char> + /// Length of the currently matched lexeme, in characters. Setting this to a value smaller than the + /// actual match effectively rewinds the scanner: the next token will start LexemeLength + /// characters into the previously-matched lexeme. Use with caution. + member LexemeLength: int with get, set + /// Get single character of matched string member LexemeChar: int -> 'Char diff --git a/src/Compiler/lex.fsl b/src/Compiler/lex.fsl index 2e72a9ab201..87c1b269ae4 100644 --- a/src/Compiler/lex.fsl +++ b/src/Compiler/lex.fsl @@ -975,8 +975,17 @@ rule token (args: LexArgs) (skip: bool) = parse | ignored_op_char* ('@'|'^') op_char* { checkExprOp lexbuf; INFIX_AT_HAT_OP(lexeme lexbuf) } + // For '=$"' (property/named-arg initialization with an interpolated string, e.g. C(Name=$"123")): + // match the 3 chars, but consume only the '=' and rewind so the next scan begins at '$"', + // letting the regular interpolated-string lexer process it (including any '{...}' holes). + // See https://github.com/dotnet/fsharp/issues/16696. + | '=' '$' '"' { + lexbuf.LexemeLength <- 1 + lexbuf.EndPos <- lexbuf.StartPos.ShiftColumnBy(1) + EQUALS } + | ignored_op_char* ('=' | "!=" | '<' | '$') op_char* { checkExprOp lexbuf; INFIX_COMPARE_OP(lexeme lexbuf) } - + | ignored_op_char* ('>') op_char* { checkExprGreaterColonOp lexbuf; INFIX_COMPARE_OP(lexeme lexbuf) } | ignored_op_char* ('&') op_char* { checkExprOp lexbuf; INFIX_AMP_OP(lexeme lexbuf) } diff --git a/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs index 61eae80d184..43ee1f7de05 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs @@ -361,3 +361,107 @@ let s = $"{f.Invoke(42)}" """ |> compileExeAndRun |> shouldSucceed + + [] + let ``Issue 16696 - let-binding with =$"..." (no space)`` () = + Fsx """ +let n = 42 +let world = "world" +let plain =$"123" +let withHole =$"hello {world}" +let withTypedHole =$"%d{n}" +if plain <> "123" then failwithf "plain: expected 123, got %s" plain +if withHole <> "hello world" then failwithf "withHole: expected 'hello world', got %s" withHole +if withTypedHole <> "42" then failwithf "withTypedHole: expected 42, got %s" withTypedHole + """ + |> compileExeAndRun + |> shouldSucceed + + [] + let ``Issue 16696 - property initialization in constructor call with Name=$"..."`` () = + Fsx """ +type C() = + member val Name = "" with get, set + +let n = 42 +let plain = C(Name=$"123") +let withHole = C(Name=$"items: %d{n}") + +if plain.Name <> "123" then failwithf "plain: expected 123, got %s" plain.Name +if withHole.Name <> "items: 42" then failwithf "withHole: expected 'items: 42', got %s" withHole.Name + """ + |> compileExeAndRun + |> shouldSucceed + + [] + let ``Issue 16696 - record creation with Field=$"..."`` () = + Fsx """ +type R = { Name: string } +let n = 42 +let r1 = { Name=$"abc" } +let r2 = { Name=$"%d{n}" } +if r1.Name <> "abc" then failwithf "r1: expected abc, got %s" r1.Name +if r2.Name <> "42" then failwithf "r2: expected 42, got %s" r2.Name + """ + |> compileExeAndRun + |> shouldSucceed + + [] + let ``Issue 16696 - record copy with Field=$"..."`` () = + Fsx """ +type R = { Name: string; Count: int } +let n = 42 +let r = { Name = ""; Count = 1 } +let r1 = { r with Name=$"abc" } +let r2 = { r with Name=$"%d{n}" } +if r1.Name <> "abc" then failwithf "r1: expected abc, got %s" r1.Name +if r2.Name <> "42" then failwithf "r2: expected 42, got %s" r2.Name + """ + |> compileExeAndRun + |> shouldSucceed + + [] + let ``Issue 16696 - =$ followed by non-quote still rejected as deprecated operator`` () = + Fsx """ +let x =$abc + """ + |> compile + |> shouldFail + |> withDiagnosticMessageMatches "is not permitted as a character in operator names" + + [] + let ``Issue 16696 - defining (=$) as a custom operator is still rejected`` () = + Fsx """ +let (=$) a b = a + b + """ + |> compile + |> shouldFail + |> withDiagnosticMessageMatches "is not permitted as a character in operator names" + + [] + let ``Issue 16696 - =$ used as infix operator is still rejected`` () = + Fsx """ +let f a b = a =$ b + """ + |> compile + |> shouldFail + |> withDiagnosticMessageMatches "is not permitted as a character in operator names" + + [] + let ``Issue 16696 - =$@ verbatim interpolation form is still rejected (out of scope)`` () = + Fsx """ +let x =$@"abc" + """ + |> compile + |> shouldFail + |> withDiagnosticMessageMatches "is not permitted as a character in operator names" + + [] + let ``Issue 16696 - = $"..." (with space) still parses unchanged`` () = + Fsx """ +let n = 42 +let x = $"%d{n}" +if x <> "42" then failwithf "expected 42, got %s" x + """ + |> compileExeAndRun + |> shouldSucceed diff --git a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEquals.fs b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEquals.fs new file mode 100644 index 00000000000..49741e61768 --- /dev/null +++ b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEquals.fs @@ -0,0 +1 @@ +let x =$"123" diff --git a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEquals.fs.bsl b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEquals.fs.bsl new file mode 100644 index 00000000000..a745892bd42 --- /dev/null +++ b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEquals.fs.bsl @@ -0,0 +1,24 @@ +ImplFile + (ParsedImplFileInput + ("/root/String/SynExprInterpolatedStringAdjacentEquals.fs", false, + QualifiedNameOfFile SynExprInterpolatedStringAdjacentEquals, [], + [SynModuleOrNamespace + ([SynExprInterpolatedStringAdjacentEquals], false, AnonModule, + [Let + (false, + [SynBinding + (None, Normal, false, false, [], + PreXmlDoc ((1,0), FSharp.Compiler.Xml.XmlDocCollector), + SynValData + (None, SynValInfo ([], SynArgInfo ([], false, None)), None), + Named (SynIdent (x, None), false, None, (1,4--1,5)), None, + InterpolatedString + ([String ("123", (1,7--1,13))], Regular, (1,7--1,13)), + (1,4--1,5), Yes (1,0--1,13), { LeadingKeyword = Let (1,0--1,3) + InlineKeyword = None + EqualsRange = Some (1,6--1,7) })], + (1,0--1,13), { InKeyword = None })], PreXmlDocEmpty, [], None, + (1,0--2,0), { LeadingKeyword = None })], (true, true), + { ConditionalDirectives = [] + WarnDirectives = [] + CodeComments = [] }, set [])) diff --git a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsTripleQuote.fs b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsTripleQuote.fs new file mode 100644 index 00000000000..c2e5e9481f5 --- /dev/null +++ b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsTripleQuote.fs @@ -0,0 +1 @@ +let x =$"""abc""" diff --git a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsTripleQuote.fs.bsl b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsTripleQuote.fs.bsl new file mode 100644 index 00000000000..064e6878fe7 --- /dev/null +++ b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsTripleQuote.fs.bsl @@ -0,0 +1,26 @@ +ImplFile + (ParsedImplFileInput + ("/root/String/SynExprInterpolatedStringAdjacentEqualsTripleQuote.fs", + false, + QualifiedNameOfFile SynExprInterpolatedStringAdjacentEqualsTripleQuote, [], + [SynModuleOrNamespace + ([SynExprInterpolatedStringAdjacentEqualsTripleQuote], false, + AnonModule, + [Let + (false, + [SynBinding + (None, Normal, false, false, [], + PreXmlDoc ((1,0), FSharp.Compiler.Xml.XmlDocCollector), + SynValData + (None, SynValInfo ([], SynArgInfo ([], false, None)), None), + Named (SynIdent (x, None), false, None, (1,4--1,5)), None, + InterpolatedString + ([String ("abc", (1,7--1,17))], TripleQuote, (1,7--1,17)), + (1,4--1,5), Yes (1,0--1,17), { LeadingKeyword = Let (1,0--1,3) + InlineKeyword = None + EqualsRange = Some (1,6--1,7) })], + (1,0--1,17), { InKeyword = None })], PreXmlDocEmpty, [], None, + (1,0--2,0), { LeadingKeyword = None })], (true, true), + { ConditionalDirectives = [] + WarnDirectives = [] + CodeComments = [] }, set [])) diff --git a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsWithHole.fs b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsWithHole.fs new file mode 100644 index 00000000000..9f41a3fda7f --- /dev/null +++ b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsWithHole.fs @@ -0,0 +1,2 @@ +let n = 42 +let x =$"{n}" diff --git a/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsWithHole.fs.bsl b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsWithHole.fs.bsl new file mode 100644 index 00000000000..bcf55431e8f --- /dev/null +++ b/tests/service/data/SyntaxTree/String/SynExprInterpolatedStringAdjacentEqualsWithHole.fs.bsl @@ -0,0 +1,38 @@ +ImplFile + (ParsedImplFileInput + ("/root/String/SynExprInterpolatedStringAdjacentEqualsWithHole.fs", false, + QualifiedNameOfFile SynExprInterpolatedStringAdjacentEqualsWithHole, [], + [SynModuleOrNamespace + ([SynExprInterpolatedStringAdjacentEqualsWithHole], false, AnonModule, + [Let + (false, + [SynBinding + (None, Normal, false, false, [], + PreXmlDoc ((1,0), FSharp.Compiler.Xml.XmlDocCollector), + SynValData + (None, SynValInfo ([], SynArgInfo ([], false, None)), None), + Named (SynIdent (n, None), false, None, (1,4--1,5)), None, + Const (Int32 42, (1,8--1,10)), (1,4--1,5), Yes (1,0--1,10), + { LeadingKeyword = Let (1,0--1,3) + InlineKeyword = None + EqualsRange = Some (1,6--1,7) })], (1,0--1,10), + { InKeyword = None }); + Let + (false, + [SynBinding + (None, Normal, false, false, [], + PreXmlDoc ((2,0), FSharp.Compiler.Xml.XmlDocCollector), + SynValData + (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,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 + EqualsRange = Some (2,6--2,7) })], + (2,0--2,13), { InKeyword = None })], PreXmlDocEmpty, [], None, + (1,0--3,0), { LeadingKeyword = None })], (true, true), + { ConditionalDirectives = [] + WarnDirectives = [] + CodeComments = [] }, set [])) From 0014e5130f3b1afe72e2b25a559f7ab1f9231cf2 Mon Sep 17 00:00:00 2001 From: edgargonzalez Date: Tue, 26 May 2026 16:45:03 -0500 Subject: [PATCH 2/2] fix test name --- .../Language/InterpolatedStringsTests.fs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs index 43ee1f7de05..8a871b57208 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/InterpolatedStringsTests.fs @@ -448,7 +448,7 @@ let f a b = a =$ b |> withDiagnosticMessageMatches "is not permitted as a character in operator names" [] - let ``Issue 16696 - =$@ verbatim interpolation form is still rejected (out of scope)`` () = + let ``Issue 16696 - verbatim interpolation form is still rejected (out of scope)`` () = Fsx """ let x =$@"abc" """