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) =