Skip to content

Refactoring between reference and struct anonymous records - #20549

Open
xperiandri wants to merge 8 commits into
dotnet:mainfrom
xperiandri:feature/anonymous-record-struct-refactoring
Open

xperiandri wants to merge 8 commits into
dotnet:mainfrom
xperiandri:feature/anonymous-record-struct-refactoring

Conversation

@xperiandri

@xperiandri xperiandri commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Description

Stacked on #20548: this branch contains its commits; the anonymous record commits on top of them (and the merge bringing #20548's latest changes) belong to this PR.

Adds an editor refactoring (Ctrl+. inside an anonymous record expression or an annotated anonymous record type) that converts it between {| … |} and struct {| … |}, following its value through the solution the same way #20548 does for tuples:

let greet (p: {| Name: string |}) = "Hi " + p.Name

let ada = {| Name = "Ada" |}
let greetings = [ greet ada; greet {| Name = "Bob" |} ]

let greet (p: struct {| Name: string |}) = "Hi " + p.Name

let ada = struct {| Name = "Ada" |}
let greetings = [ greet ada; greet struct {| Name = "Bob" |} ]

What differs from tuples. Both forms differ only by struct in front of {|, so every change is an insertion or a removal of that keyword; there are no anonymous record patterns, and reading a field (r.Name) does not depend on the form. A copy-and-update {| r with … |} has its own form regardless of r's (the checker does not carry it over), so it converts independently: converting r leaves the copy alone, and converting the copy leaves r alone.

Engine. The propagation from #20548 is made independent of the kind of node:

  • StructConversion.fs — the shared caret search and a StructKind record describing how a kind is recognized in expressions, patterns and types and how its form changes;
  • TupleConversion.fs and AnonymousRecordConversion.fs — the two kinds;
  • StructPropagation.fs (was TuplePropagation.fs) — the worklist over values, parameters, record fields and results, taking a StructKind, plus registerConversion, which both providers now call.

The tuple refactoring's behaviour and tests are unchanged.

Not offered inside quotations or in a file with a paired .fsi.

Covered cases

Converts

  • An anonymous record used nowhere else converts on its own, and back: printfn "%A" {| A = 1 |}printfn "%A" struct {| A = 1 |}; a type argument list<{| A: int |}>list<struct {| A: int |}>; a multi-line {||}struct {||}
  • A value converts together with the annotations it flows into, and field access is left as is, both ways: let person = {| Name = "Ada"; Age = 36 |} + let copy: {| Name: string; Age: int |} = person + person.Agestruct {| Name = "Ada"; Age = 36 |} + let copy: struct {| Name: string; Age: int |} = person
  • A parameter annotation converts every argument passed to it, both literals and values, both ways: let greet (p: {| Name: string |}) + let ada = {| Name = "Ada" |} + greet ada; greet {| Name = "Bob" |}let greet (p: struct {| Name: string |}) + let ada = struct {| Name = "Ada" |} + greet struct {| Name = "Bob" |}
  • A return type converts the result and the annotated values it is bound to, both ways: let origin () : {| X: int; Y: int |} = {| X = 0; Y = 0 |} + let start: {| X: int; Y: int |} = origin ()struct {| X: int; Y: int |} in all three places
  • A record field converts only its own values and the annotations that read it, both ways: { Info: {| Age: int |}; Tags: {| Count: int |} } + { Info = {| Age = 30 |}; Tags = {| Count = 0 |} } + let info: {| Age: int |} = person.Info → only Info becomes struct {| Age: int |}, struct {| Age = 30 |} and let info: struct {| Age: int |}; Tags is not changed
  • A copy-and-update expression converts separately from its source, both ways: the caret on let point = {| X = 1 |} gives struct {| X = 1 |} and leaves {| point with Y = 2 |} alone; the caret on with gives struct {| point with Y = 2 |} and leaves point alone
  • A value declared in a second file converts in that file: the caret on let name (p: {| Name: string |}) + name A.person in module B also rewrites let person = {| Name = "Ada" |} in A to struct {| Name = "Ada" |}; the result is checked as a new project and has no errors
  • The action titles name the target kind: Convert to struct anonymous record and Convert to reference anonymous record
  • Every result is re-type-checked and asserted to have no errors

Not offered

  • The caret is not on an anonymous record: let x = 1
  • A normal record: type Point = { X: int } + let point = { X = 1 }
  • An anonymous record inside a quotation: <@ {| A = 1 |} @>

Checklist

  • Test cases added
  • Performance benchmarks added in case of performance changes (not applicable)
  • Release notes entry updated

🤖 Generated with Claude Code

xperiandri and others added 4 commits September 14, 2026 16:36
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>
@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

❗ Release notes required

You can open this PR in browser to add release notes: open in github.dev


✅ Found changes and release notes in following paths:

Change path Release notes path Description
`vsintegration/src` docs/release-notes/.VisualStudio/18.vNext.md

xperiandri and others added 4 commits September 14, 2026 19:56
…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>
@xperiandri
xperiandri marked this pull request as ready for review September 14, 2026 19:40
@xperiandri
xperiandri requested a review from a team as a code owner September 14, 2026 19:40
@github-actions github-actions Bot added the ⚠️ Affects-Design-Time Tooling check: PR touches type providers or dependency manager label Sep 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Tooling Safety Check — Affects-Design-Time
Affects-Design-Time: Modifies Visual Studio code executed while projects are open.

Generated by PR Tooling Safety Check · gpt56 1.8M ·

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⚠️ Affects-Design-Time Tooling check: PR touches type providers or dependency manager

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

1 participant