diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md
index ba03f663967..d2a8056e963 100644
--- a/docs/release-notes/.VisualStudio/18.vNext.md
+++ b/docs/release-notes/.VisualStudio/18.vNext.md
@@ -1,6 +1,8 @@
### Added
+* Refactoring to convert an anonymous record between `{| … |}` and `struct {| … |}`, following its value through the solution the same way. A copy-and-update `{| r with … |}` has its own form and converts independently of `r`. ([PR #20549](https://github.com/dotnet/fsharp/pull/20549))
* Code-fixes for FS3888 (compiler-semantic attribute on the `.fs` but not the `.fsi`): copy the attribute into the `.fsi`, or remove it from the `.fs`. ([Issue #19560](https://github.com/dotnet/fsharp/issues/19560), [PR #19880](https://github.com/dotnet/fsharp/pull/19880))
+* Refactoring to convert a tuple between a reference tuple and a struct tuple, following its value through the solution: annotations of values, parameters, record fields and results it flows through, tuple patterns that take it apart, and the arguments and values that flow into it. What cannot be followed (`fst`, `snd`, generic collections) is left for the compiler to report. ([PR #20548](https://github.com/dotnet/fsharp/pull/20548))
* Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188))
### Fixed
diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
index 319bdd5a264..ea88528975b 100644
--- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
+++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
@@ -105,6 +105,12 @@
+
+
+
+
+
+
diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx
index 1f1f632d770..1f5c21c82be 100644
--- a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx
+++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx
@@ -368,4 +368,16 @@ Use live (unsaved) buffers for analysis
Returns:
+
+ Convert to struct tuple
+
+
+ Convert to reference tuple
+
+
+ Convert to struct anonymous record
+
+
+ Convert to reference anonymous record
+
\ No newline at end of file
diff --git a/vsintegration/src/FSharp.Editor/Refactor/AnonymousRecordConversion.fs b/vsintegration/src/FSharp.Editor/Refactor/AnonymousRecordConversion.fs
new file mode 100644
index 00000000000..1eb878d90ec
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Refactor/AnonymousRecordConversion.fs
@@ -0,0 +1,48 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+module internal Microsoft.VisualStudio.FSharp.Editor.AnonymousRecordConversion
+
+open Microsoft.CodeAnalysis.Text
+
+open FSharp.Compiler.Syntax
+open FSharp.Compiler.Text
+
+open StructConversion
+
+/// Both forms differ only by `struct` in front of `{|`, which the node's range starts with.
+let private keywordChanges (sourceText: SourceText) (toStruct: bool) (isStruct: bool) (m: range) =
+ match isStruct, toStruct with
+ | true, true
+ | false, false -> []
+ | false, true -> [ TextChange(TextSpan((spanOf sourceText m).Start, 0), "struct ") ]
+ | true, false -> [ TextChange(structKeyword sourceText m, "") ]
+
+let kind: StructKind =
+ {
+ IsExpr =
+ fun expr _ ->
+ match expr with
+ | SynExpr.AnonRecd _ -> true
+ | _ -> false
+ IsPat = fun _ _ -> false
+ IsType =
+ function
+ | SynType.AnonRecd _ -> true
+ | _ -> false
+ IsStruct =
+ function
+ | CaretNode.Expr(node = SynExpr.AnonRecd(isStruct = isStruct))
+ | CaretNode.Type(node = SynType.AnonRecd(isStruct = isStruct)) -> isStruct
+ | _ -> false
+ ExprChanges =
+ fun sourceText toStruct expr _ ->
+ match expr with
+ | SynExpr.AnonRecd(isStruct = isStruct; range = m) -> ValueSome(keywordChanges sourceText toStruct isStruct m)
+ | _ -> ValueNone
+ PatChanges = fun _ _ _ _ -> ValueNone
+ TypeChanges =
+ fun sourceText toStruct _ ty ->
+ match ty with
+ | SynType.AnonRecd(isStruct = isStruct; range = m) -> keywordChanges sourceText toStruct isStruct m
+ | _ -> []
+ }
diff --git a/vsintegration/src/FSharp.Editor/Refactor/ConvertAnonymousRecord.fs b/vsintegration/src/FSharp.Editor/Refactor/ConvertAnonymousRecord.fs
new file mode 100644
index 00000000000..aa4ded9061a
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Refactor/ConvertAnonymousRecord.fs
@@ -0,0 +1,22 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+namespace Microsoft.VisualStudio.FSharp.Editor
+
+open System.Composition
+
+open Microsoft.CodeAnalysis.CodeRefactorings
+
+open CancellableTasks
+
+[]
+type internal FSharpConvertAnonymousRecordRefactoring [] () =
+ inherit CodeRefactoringProvider()
+
+ override _.ComputeRefactoringsAsync context =
+ StructPropagation.registerConversion
+ context
+ AnonymousRecordConversion.kind
+ SR.ConvertToStructAnonymousRecord
+ SR.ConvertToReferenceAnonymousRecord
+ (nameof FSharpConvertAnonymousRecordRefactoring)
+ |> CancellableTask.startAsTask context.CancellationToken
diff --git a/vsintegration/src/FSharp.Editor/Refactor/ConvertTuple.fs b/vsintegration/src/FSharp.Editor/Refactor/ConvertTuple.fs
new file mode 100644
index 00000000000..ddb820c25e3
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Refactor/ConvertTuple.fs
@@ -0,0 +1,22 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+namespace Microsoft.VisualStudio.FSharp.Editor
+
+open System.Composition
+
+open Microsoft.CodeAnalysis.CodeRefactorings
+
+open CancellableTasks
+
+[]
+type internal FSharpConvertTupleRefactoring [] () =
+ inherit CodeRefactoringProvider()
+
+ override _.ComputeRefactoringsAsync context =
+ StructPropagation.registerConversion
+ context
+ TupleConversion.kind
+ SR.ConvertToStructTuple
+ SR.ConvertToReferenceTuple
+ (nameof FSharpConvertTupleRefactoring)
+ |> CancellableTask.startAsTask context.CancellationToken
diff --git a/vsintegration/src/FSharp.Editor/Refactor/StructConversion.fs b/vsintegration/src/FSharp.Editor/Refactor/StructConversion.fs
new file mode 100644
index 00000000000..23b6ceed49f
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Refactor/StructConversion.fs
@@ -0,0 +1,122 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+module internal Microsoft.VisualStudio.FSharp.Editor.StructConversion
+
+open System
+
+open Microsoft.CodeAnalysis.Text
+
+open FSharp.Compiler.Syntax
+open FSharp.Compiler.Text
+
+let spanOf (sourceText: SourceText) (m: range) =
+ RoslynHelpers.FSharpRangeToTextSpan(sourceText, m)
+
+let isSame (node: 'T) (other: 'T) = obj.ReferenceEquals(node, other)
+
+let containsPos (m: range) (position: pos) =
+ Position.posGeq position m.Start && Position.posGeq m.End position
+
+let rec stripParenTypes (ty: SynType) =
+ match ty with
+ | SynType.Paren(innerType = inner) -> stripParenTypes inner
+ | _ -> ty
+
+/// `struct` and the blanks after it, at the start of a struct node's range.
+let structKeyword (sourceText: SourceText) (m: range) =
+ let start = (spanOf sourceText m).Start
+ let mutable finish = start + "struct".Length
+
+ while finish < sourceText.Length && Char.IsWhiteSpace sourceText[finish] do
+ finish <- finish + 1
+
+ TextSpan.FromBounds(start, finish)
+
+/// What a type under the caret annotates.
+[]
+type Annotated =
+ | Pattern of pat: SynPat * path: SyntaxVisitorPath
+ | Expression of expr: SynExpr * path: SyntaxVisitorPath
+ | Return of binding: SynBinding
+ | Field of field: SynField
+
+[]
+type CaretNode =
+ | Expr of node: SynExpr * path: SyntaxVisitorPath
+ | Pat of node: SynPat * path: SyntaxVisitorPath
+ | Type of node: SynType * annotated: Annotated * isWholeAnnotation: bool
+
+/// A kind of node written in a reference and a struct form: how to recognize it and how to change its form.
+[]
+type StructKind =
+ {
+ /// Whether the expression is a node of the kind, not only shaped like one (a method's argument list).
+ IsExpr: SynExpr -> SyntaxVisitorPath -> bool
+ /// Whether the pattern is a node of the kind, not only shaped like one (a method's parameter list).
+ IsPat: SynPat -> SyntaxVisitorPath -> bool
+ IsType: SynType -> bool
+ IsStruct: CaretNode -> bool
+ /// Changes giving a node of the kind the target form; ValueNone when it cannot change in place.
+ ExprChanges: SourceText -> bool -> SynExpr -> SyntaxVisitorPath -> TextChange list voption
+ PatChanges: SourceText -> bool -> SynPat -> SyntaxVisitorPath -> TextChange list voption
+ /// Changes giving a type of the kind the target form, knowing whether it is a whole annotation.
+ TypeChanges: SourceText -> bool -> bool -> SynType -> TextChange list
+ }
+
+/// The innermost type of the kind within the type that contains the position.
+let rec private tryTypeAt (kind: StructKind) (position: pos) (ty: SynType) =
+ if not (containsPos ty.Range position) then
+ ValueNone
+ else
+ let inner =
+ match ty with
+ | SynType.Paren(innerType = inner)
+ | SynType.Array(elementType = inner)
+ | SynType.WithGlobalConstraints(typeName = inner) -> tryTypeAt kind position inner
+ | SynType.App(typeName = typeName; typeArgs = typeArgs)
+ | SynType.LongIdentApp(typeName = typeName; typeArgs = typeArgs) ->
+ typeName :: typeArgs |> Seq.tryPickV (tryTypeAt kind position)
+ | SynType.Fun(argType = argType; returnType = returnType) -> [ argType; returnType ] |> Seq.tryPickV (tryTypeAt kind position)
+ | SynType.Tuple(path = segments) ->
+ segments
+ |> Seq.tryPickV (function
+ | SynTupleTypeSegment.Type element -> tryTypeAt kind position element
+ | _ -> ValueNone)
+ | SynType.AnonRecd(fields = fields) -> fields |> Seq.tryPickV (fun (_, fieldType) -> tryTypeAt kind position fieldType)
+ | _ -> ValueNone
+
+ match inner with
+ | ValueSome _ -> inner
+ | ValueNone when kind.IsType ty -> ValueSome ty
+ | ValueNone -> ValueNone
+
+/// The innermost expression, pattern or annotated type of the kind under the caret.
+let tryCaretNode (kind: StructKind) (caret: pos) (parseTree: ParsedInput) =
+ let annotationAt (annotation: SynType) (annotated: Annotated) =
+ tryTypeAt kind caret annotation
+ |> ValueOption.map (fun node -> CaretNode.Type(node, annotated, isSame (stripParenTypes annotation) node))
+
+ (ValueNone, parseTree)
+ ||> ParsedInput.fold (fun found path node ->
+ match node with
+ | SyntaxNode.SynExpr expr when containsPos expr.Range caret && kind.IsExpr expr path -> ValueSome(CaretNode.Expr(expr, path))
+ | SyntaxNode.SynPat pat when containsPos pat.Range caret && kind.IsPat pat path -> ValueSome(CaretNode.Pat(pat, path))
+ | SyntaxNode.SynPat(SynPat.Typed(targetType = annotation) as pat) when containsPos annotation.Range caret ->
+ annotationAt annotation (Annotated.Pattern(pat, path))
+ |> ValueOption.orElse found
+ | SyntaxNode.SynExpr(SynExpr.Typed(targetType = annotation) as expr) when containsPos annotation.Range caret ->
+ annotationAt annotation (Annotated.Expression(expr, path))
+ |> ValueOption.orElse found
+ | SyntaxNode.SynBinding(SynBinding(returnInfo = Some(SynBindingReturnInfo(typeName = annotation))) as binding) when
+ containsPos annotation.Range caret
+ ->
+ annotationAt annotation (Annotated.Return binding) |> ValueOption.orElse found
+ | SyntaxNode.SynTypeDefn(SynTypeDefn(
+ typeRepr = SynTypeDefnRepr.Simple(SynTypeDefnSimpleRepr.Record(recordFieldsAndSpreads = fields), _))) ->
+ fields
+ |> Seq.tryPickV (function
+ | SynFieldOrSpread.Field(SynField(fieldType = annotation) as field) when containsPos annotation.Range caret ->
+ annotationAt annotation (Annotated.Field field)
+ | _ -> ValueNone)
+ |> ValueOption.orElse found
+ | _ -> found)
diff --git a/vsintegration/src/FSharp.Editor/Refactor/StructPropagation.fs b/vsintegration/src/FSharp.Editor/Refactor/StructPropagation.fs
new file mode 100644
index 00000000000..d96ceec790d
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Refactor/StructPropagation.fs
@@ -0,0 +1,689 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+module internal Microsoft.VisualStudio.FSharp.Editor.StructPropagation
+
+open System
+open System.Collections.Generic
+open System.Threading
+open System.Threading.Tasks
+
+open Microsoft.CodeAnalysis
+open Microsoft.CodeAnalysis.CodeActions
+open Microsoft.CodeAnalysis.CodeRefactorings
+open Microsoft.CodeAnalysis.Text
+
+open FSharp.Compiler.CodeAnalysis
+open FSharp.Compiler.Symbols
+open FSharp.Compiler.Syntax
+open FSharp.Compiler.Text
+
+open CancellableTasks
+open StructConversion
+
+[]
+type private Source =
+ {
+ Document: Document
+ Text: SourceText
+ Tree: ParsedInput
+ Check: FSharpCheckFileResults
+ }
+
+/// A place whose type changes form, and so passes the change on to what flows in and out of it.
+[]
+type private Slot =
+ /// A value, a parameter's value or a record field.
+ | Symbol of symbolUse: FSharpSymbolUse * source: Source
+ /// The result of a function or a method.
+ | Result of functionUse: FSharpSymbolUse * source: Source
+ /// A parameter, by its curried argument and, when that argument is a tuple of parameters, its position in it.
+ | Parameter of functionUse: FSharpSymbolUse * source: Source * group: int * index: int voption
+
+let private containsRange (outer: range) (inner: range) =
+ Position.posGeq inner.Start outer.Start && Position.posGeq outer.End inner.End
+
+let private symbolUseAt (source: Source) (ident: Ident) =
+ let line = source.Text.Lines[Line.toZ ident.idRange.EndLine].ToString()
+ source.Check.GetSymbolUseAtLocation(ident.idRange.EndLine, ident.idRange.EndColumn, line, [ ident.idText ])
+
+let rec private tryPatternIdent (pat: SynPat) =
+ match pat with
+ | SynPat.Named(ident = SynIdent(ident, _))
+ | SynPat.OptionalVal(ident, _) -> ValueSome ident
+ | SynPat.Paren(pat = inner)
+ | SynPat.Typed(pat = inner)
+ | SynPat.Attrib(pat = inner) -> tryPatternIdent inner
+ | _ -> ValueNone
+
+let rec private stripParenPats (pat: SynPat) =
+ match pat with
+ | SynPat.Paren(pat = inner) -> stripParenPats inner
+ | _ -> pat
+
+/// The name of the function applied by the expression, and how many arguments are applied before it.
+let rec private tryCallee (expr: SynExpr) (applied: int) =
+ match expr with
+ | SynExpr.App(isInfix = false; funcExpr = funcExpr) -> tryCallee funcExpr (applied + 1)
+ | SynExpr.TypeApp(expr = inner) -> tryCallee inner applied
+ | SynExpr.Ident ident -> ValueSome(struct (ident, applied))
+ | SynExpr.LongIdent(longDotId = SynLongIdent(id = ids))
+ | SynExpr.DotGet(longDotId = SynLongIdent(id = ids)) ->
+ match List.tryLast ids with
+ | Some ident -> ValueSome(struct (ident, applied))
+ | None -> ValueNone
+ | _ -> ValueNone
+
+/// The application of a function reference to all of its curried argument groups.
+let rec private tryApplication (node: SynExpr) (path: SyntaxVisitorPath) (remaining: int) =
+ if remaining = 0 then
+ ValueSome(struct (node, path))
+ else
+ match path with
+ | SyntaxNode.SynExpr(SynExpr.TypeApp(expr = inner) as typeApp) :: rest when isSame inner node ->
+ tryApplication typeApp rest remaining
+ | SyntaxNode.SynExpr(SynExpr.App(isInfix = false; funcExpr = funcExpr) as app) :: rest when isSame funcExpr node ->
+ tryApplication app rest (remaining - 1)
+ | _ -> ValueNone
+
+/// The argument a function reference is applied to in the given curried group.
+let rec private tryArgument (node: SynExpr) (path: SyntaxVisitorPath) (group: int) =
+ match path with
+ | SyntaxNode.SynExpr(SynExpr.TypeApp(expr = inner) as typeApp) :: rest when isSame inner node -> tryArgument typeApp rest group
+ | SyntaxNode.SynExpr(SynExpr.App(isInfix = false; funcExpr = funcExpr; argExpr = argument) as app) :: rest when isSame funcExpr node ->
+ if group = 0 then
+ ValueSome(struct (argument, SyntaxNode.SynExpr app :: rest))
+ else
+ tryArgument app rest (group - 1)
+ | _ -> ValueNone
+
+/// The function and the position of the parameter a pattern in a binding's head declares.
+let rec private tryParameterPosition (pat: SynPat) (path: SyntaxVisitorPath) =
+ match path with
+ | SyntaxNode.SynPat(SynPat.LongIdent(longDotId = SynLongIdent(id = ids); argPats = SynArgPats.Pats args)) :: SyntaxNode.SynBinding _ :: _ ->
+ let found =
+ args
+ |> List.indexed
+ |> List.tryFind (fun (_, arg) -> containsRange arg.Range pat.Range)
+
+ match found, List.tryLast ids with
+ | Some(group, arg), Some name ->
+ let index =
+ match stripParenPats arg with
+ | SynPat.Tuple(elementPats = elements) ->
+ match
+ elements
+ |> List.tryFindIndex (fun element -> containsRange element.Range pat.Range)
+ with
+ | Some index -> ValueSome index
+ | None -> ValueNone
+ | _ -> ValueNone
+
+ ValueSome(struct (name, group, index))
+ | _ -> ValueNone
+ | _ :: rest -> tryParameterPosition pat rest
+ | [] -> ValueNone
+
+/// 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
+ | SyntaxNode.SynBinding(SynBinding(headPat = headPat; expr = body)) :: _ when isSame headPat pat -> ValueSome(struct (body, path))
+ | SyntaxNode.SynMatchClause(SynMatchClause(pat = clausePat)) :: SyntaxNode.SynExpr(SynExpr.Match(expr = scrutinee) as matchExpr) :: rest when
+ isSame clausePat pat
+ ->
+ ValueSome(struct (scrutinee, SyntaxNode.SynExpr matchExpr :: rest))
+ | _ -> ValueNone
+
+/// The function and the curried group of which the pattern is the whole argument, parenthesized or (a struct tuple) not.
+let private tryWholeArgument (pat: SynPat) (path: SyntaxVisitorPath) =
+ let struct (argument, headPath) =
+ match path with
+ | SyntaxNode.SynPat(SynPat.Paren(pat = inner) as paren) :: rest when isSame inner pat -> struct (paren, rest)
+ | _ -> struct (pat, path)
+
+ match headPath with
+ | SyntaxNode.SynPat(SynPat.LongIdent(longDotId = SynLongIdent(id = ids); argPats = SynArgPats.Pats args)) :: SyntaxNode.SynBinding _ :: _ ->
+ match List.tryFindIndex (isSame argument) args, List.tryLast ids with
+ | Some group, Some name -> ValueSome(struct (name, group))
+ | _ -> ValueNone
+ | _ -> ValueNone
+
+/// The expression node a symbol use stands for: an identifier, or the last part of a dotted name.
+let private tryUseNode (tree: ParsedInput) (useRange: range) =
+ let isUse (ident: Ident) =
+ Position.posEq ident.idRange.Start useRange.Start
+ && Position.posEq ident.idRange.End useRange.End
+
+ (useRange.Start, tree)
+ ||> ParsedInput.tryPickLast (fun path node ->
+ match node with
+ | SyntaxNode.SynExpr(SynExpr.Ident ident as expr) when isUse ident -> Some(expr, path)
+ | SyntaxNode.SynExpr(SynExpr.LongIdent(longDotId = SynLongIdent(id = ids)) as expr)
+ | SyntaxNode.SynExpr(SynExpr.DotGet(longDotId = SynLongIdent(id = ids)) as expr) when
+ // A dotted use can cover the whole name, `r.Field`, not only its last part.
+ List.tryLast ids
+ |> Option.exists (fun ident -> Position.posEq ident.idRange.End useRange.End)
+ ->
+ Some(expr, path)
+ | _ -> None)
+
+/// The value given to a record field whose name is at the use, in a record construction or copy-and-update.
+let private tryRecordFieldValue (tree: ParsedInput) (useRange: range) =
+ (useRange.Start, tree)
+ ||> ParsedInput.tryPickLast (fun path node ->
+ match node with
+ | SyntaxNode.SynExpr(SynExpr.Record(recordFields = fields) as record) ->
+ fields
+ |> List.tryPick (function
+ | SynExprRecordFieldOrSpread.Field(field = SynExprRecordField(fieldName = (SynLongIdent(id = ids), _); expr = Some value)) when
+ List.tryLast ids
+ |> Option.exists (fun ident -> Position.posEq ident.idRange.Start useRange.Start)
+ ->
+ Some(value, SyntaxNode.SynExpr record :: path)
+ | _ -> None)
+ | _ -> None)
+
+/// The pattern declaring a parameter of the function declared at the range: its whole curried argument, or one
+/// element of an argument that is a tuple of parameters.
+let private tryParameterPattern (tree: ParsedInput) (declaration: range) (group: int) (index: int voption) =
+ let pathTo (pat: SynPat) (parentPath: SyntaxVisitorPath) =
+ match pat with
+ | SynPat.Paren _ -> SyntaxNode.SynPat pat :: parentPath
+ | _ -> parentPath
+
+ (ValueNone, tree)
+ ||> ParsedInput.fold (fun found path node ->
+ match found, node with
+ | ValueNone,
+ SyntaxNode.SynBinding(SynBinding(
+ headPat = SynPat.LongIdent(longDotId = SynLongIdent(id = ids); argPats = SynArgPats.Pats args) as headPat)) when
+ group < args.Length
+ && List.tryLast ids
+ |> Option.exists (fun ident -> Position.posEq ident.idRange.Start declaration.Start)
+ ->
+ let argument = List.item group args
+ let argumentPath = pathTo argument (SyntaxNode.SynPat headPat :: node :: path)
+
+ match stripParenPats argument, index with
+ | SynPat.Tuple(elementPats = elements) as tuple, ValueSome index when index < elements.Length ->
+ let element = List.item index elements
+ ValueSome(struct (stripParenPats element, pathTo element (SyntaxNode.SynPat tuple :: argumentPath)))
+ | _, ValueSome _ -> ValueNone
+ | parameter, ValueNone -> ValueSome(struct (parameter, argumentPath))
+ | _ -> found)
+
+type private Engine(solution: Solution, kind: StructKind, toStruct: bool, userOpName: string) =
+ let sources = Dictionary()
+ let changes = Dictionary>()
+ let visited = HashSet(StringComparer.Ordinal)
+ let pending = Queue()
+ let mutable failed = false
+
+ let annotationChanges (sourceText: SourceText) (annotation: SynType) =
+ match stripParenTypes annotation with
+ | ty when kind.IsType ty -> kind.TypeChanges sourceText toStruct true ty
+ | _ -> []
+
+ let isDeclaration (useRange: range) (declaration: range) =
+ String.Equals(useRange.FileName, declaration.FileName, StringComparison.OrdinalIgnoreCase)
+ && Position.posEq useRange.Start declaration.Start
+
+ let tryDeclarationDocument (symbol: FSharpSymbol) =
+ match symbol.DeclarationLocation with
+ | Some declaration ->
+ solution.TryGetDocumentFromPath declaration.FileName
+ |> ValueOption.map (fun document -> struct (declaration, document))
+ | None -> ValueNone
+
+ let keyOf (slotKind: string) (symbol: FSharpSymbol) =
+ symbol.DeclarationLocation
+ |> Option.map (fun m -> $"{slotKind}|{m.FileName}|{m.StartLine}|{m.StartColumn}")
+
+ member _.Load(document: Document) =
+ cancellableTask {
+ match sources.TryGetValue document.Id with
+ | true, source -> return source
+ | _ ->
+ let! cancellationToken = CancellableTask.getCancellationToken ()
+ let! text = document.GetTextAsync cancellationToken
+ let! parseResults, checkResults = document.GetFSharpParseAndCheckResultsAsync userOpName
+
+ let source =
+ {
+ Document = document
+ Text = text
+ Tree = parseResults.ParseTree
+ Check = checkResults
+ }
+
+ sources[document.Id] <- source
+ return source
+ }
+
+ member _.Add (source: Source) (newChanges: TextChange list) =
+ let documentChanges =
+ match changes.TryGetValue source.Document.Id with
+ | true, documentChanges -> documentChanges
+ | _ ->
+ let documentChanges = ResizeArray()
+ changes[source.Document.Id] <- documentChanges
+ documentChanges
+
+ for change in newChanges do
+ let isKnown =
+ documentChanges
+ |> Seq.exists (fun known ->
+ known.Span = change.Span
+ && String.Equals(known.NewText, change.NewText, StringComparison.Ordinal))
+
+ if not isKnown then
+ documentChanges.Add change
+
+ member this.AddOrFail (source: Source) (result: TextChange list voption) =
+ match result with
+ | ValueSome newChanges -> this.Add source newChanges
+ | ValueNone -> failed <- true
+
+ member _.Enqueue (key: string option) (slot: Slot) =
+ match key with
+ | Some key when visited.Add key -> pending.Enqueue slot
+ | _ -> ()
+
+ member this.EnqueueSymbol (source: Source) (ident: Ident) =
+ match symbolUseAt source ident with
+ | Some symbolUse when (tryDeclarationDocument symbolUse.Symbol).IsSome ->
+ let isValue =
+ match symbolUse.Symbol with
+ | :? FSharpField -> true
+ | :? FSharpMemberOrFunctionOrValue as mfv -> not mfv.IsFunction && not mfv.IsMember
+ | _ -> false
+
+ if isValue then
+ this.Enqueue (keyOf "S" symbolUse.Symbol) (Slot.Symbol(symbolUse, source))
+ | _ -> ()
+
+ member this.EnqueueResult (source: Source) (name: Ident) =
+ match symbolUseAt source name with
+ | Some functionUse when (tryDeclarationDocument functionUse.Symbol).IsSome ->
+ match functionUse.Symbol with
+ | :? FSharpMemberOrFunctionOrValue as mfv when mfv.IsFunction || mfv.IsMember ->
+ this.Enqueue (keyOf "R" functionUse.Symbol) (Slot.Result(functionUse, source))
+ | _ -> ()
+ | _ -> ()
+
+ member this.EnqueueParameter (source: Source) (functionName: Ident) (group: int) (index: int voption) =
+ match symbolUseAt source functionName with
+ | Some functionUse when (tryDeclarationDocument functionUse.Symbol).IsSome ->
+ match functionUse.Symbol with
+ | :? FSharpMemberOrFunctionOrValue as mfv when mfv.IsFunction || mfv.IsMember ->
+ let position =
+ match index with
+ | ValueSome index -> $"{group}|{index}"
+ | ValueNone -> $"{group}"
+
+ this.Enqueue
+ (keyOf "P" functionUse.Symbol |> Option.map (fun key -> $"{key}|{position}"))
+ (Slot.Parameter(functionUse, source, group, index))
+ | _ -> ()
+ | _ -> ()
+
+ /// A value of the changing form flows into a pattern.
+ member this.IntoPattern (source: Source) (pat: SynPat) (path: SyntaxVisitorPath) =
+ match pat with
+ | SynPat.Paren(pat = inner) -> this.IntoPattern source inner (SyntaxNode.SynPat pat :: path)
+ | SynPat.Typed(pat = inner; targetType = annotation) ->
+ this.Add source (annotationChanges source.Text annotation)
+ this.IntoPattern source inner (SyntaxNode.SynPat pat :: path)
+ | SynPat.Named(ident = SynIdent(ident, _))
+ | SynPat.LongIdent(longDotId = SynLongIdent(id = [ ident ]); argPats = SynArgPats.Pats []) -> this.EnqueueSymbol source ident
+ | _ when kind.IsPat pat path -> this.AddOrFail source (kind.PatChanges source.Text toStruct pat path)
+ | _ -> ()
+
+ /// The value of the node now has the changing form: pass that on to where it goes.
+ member this.FlowOut (source: Source) (node: SynExpr) (path: SyntaxVisitorPath) =
+ match path with
+ | SyntaxNode.SynExpr(SynExpr.Paren(expr = inner) as paren) :: rest when isSame inner node -> this.FlowOut source paren rest
+ | SyntaxNode.SynExpr(SynExpr.Typed(expr = inner; targetType = annotation) as typed) :: rest when isSame inner node ->
+ this.Add source (annotationChanges source.Text annotation)
+ this.FlowOut source typed rest
+ | SyntaxNode.SynBinding(SynBinding(headPat = headPat; expr = body)) :: _ when isSame body node ->
+ match headPat with
+ | SynPat.LongIdent(longDotId = SynLongIdent(id = ids); argPats = SynArgPats.Pats(_ :: _)) ->
+ match List.tryLast ids with
+ | Some name -> this.EnqueueResult source name
+ | None -> ()
+ | _ -> this.IntoPattern source headPat path
+ | SyntaxNode.SynExpr(SynExpr.Sequential(expr2 = last) as container) :: rest when isSame last node ->
+ this.FlowOut source container rest
+ | SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse as container) :: rest when isSame letOrUse.Body node ->
+ this.FlowOut source container rest
+ | SyntaxNode.SynExpr(SynExpr.IfThenElse(thenExpr = thenExpr; elseExpr = Some elseExpr) as container) :: rest when
+ isSame thenExpr node || isSame elseExpr node
+ ->
+ this.Retarget source container rest
+ this.FlowOut source container rest
+ | SyntaxNode.SynMatchClause(SynMatchClause(resultExpr = result)) :: SyntaxNode.SynExpr(SynExpr.Match _ as container) :: rest when
+ isSame result node
+ ->
+ this.Retarget source container rest
+ this.FlowOut source container rest
+ | SyntaxNode.SynExpr(SynExpr.App(isInfix = false; funcExpr = funcExpr; argExpr = argument)) :: _ when isSame argument node ->
+ match tryCallee funcExpr 0 with
+ | ValueSome(struct (name, group)) -> this.EnqueueParameter source name group ValueNone
+ | ValueNone -> ()
+ | SyntaxNode.SynExpr(SynExpr.Tuple(exprs = exprs)) :: SyntaxNode.SynExpr(SynExpr.Paren _ as paren) :: SyntaxNode.SynExpr(SynExpr.App(
+ isInfix = false; funcExpr = funcExpr; argExpr = argument)) :: _ when isSame argument paren ->
+ match tryCallee funcExpr 0, List.tryFindIndex (isSame node) exprs with
+ | ValueSome(struct (name, group)), Some index -> this.EnqueueParameter source name group (ValueSome index)
+ | _ -> ()
+ | SyntaxNode.SynExpr(SynExpr.Record(recordFields = fields)) :: _ ->
+ for field in fields do
+ match field with
+ | SynExprRecordFieldOrSpread.Field(field = SynExprRecordField(fieldName = (SynLongIdent(id = ids), _); expr = Some value)) when
+ isSame value node
+ ->
+ match List.tryLast ids with
+ | Some name -> this.EnqueueSymbol source name
+ | None -> ()
+ | _ -> ()
+ | SyntaxNode.SynExpr(SynExpr.Match(expr = scrutinee; clauses = clauses) as matchExpr) :: rest when isSame scrutinee node ->
+ for SynMatchClause(pat = pat) as clause in clauses do
+ let stripped = stripParenPats pat
+
+ let strippedPath =
+ match pat with
+ | SynPat.Paren _ -> [ SyntaxNode.SynPat pat ]
+ | _ -> [ SyntaxNode.SynMatchClause clause; SyntaxNode.SynExpr matchExpr ] @ rest
+
+ if kind.IsPat stripped strippedPath then
+ this.AddOrFail source (kind.PatChanges source.Text toStruct stripped strippedPath)
+ | _ -> ()
+
+ /// The expression must now produce the changing form: change what it is built from.
+ member this.Retarget (source: Source) (expr: SynExpr) (path: SyntaxVisitorPath) =
+ let childPath = SyntaxNode.SynExpr expr :: path
+
+ match expr with
+ | SynExpr.Paren(expr = inner) -> this.Retarget source inner childPath
+ | SynExpr.Typed(expr = inner; targetType = annotation) ->
+ this.Add source (annotationChanges source.Text annotation)
+ this.Retarget source inner childPath
+ | _ when kind.IsExpr expr path -> this.AddOrFail source (kind.ExprChanges source.Text toStruct expr path)
+ | SynExpr.Ident ident -> this.EnqueueSymbol source ident
+ | SynExpr.LongIdent(longDotId = SynLongIdent(id = ids))
+ | SynExpr.DotGet(longDotId = SynLongIdent(id = ids)) ->
+ match List.tryLast ids with
+ | Some ident -> this.EnqueueSymbol source ident
+ | None -> ()
+ | SynExpr.App(isInfix = false) ->
+ match tryCallee expr 0 with
+ | ValueSome(struct (name, _)) -> this.EnqueueResult source name
+ | ValueNone -> ()
+ | SynExpr.IfThenElse(thenExpr = thenExpr; elseExpr = Some elseExpr) ->
+ this.Retarget source thenExpr childPath
+ this.Retarget source elseExpr childPath
+ | SynExpr.Match(clauses = clauses) ->
+ for SynMatchClause(resultExpr = result) as clause in clauses do
+ this.Retarget source result (SyntaxNode.SynMatchClause clause :: childPath)
+ | SynExpr.Sequential(expr2 = last) -> this.Retarget source last childPath
+ | SynExpr.LetOrUse letOrUse -> this.Retarget source letOrUse.Body childPath
+ | _ -> ()
+
+ /// Changes the declaration of a value, a parameter or a record field.
+ member this.Define (source: Source) (declaration: range) =
+ let isDeclared (ident: Ident) =
+ Position.posEq ident.idRange.Start declaration.Start
+
+ ((), source.Tree)
+ ||> ParsedInput.fold (fun () path node ->
+ match node with
+ | SyntaxNode.SynBinding(SynBinding(headPat = SynPat.Named(ident = SynIdent(ident, _)); expr = body)) when isDeclared ident ->
+ this.Retarget source body (node :: path)
+ | SyntaxNode.SynPat(SynPat.Typed(pat = inner; targetType = annotation)) when
+ tryPatternIdent inner |> ValueOption.exists isDeclared
+ ->
+ this.Add source (annotationChanges source.Text annotation)
+ | SyntaxNode.SynTypeDefn(SynTypeDefn(
+ typeRepr = SynTypeDefnRepr.Simple(SynTypeDefnSimpleRepr.Record(recordFieldsAndSpreads = fields), _))) ->
+ for field in fields do
+ match field with
+ | SynFieldOrSpread.Field(SynField(idOpt = Some ident; fieldType = annotation)) when isDeclared ident ->
+ this.Add source (annotationChanges source.Text annotation)
+ | _ -> ()
+ | _ -> ())
+
+ /// Changes the declared or inferred result of a function.
+ member this.DefineResult (source: Source) (declaration: range) =
+ ((), source.Tree)
+ ||> ParsedInput.fold (fun () path node ->
+ match node with
+ | SyntaxNode.SynBinding(SynBinding(headPat = SynPat.LongIdent(longDotId = SynLongIdent(id = ids)); expr = body)) when
+ List.tryLast ids
+ |> Option.exists (fun ident -> Position.posEq ident.idRange.Start declaration.Start)
+ ->
+ this.Retarget source body (node :: path)
+ | _ -> ())
+
+ member this.ProcessSymbol (symbolUse: FSharpSymbolUse) (source: Source) =
+ cancellableTask {
+ match tryDeclarationDocument symbolUse.Symbol with
+ | ValueSome(struct (declaration, document)) ->
+ let! definition = this.Load document
+ this.Define definition declaration
+ let! uses = SymbolHelpers.getSymbolUses symbolUse source.Document source.Check
+
+ for useDocument, useRange in uses do
+ if not (isDeclaration useRange declaration) then
+ let! useSource = this.Load useDocument
+
+ match tryRecordFieldValue useSource.Tree useRange with
+ | Some(value, path) -> this.Retarget useSource value path
+ | None ->
+ match tryUseNode useSource.Tree useRange with
+ | Some(node, path) -> this.FlowOut useSource node path
+ | None -> ()
+ | ValueNone -> ()
+ }
+
+ member this.ProcessResult (functionUse: FSharpSymbolUse) (source: Source) =
+ cancellableTask {
+ match tryDeclarationDocument functionUse.Symbol, functionUse.Symbol with
+ | ValueSome(struct (declaration, document)), (:? FSharpMemberOrFunctionOrValue as mfv) ->
+ let! definition = this.Load document
+ this.DefineResult definition declaration
+ let groups = max 1 mfv.CurriedParameterGroups.Count
+ let! uses = SymbolHelpers.getSymbolUses functionUse source.Document source.Check
+
+ for useDocument, useRange in uses do
+ if not (isDeclaration useRange declaration) then
+ let! useSource = this.Load useDocument
+
+ match tryUseNode useSource.Tree useRange with
+ | Some(node, path) ->
+ match tryApplication node path groups with
+ | ValueSome(struct (application, rest)) -> this.FlowOut useSource application rest
+ | ValueNone -> ()
+ | None -> ()
+ | _ -> ()
+ }
+
+ member this.ProcessParameter (functionUse: FSharpSymbolUse) (source: Source) (group: int) (index: int voption) =
+ cancellableTask {
+ match tryDeclarationDocument functionUse.Symbol with
+ | ValueSome(struct (declaration, document)) ->
+ let! definition = this.Load document
+
+ match tryParameterPattern definition.Tree declaration group index with
+ | ValueSome(struct (SynPat.Tuple _ as parameter, parameterPath)) when not (kind.IsPat parameter parameterPath) -> ()
+ | ValueSome(struct (parameter, parameterPath)) ->
+ match parameter with
+ | SynPat.Typed(targetType = annotation) -> this.Add definition (annotationChanges definition.Text annotation)
+ | _ when kind.IsPat parameter parameterPath ->
+ this.AddOrFail definition (kind.PatChanges definition.Text toStruct parameter parameterPath)
+ | _ -> ()
+
+ match tryPatternIdent parameter with
+ | ValueSome ident -> this.EnqueueSymbol definition ident
+ | ValueNone -> ()
+
+ let! uses = SymbolHelpers.getSymbolUses functionUse source.Document source.Check
+
+ for useDocument, useRange in uses do
+ if not (isDeclaration useRange declaration) then
+ let! useSource = this.Load useDocument
+
+ match tryUseNode useSource.Tree useRange with
+ | Some(node, path) ->
+ match tryArgument node path group, index with
+ | ValueSome(struct (SynExpr.Paren(expr = SynExpr.Tuple(exprs = exprs) as tuple) as argument, argumentPath)),
+ ValueSome index when index < exprs.Length ->
+ this.Retarget
+ useSource
+ (List.item index exprs)
+ (SyntaxNode.SynExpr tuple :: SyntaxNode.SynExpr argument :: argumentPath)
+ | ValueSome(struct (argument, argumentPath)), ValueNone -> this.Retarget useSource argument argumentPath
+ | _ -> ()
+ | None -> ()
+ | ValueNone -> ()
+ | ValueNone -> ()
+ }
+
+ member this.Seed (source: Source) (caretNode: CaretNode) =
+ match caretNode with
+ | CaretNode.Expr(node, path) ->
+ this.AddOrFail source (kind.ExprChanges source.Text toStruct node path)
+ this.FlowOut source node path
+ | CaretNode.Pat(node, path) ->
+ this.AddOrFail source (kind.PatChanges source.Text toStruct node path)
+
+ match tryMatchedExpression node path, tryWholeArgument node path with
+ | ValueSome(struct (matched, matchedPath)), _ -> this.Retarget source matched matchedPath
+ | ValueNone, ValueSome(struct (name, group)) -> this.EnqueueParameter source name group ValueNone
+ | ValueNone, ValueNone -> ()
+ | CaretNode.Type(node, annotated, isWholeAnnotation) ->
+ this.Add source (kind.TypeChanges source.Text toStruct isWholeAnnotation node)
+
+ if isWholeAnnotation then
+ match annotated with
+ | Annotated.Pattern(SynPat.Typed(pat = inner) as pat, path) ->
+ match tryPatternIdent inner with
+ | ValueSome ident -> this.EnqueueSymbol source ident
+ | ValueNone -> ()
+
+ match tryParameterPosition pat path with
+ | ValueSome(struct (name, group, index)) -> this.EnqueueParameter source name group index
+ | ValueNone -> ()
+ | Annotated.Pattern _ -> ()
+ | Annotated.Expression(expr, path) ->
+ this.Retarget source expr path
+ this.FlowOut source expr path
+ | Annotated.Return(SynBinding(
+ headPat = SynPat.LongIdent(longDotId = SynLongIdent(id = ids); argPats = SynArgPats.Pats(_ :: _)))) ->
+ match List.tryLast ids with
+ | Some name -> this.EnqueueResult source name
+ | None -> ()
+ | Annotated.Return(SynBinding(headPat = headPat)) ->
+ match tryPatternIdent headPat with
+ | ValueSome ident -> this.EnqueueSymbol source ident
+ | ValueNone -> ()
+ | Annotated.Field(SynField(idOpt = Some ident)) -> this.EnqueueSymbol source ident
+ | Annotated.Field _ -> ()
+
+ /// The solution with every change of the chain, or ValueNone when a part cannot change or changes collide.
+ member this.Run(document: Document, caretNode: CaretNode) =
+ cancellableTask {
+ let! source = this.Load document
+ this.Seed source caretNode
+
+ while pending.Count > 0 && not failed do
+ match pending.Dequeue() with
+ | Slot.Symbol(symbolUse, slotSource) -> do! this.ProcessSymbol symbolUse slotSource
+ | Slot.Result(functionUse, slotSource) -> do! this.ProcessResult functionUse slotSource
+ | Slot.Parameter(functionUse, slotSource, group, index) -> do! this.ProcessParameter functionUse slotSource group index
+
+ let mutable result = ValueSome solution
+
+ for KeyValue(documentId, documentChanges) in changes do
+ let ordered = documentChanges |> Seq.sortBy _.Span.Start |> Seq.toArray
+
+ let collides =
+ ordered
+ |> Array.pairwise
+ |> Array.exists (fun (first, second) -> first.Span.End > second.Span.Start)
+
+ match result with
+ | ValueSome current when not collides && not failed ->
+ result <- ValueSome(current.WithDocumentText(documentId, sources[documentId].Text.WithChanges ordered))
+ | _ -> result <- ValueNone
+
+ return if failed then ValueNone else result
+ }
+
+let private hasSignatureFile (document: Document) =
+ let signaturePath = document.FilePath + "i"
+
+ document.Project.Documents
+ |> Seq.exists (fun d -> String.Equals(d.FilePath, signaturePath, StringComparison.OrdinalIgnoreCase))
+
+let private isInQuotation (caretNode: CaretNode) =
+ let path =
+ match caretNode with
+ | CaretNode.Expr(path = path)
+ | CaretNode.Pat(path = path)
+ | CaretNode.Type(annotated = Annotated.Pattern(path = path))
+ | CaretNode.Type(annotated = Annotated.Expression(path = path)) -> path
+ | CaretNode.Type _ -> []
+
+ path
+ |> List.exists (function
+ | SyntaxNode.SynExpr(SynExpr.Quote _) -> true
+ | _ -> false)
+
+/// Offers to change the node of the kind under the caret to its other form, together with everything its value flows
+/// through.
+let registerConversion
+ (context: CodeRefactoringContext)
+ (kind: StructKind)
+ (toStructTitle: unit -> string)
+ (toReferenceTitle: unit -> string)
+ (userOpName: string)
+ =
+ cancellableTask {
+ let document = context.Document
+
+ if not (document.IsFSharpSignatureFile || hasSignatureFile document) then
+ let! cancellationToken = CancellableTask.getCancellationToken ()
+ let! sourceText = document.GetTextAsync cancellationToken
+ let! parseResults = document.GetFSharpParseResultsAsync userOpName
+
+ let caret =
+ let linePosition = sourceText.Lines.GetLinePosition context.Span.Start
+ Position.mkPos (Line.fromZ linePosition.Line) linePosition.Character
+
+ match tryCaretNode kind caret parseResults.ParseTree with
+ | ValueSome caretNode when not (isInQuotation caretNode) ->
+ let isStruct = kind.IsStruct caretNode
+
+ let title = if isStruct then toReferenceTitle () else toStructTitle ()
+
+ let changedSolution =
+ cancellableTask {
+ let! converted = Engine(document.Project.Solution, kind, not isStruct, userOpName).Run(document, caretNode)
+
+ return
+ match converted with
+ | ValueSome solution -> solution
+ | ValueNone -> document.Project.Solution
+ }
+
+ let action =
+ CodeAction.Create(
+ title,
+ Func>(fun cancellationToken ->
+ CancellableTask.start cancellationToken changedSolution),
+ title
+ )
+
+ context.RegisterRefactoring action
+ | _ -> ()
+ }
diff --git a/vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs b/vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs
new file mode 100644
index 00000000000..cb55945c4dc
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs
@@ -0,0 +1,171 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+module internal Microsoft.VisualStudio.FSharp.Editor.TupleConversion
+
+open System
+
+open Microsoft.CodeAnalysis.Text
+
+open FSharp.Compiler.Syntax
+
+open StructConversion
+
+/// The position of the `(` that, with its `)`, encloses only the span and blanks.
+let private tryEnclosingParen (sourceText: SourceText) (span: TextSpan) =
+ let mutable before = span.Start - 1
+
+ while before >= 0 && Char.IsWhiteSpace sourceText[before] do
+ before <- before - 1
+
+ let mutable after = span.End
+
+ while after < sourceText.Length && Char.IsWhiteSpace sourceText[after] do
+ after <- after + 1
+
+ if
+ before >= 0
+ && after < sourceText.Length
+ && sourceText[before] = '('
+ && sourceText[after] = ')'
+ then
+ ValueSome before
+ else
+ ValueNone
+
+/// `struct ` to insert in front of the `(` at the position, after a space when it would run into a name: `f(a, b)`.
+let private structAt (sourceText: SourceText) (position: int) =
+ match if position > 0 then sourceText[position - 1] else ' ' with
+ | c when Char.IsLetterOrDigit c || c = '_' || c = '\'' || c = '`' || c = ')' || c = ']' -> " struct "
+ | _ -> "struct "
+
+/// Whether the text between start and finish is a whole generic argument: `<` or `,` before it, `>` or `,` after.
+let private isGenericArgument (sourceText: SourceText) (start: int) (finish: int) =
+ let mutable before = start - 1
+
+ while before >= 0 && Char.IsWhiteSpace sourceText[before] do
+ before <- before - 1
+
+ let mutable after = finish
+
+ while after < sourceText.Length && Char.IsWhiteSpace sourceText[after] do
+ after <- after + 1
+
+ before >= 0
+ && after < sourceText.Length
+ && (sourceText[before] = '<' || sourceText[before] = ',')
+ && (sourceText[after] = '>' || sourceText[after] = ',')
+
+/// Changes giving a tuple type the target kind; a whole annotation also loses the parentheses `struct` needed.
+let private typeChanges (sourceText: SourceText) (toStruct: bool) (isWholeAnnotation: bool) (tupleType: SynType) =
+ match tupleType with
+ | SynType.Tuple(isStruct = isStruct; range = m) when isStruct <> toStruct ->
+ let span = spanOf sourceText m
+
+ if toStruct then
+ match tryEnclosingParen sourceText span with
+ | ValueSome openParen -> [ TextChange(TextSpan(openParen, 0), "struct ") ]
+ | ValueNone ->
+ [
+ TextChange(TextSpan(span.Start, 0), "struct (")
+ TextChange(TextSpan(span.End, 0), ")")
+ ]
+ else
+ let keyword = structKeyword sourceText m
+
+ if isWholeAnnotation || isGenericArgument sourceText keyword.Start span.End then
+ [
+ TextChange(TextSpan(keyword.Start, keyword.Length + 1), "")
+ TextChange(TextSpan(span.End - 1, 1), "")
+ ]
+ else
+ [ TextChange(keyword, "") ]
+ | _ -> []
+
+/// Whether the tuple is the argument list of a method, constructor or union case call rather than a tuple value.
+let private isArgumentList (tuple: SynExpr) (path: SyntaxVisitorPath) =
+ match path with
+ | SyntaxNode.SynExpr(SynExpr.Paren(expr = inner) as paren) :: SyntaxNode.SynExpr(SynExpr.App(flag = ExprAtomicFlag.Atomic; argExpr = arg) | SynExpr.New(
+ expr = arg)) :: _ -> isSame inner tuple && isSame arg paren
+ | _ -> false
+
+/// Changes giving a tuple expression the target kind; ValueNone when it is not a tuple or cannot change in place.
+let private tryExprChanges (sourceText: SourceText) (toStruct: bool) (tuple: SynExpr) (path: SyntaxVisitorPath) =
+ match tuple with
+ | SynExpr.Tuple(isStruct = isStruct) when isStruct = toStruct -> ValueSome []
+ | SynExpr.Tuple(range = m) when not toStruct -> ValueSome [ TextChange(structKeyword sourceText m, "") ]
+ | SynExpr.Tuple(range = m) ->
+ match path with
+ | SyntaxNode.SynExpr(SynExpr.Paren(expr = inner; range = parenRange)) :: _ when isSame inner tuple ->
+ let start = (spanOf sourceText parenRange).Start
+ ValueSome [ TextChange(TextSpan(start, 0), structAt sourceText start) ]
+ | _ when m.StartLine = m.EndLine ->
+ let span = spanOf sourceText m
+
+ ValueSome
+ [
+ TextChange(TextSpan(span.Start, 0), "struct (")
+ TextChange(TextSpan(span.End, 0), ")")
+ ]
+ | _ -> ValueNone
+ | _ -> ValueNone
+
+/// Changes giving a tuple pattern the target kind; ValueNone when it is not a tuple or cannot change in place.
+let private tryPatChanges (sourceText: SourceText) (toStruct: bool) (tuple: SynPat) (path: SyntaxVisitorPath) =
+ match tuple with
+ | SynPat.Tuple(isStruct = isStruct) when isStruct = toStruct -> ValueSome []
+ | SynPat.Tuple(range = m) when not toStruct -> ValueSome [ TextChange(structKeyword sourceText m, "") ]
+ | SynPat.Tuple(range = m) ->
+ match path with
+ | SyntaxNode.SynPat(SynPat.Paren(pat = inner; range = parenRange)) :: _ when isSame inner tuple ->
+ let start = (spanOf sourceText parenRange).Start
+ ValueSome [ TextChange(TextSpan(start, 0), structAt sourceText start) ]
+ | _ when m.StartLine = m.EndLine ->
+ let span = spanOf sourceText m
+
+ ValueSome
+ [
+ TextChange(TextSpan(span.Start, 0), "struct (")
+ TextChange(TextSpan(span.End, 0), ")")
+ ]
+ | _ -> ValueNone
+ | _ -> ValueNone
+
+/// Whether the tuple pattern is the parameter list of a member or constructor: its only argument, parenthesized or
+/// (a struct tuple) not.
+let private isParameterList (tuple: SynPat) (path: SyntaxVisitorPath) =
+ let struct (argument, headPath) =
+ match path with
+ | SyntaxNode.SynPat(SynPat.Paren(pat = inner) as paren) :: rest when isSame inner tuple -> struct (paren, rest)
+ | _ -> struct (tuple, path)
+
+ match headPath with
+ | SyntaxNode.SynPat(SynPat.LongIdent(argPats = SynArgPats.Pats [ only ])) :: SyntaxNode.SynBinding(SynBinding(
+ valData = SynValData(memberFlags = Some _))) :: _ -> isSame only argument
+ | _ -> false
+
+let kind: StructKind =
+ {
+ IsExpr =
+ fun expr path ->
+ match expr with
+ | SynExpr.Tuple _ -> not (isArgumentList expr path)
+ | _ -> false
+ IsPat =
+ fun pat path ->
+ match pat with
+ | SynPat.Tuple _ -> not (isParameterList pat path)
+ | _ -> false
+ IsType =
+ function
+ | SynType.Tuple _ -> true
+ | _ -> false
+ IsStruct =
+ function
+ | CaretNode.Expr(node = SynExpr.Tuple(isStruct = isStruct))
+ | CaretNode.Pat(node = SynPat.Tuple(isStruct = isStruct))
+ | CaretNode.Type(node = SynType.Tuple(isStruct = isStruct)) -> isStruct
+ | _ -> false
+ ExprChanges = tryExprChanges
+ PatChanges = tryPatChanges
+ TypeChanges = typeChanges
+ }
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf
index cd8c46bf705..cea9423170b 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf
@@ -95,6 +95,16 @@ Navrhnout názvy pro nerozpoznané identifikátory;
Pro kontrolu nerovnosti použijte <>.
+
+ Convert to reference anonymous record
+ Convert to reference anonymous record
+
+
+
+ Convert to reference tuple
+ Convert to reference tuple
+
+
Use '=' for equality check
Pro kontrolu rovnosti použijte =.
@@ -105,6 +115,16 @@ Navrhnout názvy pro nerozpoznané identifikátory;
Použít místo negace odčítání
+
+ Convert to struct anonymous record
+ Convert to struct anonymous record
+
+
+
+ Convert to struct tuple
+ Convert to struct tuple
+
+
F# Disposable Values (locals)
Uvolnitelné hodnoty jazyka F# (místní)
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf
index bce1941f0b1..b9e746a1b93 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf
@@ -95,6 +95,16 @@ Namen für nicht aufgelöste Bezeichner vorschlagen;
"<>" für die Überprüfung auf Ungleichheit verwenden
+
+ Convert to reference anonymous record
+ Convert to reference anonymous record
+
+
+
+ Convert to reference tuple
+ Convert to reference tuple
+
+
Use '=' for equality check
"=" für Gleichheitsüberprüfung verwenden
@@ -105,6 +115,16 @@ Namen für nicht aufgelöste Bezeichner vorschlagen;
Subtraktion anstelle von Negation verwenden
+
+ Convert to struct anonymous record
+ Convert to struct anonymous record
+
+
+
+ Convert to struct tuple
+ Convert to struct tuple
+
+
F# Disposable Values (locals)
Disposable-Werte in F# (lokal)
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf
index fa8cb62c422..796de1b4c88 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf
@@ -95,6 +95,16 @@ Sugerir nombres para identificadores sin resolver;
Usar "<>" para la comprobación de desigualdad
+
+ Convert to reference anonymous record
+ Convert to reference anonymous record
+
+
+
+ Convert to reference tuple
+ Convert to reference tuple
+
+
Use '=' for equality check
Usar "=" para la comprobación de igualdad
@@ -105,6 +115,16 @@ Sugerir nombres para identificadores sin resolver;
Usar la resta en lugar de la negación
+
+ Convert to struct anonymous record
+ Convert to struct anonymous record
+
+
+
+ Convert to struct tuple
+ Convert to struct tuple
+
+
F# Disposable Values (locals)
Valores de F# descartables (locales)
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf
index e7ec71e839e..f482aea24b5 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf
@@ -95,6 +95,16 @@ Suggérer des noms pour les identificateurs non résolus ;
Utiliser '<>' pour vérifier l'inégalité
+
+ Convert to reference anonymous record
+ Convert to reference anonymous record
+
+
+
+ Convert to reference tuple
+ Convert to reference tuple
+
+
Use '=' for equality check
Utiliser '=' pour vérifier l'égalité
@@ -105,6 +115,16 @@ Suggérer des noms pour les identificateurs non résolus ;
Utiliser la soustraction à la place de la négation
+
+ Convert to struct anonymous record
+ Convert to struct anonymous record
+
+
+
+ Convert to struct tuple
+ Convert to struct tuple
+
+
F# Disposable Values (locals)
Valeurs F# pouvant être supprimées (variables locales)
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf
index 327a7ca362f..e3d71e9a7ba 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf
@@ -95,6 +95,16 @@ Suggerisci i nomi per gli identificatori non risolti;
Usare '<>' per il controllo di disuguaglianza
+
+ Convert to reference anonymous record
+ Convert to reference anonymous record
+
+
+
+ Convert to reference tuple
+ Convert to reference tuple
+
+
Use '=' for equality check
Usare '=' per il controllo di uguaglianza
@@ -105,6 +115,16 @@ Suggerisci i nomi per gli identificatori non risolti;
Usare la sottrazione invece della negazione
+
+ Convert to struct anonymous record
+ Convert to struct anonymous record
+
+
+
+ Convert to struct tuple
+ Convert to struct tuple
+
+
F# Disposable Values (locals)
Valori eliminabili F# (variabili locali)
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf
index d45234c011a..705a059bcad 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf
@@ -95,6 +95,16 @@ Suggest names for unresolved identifiers;
非等値のチェックには '<>' を使用します
+
+ Convert to reference anonymous record
+ Convert to reference anonymous record
+
+
+
+ Convert to reference tuple
+ Convert to reference tuple
+
+
Use '=' for equality check
等値性のチェックには '=' を使用します
@@ -105,6 +115,16 @@ Suggest names for unresolved identifiers;
否定の代わりに減算を使用する
+
+ Convert to struct anonymous record
+ Convert to struct anonymous record
+
+
+
+ Convert to struct tuple
+ Convert to struct tuple
+
+
F# Disposable Values (locals)
F# の破棄可能な値 (ローカル)
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf
index 3248e0641ea..627c26aa2e9 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf
@@ -95,6 +95,16 @@ Suggest names for unresolved identifiers;
같지 않음 검사에 '<>' 사용
+
+ Convert to reference anonymous record
+ Convert to reference anonymous record
+
+
+
+ Convert to reference tuple
+ Convert to reference tuple
+
+
Use '=' for equality check
같음 검사에 '=' 사용
@@ -105,6 +115,16 @@ Suggest names for unresolved identifiers;
부정 대신 빼기 사용
+
+ Convert to struct anonymous record
+ Convert to struct anonymous record
+
+
+
+ Convert to struct tuple
+ Convert to struct tuple
+
+
F# Disposable Values (locals)
F# 삭제 가능한 값(로컬)
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf
index abc39f15da5..4b0b97ba7e0 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf
@@ -95,6 +95,16 @@ Sugeruj nazwy dla nierozpoznanych identyfikatorów;
Użyj operatora „<>” do sprawdzenia nierówności
+
+ Convert to reference anonymous record
+ Convert to reference anonymous record
+
+
+
+ Convert to reference tuple
+ Convert to reference tuple
+
+
Use '=' for equality check
Użyj znaku „=” w celu sprawdzenia równości
@@ -105,6 +115,16 @@ Sugeruj nazwy dla nierozpoznanych identyfikatorów;
Użyj odejmowania zamiast negacji
+
+ Convert to struct anonymous record
+ Convert to struct anonymous record
+
+
+
+ Convert to struct tuple
+ Convert to struct tuple
+
+
F# Disposable Values (locals)
Wartości możliwe do likwidacji języka F# (lokalne)
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf
index dfde43120f5..574030dbec0 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf
@@ -95,6 +95,16 @@ Sugerir nomes para identificadores não resolvidos;
Usar '<>' para a verificação de desigualdade
+
+ Convert to reference anonymous record
+ Convert to reference anonymous record
+
+
+
+ Convert to reference tuple
+ Convert to reference tuple
+
+
Use '=' for equality check
Usar '=' para verificação de igualdade
@@ -105,6 +115,16 @@ Sugerir nomes para identificadores não resolvidos;
Use a subtração em vez da negação
+
+ Convert to struct anonymous record
+ Convert to struct anonymous record
+
+
+
+ Convert to struct tuple
+ Convert to struct tuple
+
+
F# Disposable Values (locals)
Valores F# Descartáveis (locais)
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf
index 47cda215312..fd1b6f8aa5f 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf
@@ -95,6 +95,16 @@ Suggest names for unresolved identifiers;
Используйте "<>" для проверки на неравенство
+
+ Convert to reference anonymous record
+ Convert to reference anonymous record
+
+
+
+ Convert to reference tuple
+ Convert to reference tuple
+
+
Use '=' for equality check
Используйте "=" для проверки равенства
@@ -105,6 +115,16 @@ Suggest names for unresolved identifiers;
Используйте вычитание вместо отрицания.
+
+ Convert to struct anonymous record
+ Convert to struct anonymous record
+
+
+
+ Convert to struct tuple
+ Convert to struct tuple
+
+
F# Disposable Values (locals)
Освобождаемые значения F# (локальные)
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf
index 58aa5d54c43..7f098532475 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf
@@ -95,6 +95,16 @@ Kullanılmayan değerleri analiz et ve bunlara düzeltmeler öner;
Eşitsizlik denetimi için '<>' kullanın
+
+ Convert to reference anonymous record
+ Convert to reference anonymous record
+
+
+
+ Convert to reference tuple
+ Convert to reference tuple
+
+
Use '=' for equality check
Eşitlik denetimi için '=' kullan
@@ -105,6 +115,16 @@ Kullanılmayan değerleri analiz et ve bunlara düzeltmeler öner;
Negatif yapma yerine çıkarmayı kullanın
+
+ Convert to struct anonymous record
+ Convert to struct anonymous record
+
+
+
+ Convert to struct tuple
+ Convert to struct tuple
+
+
F# Disposable Values (locals)
F# Atılabilir Değerleri (yereller)
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf
index 4fa703776fb..445902ea878 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf
@@ -95,6 +95,16 @@ Suggest names for unresolved identifiers;
使用 "<>" 进行不相等检查
+
+ Convert to reference anonymous record
+ Convert to reference anonymous record
+
+
+
+ Convert to reference tuple
+ Convert to reference tuple
+
+
Use '=' for equality check
使用 "=" 进行同等性检查
@@ -105,6 +115,16 @@ Suggest names for unresolved identifiers;
使用减法代替求反
+
+ Convert to struct anonymous record
+ Convert to struct anonymous record
+
+
+
+ Convert to struct tuple
+ Convert to struct tuple
+
+
F# Disposable Values (locals)
F# 可释放值(局部值)
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf
index fd46ef9919a..5fda6d499d9 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf
@@ -95,6 +95,16 @@ Suggest names for unresolved identifiers;
使用 '<>' 進行不等式檢查
+
+ Convert to reference anonymous record
+ Convert to reference anonymous record
+
+
+
+ Convert to reference tuple
+ Convert to reference tuple
+
+
Use '=' for equality check
使用 '=' 檢查是否相等
@@ -105,6 +115,16 @@ Suggest names for unresolved identifiers;
使用減號代替否定
+
+ Convert to struct anonymous record
+ Convert to struct anonymous record
+
+
+
+ Convert to struct tuple
+ Convert to struct tuple
+
+
F# Disposable Values (locals)
F# 可處置的值 (區域)
diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
index ecce1205b8c..344bd584e21 100644
--- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
+++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
@@ -74,6 +74,8 @@
+
+
diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertAnonymousRecordTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertAnonymousRecordTests.fs
new file mode 100644
index 00000000000..177a17b3966
--- /dev/null
+++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertAnonymousRecordTests.fs
@@ -0,0 +1,301 @@
+module FSharp.Editor.Tests.Refactors.ConvertAnonymousRecordTests
+
+open System
+
+open Microsoft.CodeAnalysis
+open Microsoft.VisualStudio.FSharp.Editor
+open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks
+
+open Xunit
+
+open FSharp.Compiler.Diagnostics
+
+open FSharp.Editor.Tests.Refactors.RefactorTestFramework
+
+let private caretAt (code: string) (marker: string) =
+ code.IndexOf(marker, StringComparison.Ordinal)
+
+let private textOf (document: Document) =
+ (document.GetTextAsync() |> GetTaskResult).ToString()
+
+let private errorsOf (document: Document) =
+ let _, checkResults =
+ document.GetFSharpParseAndCheckResultsAsync "test"
+ |> CancellableTask.runSynchronouslyWithoutCancellation
+
+ checkResults.Diagnostics
+ |> Array.filter (fun diagnostic -> diagnostic.Severity = FSharpDiagnosticSeverity.Error)
+
+let private refactorIn (context: TestContext) (code: string) (marker: string) =
+ tryRefactor code (caretAt code marker) context (new FSharpConvertAnonymousRecordRefactoring())
+
+let private refactored (code: string) (marker: string) =
+ use context = TestContext.CreateWithCode code
+ let document = refactorIn context code marker
+ Assert.Empty(errorsOf document)
+ textOf document
+
+let private actionsAt (code: string) (marker: string) =
+ use context = TestContext.CreateWithCode code
+ tryGetRefactoringActions code (caretAt code marker) context (new FSharpConvertAnonymousRecordRefactoring())
+
+[]
+[]
+[ = []
+""",
+ "A: int",
+ """
+module M
+
+let items: list = []
+""")>]
+[]
+let ``Anonymous record that flows nowhere else converts on its own`` (reference: string, marker: string, structs: string) =
+ Assert.Equal(structs, refactored reference marker)
+ Assert.Equal(reference, refactored structs marker)
+
+[]
+let ``Value and the annotations it flows into convert together`` () =
+ let reference =
+ """
+module M
+
+let person = {| Name = "Ada"; Age = 36 |}
+let copy: {| Name: string; Age: int |} = person
+let age = person.Age
+"""
+
+ let structs =
+ """
+module M
+
+let person = struct {| Name = "Ada"; Age = 36 |}
+let copy: struct {| Name: string; Age: int |} = person
+let age = person.Age
+"""
+
+ Assert.Equal(structs, refactored reference "Ada")
+ Assert.Equal(reference, refactored structs "Ada")
+
+[]
+let ``Parameter annotation converts its arguments`` () =
+ let reference =
+ """
+module M
+
+let greet (p: {| Name: string |}) = "Hi " + p.Name
+
+let ada = {| Name = "Ada" |}
+let greetings = [ greet ada; greet {| Name = "Bob" |} ]
+"""
+
+ let structs =
+ """
+module M
+
+let greet (p: struct {| Name: string |}) = "Hi " + p.Name
+
+let ada = struct {| Name = "Ada" |}
+let greetings = [ greet ada; greet struct {| Name = "Bob" |} ]
+"""
+
+ Assert.Equal(structs, refactored reference "Name: string")
+ Assert.Equal(reference, refactored structs "Name: string")
+
+[]
+let ``Return type converts the result and the values it is bound to`` () =
+ let reference =
+ """
+module M
+
+let origin () : {| X: int; Y: int |} = {| X = 0; Y = 0 |}
+let start: {| X: int; Y: int |} = origin ()
+"""
+
+ let structs =
+ """
+module M
+
+let origin () : struct {| X: int; Y: int |} = struct {| X = 0; Y = 0 |}
+let start: struct {| X: int; Y: int |} = origin ()
+"""
+
+ Assert.Equal(structs, refactored reference "X: int")
+ Assert.Equal(reference, refactored structs "X: int")
+
+[]
+let ``Record field converts its values and the annotations reading it`` () =
+ let reference =
+ """
+module M
+
+type Person = { Info: {| Age: int |}; Tags: {| Count: int |} }
+
+let person = { Info = {| Age = 30 |}; Tags = {| Count = 0 |} }
+let info: {| Age: int |} = person.Info
+"""
+
+ let structs =
+ """
+module M
+
+type Person = { Info: struct {| Age: int |}; Tags: {| Count: int |} }
+
+let person = { Info = struct {| Age = 30 |}; Tags = {| Count = 0 |} }
+let info: struct {| Age: int |} = person.Info
+"""
+
+ Assert.Equal(structs, refactored reference "Age: int")
+ Assert.Equal(reference, refactored structs "Age: int")
+
+[]
+[]
+[]
+let ``Copy-and-update converts independently of its source`` (reference: string, marker: string, structs: string) =
+ Assert.Equal(structs, refactored reference marker)
+ Assert.Equal(reference, refactored structs marker)
+
+[]
+let ``Value declared in another file converts there`` () =
+ let definition =
+ """
+module A
+
+let person = {| Name = "Ada" |}
+"""
+
+ let code =
+ """
+module B
+
+let name (p: {| Name: string |}) = p.Name
+let text = name A.person
+"""
+
+ use context = TestContext.CreateWithCodeAndDependency code definition
+ let document = refactorIn context code "Name: string"
+
+ Assert.Equal(
+ """
+module B
+
+let name (p: struct {| Name: string |}) = p.Name
+let text = name A.person
+""",
+ textOf document
+ )
+
+ Assert.Equal(
+ """
+module A
+
+let person = struct {| Name = "Ada" |}
+""",
+ (context.Solution.Projects |> Seq.head).Documents |> Seq.head |> textOf
+ )
+
+ // The checker reads the other file of a synthetic project from disk, so the result is checked as a new project.
+ let definitionAfter =
+ (context.Solution.Projects |> Seq.head).Documents |> Seq.head |> textOf
+
+ use checkContext =
+ TestContext.CreateWithCodeAndDependency (textOf document) definitionAfter
+
+ Assert.Empty((checkContext.Solution.Projects |> Seq.head).Documents |> Seq.last |> errorsOf)
+
+[]
+let ``Title names the target kind`` () =
+ let reference =
+ """
+module M
+
+let point = {| X = 1 |}
+"""
+
+ let structs =
+ """
+module M
+
+let point = struct {| X = 1 |}
+"""
+
+ Assert.Equal("Convert to struct anonymous record", (actionsAt reference "X = 1" |> Seq.exactlyOne).Title)
+ Assert.Equal("Convert to reference anonymous record", (actionsAt structs "X = 1" |> Seq.exactlyOne).Title)
+
+[]
+[]
+[]
+[
+""",
+ "A = 1")>]
+let ``No action`` (code: string, marker: string) = Assert.Empty(actionsAt code marker)
diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertTupleTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertTupleTests.fs
new file mode 100644
index 00000000000..e5b20a66fba
--- /dev/null
+++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertTupleTests.fs
@@ -0,0 +1,397 @@
+module FSharp.Editor.Tests.Refactors.ConvertTupleTests
+
+open System
+
+open Microsoft.CodeAnalysis
+open Microsoft.VisualStudio.FSharp.Editor
+open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks
+
+open Xunit
+
+open FSharp.Compiler.Diagnostics
+
+open FSharp.Editor.Tests.Refactors.RefactorTestFramework
+
+let private caretAt (code: string) (marker: string) =
+ code.IndexOf(marker, StringComparison.Ordinal)
+
+let private textOf (document: Document) =
+ (document.GetTextAsync() |> GetTaskResult).ToString()
+
+let private errorsOf (document: Document) =
+ let _, checkResults =
+ document.GetFSharpParseAndCheckResultsAsync "test"
+ |> CancellableTask.runSynchronouslyWithoutCancellation
+
+ checkResults.Diagnostics
+ |> Array.filter (fun diagnostic -> diagnostic.Severity = FSharpDiagnosticSeverity.Error)
+
+let private refactorIn (context: TestContext) (code: string) (marker: string) =
+ tryRefactor code (caretAt code marker) context (new FSharpConvertTupleRefactoring())
+
+let private refactored (code: string) (marker: string) =
+ use context = TestContext.CreateWithCode code
+ let document = refactorIn context code marker
+ Assert.Empty(errorsOf document)
+ textOf document
+
+let private actionsAt (code: string) (marker: string) =
+ use context = TestContext.CreateWithCode code
+ tryGetRefactoringActions code (caretAt code marker) context (new FSharpConvertTupleRefactoring())
+
+[]
+[]
+[ = []
+""",
+ "int * int",
+ """
+module M
+
+let pairs: list = []
+""")>]
+let ``Tuple that flows nowhere else converts on its own`` (before: string, marker: string, after: string) =
+ Assert.Equal(after, refactored before marker)
+ Assert.Equal(before, refactored after marker)
+
+[]
+let ``Value, its tuple patterns and the annotations it flows into convert together`` () =
+ let reference =
+ """
+module M
+
+let pair = (1, 2)
+let (a, b) = pair
+let copy: int * int = pair
+"""
+
+ let structs =
+ """
+module M
+
+let pair = struct (1, 2)
+let struct (a, b) = pair
+let copy: struct (int * int) = pair
+"""
+
+ Assert.Equal(structs, refactored reference "1, 2")
+ Assert.Equal(reference, refactored structs "1, 2")
+
+[]
+let ``Parameter annotation converts its arguments and the patterns on it`` () =
+ let reference =
+ """
+module M
+
+let sum (p: int * int) =
+ let (a, b) = p
+ a + b
+
+let pair = (3, 4)
+let total = sum pair + sum (1, 2)
+"""
+
+ let structs =
+ """
+module M
+
+let sum (p: struct (int * int)) =
+ let struct (a, b) = p
+ a + b
+
+let pair = struct (3, 4)
+let total = sum pair + sum struct (1, 2)
+"""
+
+ Assert.Equal(structs, refactored reference "int * int")
+ Assert.Equal(reference, refactored structs "int * int")
+
+[]
+[]
+[]
+let ``Parameter converts the matching argument of every call`` (reference: string, structs: string) =
+ Assert.Equal(structs, refactored reference "int * int")
+ Assert.Equal(reference, refactored structs "int * int")
+
+[]
+[]
+[]
+let ``Tuple argument of a curried function or member converts with its calls`` (reference: string, marker: string, structs: string) =
+ Assert.Equal(structs, refactored reference marker)
+ Assert.Equal(reference, refactored structs marker)
+
+[]
+let ``Struct keyword is separated from a name the parenthesis follows`` () =
+ let code =
+ """
+module M
+
+let add(a, b) c = a + b + c
+
+let total = add (1, 2) 3
+"""
+
+ Assert.Equal(
+ """
+module M
+
+let add struct (a, b) c = a + b + c
+
+let total = add struct (1, 2) 3
+""",
+ refactored code "a, b"
+ )
+
+[]
+let ``Value declared in another file converts there`` () =
+ let definition =
+ """
+module A
+
+let pair = (1, 2)
+"""
+
+ let code =
+ """
+module B
+
+let (a, b) = A.pair
+"""
+
+ use context = TestContext.CreateWithCodeAndDependency code definition
+ let document = refactorIn context code "a, b"
+
+ Assert.Equal(
+ """
+module B
+
+let struct (a, b) = A.pair
+""",
+ textOf document
+ )
+
+ Assert.Equal(
+ """
+module A
+
+let pair = struct (1, 2)
+""",
+ (context.Solution.Projects |> Seq.head).Documents |> Seq.head |> textOf
+ )
+
+ // The checker reads the other file of a synthetic project from disk, so the result is checked as a new project.
+ let definitionAfter =
+ (context.Solution.Projects |> Seq.head).Documents |> Seq.head |> textOf
+
+ use checkContext =
+ TestContext.CreateWithCodeAndDependency (textOf document) definitionAfter
+
+ Assert.Empty((checkContext.Solution.Projects |> Seq.head).Documents |> Seq.last |> errorsOf)
+
+[]
+let ``Use that cannot be followed is left for the compiler to report`` () =
+ let code =
+ """
+module M
+
+let pair = (1, 2)
+let first = fst pair
+"""
+
+ use context = TestContext.CreateWithCode code
+ let document = refactorIn context code "1, 2"
+
+ Assert.Equal(
+ """
+module M
+
+let pair = struct (1, 2)
+let first = fst pair
+""",
+ textOf document
+ )
+
+ Assert.Equal(5, (errorsOf document |> Array.exactlyOne).StartLine)
+
+[]
+let ``Return type converts the result and the patterns taking it apart`` () =
+ let reference =
+ """
+module M
+
+let origin () : int * int = (0, 0)
+let (x, y) = origin ()
+"""
+
+ let structs =
+ """
+module M
+
+let origin () : struct (int * int) = struct (0, 0)
+let struct (x, y) = origin ()
+"""
+
+ Assert.Equal(structs, refactored reference "int * int")
+ Assert.Equal(reference, refactored structs "int * int")
+
+[]
+let ``Record field converts its values and the patterns on it`` () =
+ let reference =
+ """
+module M
+
+type Line = { Start: int * int; Finish: int * int }
+
+let line = { Start = (0, 0); Finish = (1, 1) }
+let (sx, sy) = line.Start
+"""
+
+ let structs =
+ """
+module M
+
+type Line = { Start: struct (int * int); Finish: int * int }
+
+let line = { Start = struct (0, 0); Finish = (1, 1) }
+let struct (sx, sy) = line.Start
+"""
+
+ Assert.Equal(structs, refactored reference "int * int; Finish")
+ Assert.Equal(reference, refactored structs "int * int); Finish")
+
+[]
+let ``Title names the target kind`` () =
+ let reference =
+ """
+module M
+
+let pair = (1, 2)
+"""
+
+ let structs =
+ """
+module M
+
+let pair = struct (1, 2)
+"""
+
+ Assert.Equal("Convert to struct tuple", (actionsAt reference "1, 2" |> Seq.exactlyOne).Title)
+ Assert.Equal("Convert to reference tuple", (actionsAt structs "1, 2" |> Seq.exactlyOne).Title)
+
+[]
+[]
+[]
+[
+""",
+ "1, 2")>]
+[]
+[]
+[]
+let ``No action`` (code: string, marker: string) = Assert.Empty(actionsAt code marker)
diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs
index 849da8c84ec..c5f2602b108 100644
--- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs
+++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs
@@ -8,6 +8,8 @@ open Microsoft.CodeAnalysis
open Microsoft.CodeAnalysis.Text
open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks
+open FSharp.Test.ProjectGeneration
+
open FSharp.Editor.Tests.Helpers
open Microsoft.CodeAnalysis.CodeRefactorings
open Microsoft.CodeAnalysis.CodeActions
@@ -31,11 +33,19 @@ type TestContext(Solution: Solution) =
new TestContext(solution)
static member CreateWithCodeAndDependency (code: string) (codeForPreviousFile: string) =
- let mutable solution = RoslynTestHelpers.CreateSolution(codeForPreviousFile)
-
- let firstProject = solution.Projects.First()
- solution <- solution.AddDocument(DocumentId.CreateNewId(firstProject.Id), "test2.fs", code, filePath = "C:\\test2.fs")
-
+ let project =
+ { SyntheticProject.Create(
+ { sourceFile "First" [] with
+ Source = codeForPreviousFile
+ },
+ { sourceFile "Second" [ "First" ] with
+ Source = code
+ }
+ ) with
+ AutoAddModules = false
+ }
+
+ let solution, _ = RoslynTestHelpers.CreateSolution project
new TestContext(solution)
let tryRefactor (code: string) (cursorPosition) (context: TestContext) (refactorProvider: 'T :> CodeRefactoringProvider) =