From 512e8cba742535fbba0187b1e309cc9fd043b7a4 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 16:33:21 +0200 Subject: [PATCH 1/5] 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/5] 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/5] 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 7fad7aec72599729921972d299712139b72b5aed Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 19:59:10 +0200 Subject: [PATCH 4/5] 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 a8a98ed43015b6b8848bf0ef4382142f50a63a14 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 21:36:25 +0200 Subject: [PATCH 5/5] 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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 99ae282a92d..29b3c84e594 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -1,8 +1,8 @@ ### Added * 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. ### Fixed