From c7ef8157498f2a1e48155ab11bf1dbc8b902518d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 19 Mar 2026 14:29:26 +0000 Subject: [PATCH 1/4] 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> --- .../CodeFixes/GenerateAnonRecordStub.fs | 111 ++++++++++++++++++ .../CodeFixes/GenerateAnonRecordStub.fsi | 6 + .../LspServers/AdaptiveServerState.fs | 3 +- .../GenerateAnonRecordStubTests.fs | 39 ++++++ .../CodeFixTests/Tests.fs | 3 +- 5 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 src/FsAutoComplete/CodeFixes/GenerateAnonRecordStub.fs create mode 100644 src/FsAutoComplete/CodeFixes/GenerateAnonRecordStub.fsi create mode 100644 test/FsAutoComplete.Tests.Lsp/CodeFixTests/GenerateAnonRecordStubTests.fs diff --git a/src/FsAutoComplete/CodeFixes/GenerateAnonRecordStub.fs b/src/FsAutoComplete/CodeFixes/GenerateAnonRecordStub.fs new file mode 100644 index 000000000..cc193b02e --- /dev/null +++ b/src/FsAutoComplete/CodeFixes/GenerateAnonRecordStub.fs @@ -0,0 +1,111 @@ +module FsAutoComplete.CodeFix.GenerateAnonRecordStub + +open System.Text.RegularExpressions +open FSharp.Compiler.Syntax +open FSharp.Compiler.Text +open FsToolkit.ErrorHandling +open FsAutoComplete.CodeFix.Types +open Ionide.LanguageServerProtocol.Types +open FsAutoComplete +open FsAutoComplete.LspHelpers + +let title = "Add missing anonymous record fields" + +/// Parse field names out of a bracket list section of the FS3578 message, +/// e.g. the content `"A"; "B"` from inside `["A"; "B"]`. +let private parseFieldNames (bracketContent: string) = + Regex.Matches(bracketContent, "\"([^\"]+)\"") + |> Seq.cast + |> Seq.map (fun m -> m.Groups.[1].Value) + |> Set.ofSeq + +// FS3578 diagnostic message format: +// Two anonymous record types have mismatched sets of field names '["A"; "B"]' and '["A"]' +let private msgPattern = + Regex(@"'\[([^\]]*)\]' and '\[([^\]]*)\]'", RegexOptions.Compiled) + +/// A code fix for FS3578: when an anonymous record literal is missing fields required by its +/// expected type, inserts stub bindings `fieldName = failwith "Not Implemented"` for each +/// missing field before the closing `|}`. +let fix (getParseResultsForFile: GetParseResultsForFile) : CodeFix = + Run.ifDiagnosticByCode (Set.ofList [ "3578" ]) (fun diagnostic codeActionParams -> + asyncResult { + let fileName = codeActionParams.TextDocument.GetFilePath() |> Utils.normalizePath + let fcsPos = protocolPosToPos diagnostic.Range.Start + let! (parseAndCheck, _, _sourceText) = getParseResultsForFile fileName fcsPos + + let m = msgPattern.Match(diagnostic.Message) + + if not m.Success then + return [] + else + + let set1 = parseFieldNames m.Groups.[1].Value + let set2 = parseFieldNames m.Groups.[2].Value + + // Find the innermost anonymous record expression that contains the diagnostic start position. + let anonRecdOpt = + (fcsPos, parseAndCheck.GetParseResults.ParseTree) + ||> ParsedInput.tryPick (fun _path node -> + match node with + | SyntaxNode.SynExpr(SynExpr.AnonRecd(recordFields = fields; range = r)) when + Range.rangeContainsPos r fcsPos + -> + let currentNames = + fields + |> List.map (fun (synLongIdent, _, _) -> (synLongIdent.LongIdent |> List.last).idText) + |> Set.ofList + + Some(r, currentNames) + | _ -> None) + + match anonRecdOpt with + | None -> return [] + | Some(r, currentFields) -> + + // Determine which fields are missing: present in one of the two error sets but absent + // from the current anonymous record literal. + let missingFromSet1 = set1 - currentFields + let missingFromSet2 = set2 - currentFields + + let missingFields = + if missingFromSet1.IsEmpty then missingFromSet2 + elif missingFromSet2.IsEmpty then missingFromSet1 + else Set.union missingFromSet1 missingFromSet2 + + if missingFields.IsEmpty then + return [] + else + + // Build "; fieldName = failwith "Not Implemented"" stubs for each missing field. + let fieldStubs = + missingFields + |> Set.toList // Set.toList is sorted, giving a deterministic field order + |> List.map (fun f -> $"{f} = failwith \"Not Implemented\"") + |> String.concat "; " + + // Prefix with "; " if there are already fields in the expression; with a space if the + // record body is empty. + let insertText = + if currentFields.IsEmpty then + $" {fieldStubs} " + else + $"; {fieldStubs} " + + // The anonymous record range ends just after '}' in '|}', so '|' is at EndColumn − 2. + let insertPos = Position.mkPos r.EndLine (r.EndColumn - 2) + let insertLspPos = fcsPosToLsp insertPos + + let insertRange = + { Start = insertLspPos + End = insertLspPos } + + return + [ { Title = title + File = codeActionParams.TextDocument + SourceDiagnostic = Some diagnostic + Edits = + [| { Range = insertRange + NewText = insertText } |] + Kind = FixKind.Fix } ] + }) diff --git a/src/FsAutoComplete/CodeFixes/GenerateAnonRecordStub.fsi b/src/FsAutoComplete/CodeFixes/GenerateAnonRecordStub.fsi new file mode 100644 index 000000000..55d3f4763 --- /dev/null +++ b/src/FsAutoComplete/CodeFixes/GenerateAnonRecordStub.fsi @@ -0,0 +1,6 @@ +module FsAutoComplete.CodeFix.GenerateAnonRecordStub + +open FsAutoComplete.CodeFix.Types + +val title: string +val fix: getParseResultsForFile: GetParseResultsForFile -> CodeFix diff --git a/src/FsAutoComplete/LspServers/AdaptiveServerState.fs b/src/FsAutoComplete/LspServers/AdaptiveServerState.fs index 3284c83c4..ddebb11f0 100644 --- a/src/FsAutoComplete/LspServers/AdaptiveServerState.fs +++ b/src/FsAutoComplete/LspServers/AdaptiveServerState.fs @@ -2388,7 +2388,8 @@ type AdaptiveState IgnoreExpression.fix tryGetParseAndCheckResultsForFile ExprTypeMismatch.fix tryGetParseAndCheckResultsForFile AddMissingSeq.fix tryGetParseAndCheckResultsForFile - IntroduceMissingBinding.fix tryGetParseAndCheckResultsForFile getLineText |]) + IntroduceMissingBinding.fix tryGetParseAndCheckResultsForFile getLineText + GenerateAnonRecordStub.fix tryGetParseAndCheckResultsForFile |]) let forgetDocument (uri: DocumentUri) = async { diff --git a/test/FsAutoComplete.Tests.Lsp/CodeFixTests/GenerateAnonRecordStubTests.fs b/test/FsAutoComplete.Tests.Lsp/CodeFixTests/GenerateAnonRecordStubTests.fs new file mode 100644 index 000000000..92e067254 --- /dev/null +++ b/test/FsAutoComplete.Tests.Lsp/CodeFixTests/GenerateAnonRecordStubTests.fs @@ -0,0 +1,39 @@ +module private FsAutoComplete.Tests.CodeFixTests.GenerateAnonRecordStubTests + +open Expecto +open Helpers +open Utils.ServerTests +open Utils.CursorbasedTests +open FsAutoComplete.CodeFix + +let tests state = + serverTestList (nameof GenerateAnonRecordStub) state defaultConfigDto None (fun server -> + [ testCaseAsync "add one missing field to anonymous record" + <| CodeFix.check + server + """let f (x: {| A: int; B: string |}) = x +let y = f {| A$0 = 1 |}""" + Diagnostics.acceptAll + (CodeFix.withTitle GenerateAnonRecordStub.title) + """let f (x: {| A: int; B: string |}) = x +let y = f {| A = 1; B = failwith "Not Implemented" |}""" + + testCaseAsync "add multiple missing fields to anonymous record" + <| CodeFix.check + server + """let f (x: {| A: int; B: string; C: bool |}) = x +let y = f {| A$0 = 1 |}""" + Diagnostics.acceptAll + (CodeFix.withTitle GenerateAnonRecordStub.title) + """let f (x: {| A: int; B: string; C: bool |}) = x +let y = f {| A = 1; B = failwith "Not Implemented"; C = failwith "Not Implemented" |}""" + + testCaseAsync "add missing field to empty anonymous record" + <| CodeFix.check + server + """let f (x: {| A: int |}) = x +let y = f {|$0|}""" + Diagnostics.acceptAll + (CodeFix.withTitle GenerateAnonRecordStub.title) + """let f (x: {| A: int |}) = x +let y = f {| A = failwith "Not Implemented" |}""" ]) diff --git a/test/FsAutoComplete.Tests.Lsp/CodeFixTests/Tests.fs b/test/FsAutoComplete.Tests.Lsp/CodeFixTests/Tests.fs index ba6a8bf8d..1f03e238b 100644 --- a/test/FsAutoComplete.Tests.Lsp/CodeFixTests/Tests.fs +++ b/test/FsAutoComplete.Tests.Lsp/CodeFixTests/Tests.fs @@ -3735,4 +3735,5 @@ let tests textFactory state = ExprTypeMismatchTests.tests state AddMissingWildcardOperatorTests.tests state AddMissingSeqTests.tests state - IntroduceMissingBindingTests.tests state ] + IntroduceMissingBindingTests.tests state + GenerateAnonRecordStubTests.tests state ] From aa8e196c559e554d74aa4b7472b0888776ccc52a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 19 Mar 2026 14:33:24 +0000 Subject: [PATCH 2/4] ci: trigger checks From 0cda2704b2f3c8f005a77e1238fecf3e998cc24a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:03:30 +0000 Subject: [PATCH 3/4] 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> --- .../CodeFixes/GenerateAnonRecordStub.fs | 118 ++++++++++++------ 1 file changed, 79 insertions(+), 39 deletions(-) diff --git a/src/FsAutoComplete/CodeFixes/GenerateAnonRecordStub.fs b/src/FsAutoComplete/CodeFixes/GenerateAnonRecordStub.fs index cc193b02e..fc3d13d35 100644 --- a/src/FsAutoComplete/CodeFixes/GenerateAnonRecordStub.fs +++ b/src/FsAutoComplete/CodeFixes/GenerateAnonRecordStub.fs @@ -11,37 +11,84 @@ open FsAutoComplete.LspHelpers let title = "Add missing anonymous record fields" -/// Parse field names out of a bracket list section of the FS3578 message, -/// e.g. the content `"A"; "B"` from inside `["A"; "B"]`. -let private parseFieldNames (bracketContent: string) = - Regex.Matches(bracketContent, "\"([^\"]+)\"") - |> Seq.cast - |> Seq.map (fun m -> m.Groups.[1].Value) - |> Set.ofSeq - -// FS3578 diagnostic message format: -// Two anonymous record types have mismatched sets of field names '["A"; "B"]' and '["A"]' -let private msgPattern = - Regex(@"'\[([^\]]*)\]' and '\[([^\]]*)\]'", RegexOptions.Compiled) - -/// A code fix for FS3578: when an anonymous record literal is missing fields required by its -/// expected type, inserts stub bindings `fieldName = failwith "Not Implemented"` for each -/// missing field before the closing `|}`. +// FS0001 message patterns for anonymous record field mismatches (current F# compiler 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]." + +/// Extract missing field names from an FS0001 anonymous-record diagnostic message. +/// Returns `Some fields` when the message describes fields that should be added; `None` otherwise. +let private tryParseMissingFields (message: string) : string list option = + // Case 1: single missing field – "This anonymous record is missing field 'X'." + let m1 = Regex.Match(message, @"missing field '([^']+)'") + + if m1.Success then + Some [ m1.Groups.[1].Value ] + else + // Case 2: multiple missing fields in quotes – "This anonymous record is missing fields 'X', 'Y'." + // Use a more specific pattern that requires quoted field names. + let m2 = Regex.Match(message, @"missing fields '([^']+)'") + + if m2.Success then + // The full field list group includes all quoted names; extract each individually. + let fullMatch = Regex.Match(message, @"missing fields (.+?)\.") + + let fieldList = + if fullMatch.Success then + fullMatch.Groups.[1].Value + else + m2.Value + + let fields = + Regex.Matches(fieldList, "'([^']+)'") + |> Seq.cast + |> Seq.map (fun m -> m.Groups.[1].Value) + |> Seq.toList + + if fields.IsEmpty then None else Some fields + else + // Case 3: "does not exactly match" – extract from "Add the missing fields [X; Y]" + let m3 = Regex.Match(message, @"Add the missing fields \[([^\]]+)\]") + + if m3.Success then + let fieldsStr = m3.Groups.[1].Value + + let fields = + fieldsStr.Split(';') + |> Array.map (fun s -> s.Trim()) + |> Array.filter (fun s -> s.Length > 0) + |> Array.toList + + if fields.IsEmpty then None else Some fields + else + None + +/// A code fix for FS0001 anonymous-record type mismatches: when an anonymous record literal is +/// missing fields required by its expected type, inserts stub bindings +/// `fieldName = failwith "Not Implemented"` for each missing field before the closing `|}`. let fix (getParseResultsForFile: GetParseResultsForFile) : CodeFix = - Run.ifDiagnosticByCode (Set.ofList [ "3578" ]) (fun diagnostic codeActionParams -> + Run.ifDiagnosticByCode (Set.ofList [ "1" ]) (fun diagnostic codeActionParams -> asyncResult { - let fileName = codeActionParams.TextDocument.GetFilePath() |> Utils.normalizePath - let fcsPos = protocolPosToPos diagnostic.Range.Start - let! (parseAndCheck, _, _sourceText) = getParseResultsForFile fileName fcsPos - - let m = msgPattern.Match(diagnostic.Message) - - if not m.Success then + // Only act on anonymous-record field-mismatch errors + do! + Result.guard + (fun _ -> + diagnostic.Message.Contains("anonymous record") + && diagnostic.Message.Contains("missing")) + "Diagnostic is not an anonymous record missing-field error" + + let missingFields = + match tryParseMissingFields diagnostic.Message with + | Some fields -> fields + | None -> [] + + if missingFields.IsEmpty then return [] else - let set1 = parseFieldNames m.Groups.[1].Value - let set2 = parseFieldNames m.Groups.[2].Value + let fileName = codeActionParams.TextDocument.GetFilePath() |> Utils.normalizePath + let fcsPos = protocolPosToPos diagnostic.Range.Start + let! (parseAndCheck, _, _sourceText) = getParseResultsForFile fileName fcsPos // Find the innermost anonymous record expression that contains the diagnostic start position. let anonRecdOpt = @@ -63,24 +110,17 @@ let fix (getParseResultsForFile: GetParseResultsForFile) : CodeFix = | None -> return [] | Some(r, currentFields) -> - // Determine which fields are missing: present in one of the two error sets but absent - // from the current anonymous record literal. - let missingFromSet1 = set1 - currentFields - let missingFromSet2 = set2 - currentFields - - let missingFields = - if missingFromSet1.IsEmpty then missingFromSet2 - elif missingFromSet2.IsEmpty then missingFromSet1 - else Set.union missingFromSet1 missingFromSet2 + // Exclude any fields that are already present (defensive: should already be absent). + let fieldsToAdd = + missingFields |> List.filter (fun f -> not (Set.contains f currentFields)) - if missingFields.IsEmpty then + if fieldsToAdd.IsEmpty then return [] else - // Build "; fieldName = failwith "Not Implemented"" stubs for each missing field. + // Build "fieldName = failwith "Not Implemented"" stubs for each missing field. let fieldStubs = - missingFields - |> Set.toList // Set.toList is sorted, giving a deterministic field order + fieldsToAdd |> List.map (fun f -> $"{f} = failwith \"Not Implemented\"") |> String.concat "; " From 2a89c29d409b651d52bca0bb5f3f0a356e7e5988 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:18:27 +0000 Subject: [PATCH 4/4] 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> --- .../CodeFixes/GenerateAnonRecordStub.fs | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/FsAutoComplete/CodeFixes/GenerateAnonRecordStub.fs b/src/FsAutoComplete/CodeFixes/GenerateAnonRecordStub.fs index fc3d13d35..b934efe27 100644 --- a/src/FsAutoComplete/CodeFixes/GenerateAnonRecordStub.fs +++ b/src/FsAutoComplete/CodeFixes/GenerateAnonRecordStub.fs @@ -88,7 +88,7 @@ let fix (getParseResultsForFile: GetParseResultsForFile) : CodeFix = let fileName = codeActionParams.TextDocument.GetFilePath() |> Utils.normalizePath let fcsPos = protocolPosToPos diagnostic.Range.Start - let! (parseAndCheck, _, _sourceText) = getParseResultsForFile fileName fcsPos + let! (parseAndCheck, _, sourceText) = getParseResultsForFile fileName fcsPos // Find the innermost anonymous record expression that contains the diagnostic start position. let anonRecdOpt = @@ -124,21 +124,34 @@ let fix (getParseResultsForFile: GetParseResultsForFile) : CodeFix = |> List.map (fun f -> $"{f} = failwith \"Not Implemented\"") |> String.concat "; " - // Prefix with "; " if there are already fields in the expression; with a space if the - // record body is empty. - let insertText = - if currentFields.IsEmpty then - $" {fieldStubs} " - else - $"; {fieldStubs} " - // The anonymous record range ends just after '}' in '|}', so '|' is at EndColumn − 2. - let insertPos = Position.mkPos r.EndLine (r.EndColumn - 2) - let insertLspPos = fcsPosToLsp insertPos + let endBarCol = r.EndColumn - 2 - let insertRange = - { Start = insertLspPos - End = insertLspPos } + // Build the insert text and range depending on whether the record already has fields. + // For non-empty records, trailing whitespace before `|}` must be consumed by the edit + // to avoid producing `{| A = 1 ; B = ... |}` (space before the semicolon). + let insertText, insertRange = + if currentFields.IsEmpty then + // Empty record: simple zero-width insert before `|}`. + let lspPos = fcsPosToLsp (Position.mkPos r.EndLine endBarCol) + $" {fieldStubs} ", { Start = lspPos; End = lspPos } + else + // Non-empty record: replace any trailing whitespace before `|}` so the result + // is e.g. `{| A = 1; B = failwith "Not Implemented" |}` (no stray space). + let insertStartCol = + match sourceText.GetLine(Position.mkPos r.EndLine 0) with + | None -> endBarCol + | Some line -> + let mutable col = endBarCol - 1 + + while col >= 0 && col < line.Length && System.Char.IsWhiteSpace(line.[col]) do + col <- col - 1 + + col + 1 + + let lspStart = fcsPosToLsp (Position.mkPos r.EndLine insertStartCol) + let lspEnd = fcsPosToLsp (Position.mkPos r.EndLine endBarCol) + $"; {fieldStubs} ", { Start = lspStart; End = lspEnd } return [ { Title = title