Add a refactoring between reference and struct anonymous records - #20549
xperiandri wants to merge 13 commits into
Conversation
Ctrl+. on a tuple expression, pattern or annotated tuple type converts it to the other kind and follows the value through the solution: annotations of values, parameters, record fields and function results it flows through, tuple patterns taking it apart, and the arguments and values flowing into it. Uses that cannot be followed (fst, snd, generic collections) are left for the compiler to report. CreateWithCodeAndDependency now tells FCS about both files, so the second file can be type-checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding the second document to a single-file solution left Find All References unable to see either file, so a chain through a function's call sites stopped at the first file. The synthetic project gives both files to the checker the way AddReturnTypeTests and FindReferencesTests set up theirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The synthetic project's checker reads the other file from disk, so checking the refactored document saw the old definition; a struct tuple pattern happens to accept a reference tuple, which hid that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ctrl+. inside an anonymous record expression or an annotated anonymous
record type converts it between {| ... |} and struct {| ... |}, following
the value through the solution like the tuple refactoring. A
copy-and-update has its own form and converts independently of its
source.
The propagation engine becomes independent of the kind of node:
StructConversion holds the shared caret search and the StructKind
description, TupleConversion and AnonymousRecordConversion are the two
kinds, and StructPropagation (was TuplePropagation) takes a kind and
registers the code action for both providers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ Release notes checked
|
…ents with their calls The only argument of a member or constructor is its parameter list, not a tuple, so it is no longer offered. A tuple that is a whole curried argument of a function or member now converts the matching argument at every call, and `struct` no longer runs into a name the parenthesis follows (`f(a, b)`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ents with their calls The only argument of a member or constructor is its parameter list, not a tuple, so it is no longer offered. A tuple that is a whole curried argument of a function or member now converts the matching argument at every call, and `struct` no longer runs into a name the parenthesis follows (`f(a, b)`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s-record-struct-refactoring # Conflicts: # vsintegration/src/FSharp.Editor/Refactor/StructPropagation.fs # vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs
…e of its section Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e of its section Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
Covered casesConverts
Not offered
|
T-Gro
left a comment
There was a problem hiding this comment.
🤖 🕵️ AI review — verify independently.
| useSource | ||
| (List.item index exprs) | ||
| (SyntaxNode.SynExpr tuple :: SyntaxNode.SynExpr argument :: argumentPath) | ||
| | ValueSome(struct (argument, argumentPath)), ValueNone -> this.Retarget useSource argument argumentPath |
There was a problem hiding this comment.
🤖 🕵️ Converting a also converts the independent b argument, producing FS0001 against its explicit reference-record type.
module M
let identity<'T> (x: 'T) = x
let a = identity {| A = 1 |} // Invoke conversion on A = 1.
let b = identity<{| B: int |}> {| B = 2 |}There was a problem hiding this comment.
Fixed in 3021452: propagation stops at a parameter, result or value whose declared type involves a generic type parameter, so converting a leaves b alone. Test: Generic parameter stops the conversion.
| match isStruct, toStruct with | ||
| | true, true | ||
| | false, false -> [] | ||
| | false, true -> [ TextChange(TextSpan((spanOf sourceText m).Start, 0), "struct ") ] |
There was a problem hiding this comment.
🤖 🕵️ Converting this valid adjacent application produces idstruct {| A = 1 |}, causing FS0039.
module M
let x = id{| A = 1 |} // Invoke conversion on A = 1.There was a problem hiding this comment.
Fixed in 3021452: struct inserted before {| gets a leading space after an identifier character (by the lexer's categories) or a closing bracket, using the helper shared with tuples. Test: Struct keyword is separated from a name the record follows (id{| A = 1 |} and a name ending in U+0301).
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
|
🔍 Tooling Safety Check — Affects-Build-Infra, Affects-Design-Time
|
…y and match aliases A parenthesized tuple applied to a method, a constructor or a union case of several fields is an argument list however the call is spaced. Results flow through try/finally and try/with, a match clause that names the value passes it on, and removing `struct` from a type keeps a comment before its parenthesis. `struct` is kept apart from a name ending in any identifier character the lexer accepts, combining marks included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s-record-struct-refactoring The call resolution, trivia skipping and identifier-character helpers move into StructConversion, where both kinds share them; StructKind.IsExpr takes whether an applied expression calls a method. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n anonymous record
A parameter, a result or a value whose declared type involves a generic
type parameter is not followed, so converting one call of `identity` leaves
the others alone. `struct` inserted in front of `{|` gets a space after a
name or closing bracket. Anonymous-record tests cover try, match aliases,
generic parameters and the spacing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Several supported syntax paths can produce invalid or incomplete edits, including typed bindings, typed parameters, match patterns, and primary constructors.
Review effort: Balanced
Findings: 1
Open (3)
What changed in this PR
Adds reference/struct conversion refactorings for anonymous records, building on the stacked tuple conversion infrastructure.
Changes:
- Adds shared syntax conversion and solution-wide value propagation.
- Adds tuple and anonymous-record providers, resources, and localization entries.
- Adds comprehensive refactoring tests and release notes.
| File | Description |
|---|---|
RefactorTestFramework.fs |
Adds multi-file test projects. |
ConvertTupleTests.fs |
Tests tuple conversion behavior. |
ConvertAnonymousRecordTests.fs |
Tests anonymous-record conversion. |
FSharp.Editor.Tests.fsproj |
Includes new tests. |
FSharp.Editor.zh-Hant.xlf |
Adds localized resource entries. |
FSharp.Editor.zh-Hans.xlf |
Adds localized resource entries. |
FSharp.Editor.tr.xlf |
Adds localized resource entries. |
FSharp.Editor.ru.xlf |
Adds localized resource entries. |
FSharp.Editor.pt-BR.xlf |
Adds localized resource entries. |
FSharp.Editor.pl.xlf |
Adds localized resource entries. |
FSharp.Editor.ko.xlf |
Adds localized resource entries. |
FSharp.Editor.ja.xlf |
Adds localized resource entries. |
FSharp.Editor.it.xlf |
Adds localized resource entries. |
FSharp.Editor.fr.xlf |
Adds localized resource entries. |
FSharp.Editor.es.xlf |
Adds localized resource entries. |
FSharp.Editor.de.xlf |
Adds localized resource entries. |
FSharp.Editor.cs.xlf |
Adds localized resource entries. |
TupleConversion.fs |
Implements tuple syntax edits and exclusions. |
StructPropagation.fs |
Propagates conversions through symbols and flow. |
StructConversion.fs |
Provides shared conversion utilities. |
ConvertTuple.fs |
Registers the tuple refactoring. |
ConvertAnonymousRecord.fs |
Registers the anonymous-record refactoring. |
AnonymousRecordConversion.fs |
Implements anonymous-record syntax edits. |
FSharp.Editor.resx |
Adds action titles. |
FSharp.Editor.fsproj |
Includes new implementation files. |
18.vNext.md |
Documents both refactorings. |
| match headPath with | ||
| | SyntaxNode.SynPat(SynPat.LongIdent(argPats = SynArgPats.Pats [ only ])) :: SyntaxNode.SynBinding(SynBinding( | ||
| valData = SynValData(memberFlags = Some _))) :: _ -> isSame only argument | ||
| | _ -> false |
| /// The expression a pattern takes apart: the right-hand side of its binding or the matched expression. | ||
| let rec private tryMatchedExpression (pat: SynPat) (path: SyntaxVisitorPath) = | ||
| match path with | ||
| | SyntaxNode.SynPat(SynPat.Paren _ as paren) :: rest -> tryMatchedExpression paren rest |
| let private caretAt (code: string) (marker: string) = | ||
| code.IndexOf(marker, StringComparison.Ordinal) | ||
|
|
||
| let private textOf (document: Document) = | ||
| (document.GetTextAsync() |> GetTaskResult).ToString() |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



Stacked on #20548.
Offers
Ctrl+.to switch between{| … |}andstruct {| … |}, following the value the same way as the tuple refactoring. A copy-and-update{| r with … |}has its own form, so it converts independently ofr.🤖 Generated with Claude Code