From 512e8cba742535fbba0187b1e309cc9fd043b7a4 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 16:33:21 +0200 Subject: [PATCH 1/7] Add a refactoring between reference and struct tuples Ctrl+. on a tuple expression, pattern or annotated tuple type converts it to the other kind and follows the value through the solution: annotations of values, parameters, record fields and function results it flows through, tuple patterns taking it apart, and the arguments and values flowing into it. Uses that cannot be followed (fst, snd, generic collections) are left for the compiler to report. CreateWithCodeAndDependency now tells FCS about both files, so the second file can be type-checked. Co-Authored-By: Claude Opus 5 (1M context) --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + .../src/FSharp.Editor/FSharp.Editor.fsproj | 3 + .../src/FSharp.Editor/FSharp.Editor.resx | 6 + .../FSharp.Editor/Refactor/ConvertTuple.fs | 86 +++ .../FSharp.Editor/Refactor/TupleConversion.fs | 224 +++++++ .../Refactor/TuplePropagation.fs | 593 ++++++++++++++++++ .../FSharp.Editor/xlf/FSharp.Editor.cs.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.de.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.es.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.fr.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.it.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.ja.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.ko.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.pl.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.ru.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.tr.xlf | 10 + .../xlf/FSharp.Editor.zh-Hans.xlf | 10 + .../xlf/FSharp.Editor.zh-Hant.xlf | 10 + .../FSharp.Editor.Tests.fsproj | 1 + .../Refactors/ConvertTupleTests.fs | 310 +++++++++ .../Refactors/RefactorTestFramework.fs | 10 +- 22 files changed, 1363 insertions(+), 1 deletion(-) create mode 100644 vsintegration/src/FSharp.Editor/Refactor/ConvertTuple.fs create mode 100644 vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs create mode 100644 vsintegration/src/FSharp.Editor/Refactor/TuplePropagation.fs create mode 100644 vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertTupleTests.fs diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index ba03f663967..99ae282a92d 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -2,6 +2,7 @@ * 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)) * 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)) +* 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. ### Fixed diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 319bdd5a264..deb62507428 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -105,6 +105,9 @@ + + + diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx index 1f1f632d770..a08c2b11c9c 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx @@ -368,4 +368,10 @@ Use live (unsaved) buffers for analysis Returns: + + Convert to struct tuple + + + Convert to reference tuple + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/Refactor/ConvertTuple.fs b/vsintegration/src/FSharp.Editor/Refactor/ConvertTuple.fs new file mode 100644 index 00000000000..57c69b9a132 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Refactor/ConvertTuple.fs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open System +open System.Composition +open System.Threading +open System.Threading.Tasks + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.CodeActions +open Microsoft.CodeAnalysis.CodeRefactorings + +open FSharp.Compiler.Syntax +open FSharp.Compiler.Text + +open CancellableTasks +open TupleConversion + +[] +type internal FSharpConvertTupleRefactoring [] () = + inherit CodeRefactoringProvider() + + static let hasSignatureFile (document: Document) = + let signaturePath = document.FilePath + "i" + + document.Project.Documents + |> Seq.exists (fun d -> String.Equals(d.FilePath, signaturePath, StringComparison.OrdinalIgnoreCase)) + + static let 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) + + override _.ComputeRefactoringsAsync context = + 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(nameof FSharpConvertTupleRefactoring) + + let caret = + let linePosition = sourceText.Lines.GetLinePosition context.Span.Start + Position.mkPos (Line.fromZ linePosition.Line) linePosition.Character + + match tryCaretNode caret parseResults.ParseTree with + | ValueSome caretNode when not (isInQuotation caretNode) -> + let title = + if isStructNode caretNode then + SR.ConvertToReferenceTuple() + else + SR.ConvertToStructTuple() + + let changedSolution = + cancellableTask { + let! converted = TuplePropagation.tryConvert document caretNode (nameof FSharpConvertTupleRefactoring) + + 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 + | _ -> () + } + |> CancellableTask.startAsTask context.CancellationToken diff --git a/vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs b/vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs new file mode 100644 index 00000000000..fb3ec7ddc23 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs @@ -0,0 +1,224 @@ +// 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 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 tuple's range. +let private 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) + +/// 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 + +/// 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 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 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 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 -> + ValueSome [ TextChange(TextSpan((spanOf sourceText parenRange).Start, 0), "struct ") ] + | _ 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 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 -> + ValueSome [ TextChange(TextSpan((spanOf sourceText parenRange).Start, 0), "struct ") ] + | _ when m.StartLine = m.EndLine -> + let span = spanOf sourceText m + + ValueSome + [ + TextChange(TextSpan(span.Start, 0), "struct (") + TextChange(TextSpan(span.End, 0), ")") + ] + | _ -> ValueNone + | _ -> ValueNone + +/// The innermost tuple type within the type that contains the position. +let rec tryTupleTypeAt (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) -> tryTupleTypeAt position inner + | SynType.App(typeName = typeName; typeArgs = typeArgs) + | SynType.LongIdentApp(typeName = typeName; typeArgs = typeArgs) -> + typeName :: typeArgs |> Seq.tryPickV (tryTupleTypeAt position) + | SynType.Fun(argType = argType; returnType = returnType) -> [ argType; returnType ] |> Seq.tryPickV (tryTupleTypeAt position) + | SynType.Tuple(path = segments) -> + segments + |> Seq.tryPickV (function + | SynTupleTypeSegment.Type element -> tryTupleTypeAt position element + | _ -> ValueNone) + | _ -> ValueNone + + match inner, ty with + | ValueSome _, _ -> inner + | ValueNone, SynType.Tuple _ -> ValueSome ty + | ValueNone, _ -> ValueNone + +/// What a tuple 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 tuple: SynExpr * path: SyntaxVisitorPath + | Pat of tuple: SynPat * path: SyntaxVisitorPath + | Type of tuple: SynType * annotated: Annotated * isWholeAnnotation: bool + +let isStructNode (node: CaretNode) = + match node with + | CaretNode.Expr(tuple = SynExpr.Tuple(isStruct = isStruct)) + | CaretNode.Pat(tuple = SynPat.Tuple(isStruct = isStruct)) + | CaretNode.Type(tuple = SynType.Tuple(isStruct = isStruct)) -> isStruct + | _ -> false + +/// The innermost tuple expression, pattern or annotated tuple type under the caret. +let tryCaretNode (caret: pos) (parseTree: ParsedInput) = + let annotationAt (annotation: SynType) (annotated: Annotated) = + tryTupleTypeAt caret annotation + |> ValueOption.map (fun tuple -> CaretNode.Type(tuple, annotated, isSame (stripParenTypes annotation) tuple)) + + (ValueNone, parseTree) + ||> ParsedInput.fold (fun found path node -> + match node with + | SyntaxNode.SynExpr(SynExpr.Tuple(range = m) as tuple) when containsPos m caret && not (isArgumentList tuple path) -> + ValueSome(CaretNode.Expr(tuple, path)) + | SyntaxNode.SynPat(SynPat.Tuple(range = m) as tuple) when containsPos m caret -> ValueSome(CaretNode.Pat(tuple, 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/TuplePropagation.fs b/vsintegration/src/FSharp.Editor/Refactor/TuplePropagation.fs new file mode 100644 index 00000000000..d816c0ffc3d --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Refactor/TuplePropagation.fs @@ -0,0 +1,593 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +module internal Microsoft.VisualStudio.FSharp.Editor.TuplePropagation + +open System +open System.Collections.Generic + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.Text + +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Symbols +open FSharp.Compiler.Syntax +open FSharp.Compiler.Text + +open CancellableTasks +open TupleConversion + +[] +type private Source = + { + Document: Document + Text: SourceText + Tree: ParsedInput + Check: FSharpCheckFileResults + } + +/// A place whose tuple type changes kind, 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 tuple 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 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) = + (ValueNone, tree) + ||> ParsedInput.fold (fun found _ node -> + match found, node with + | ValueNone, + SyntaxNode.SynBinding(SynBinding(headPat = SynPat.LongIdent(longDotId = SynLongIdent(id = ids); argPats = SynArgPats.Pats args))) when + group < args.Length + && List.tryLast ids + |> Option.exists (fun ident -> Position.posEq ident.idRange.Start declaration.Start) + -> + match stripParenPats (List.item group args), index with + | SynPat.Tuple(elementPats = elements), ValueSome index when index < elements.Length -> + ValueSome(stripParenPats (List.item index elements)) + | SynPat.Tuple _, _ + | _, ValueSome _ -> ValueNone + | parameter, ValueNone -> ValueSome parameter + | _ -> found) + +let private annotationChanges (sourceText: SourceText) (toStruct: bool) (annotation: SynType) = + match stripParenTypes annotation with + | SynType.Tuple _ as tuple -> typeChanges sourceText toStruct true tuple + | _ -> [] + +type private Engine(solution: Solution, toStruct: bool, userOpName: string) = + let sources = Dictionary() + let changes = Dictionary>() + let visited = HashSet(StringComparer.Ordinal) + let pending = Queue() + let mutable failed = false + + 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 (kind: string) (symbol: FSharpSymbol) = + symbol.DeclarationLocation + |> Option.map (fun m -> $"{kind}|{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 kind 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 toStruct 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 + | SynPat.Tuple _ -> this.AddOrFail source (tryPatChanges source.Text toStruct pat path) + | _ -> () + + /// The value of the node now has the changing kind: 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 toStruct 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 + match stripParenPats pat with + | SynPat.Tuple _ as tuple -> + let tuplePath = + match pat with + | SynPat.Paren _ -> [ SyntaxNode.SynPat pat ] + | _ -> [ SyntaxNode.SynMatchClause clause; SyntaxNode.SynExpr matchExpr ] @ rest + + this.AddOrFail source (tryPatChanges source.Text toStruct tuple tuplePath) + | _ -> () + | _ -> () + + /// The expression must now produce the changing kind: 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 toStruct annotation) + this.Retarget source inner childPath + | SynExpr.Tuple _ when not (isArgumentList expr path) -> this.AddOrFail source (tryExprChanges 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 toStruct 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 toStruct 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 parameter -> + match parameter with + | SynPat.Typed(targetType = annotation) -> this.Add definition (annotationChanges definition.Text toStruct annotation) + | _ -> () + + 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(tuple, path) -> + this.AddOrFail source (tryExprChanges source.Text toStruct tuple path) + this.FlowOut source tuple path + | CaretNode.Pat(tuple, path) -> + this.AddOrFail source (tryPatChanges source.Text toStruct tuple path) + + match tryMatchedExpression tuple path with + | ValueSome(struct (matched, matchedPath)) -> this.Retarget source matched matchedPath + | ValueNone -> () + | CaretNode.Type(tuple, annotated, isWholeAnnotation) -> + this.Add source (typeChanges source.Text toStruct isWholeAnnotation tuple) + + 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 + } + +/// Changes the tuple under the caret to the other kind, together with everything its value flows through. +let tryConvert (document: Document) (caretNode: CaretNode) (userOpName: string) = + Engine(document.Project.Solution, not (isStructNode caretNode), userOpName).Run(document, caretNode) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf index cd8c46bf705..3845d0b803e 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf @@ -95,6 +95,11 @@ Navrhnout názvy pro nerozpoznané identifikátory; Pro kontrolu nerovnosti použijte <>. + + Convert to reference tuple + Convert to reference tuple + + Use '=' for equality check Pro kontrolu rovnosti použijte =. @@ -105,6 +110,11 @@ Navrhnout názvy pro nerozpoznané identifikátory; Použít místo negace odčítání + + 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..927b263fd15 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf @@ -95,6 +95,11 @@ Namen für nicht aufgelöste Bezeichner vorschlagen; "<>" für die Überprüfung auf Ungleichheit verwenden + + Convert to reference tuple + Convert to reference tuple + + Use '=' for equality check "=" für Gleichheitsüberprüfung verwenden @@ -105,6 +110,11 @@ Namen für nicht aufgelöste Bezeichner vorschlagen; Subtraktion anstelle von Negation verwenden + + 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..a4b2893bd72 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf @@ -95,6 +95,11 @@ Sugerir nombres para identificadores sin resolver; Usar "<>" para la comprobación de desigualdad + + Convert to reference tuple + Convert to reference tuple + + Use '=' for equality check Usar "=" para la comprobación de igualdad @@ -105,6 +110,11 @@ Sugerir nombres para identificadores sin resolver; Usar la resta en lugar de la negación + + 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..d725f11ab73 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf @@ -95,6 +95,11 @@ Suggérer des noms pour les identificateurs non résolus ; Utiliser '<>' pour vérifier l'inégalité + + Convert to reference tuple + Convert to reference tuple + + Use '=' for equality check Utiliser '=' pour vérifier l'égalité @@ -105,6 +110,11 @@ Suggérer des noms pour les identificateurs non résolus ; Utiliser la soustraction à la place de la négation + + 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..8fa3ceaa7d2 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf @@ -95,6 +95,11 @@ Suggerisci i nomi per gli identificatori non risolti; Usare '<>' per il controllo di disuguaglianza + + Convert to reference tuple + Convert to reference tuple + + Use '=' for equality check Usare '=' per il controllo di uguaglianza @@ -105,6 +110,11 @@ Suggerisci i nomi per gli identificatori non risolti; Usare la sottrazione invece della negazione + + 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..c1e072c70c3 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf @@ -95,6 +95,11 @@ Suggest names for unresolved identifiers; 非等値のチェックには '<>' を使用します + + Convert to reference tuple + Convert to reference tuple + + Use '=' for equality check 等値性のチェックには '=' を使用します @@ -105,6 +110,11 @@ Suggest names for unresolved identifiers; 否定の代わりに減算を使用する + + 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..387620b5a70 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf @@ -95,6 +95,11 @@ Suggest names for unresolved identifiers; 같지 않음 검사에 '<>' 사용 + + Convert to reference tuple + Convert to reference tuple + + Use '=' for equality check 같음 검사에 '=' 사용 @@ -105,6 +110,11 @@ Suggest names for unresolved identifiers; 부정 대신 빼기 사용 + + 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..1c65a4b44a1 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf @@ -95,6 +95,11 @@ Sugeruj nazwy dla nierozpoznanych identyfikatorów; Użyj operatora „<>” do sprawdzenia nierówności + + Convert to reference tuple + Convert to reference tuple + + Use '=' for equality check Użyj znaku „=” w celu sprawdzenia równości @@ -105,6 +110,11 @@ Sugeruj nazwy dla nierozpoznanych identyfikatorów; Użyj odejmowania zamiast negacji + + 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..e500126ffa5 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,11 @@ Sugerir nomes para identificadores não resolvidos; Usar '<>' para a verificação de desigualdade + + Convert to reference tuple + Convert to reference tuple + + Use '=' for equality check Usar '=' para verificação de igualdade @@ -105,6 +110,11 @@ Sugerir nomes para identificadores não resolvidos; Use a subtração em vez da negação + + 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..06997d182cf 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf @@ -95,6 +95,11 @@ Suggest names for unresolved identifiers; Используйте "<>" для проверки на неравенство + + Convert to reference tuple + Convert to reference tuple + + Use '=' for equality check Используйте "=" для проверки равенства @@ -105,6 +110,11 @@ Suggest names for unresolved identifiers; Используйте вычитание вместо отрицания. + + 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..e0984ff97de 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf @@ -95,6 +95,11 @@ Kullanılmayan değerleri analiz et ve bunlara düzeltmeler öner; Eşitsizlik denetimi için '<>' kullanın + + Convert to reference tuple + Convert to reference tuple + + Use '=' for equality check Eşitlik denetimi için '=' kullan @@ -105,6 +110,11 @@ Kullanılmayan değerleri analiz et ve bunlara düzeltmeler öner; Negatif yapma yerine çıkarmayı kullanın + + 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..2bad999deda 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,11 @@ Suggest names for unresolved identifiers; 使用 "<>" 进行不相等检查 + + Convert to reference tuple + Convert to reference tuple + + Use '=' for equality check 使用 "=" 进行同等性检查 @@ -105,6 +110,11 @@ Suggest names for unresolved identifiers; 使用减法代替求反 + + 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..b19bc4dd4e5 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,11 @@ Suggest names for unresolved identifiers; 使用 '<>' 進行不等式檢查 + + Convert to reference tuple + Convert to reference tuple + + Use '=' for equality check 使用 '=' 檢查是否相等 @@ -105,6 +110,11 @@ Suggest names for unresolved identifiers; 使用減號代替否定 + + 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..19b83ee32a2 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -74,6 +74,7 @@ + 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..adcacd08618 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertTupleTests.fs @@ -0,0 +1,310 @@ +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 ``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.Empty(errorsOf document) + + 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 + ) + +[] +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..88800677c38 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.Compiler.CodeAnalysis + open FSharp.Editor.Tests.Helpers open Microsoft.CodeAnalysis.CodeRefactorings open Microsoft.CodeAnalysis.CodeActions @@ -31,7 +33,13 @@ type TestContext(Solution: Solution) = new TestContext(solution) static member CreateWithCodeAndDependency (code: string) (codeForPreviousFile: string) = - let mutable solution = RoslynTestHelpers.CreateSolution(codeForPreviousFile) + let options = + { RoslynTestHelpers.DefaultProjectOptions with + SourceFiles = [| "C:\\test.fs"; "C:\\test2.fs" |] + } + + let mutable solution = + RoslynTestHelpers.CreateSolution(codeForPreviousFile, options) let firstProject = solution.Projects.First() solution <- solution.AddDocument(DocumentId.CreateNewId(firstProject.Id), "test2.fs", code, filePath = "C:\\test2.fs") From 72217664ed53654f2291385a643d299938f286d6 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 17:01:36 +0200 Subject: [PATCH 2/7] Build the two-file refactoring test project as a synthetic project Adding the second document to a single-file solution left Find All References unable to see either file, so a chain through a function's call sites stopped at the first file. The synthetic project gives both files to the checker the way AddReturnTypeTests and FindReferencesTests set up theirs. Co-Authored-By: Claude Opus 5 (1M context) --- .../Refactors/RefactorTestFramework.fs | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs index 88800677c38..c5f2602b108 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/RefactorTestFramework.fs @@ -8,7 +8,7 @@ open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.Text open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks -open FSharp.Compiler.CodeAnalysis +open FSharp.Test.ProjectGeneration open FSharp.Editor.Tests.Helpers open Microsoft.CodeAnalysis.CodeRefactorings @@ -33,17 +33,19 @@ type TestContext(Solution: Solution) = new TestContext(solution) static member CreateWithCodeAndDependency (code: string) (codeForPreviousFile: string) = - let options = - { RoslynTestHelpers.DefaultProjectOptions with - SourceFiles = [| "C:\\test.fs"; "C:\\test2.fs" |] + let project = + { SyntheticProject.Create( + { sourceFile "First" [] with + Source = codeForPreviousFile + }, + { sourceFile "Second" [ "First" ] with + Source = code + } + ) with + AutoAddModules = false } - let mutable solution = - RoslynTestHelpers.CreateSolution(codeForPreviousFile, options) - - let firstProject = solution.Projects.First() - solution <- solution.AddDocument(DocumentId.CreateNewId(firstProject.Id), "test2.fs", code, filePath = "C:\\test2.fs") - + let solution, _ = RoslynTestHelpers.CreateSolution project new TestContext(solution) let tryRefactor (code: string) (cursorPosition) (context: TestContext) (refactorProvider: 'T :> CodeRefactoringProvider) = From 8be24fe1e845fcce3d88a82a0f5ede0661d880cc Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 17:09:15 +0200 Subject: [PATCH 3/7] Check the two-file tuple result as a new project The synthetic project's checker reads the other file from disk, so checking the refactored document saw the old definition; a struct tuple pattern happens to accept a reference tuple, which hid that. Co-Authored-By: Claude Opus 5 (1M context) --- .../Refactors/ConvertTupleTests.fs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertTupleTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertTupleTests.fs index adcacd08618..e8c89da12f5 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertTupleTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertTupleTests.fs @@ -178,8 +178,6 @@ let (a, b) = A.pair use context = TestContext.CreateWithCodeAndDependency code definition let document = refactorIn context code "a, b" - Assert.Empty(errorsOf document) - Assert.Equal( """ module B @@ -198,6 +196,15 @@ 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 = From d6f8aa4c7cca804d4941c3f27b2db539373fd842 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 17:09:57 +0200 Subject: [PATCH 4/7] Add a refactoring between reference and struct anonymous records Ctrl+. inside an anonymous record expression or an annotated anonymous record type converts it between {| ... |} and struct {| ... |}, following the value through the solution like the tuple refactoring. A copy-and-update has its own form and converts independently of its source. The propagation engine becomes independent of the kind of node: StructConversion holds the shared caret search and the StructKind description, TupleConversion and AnonymousRecordConversion are the two kinds, and StructPropagation (was TuplePropagation) takes a kind and registers the code action for both providers. Co-Authored-By: Claude Opus 5 (1M context) --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + .../src/FSharp.Editor/FSharp.Editor.fsproj | 5 +- .../src/FSharp.Editor/FSharp.Editor.resx | 6 + .../Refactor/AnonymousRecordConversion.fs | 48 +++ .../Refactor/ConvertAnonymousRecord.fs | 22 ++ .../FSharp.Editor/Refactor/ConvertTuple.fs | 76 +---- .../Refactor/StructConversion.fs | 121 +++++++ ...plePropagation.fs => StructPropagation.fs} | 143 ++++++--- .../FSharp.Editor/Refactor/TupleConversion.fs | 135 ++------ .../FSharp.Editor/xlf/FSharp.Editor.cs.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.de.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.es.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.fr.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.it.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.ja.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.ko.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.pl.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.ru.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.tr.xlf | 10 + .../xlf/FSharp.Editor.zh-Hans.xlf | 10 + .../xlf/FSharp.Editor.zh-Hant.xlf | 10 + .../FSharp.Editor.Tests.fsproj | 1 + .../Refactors/ConvertAnonymousRecordTests.fs | 301 ++++++++++++++++++ 24 files changed, 776 insertions(+), 213 deletions(-) create mode 100644 vsintegration/src/FSharp.Editor/Refactor/AnonymousRecordConversion.fs create mode 100644 vsintegration/src/FSharp.Editor/Refactor/ConvertAnonymousRecord.fs create mode 100644 vsintegration/src/FSharp.Editor/Refactor/StructConversion.fs rename vsintegration/src/FSharp.Editor/Refactor/{TuplePropagation.fs => StructPropagation.fs} (84%) create mode 100644 vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertAnonymousRecordTests.fs diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 99ae282a92d..1a274b91e2c 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -3,6 +3,7 @@ * 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)) * 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)) * 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. +* 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`. ### Fixed diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index deb62507428..ea88528975b 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -105,9 +105,12 @@ + - + + + diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx index a08c2b11c9c..1f5c21c82be 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx @@ -374,4 +374,10 @@ Use live (unsaved) buffers for analysis 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..b993ce1a5f0 --- /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 index 57c69b9a132..ddb820c25e3 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ConvertTuple.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ConvertTuple.fs @@ -2,85 +2,21 @@ namespace Microsoft.VisualStudio.FSharp.Editor -open System open System.Composition -open System.Threading -open System.Threading.Tasks -open Microsoft.CodeAnalysis -open Microsoft.CodeAnalysis.CodeActions open Microsoft.CodeAnalysis.CodeRefactorings -open FSharp.Compiler.Syntax -open FSharp.Compiler.Text - open CancellableTasks -open TupleConversion [] type internal FSharpConvertTupleRefactoring [] () = inherit CodeRefactoringProvider() - static let hasSignatureFile (document: Document) = - let signaturePath = document.FilePath + "i" - - document.Project.Documents - |> Seq.exists (fun d -> String.Equals(d.FilePath, signaturePath, StringComparison.OrdinalIgnoreCase)) - - static let 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) - override _.ComputeRefactoringsAsync context = - 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(nameof FSharpConvertTupleRefactoring) - - let caret = - let linePosition = sourceText.Lines.GetLinePosition context.Span.Start - Position.mkPos (Line.fromZ linePosition.Line) linePosition.Character - - match tryCaretNode caret parseResults.ParseTree with - | ValueSome caretNode when not (isInQuotation caretNode) -> - let title = - if isStructNode caretNode then - SR.ConvertToReferenceTuple() - else - SR.ConvertToStructTuple() - - let changedSolution = - cancellableTask { - let! converted = TuplePropagation.tryConvert document caretNode (nameof FSharpConvertTupleRefactoring) - - 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 - | _ -> () - } + 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..951f0d8008c --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Refactor/StructConversion.fs @@ -0,0 +1,121 @@ +// 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 + IsPat: SynPat -> 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 -> 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/TuplePropagation.fs b/vsintegration/src/FSharp.Editor/Refactor/StructPropagation.fs similarity index 84% rename from vsintegration/src/FSharp.Editor/Refactor/TuplePropagation.fs rename to vsintegration/src/FSharp.Editor/Refactor/StructPropagation.fs index d816c0ffc3d..d289265ee11 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/TuplePropagation.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/StructPropagation.fs @@ -1,11 +1,15 @@ // Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. -module internal Microsoft.VisualStudio.FSharp.Editor.TuplePropagation +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 @@ -14,7 +18,7 @@ open FSharp.Compiler.Syntax open FSharp.Compiler.Text open CancellableTasks -open TupleConversion +open StructConversion [] type private Source = @@ -25,7 +29,7 @@ type private Source = Check: FSharpCheckFileResults } -/// A place whose tuple type changes kind, and so passes the change on to what flows in and out of it. +/// 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. @@ -119,7 +123,7 @@ let rec private tryParameterPosition (pat: SynPat) (path: SyntaxVisitorPath) = | _ :: rest -> tryParameterPosition pat rest | [] -> ValueNone -/// The expression a tuple pattern takes apart: the right-hand side of its binding or the matched expression. +/// 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 @@ -185,18 +189,18 @@ let private tryParameterPattern (tree: ParsedInput) (declaration: range) (group: | parameter, ValueNone -> ValueSome parameter | _ -> found) -let private annotationChanges (sourceText: SourceText) (toStruct: bool) (annotation: SynType) = - match stripParenTypes annotation with - | SynType.Tuple _ as tuple -> typeChanges sourceText toStruct true tuple - | _ -> [] - -type private Engine(solution: Solution, toStruct: bool, userOpName: string) = +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 @@ -208,9 +212,9 @@ type private Engine(solution: Solution, toStruct: bool, userOpName: string) = |> ValueOption.map (fun document -> struct (declaration, document)) | None -> ValueNone - let keyOf (kind: string) (symbol: FSharpSymbol) = + let keyOf (slotKind: string) (symbol: FSharpSymbol) = symbol.DeclarationLocation - |> Option.map (fun m -> $"{kind}|{m.FileName}|{m.StartLine}|{m.StartColumn}") + |> Option.map (fun m -> $"{slotKind}|{m.FileName}|{m.StartLine}|{m.StartColumn}") member _.Load(document: Document) = cancellableTask { @@ -300,24 +304,24 @@ type private Engine(solution: Solution, toStruct: bool, userOpName: string) = | _ -> () | _ -> () - /// A value of the changing kind flows into a pattern. + /// 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 toStruct 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 - | SynPat.Tuple _ -> this.AddOrFail source (tryPatChanges source.Text toStruct pat path) + | _ when kind.IsPat pat -> this.AddOrFail source (kind.PatChanges source.Text toStruct pat path) | _ -> () - /// The value of the node now has the changing kind: pass that on to where it goes. + /// 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 toStruct annotation) + 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 @@ -362,26 +366,26 @@ type private Engine(solution: Solution, toStruct: bool, userOpName: string) = | SyntaxNode.SynExpr(SynExpr.Match(expr = scrutinee; clauses = clauses) as matchExpr) :: rest when isSame scrutinee node -> for SynMatchClause(pat = pat) as clause in clauses do match stripParenPats pat with - | SynPat.Tuple _ as tuple -> - let tuplePath = + | stripped when kind.IsPat stripped -> + let strippedPath = match pat with | SynPat.Paren _ -> [ SyntaxNode.SynPat pat ] | _ -> [ SyntaxNode.SynMatchClause clause; SyntaxNode.SynExpr matchExpr ] @ rest - this.AddOrFail source (tryPatChanges source.Text toStruct tuple tuplePath) + this.AddOrFail source (kind.PatChanges source.Text toStruct stripped strippedPath) | _ -> () | _ -> () - /// The expression must now produce the changing kind: change what it is built from. + /// 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 toStruct annotation) + this.Add source (annotationChanges source.Text annotation) this.Retarget source inner childPath - | SynExpr.Tuple _ when not (isArgumentList expr path) -> this.AddOrFail source (tryExprChanges source.Text toStruct expr path) + | _ 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)) -> @@ -415,13 +419,13 @@ type private Engine(solution: Solution, toStruct: bool, userOpName: string) = | SyntaxNode.SynPat(SynPat.Typed(pat = inner; targetType = annotation)) when tryPatternIdent inner |> ValueOption.exists isDeclared -> - this.Add source (annotationChanges source.Text toStruct annotation) + 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 toStruct annotation) + this.Add source (annotationChanges source.Text annotation) | _ -> () | _ -> ()) @@ -489,7 +493,7 @@ type private Engine(solution: Solution, toStruct: bool, userOpName: string) = match tryParameterPattern definition.Tree declaration group index with | ValueSome parameter -> match parameter with - | SynPat.Typed(targetType = annotation) -> this.Add definition (annotationChanges definition.Text toStruct annotation) + | SynPat.Typed(targetType = annotation) -> this.Add definition (annotationChanges definition.Text annotation) | _ -> () match tryPatternIdent parameter with @@ -520,17 +524,17 @@ type private Engine(solution: Solution, toStruct: bool, userOpName: string) = member this.Seed (source: Source) (caretNode: CaretNode) = match caretNode with - | CaretNode.Expr(tuple, path) -> - this.AddOrFail source (tryExprChanges source.Text toStruct tuple path) - this.FlowOut source tuple path - | CaretNode.Pat(tuple, path) -> - this.AddOrFail source (tryPatChanges source.Text toStruct tuple path) + | 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 tuple path with + match tryMatchedExpression node path with | ValueSome(struct (matched, matchedPath)) -> this.Retarget source matched matchedPath | ValueNone -> () - | CaretNode.Type(tuple, annotated, isWholeAnnotation) -> - this.Add source (typeChanges source.Text toStruct isWholeAnnotation tuple) + | CaretNode.Type(node, annotated, isWholeAnnotation) -> + this.Add source (kind.TypeChanges source.Text toStruct isWholeAnnotation node) if isWholeAnnotation then match annotated with @@ -588,6 +592,71 @@ type private Engine(solution: Solution, toStruct: bool, userOpName: string) = return if failed then ValueNone else result } -/// Changes the tuple under the caret to the other kind, together with everything its value flows through. -let tryConvert (document: Document) (caretNode: CaretNode) (userOpName: string) = - Engine(document.Project.Solution, not (isStructNode caretNode), userOpName).Run(document, caretNode) +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 index fb3ec7ddc23..8531a3c7c08 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs @@ -7,30 +7,8 @@ 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 tuple's range. -let private 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) +open StructConversion /// The position of the `(` that, with its `)`, encloses only the span and blanks. let private tryEnclosingParen (sourceText: SourceText) (span: TextSpan) = @@ -72,7 +50,7 @@ let private isGenericArgument (sourceText: SourceText) (start: int) (finish: int && (sourceText[after] = '>' || sourceText[after] = ',') /// Changes giving a tuple type the target kind; a whole annotation also loses the parentheses `struct` needed. -let typeChanges (sourceText: SourceText) (toStruct: bool) (isWholeAnnotation: bool) (tupleType: SynType) = +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 @@ -98,14 +76,14 @@ let typeChanges (sourceText: SourceText) (toStruct: bool) (isWholeAnnotation: bo | _ -> [] /// Whether the tuple is the argument list of a method, constructor or union case call rather than a tuple value. -let isArgumentList (tuple: SynExpr) (path: SyntaxVisitorPath) = +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 tryExprChanges (sourceText: SourceText) (toStruct: bool) (tuple: SynExpr) (path: SyntaxVisitorPath) = +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, "") ] @@ -125,7 +103,7 @@ let tryExprChanges (sourceText: SourceText) (toStruct: bool) (tuple: SynExpr) (p | _ -> ValueNone /// Changes giving a tuple pattern the target kind; ValueNone when it is not a tuple or cannot change in place. -let tryPatChanges (sourceText: SourceText) (toStruct: bool) (tuple: SynPat) (path: SyntaxVisitorPath) = +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, "") ] @@ -144,81 +122,28 @@ let tryPatChanges (sourceText: SourceText) (toStruct: bool) (tuple: SynPat) (pat | _ -> ValueNone | _ -> ValueNone -/// The innermost tuple type within the type that contains the position. -let rec tryTupleTypeAt (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) -> tryTupleTypeAt position inner - | SynType.App(typeName = typeName; typeArgs = typeArgs) - | SynType.LongIdentApp(typeName = typeName; typeArgs = typeArgs) -> - typeName :: typeArgs |> Seq.tryPickV (tryTupleTypeAt position) - | SynType.Fun(argType = argType; returnType = returnType) -> [ argType; returnType ] |> Seq.tryPickV (tryTupleTypeAt position) - | SynType.Tuple(path = segments) -> - segments - |> Seq.tryPickV (function - | SynTupleTypeSegment.Type element -> tryTupleTypeAt position element - | _ -> ValueNone) - | _ -> ValueNone - - match inner, ty with - | ValueSome _, _ -> inner - | ValueNone, SynType.Tuple _ -> ValueSome ty - | ValueNone, _ -> ValueNone - -/// What a tuple 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 tuple: SynExpr * path: SyntaxVisitorPath - | Pat of tuple: SynPat * path: SyntaxVisitorPath - | Type of tuple: SynType * annotated: Annotated * isWholeAnnotation: bool - -let isStructNode (node: CaretNode) = - match node with - | CaretNode.Expr(tuple = SynExpr.Tuple(isStruct = isStruct)) - | CaretNode.Pat(tuple = SynPat.Tuple(isStruct = isStruct)) - | CaretNode.Type(tuple = SynType.Tuple(isStruct = isStruct)) -> isStruct - | _ -> false - -/// The innermost tuple expression, pattern or annotated tuple type under the caret. -let tryCaretNode (caret: pos) (parseTree: ParsedInput) = - let annotationAt (annotation: SynType) (annotated: Annotated) = - tryTupleTypeAt caret annotation - |> ValueOption.map (fun tuple -> CaretNode.Type(tuple, annotated, isSame (stripParenTypes annotation) tuple)) - - (ValueNone, parseTree) - ||> ParsedInput.fold (fun found path node -> - match node with - | SyntaxNode.SynExpr(SynExpr.Tuple(range = m) as tuple) when containsPos m caret && not (isArgumentList tuple path) -> - ValueSome(CaretNode.Expr(tuple, path)) - | SyntaxNode.SynPat(SynPat.Tuple(range = m) as tuple) when containsPos m caret -> ValueSome(CaretNode.Pat(tuple, 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) +let kind: StructKind = + { + IsExpr = + fun expr path -> + match expr with + | SynExpr.Tuple _ -> not (isArgumentList expr path) + | _ -> false + IsPat = + function + | SynPat.Tuple _ -> true + | _ -> 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 3845d0b803e..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,11 @@ 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 @@ -110,6 +115,11 @@ 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 diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf index 927b263fd15..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,11 @@ 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 @@ -110,6 +115,11 @@ 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 diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf index a4b2893bd72..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,11 @@ 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 @@ -110,6 +115,11 @@ 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 diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf index d725f11ab73..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,11 @@ 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 @@ -110,6 +115,11 @@ 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 diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf index 8fa3ceaa7d2..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,11 @@ 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 @@ -110,6 +115,11 @@ 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 diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf index c1e072c70c3..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,11 @@ Suggest names for unresolved identifiers; 非等値のチェックには '<>' を使用します + + Convert to reference anonymous record + Convert to reference anonymous record + + Convert to reference tuple Convert to reference tuple @@ -110,6 +115,11 @@ Suggest names for unresolved identifiers; 否定の代わりに減算を使用する + + Convert to struct anonymous record + Convert to struct anonymous record + + Convert to struct tuple Convert to struct tuple diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf index 387620b5a70..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,11 @@ Suggest names for unresolved identifiers; 같지 않음 검사에 '<>' 사용 + + Convert to reference anonymous record + Convert to reference anonymous record + + Convert to reference tuple Convert to reference tuple @@ -110,6 +115,11 @@ Suggest names for unresolved identifiers; 부정 대신 빼기 사용 + + Convert to struct anonymous record + Convert to struct anonymous record + + Convert to struct tuple Convert to struct tuple diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf index 1c65a4b44a1..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,11 @@ 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 @@ -110,6 +115,11 @@ 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 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 e500126ffa5..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,11 @@ 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 @@ -110,6 +115,11 @@ 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 diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf index 06997d182cf..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,11 @@ Suggest names for unresolved identifiers; Используйте "<>" для проверки на неравенство + + Convert to reference anonymous record + Convert to reference anonymous record + + Convert to reference tuple Convert to reference tuple @@ -110,6 +115,11 @@ Suggest names for unresolved identifiers; Используйте вычитание вместо отрицания. + + Convert to struct anonymous record + Convert to struct anonymous record + + Convert to struct tuple Convert to struct tuple diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf index e0984ff97de..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,11 @@ 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 @@ -110,6 +115,11 @@ 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 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 2bad999deda..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,11 @@ Suggest names for unresolved identifiers; 使用 "<>" 进行不相等检查 + + Convert to reference anonymous record + Convert to reference anonymous record + + Convert to reference tuple Convert to reference tuple @@ -110,6 +115,11 @@ Suggest names for unresolved identifiers; 使用减法代替求反 + + Convert to struct anonymous record + Convert to struct anonymous record + + Convert to struct tuple Convert to struct tuple 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 b19bc4dd4e5..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,11 @@ Suggest names for unresolved identifiers; 使用 '<>' 進行不等式檢查 + + Convert to reference anonymous record + Convert to reference anonymous record + + Convert to reference tuple Convert to reference tuple @@ -110,6 +115,11 @@ Suggest names for unresolved identifiers; 使用減號代替否定 + + Convert to struct anonymous record + Convert to struct anonymous record + + Convert to struct tuple Convert to struct tuple diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index 19b83ee32a2..344bd584e21 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -75,6 +75,7 @@ + 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) From 67af6be7d28a0833dca7e515003a0c9c9193e9a9 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 19:56:49 +0200 Subject: [PATCH 5/7] Leave a method's parameter list alone and convert curried tuple arguments with their calls The only argument of a member or constructor is its parameter list, not a tuple, so it is no longer offered. A tuple that is a whole curried argument of a function or member now converts the matching argument at every call, and `struct` no longer runs into a name the parenthesis follows (`f(a, b)`). Co-Authored-By: Claude Opus 5 (1M context) --- .../Refactor/AnonymousRecordConversion.fs | 2 +- .../Refactor/StructConversion.fs | 5 +- .../Refactor/StructPropagation.fs | 65 ++++++++++----- .../FSharp.Editor/Refactor/TupleConversion.fs | 32 ++++++-- .../Refactors/ConvertTupleTests.fs | 80 +++++++++++++++++++ 5 files changed, 157 insertions(+), 27 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/AnonymousRecordConversion.fs b/vsintegration/src/FSharp.Editor/Refactor/AnonymousRecordConversion.fs index b993ce1a5f0..1eb878d90ec 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/AnonymousRecordConversion.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/AnonymousRecordConversion.fs @@ -24,7 +24,7 @@ let kind: StructKind = match expr with | SynExpr.AnonRecd _ -> true | _ -> false - IsPat = fun _ -> false + IsPat = fun _ _ -> false IsType = function | SynType.AnonRecd _ -> true diff --git a/vsintegration/src/FSharp.Editor/Refactor/StructConversion.fs b/vsintegration/src/FSharp.Editor/Refactor/StructConversion.fs index 951f0d8008c..23b6ceed49f 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/StructConversion.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/StructConversion.fs @@ -52,7 +52,8 @@ 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 - IsPat: SynPat -> 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. @@ -99,7 +100,7 @@ let tryCaretNode (kind: StructKind) (caret: pos) (parseTree: ParsedInput) = ||> 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 -> ValueSome(CaretNode.Pat(pat, 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 diff --git a/vsintegration/src/FSharp.Editor/Refactor/StructPropagation.fs b/vsintegration/src/FSharp.Editor/Refactor/StructPropagation.fs index d289265ee11..d96ceec790d 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/StructPropagation.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/StructPropagation.fs @@ -134,6 +134,20 @@ let rec private tryMatchedExpression (pat: SynPat) (path: SyntaxVisitorPath) = 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) = @@ -172,21 +186,30 @@ let private tryRecordFieldValue (tree: ParsedInput) (useRange: range) = /// 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 _ node -> + ||> 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))) when + 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) -> - match stripParenPats (List.item group args), index with - | SynPat.Tuple(elementPats = elements), ValueSome index when index < elements.Length -> - ValueSome(stripParenPats (List.item index elements)) - | SynPat.Tuple _, _ + 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 parameter + | parameter, ValueNone -> ValueSome(struct (parameter, argumentPath)) | _ -> found) type private Engine(solution: Solution, kind: StructKind, toStruct: bool, userOpName: string) = @@ -313,7 +336,7 @@ type private Engine(solution: Solution, kind: StructKind, toStruct: bool, userOp 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 -> this.AddOrFail source (kind.PatChanges source.Text toStruct pat path) + | _ 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. @@ -365,15 +388,15 @@ type private Engine(solution: Solution, kind: StructKind, toStruct: bool, userOp | _ -> () | SyntaxNode.SynExpr(SynExpr.Match(expr = scrutinee; clauses = clauses) as matchExpr) :: rest when isSame scrutinee node -> for SynMatchClause(pat = pat) as clause in clauses do - match stripParenPats pat with - | stripped when kind.IsPat stripped -> - let strippedPath = - match pat with - | SynPat.Paren _ -> [ SyntaxNode.SynPat pat ] - | _ -> [ SyntaxNode.SynMatchClause clause; SyntaxNode.SynExpr matchExpr ] @ rest + 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. @@ -491,9 +514,12 @@ type private Engine(solution: Solution, kind: StructKind, toStruct: bool, userOp let! definition = this.Load document match tryParameterPattern definition.Tree declaration group index with - | ValueSome parameter -> + | 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 @@ -530,9 +556,10 @@ type private Engine(solution: Solution, kind: StructKind, toStruct: bool, userOp | CaretNode.Pat(node, path) -> this.AddOrFail source (kind.PatChanges source.Text toStruct node path) - match tryMatchedExpression node path with - | ValueSome(struct (matched, matchedPath)) -> this.Retarget source matched matchedPath - | ValueNone -> () + 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) diff --git a/vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs b/vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs index 8531a3c7c08..cb55945c4dc 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs @@ -32,6 +32,12 @@ let private tryEnclosingParen (sourceText: SourceText) (span: TextSpan) = 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 @@ -90,7 +96,8 @@ let private tryExprChanges (sourceText: SourceText) (toStruct: bool) (tuple: Syn | SynExpr.Tuple(range = m) -> match path with | SyntaxNode.SynExpr(SynExpr.Paren(expr = inner; range = parenRange)) :: _ when isSame inner tuple -> - ValueSome [ TextChange(TextSpan((spanOf sourceText parenRange).Start, 0), "struct ") ] + let start = (spanOf sourceText parenRange).Start + ValueSome [ TextChange(TextSpan(start, 0), structAt sourceText start) ] | _ when m.StartLine = m.EndLine -> let span = spanOf sourceText m @@ -110,7 +117,8 @@ let private tryPatChanges (sourceText: SourceText) (toStruct: bool) (tuple: SynP | SynPat.Tuple(range = m) -> match path with | SyntaxNode.SynPat(SynPat.Paren(pat = inner; range = parenRange)) :: _ when isSame inner tuple -> - ValueSome [ TextChange(TextSpan((spanOf sourceText parenRange).Start, 0), "struct ") ] + let start = (spanOf sourceText parenRange).Start + ValueSome [ TextChange(TextSpan(start, 0), structAt sourceText start) ] | _ when m.StartLine = m.EndLine -> let span = spanOf sourceText m @@ -122,6 +130,19 @@ let private tryPatChanges (sourceText: SourceText) (toStruct: bool) (tuple: SynP | _ -> 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 = @@ -130,9 +151,10 @@ let kind: StructKind = | SynExpr.Tuple _ -> not (isArgumentList expr path) | _ -> false IsPat = - function - | SynPat.Tuple _ -> true - | _ -> false + fun pat path -> + match pat with + | SynPat.Tuple _ -> not (isParameterList pat path) + | _ -> false IsType = function | SynType.Tuple _ -> true diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertTupleTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertTupleTests.fs index e8c89da12f5..e5b20a66fba 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertTupleTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertTupleTests.fs @@ -159,6 +159,65 @@ let ``Parameter converts the matching argument of every call`` (reference: strin 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 = @@ -314,4 +373,25 @@ module M let quoted = <@ (1, 2) @> """, "1, 2")>] +[] +[] +[] let ``No action`` (code: string, marker: string) = Assert.Empty(actionsAt code marker) From 7fad7aec72599729921972d299712139b72b5aed Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 19:59:10 +0200 Subject: [PATCH 6/7] Leave a method's parameter list alone and convert curried tuple arguments with their calls The only argument of a member or constructor is its parameter list, not a tuple, so it is no longer offered. A tuple that is a whole curried argument of a function or member now converts the matching argument at every call, and `struct` no longer runs into a name the parenthesis follows (`f(a, b)`). Co-Authored-By: Claude Opus 5 (1M context) --- .../FSharp.Editor/Refactor/TupleConversion.fs | 28 ++++++- .../Refactor/TuplePropagation.fs | 50 +++++++++--- .../Refactors/ConvertTupleTests.fs | 80 +++++++++++++++++++ 3 files changed, 143 insertions(+), 15 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs b/vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs index fb3ec7ddc23..979b730a7b6 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/TupleConversion.fs @@ -54,6 +54,12 @@ let private tryEnclosingParen (sourceText: SourceText) (span: TextSpan) = 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 @@ -104,6 +110,19 @@ let isArgumentList (tuple: SynExpr) (path: SyntaxVisitorPath) = expr = arg)) :: _ -> isSame inner tuple && isSame arg paren | _ -> false +/// Whether the tuple pattern is the parameter list of a member or constructor: its only argument, parenthesized or +/// (a struct tuple) not. +let 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 + /// Changes giving a tuple expression the target kind; ValueNone when it is not a tuple or cannot change in place. let tryExprChanges (sourceText: SourceText) (toStruct: bool) (tuple: SynExpr) (path: SyntaxVisitorPath) = match tuple with @@ -112,7 +131,8 @@ let tryExprChanges (sourceText: SourceText) (toStruct: bool) (tuple: SynExpr) (p | SynExpr.Tuple(range = m) -> match path with | SyntaxNode.SynExpr(SynExpr.Paren(expr = inner; range = parenRange)) :: _ when isSame inner tuple -> - ValueSome [ TextChange(TextSpan((spanOf sourceText parenRange).Start, 0), "struct ") ] + let start = (spanOf sourceText parenRange).Start + ValueSome [ TextChange(TextSpan(start, 0), structAt sourceText start) ] | _ when m.StartLine = m.EndLine -> let span = spanOf sourceText m @@ -132,7 +152,8 @@ let tryPatChanges (sourceText: SourceText) (toStruct: bool) (tuple: SynPat) (pat | SynPat.Tuple(range = m) -> match path with | SyntaxNode.SynPat(SynPat.Paren(pat = inner; range = parenRange)) :: _ when isSame inner tuple -> - ValueSome [ TextChange(TextSpan((spanOf sourceText parenRange).Start, 0), "struct ") ] + let start = (spanOf sourceText parenRange).Start + ValueSome [ TextChange(TextSpan(start, 0), structAt sourceText start) ] | _ when m.StartLine = m.EndLine -> let span = spanOf sourceText m @@ -202,7 +223,8 @@ let tryCaretNode (caret: pos) (parseTree: ParsedInput) = match node with | SyntaxNode.SynExpr(SynExpr.Tuple(range = m) as tuple) when containsPos m caret && not (isArgumentList tuple path) -> ValueSome(CaretNode.Expr(tuple, path)) - | SyntaxNode.SynPat(SynPat.Tuple(range = m) as tuple) when containsPos m caret -> ValueSome(CaretNode.Pat(tuple, path)) + | SyntaxNode.SynPat(SynPat.Tuple(range = m) as tuple) when containsPos m caret && not (isParameterList tuple path) -> + ValueSome(CaretNode.Pat(tuple, path)) | SyntaxNode.SynPat(SynPat.Typed(targetType = annotation) as pat) when containsPos annotation.Range caret -> annotationAt annotation (Annotated.Pattern(pat, path)) |> ValueOption.orElse found diff --git a/vsintegration/src/FSharp.Editor/Refactor/TuplePropagation.fs b/vsintegration/src/FSharp.Editor/Refactor/TuplePropagation.fs index d816c0ffc3d..4e87537351b 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/TuplePropagation.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/TuplePropagation.fs @@ -130,6 +130,20 @@ let rec private tryMatchedExpression (pat: SynPat) (path: SyntaxVisitorPath) = 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) = @@ -168,21 +182,30 @@ let private tryRecordFieldValue (tree: ParsedInput) (useRange: range) = /// 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 _ node -> + ||> 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))) when + 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) -> - match stripParenPats (List.item group args), index with - | SynPat.Tuple(elementPats = elements), ValueSome index when index < elements.Length -> - ValueSome(stripParenPats (List.item index elements)) - | SynPat.Tuple _, _ + 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 parameter + | parameter, ValueNone -> ValueSome(struct (parameter, argumentPath)) | _ -> found) let private annotationChanges (sourceText: SourceText) (toStruct: bool) (annotation: SynType) = @@ -309,7 +332,7 @@ type private Engine(solution: Solution, toStruct: bool, userOpName: string) = 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 - | SynPat.Tuple _ -> this.AddOrFail source (tryPatChanges source.Text toStruct pat path) + | SynPat.Tuple _ when not (isParameterList pat path) -> this.AddOrFail source (tryPatChanges source.Text toStruct pat path) | _ -> () /// The value of the node now has the changing kind: pass that on to where it goes. @@ -487,9 +510,11 @@ type private Engine(solution: Solution, toStruct: bool, userOpName: string) = let! definition = this.Load document match tryParameterPattern definition.Tree declaration group index with - | ValueSome parameter -> + | ValueSome(struct (SynPat.Tuple _ as parameter, parameterPath)) when isParameterList parameter parameterPath -> () + | ValueSome(struct (parameter, parameterPath)) -> match parameter with | SynPat.Typed(targetType = annotation) -> this.Add definition (annotationChanges definition.Text toStruct annotation) + | SynPat.Tuple _ -> this.AddOrFail definition (tryPatChanges definition.Text toStruct parameter parameterPath) | _ -> () match tryPatternIdent parameter with @@ -526,9 +551,10 @@ type private Engine(solution: Solution, toStruct: bool, userOpName: string) = | CaretNode.Pat(tuple, path) -> this.AddOrFail source (tryPatChanges source.Text toStruct tuple path) - match tryMatchedExpression tuple path with - | ValueSome(struct (matched, matchedPath)) -> this.Retarget source matched matchedPath - | ValueNone -> () + match tryMatchedExpression tuple path, tryWholeArgument tuple 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(tuple, annotated, isWholeAnnotation) -> this.Add source (typeChanges source.Text toStruct isWholeAnnotation tuple) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertTupleTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertTupleTests.fs index e8c89da12f5..e5b20a66fba 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertTupleTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertTupleTests.fs @@ -159,6 +159,65 @@ let ``Parameter converts the matching argument of every call`` (reference: strin 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 = @@ -314,4 +373,25 @@ module M let quoted = <@ (1, 2) @> """, "1, 2")>] +[] +[] +[] let ``No action`` (code: string, marker: string) = Assert.Empty(actionsAt code marker) From 1bdf35cdbcfbece66560dc995973ab70c79e2348 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 21:36:28 +0200 Subject: [PATCH 7/7] Link the release note to the pull request and move it to a random line of its section Co-Authored-By: Claude Opus 5 (1M context) --- docs/release-notes/.VisualStudio/18.vNext.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 1a274b91e2c..d2a8056e963 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -1,9 +1,9 @@ ### 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)) -* 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. -* 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`. ### Fixed