Skip to content

Commit 452369c

Browse files
[Repo Assist] feat: add GenerateAnonRecordStub code fix for FS3578 (closes #455) (#1510)
* feat: add GenerateAnonRecordStub code fix for FS3578 (closes #455) When an anonymous record literal is missing fields required by its expected type, offer a code action that inserts stub bindings `fieldName = failwith "Not Implemented"` for each missing field before the closing `|}`. The fix is triggered by FS3578 ('Two anonymous record types have mismatched sets of field names'). It parses the two field-name lists from the diagnostic message, finds the anonymous record expression in the parse tree, and computes which fields are absent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: trigger checks * fix: update GenerateAnonRecordStub to use FS0001 with current compiler message formats The code fix was using diagnostic code 3578 with an old message format ('Two anonymous record types have mismatched sets of field names ["A"; "B"] and ["A"]') that the current F# compiler no longer produces. Current F# compiler (>= 8) emits FS0001 for anonymous record field mismatches with these message formats: - "This anonymous record is missing field 'B'." - "This anonymous record is missing fields 'B', 'C'." - "This anonymous record does not exactly match the expected shape. Add the missing fields [B; C] and remove the extra fields [D; E]." Updated the code fix to: 1. Trigger on FS0001 with a message guard for 'anonymous record' + 'missing' 2. Parse missing field names from the three new message formats 3. Preserve the existing AST-based insertion logic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: GenerateAnonRecordStub - trim trailing whitespace before inserting fields When an anonymous record has existing fields (e.g. `{| A = 1 |}`), the previous code inserted the fix text at the column of `|` in `|}`, but there was already a trailing space before `|}`. This produced `{| A = 1 ; B = failwith "Not Implemented" |}` (stray space before `;'}). Fix: for non-empty records, scan backward from the `|` position using the source text to find the end of actual content, then use a replace range that covers the trailing whitespace. The empty-record case is unchanged (zero-width insert). All 3 GenerateAnonRecordStub tests now pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 5bc4d9e commit 452369c

5 files changed

Lines changed: 213 additions & 2 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
module FsAutoComplete.CodeFix.GenerateAnonRecordStub
2+
3+
open System.Text.RegularExpressions
4+
open FSharp.Compiler.Syntax
5+
open FSharp.Compiler.Text
6+
open FsToolkit.ErrorHandling
7+
open FsAutoComplete.CodeFix.Types
8+
open Ionide.LanguageServerProtocol.Types
9+
open FsAutoComplete
10+
open FsAutoComplete.LspHelpers
11+
12+
let title = "Add missing anonymous record fields"
13+
14+
// FS0001 message patterns for anonymous record field mismatches (current F# compiler formats):
15+
// "This anonymous record is missing field 'B'."
16+
// "This anonymous record is missing fields 'B', 'C'."
17+
// "This anonymous record does not exactly match the expected shape. Add the missing fields [B; C] and remove the extra fields [D; E]."
18+
19+
/// Extract missing field names from an FS0001 anonymous-record diagnostic message.
20+
/// Returns `Some fields` when the message describes fields that should be added; `None` otherwise.
21+
let private tryParseMissingFields (message: string) : string list option =
22+
// Case 1: single missing field – "This anonymous record is missing field 'X'."
23+
let m1 = Regex.Match(message, @"missing field '([^']+)'")
24+
25+
if m1.Success then
26+
Some [ m1.Groups.[1].Value ]
27+
else
28+
// Case 2: multiple missing fields in quotes – "This anonymous record is missing fields 'X', 'Y'."
29+
// Use a more specific pattern that requires quoted field names.
30+
let m2 = Regex.Match(message, @"missing fields '([^']+)'")
31+
32+
if m2.Success then
33+
// The full field list group includes all quoted names; extract each individually.
34+
let fullMatch = Regex.Match(message, @"missing fields (.+?)\.")
35+
36+
let fieldList =
37+
if fullMatch.Success then
38+
fullMatch.Groups.[1].Value
39+
else
40+
m2.Value
41+
42+
let fields =
43+
Regex.Matches(fieldList, "'([^']+)'")
44+
|> Seq.cast<Match>
45+
|> Seq.map (fun m -> m.Groups.[1].Value)
46+
|> Seq.toList
47+
48+
if fields.IsEmpty then None else Some fields
49+
else
50+
// Case 3: "does not exactly match" – extract from "Add the missing fields [X; Y]"
51+
let m3 = Regex.Match(message, @"Add the missing fields \[([^\]]+)\]")
52+
53+
if m3.Success then
54+
let fieldsStr = m3.Groups.[1].Value
55+
56+
let fields =
57+
fieldsStr.Split(';')
58+
|> Array.map (fun s -> s.Trim())
59+
|> Array.filter (fun s -> s.Length > 0)
60+
|> Array.toList
61+
62+
if fields.IsEmpty then None else Some fields
63+
else
64+
None
65+
66+
/// A code fix for FS0001 anonymous-record type mismatches: when an anonymous record literal is
67+
/// missing fields required by its expected type, inserts stub bindings
68+
/// `fieldName = failwith "Not Implemented"` for each missing field before the closing `|}`.
69+
let fix (getParseResultsForFile: GetParseResultsForFile) : CodeFix =
70+
Run.ifDiagnosticByCode (Set.ofList [ "1" ]) (fun diagnostic codeActionParams ->
71+
asyncResult {
72+
// Only act on anonymous-record field-mismatch errors
73+
do!
74+
Result.guard
75+
(fun _ ->
76+
diagnostic.Message.Contains("anonymous record")
77+
&& diagnostic.Message.Contains("missing"))
78+
"Diagnostic is not an anonymous record missing-field error"
79+
80+
let missingFields =
81+
match tryParseMissingFields diagnostic.Message with
82+
| Some fields -> fields
83+
| None -> []
84+
85+
if missingFields.IsEmpty then
86+
return []
87+
else
88+
89+
let fileName = codeActionParams.TextDocument.GetFilePath() |> Utils.normalizePath
90+
let fcsPos = protocolPosToPos diagnostic.Range.Start
91+
let! (parseAndCheck, _, sourceText) = getParseResultsForFile fileName fcsPos
92+
93+
// Find the innermost anonymous record expression that contains the diagnostic start position.
94+
let anonRecdOpt =
95+
(fcsPos, parseAndCheck.GetParseResults.ParseTree)
96+
||> ParsedInput.tryPick (fun _path node ->
97+
match node with
98+
| SyntaxNode.SynExpr(SynExpr.AnonRecd(recordFields = fields; range = r)) when
99+
Range.rangeContainsPos r fcsPos
100+
->
101+
let currentNames =
102+
fields
103+
|> List.map (fun (synLongIdent, _, _) -> (synLongIdent.LongIdent |> List.last).idText)
104+
|> Set.ofList
105+
106+
Some(r, currentNames)
107+
| _ -> None)
108+
109+
match anonRecdOpt with
110+
| None -> return []
111+
| Some(r, currentFields) ->
112+
113+
// Exclude any fields that are already present (defensive: should already be absent).
114+
let fieldsToAdd =
115+
missingFields |> List.filter (fun f -> not (Set.contains f currentFields))
116+
117+
if fieldsToAdd.IsEmpty then
118+
return []
119+
else
120+
121+
// Build "fieldName = failwith "Not Implemented"" stubs for each missing field.
122+
let fieldStubs =
123+
fieldsToAdd
124+
|> List.map (fun f -> $"{f} = failwith \"Not Implemented\"")
125+
|> String.concat "; "
126+
127+
// The anonymous record range ends just after '}' in '|}', so '|' is at EndColumn − 2.
128+
let endBarCol = r.EndColumn - 2
129+
130+
// Build the insert text and range depending on whether the record already has fields.
131+
// For non-empty records, trailing whitespace before `|}` must be consumed by the edit
132+
// to avoid producing `{| A = 1 ; B = ... |}` (space before the semicolon).
133+
let insertText, insertRange =
134+
if currentFields.IsEmpty then
135+
// Empty record: simple zero-width insert before `|}`.
136+
let lspPos = fcsPosToLsp (Position.mkPos r.EndLine endBarCol)
137+
$" {fieldStubs} ", { Start = lspPos; End = lspPos }
138+
else
139+
// Non-empty record: replace any trailing whitespace before `|}` so the result
140+
// is e.g. `{| A = 1; B = failwith "Not Implemented" |}` (no stray space).
141+
let insertStartCol =
142+
match sourceText.GetLine(Position.mkPos r.EndLine 0) with
143+
| None -> endBarCol
144+
| Some line ->
145+
let mutable col = endBarCol - 1
146+
147+
while col >= 0 && col < line.Length && System.Char.IsWhiteSpace(line.[col]) do
148+
col <- col - 1
149+
150+
col + 1
151+
152+
let lspStart = fcsPosToLsp (Position.mkPos r.EndLine insertStartCol)
153+
let lspEnd = fcsPosToLsp (Position.mkPos r.EndLine endBarCol)
154+
$"; {fieldStubs} ", { Start = lspStart; End = lspEnd }
155+
156+
return
157+
[ { Title = title
158+
File = codeActionParams.TextDocument
159+
SourceDiagnostic = Some diagnostic
160+
Edits =
161+
[| { Range = insertRange
162+
NewText = insertText } |]
163+
Kind = FixKind.Fix } ]
164+
})
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module FsAutoComplete.CodeFix.GenerateAnonRecordStub
2+
3+
open FsAutoComplete.CodeFix.Types
4+
5+
val title: string
6+
val fix: getParseResultsForFile: GetParseResultsForFile -> CodeFix

src/FsAutoComplete/LspServers/AdaptiveServerState.fs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2388,7 +2388,8 @@ type AdaptiveState
23882388
IgnoreExpression.fix tryGetParseAndCheckResultsForFile
23892389
ExprTypeMismatch.fix tryGetParseAndCheckResultsForFile
23902390
AddMissingSeq.fix tryGetParseAndCheckResultsForFile
2391-
IntroduceMissingBinding.fix tryGetParseAndCheckResultsForFile getLineText |])
2391+
IntroduceMissingBinding.fix tryGetParseAndCheckResultsForFile getLineText
2392+
GenerateAnonRecordStub.fix tryGetParseAndCheckResultsForFile |])
23922393

23932394
let forgetDocument (uri: DocumentUri) =
23942395
async {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
module private FsAutoComplete.Tests.CodeFixTests.GenerateAnonRecordStubTests
2+
3+
open Expecto
4+
open Helpers
5+
open Utils.ServerTests
6+
open Utils.CursorbasedTests
7+
open FsAutoComplete.CodeFix
8+
9+
let tests state =
10+
serverTestList (nameof GenerateAnonRecordStub) state defaultConfigDto None (fun server ->
11+
[ testCaseAsync "add one missing field to anonymous record"
12+
<| CodeFix.check
13+
server
14+
"""let f (x: {| A: int; B: string |}) = x
15+
let y = f {| A$0 = 1 |}"""
16+
Diagnostics.acceptAll
17+
(CodeFix.withTitle GenerateAnonRecordStub.title)
18+
"""let f (x: {| A: int; B: string |}) = x
19+
let y = f {| A = 1; B = failwith "Not Implemented" |}"""
20+
21+
testCaseAsync "add multiple missing fields to anonymous record"
22+
<| CodeFix.check
23+
server
24+
"""let f (x: {| A: int; B: string; C: bool |}) = x
25+
let y = f {| A$0 = 1 |}"""
26+
Diagnostics.acceptAll
27+
(CodeFix.withTitle GenerateAnonRecordStub.title)
28+
"""let f (x: {| A: int; B: string; C: bool |}) = x
29+
let y = f {| A = 1; B = failwith "Not Implemented"; C = failwith "Not Implemented" |}"""
30+
31+
testCaseAsync "add missing field to empty anonymous record"
32+
<| CodeFix.check
33+
server
34+
"""let f (x: {| A: int |}) = x
35+
let y = f {|$0|}"""
36+
Diagnostics.acceptAll
37+
(CodeFix.withTitle GenerateAnonRecordStub.title)
38+
"""let f (x: {| A: int |}) = x
39+
let y = f {| A = failwith "Not Implemented" |}""" ])

test/FsAutoComplete.Tests.Lsp/CodeFixTests/Tests.fs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3735,4 +3735,5 @@ let tests textFactory state =
37353735
ExprTypeMismatchTests.tests state
37363736
AddMissingWildcardOperatorTests.tests state
37373737
AddMissingSeqTests.tests state
3738-
IntroduceMissingBindingTests.tests state ]
3738+
IntroduceMissingBindingTests.tests state
3739+
GenerateAnonRecordStubTests.tests state ]

0 commit comments

Comments
 (0)