Skip to content

Commit 6396a18

Browse files
authored
Fix syntax highlighting for optional parameters with ? prefix (#19162)
1 parent 3ce1e50 commit 6396a18

File tree

6 files changed

+172
-0
lines changed

6 files changed

+172
-0
lines changed

docs/release-notes/.FSharp.Compiler.Service/10.0.200.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
### Fixed
22

33
* Type relations cache: optimize key generation ([Issue #19116](https://github.com/dotnet/fsharp/issues/18767)) ([PR #19120](https://github.com/dotnet/fsharp/pull/19120))
4+
* Fixed QuickParse to correctly handle optional parameter syntax with `?` prefix, resolving syntax highlighting issues. ([Issue #11008753](https://developercommunity.visualstudio.com/t/F-Highlighting-fails-on-optional-parame/11008753)) ([PR #XXXXX](https://github.com/dotnet/fsharp/pull/XXXXX))
45
* Fix `--preferreduilang` switch leaking into `fsi.CommandLineArgs` when positioned after script file ([PR #19151](https://github.com/dotnet/fsharp/pull/19151))
56

67
### Added

src/Compiler/Service/QuickParse.fs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ module QuickParse =
9090
&& (lineStr[index] = '|' || IsIdentifierPartCharacter lineStr[index])
9191
->
9292
Some index
93+
// Handle optional parameter syntax: if we're on '?' and the next char is an identifier, use the next position
94+
| _ when
95+
(index < lineStr.Length)
96+
&& lineStr[index] = '?'
97+
&& (index + 1 < lineStr.Length)
98+
&& IsIdentifierPartCharacter lineStr[index + 1]
99+
->
100+
Some(index + 1)
93101
| _ -> None // not on a word or '.'
94102

95103
let (|Char|_|) p =

tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
<Compile Include="RangeTests.fs" />
5555
<Compile Include="TooltipTests.fs" />
5656
<Compile Include="TokenizerTests.fs" />
57+
<Compile Include="QuickParseTests.fs" />
5758
<Compile Include="CompilerTestHelpers.fs" />
5859
<Compile Include="ManglingNameOfProvidedTypes.fs" />
5960
<Compile Include="HashIfExpression.fs" />
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
module FSharp.Compiler.Service.Tests.QuickParseTests
2+
3+
open Xunit
4+
open FSharp.Compiler.EditorServices
5+
6+
// QuickParse.GetCompleteIdentifierIsland is used by language service features
7+
// to extract identifier context from source text at cursor positions.
8+
// When it returns None (as it did for '?' before the fix), downstream services
9+
// like semantic classification, completion, and hover can misinterpret the context.
10+
// This impacts Visual Studio's syntax highlighting - see issue #11008753
11+
12+
[<Fact>]
13+
let ``QuickParse handles optional parameter identifier extraction when cursor is on question mark``() =
14+
let lineStr = "member _.memb(?optional:string) = optional"
15+
16+
// Test when cursor is exactly on the '?' character
17+
let posOnQuestionMark = 14
18+
Assert.Equal('?', lineStr[posOnQuestionMark])
19+
20+
let island = QuickParse.GetCompleteIdentifierIsland false lineStr posOnQuestionMark
21+
22+
// We expect to get "optional" as the identifier
23+
Assert.True(Option.isSome island, "Should extract identifier island when positioned on '?'")
24+
25+
match island with
26+
| Some(ident, startCol, isQuoted) ->
27+
Assert.Equal("optional", ident)
28+
Assert.False(isQuoted)
29+
// The identifier should start after the '?'
30+
Assert.True(startCol >= 15, sprintf "Start column %d should be >= 15" startCol)
31+
| None ->
32+
Assert.Fail("Expected to find identifier 'optional' when positioned on '?'")
33+
34+
[<Fact>]
35+
let ``QuickParse handles optional parameter identifier extraction when cursor is on identifier``() =
36+
let lineStr = "member _.memb(?optional:string) = optional"
37+
38+
// Test when cursor is on the identifier "optional" after the '?'
39+
let posOnOptional = 17
40+
Assert.Equal('t', lineStr[posOnOptional])
41+
42+
let island = QuickParse.GetCompleteIdentifierIsland false lineStr posOnOptional
43+
44+
// We expect to get "optional" as the identifier
45+
Assert.True(Option.isSome island, "Should extract identifier island when positioned on identifier")
46+
47+
match island with
48+
| Some(ident, startCol, isQuoted) ->
49+
Assert.Equal("optional", ident)
50+
Assert.False(isQuoted)
51+
| None ->
52+
Assert.Fail("Expected to find identifier 'optional'")
53+
54+
[<Fact>]
55+
let ``QuickParse does not treat question mark as identifier in other contexts``() =
56+
let lineStr = "let x = y ? z"
57+
58+
// Test when cursor is on the '?' in a different context (not optional parameter)
59+
let posOnQuestionMark = 10
60+
Assert.Equal('?', lineStr[posOnQuestionMark])
61+
62+
let island = QuickParse.GetCompleteIdentifierIsland false lineStr posOnQuestionMark
63+
64+
// In this context, '?' is followed by space, not an identifier start
65+
// So we should get None or the next identifier 'z'
66+
// Let's check what we actually get
67+
match island with
68+
| Some(ident, _, _) ->
69+
// If we get something, it should be 'z' (the next identifier after the space)
70+
Assert.Equal("z", ident)
71+
| None ->
72+
// Or we might get None, which is also acceptable
73+
()

tests/FSharp.Compiler.Service.Tests/TokenizerTests.fs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,3 +223,26 @@ let ``Unfinished idents``() =
223223
["IDENT", "```"]]
224224

225225
actual |> Assert.shouldBe expected
226+
227+
[<Fact>]
228+
let ``Tokenizer test - optional parameters with question mark``() =
229+
let tokenizedLines =
230+
tokenizeLines
231+
[| "member _.memb(?optional:string) = optional" |]
232+
233+
let actual =
234+
[ for lineNo, lineToks in tokenizedLines do
235+
yield lineNo, [ for str, info in lineToks do yield info.TokenName, str ] ]
236+
237+
let expected =
238+
[(0,
239+
[("MEMBER", "member"); ("WHITESPACE", " "); ("UNDERSCORE", "_"); ("DOT", ".");
240+
("IDENT", "memb"); ("LPAREN", "("); ("QMARK", "?");
241+
("IDENT", "optional"); ("COLON", ":"); ("IDENT", "string");
242+
("RPAREN", ")"); ("WHITESPACE", " "); ("EQUALS", "="); ("WHITESPACE", " ");
243+
("IDENT", "optional")])]
244+
245+
if actual <> expected then
246+
printfn "actual = %A" actual
247+
printfn "expected = %A" expected
248+
actual |> Assert.shouldBeEqualWith expected (sprintf "actual and expected did not match,actual =\n%A\nexpected=\n%A\n" actual expected)

vsintegration/tests/FSharp.Editor.Tests/SemanticClassificationServiceTests.fs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,69 @@ module ``It should still show up as a keyword even if the type parameter is inva
240240
"""
241241

242242
verifyClassificationAtEndOfMarker (sourceText, marker, classificationType)
243+
244+
[<Fact>]
245+
member _.``Optional parameters should be classified correctly``() =
246+
let sourceText =
247+
"""
248+
type TestType() =
249+
member _.memb(?optional:string) = optional
250+
"""
251+
252+
let ranges = getRanges sourceText
253+
254+
// The issue was that QuickParse returning None for '?' caused misclassification
255+
// This test verifies that we get semantic classification data and nothing is
256+
// incorrectly classified as a type or namespace due to the ? prefix
257+
258+
// Look for any identifier "optional" in the classifications
259+
let text = SourceText.From(sourceText)
260+
261+
let optionalRanges =
262+
ranges
263+
|> List.filter (fun item ->
264+
try
265+
// Get the actual text from the source using SourceText
266+
let span = RoslynHelpers.TryFSharpRangeToTextSpan(text, item.Range)
267+
268+
match span with
269+
| ValueSome textSpan ->
270+
let actualText = text.GetSubText(textSpan).ToString()
271+
actualText = "optional"
272+
| ValueNone -> false
273+
with _ ->
274+
false)
275+
276+
// Provide detailed diagnostics if test fails
277+
let allClassifications =
278+
ranges
279+
|> List.map (fun item ->
280+
try
281+
let span = RoslynHelpers.TryFSharpRangeToTextSpan(text, item.Range)
282+
283+
let textStr =
284+
match span with
285+
| ValueSome ts -> text.GetSubText(ts).ToString()
286+
| ValueNone -> "[no span]"
287+
288+
sprintf "Range %A: '%s' (%A)" item.Range textStr item.Type
289+
with ex ->
290+
sprintf "Range %A: [error: %s] (%A)" item.Range ex.Message item.Type)
291+
|> String.concat "\n"
292+
293+
let errorMessage =
294+
sprintf
295+
"Should have classification data for 'optional' identifier.\nFound %d ranges total.\nAll classifications:\n%s"
296+
ranges.Length
297+
allClassifications
298+
299+
Assert.True(optionalRanges.Length > 0, errorMessage)
300+
301+
// Verify that none of the "optional" occurrences are classified as type/namespace
302+
// (which would indicate the bug is present)
303+
for optionalRange in optionalRanges do
304+
let classificationType =
305+
FSharpClassificationTypes.getClassificationTypeName optionalRange.Type
306+
307+
Assert.NotEqual<string>(ClassificationTypeNames.ClassName, classificationType)
308+
Assert.NotEqual<string>(ClassificationTypeNames.NamespaceName, classificationType)

0 commit comments

Comments
 (0)