diff --git a/src/EditorFeatures/CSharpTest/CodeActions/ConvertLocalFunctionToMethod/ConvertLocalFunctionToMethodTests.cs b/src/EditorFeatures/CSharpTest/CodeActions/ConvertLocalFunctionToMethod/ConvertLocalFunctionToMethodTests.cs index c97b0724871cd..aaef27add2538 100644 --- a/src/EditorFeatures/CSharpTest/CodeActions/ConvertLocalFunctionToMethod/ConvertLocalFunctionToMethodTests.cs +++ b/src/EditorFeatures/CSharpTest/CodeActions/ConvertLocalFunctionToMethod/ConvertLocalFunctionToMethodTests.cs @@ -505,10 +505,11 @@ public async Task TestCaretPositon() await TestAsync("C Local[||]Function(C c)"); await TestAsync("C [|LocalFunction|](C c)"); await TestAsync("C LocalFunction[||](C c)"); - await TestMissingAsync("C Local[|Function|](C c)"); - await TestMissingAsync("[||]C LocalFunction(C c)"); + await TestAsync("C Local[|Function|](C c)"); + await TestAsync("[||]C LocalFunction(C c)"); await TestMissingAsync("[|C|] LocalFunction(C c)"); await TestMissingAsync("C[||] LocalFunction(C c)"); + await TestMissingAsync("C[| |]LocalFunction(C c)"); await TestMissingAsync("C LocalFunction([||]C c)"); await TestMissingAsync("C LocalFunction(C [||]c)"); @@ -553,5 +554,256 @@ void M() }}"); } } + + [Fact, Trait(Traits.Feature, Traits.Features.CodeActionsConvertLocalFunctionToMethod)] + public async Task TestMethodBlockSelection1() + { + await TestInRegularAndScriptAsync( +@"class C +{ + void M() + { + [|C LocalFunction(C c) + { + return null; + }|] + } +}", +@"class C +{ + void M() + { + } + + private static C LocalFunction(C c) + { + return null; + } +}"); + } + + [Fact, Trait(Traits.Feature, Traits.Features.CodeActionsConvertLocalFunctionToMethod)] + public async Task TestMethodBlockSelection2() + { + + await TestMissingAsync( +@"class C +{ + void M() + { + C LocalFunction(C c)[| + { + return null; + }|] + } +}"); + } + + [Fact, Trait(Traits.Feature, Traits.Features.CodeActionsConvertLocalFunctionToMethod)] + public async Task TestMethodBlockSelection3() + { + await TestInRegularAndScriptAsync( + +@"class C +{ + void M() + { +[| + C LocalFunction(C c) + { + return null; + } + |] + } +}", +@"class C +{ + void M() + { + + } + + private static C LocalFunction(C c) + { + return null; + } +}"); + } + + [Fact, Trait(Traits.Feature, Traits.Features.CodeActionsConvertLocalFunctionToMethod)] + public async Task TestMethodBlockSelection4() + { + + await this.TestMissingAsync( + @"class C +{ + void M() + { + + object a = null[|; + C LocalFunction(C c) + { + return null; + + }|] + } +}"); + } + + [Fact, Trait(Traits.Feature, Traits.Features.CodeActionsConvertLocalFunctionToMethod)] + public async Task TestMethodBlockSelection5() + { + + await this.TestMissingAsync( + @"class C +{ + void M() + { + + [| + C LocalFunction(C c) + { + return null; + + } + object|] a = null + } +}"); + } + + [Fact, Trait(Traits.Feature, Traits.Features.CodeActionsConvertLocalFunctionToMethod)] + public async Task TestMethodBlockSelection6() + { + + await this.TestMissingAsync( + @"class C +{ + void M() + { + C LocalFunction(C c) + { + object b = null; + [| + object a = null; + return null; + |] + + } + } +}"); + } + + [Fact, Trait(Traits.Feature, Traits.Features.CodeActionsConvertLocalFunctionToMethod)] + public async Task TestMethodBlockSelection7() + { + await TestMissingAsync( +@"class C +{ + void M() + { + C LocalFunction(C c) + { + [|return null;|] + } + } +}"); + } + + [Fact, Trait(Traits.Feature, Traits.Features.CodeActionsConvertLocalFunctionToMethod)] + public async Task TestMethodBlockSelection8() + { + await TestInRegularAndScriptAsync( +@"class C +{ + void M() + { + [|C LocalFunction(C c)|] + { + return null; + } + } +}", +@"class C +{ + void M() + { + } + + private static C LocalFunction(C c) + { + return null; + } +}"); + } + + [Fact, Trait(Traits.Feature, Traits.Features.CodeActionsConvertLocalFunctionToMethod)] + public async Task TestMethodBlockSelection9() + { + await TestInRegularAndScriptAsync( +@"class C +{ + void M() + { + C LocalFunction(C c) + { + return null; + }[||] + } +}", +@"class C +{ + void M() + { + } + + private static C LocalFunction(C c) + { + return null; + } +}"); + } + + [Fact, Trait(Traits.Feature, Traits.Features.CodeActionsConvertLocalFunctionToMethod)] + public async Task TestMethodBlockSelection10() + { + await TestInRegularAndScriptAsync( +@"class C +{ + void M() + { + [||]C LocalFunction(C c) + { + return null; + } + } +}", +@"class C +{ + void M() + { + } + + private static C LocalFunction(C c) + { + return null; + } +}"); + } + [Fact, Trait(Traits.Feature, Traits.Features.CodeActionsConvertLocalFunctionToMethod)] + public async Task TestMethodBlockSelection11() + { + await TestMissingAsync( +@"class C +{ + void M() + { + object a = null;[||] + C LocalFunction(C c) + { + return null; + } + } +}"); + } } } diff --git a/src/Features/CSharp/Portable/CodeRefactorings/ConvertLocalFunctionToMethod/CSharpConvertLocalFunctionToMethodCodeRefactoringProvider.cs b/src/Features/CSharp/Portable/CodeRefactorings/ConvertLocalFunctionToMethod/CSharpConvertLocalFunctionToMethodCodeRefactoringProvider.cs index dfd20d1a0b58a..ed4543544d08a 100644 --- a/src/Features/CSharp/Portable/CodeRefactorings/ConvertLocalFunctionToMethod/CSharpConvertLocalFunctionToMethodCodeRefactoringProvider.cs +++ b/src/Features/CSharp/Portable/CodeRefactorings/ConvertLocalFunctionToMethod/CSharpConvertLocalFunctionToMethodCodeRefactoringProvider.cs @@ -16,6 +16,7 @@ using Microsoft.CodeAnalysis.Shared.Extensions; using Microsoft.CodeAnalysis.Shared.Utilities; using Microsoft.CodeAnalysis.Simplification; +using Microsoft.CodeAnalysis.Text; using Roslyn.Utilities; namespace Microsoft.CodeAnalysis.CSharp.CodeRefactorings.ConvertLocalFunctionToMethod @@ -40,23 +41,9 @@ public override async Task ComputeRefactoringsAsync(CodeRefactoringContext conte } var cancellationToken = context.CancellationToken; - var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); - - var identifier = await root.SyntaxTree.GetTouchingTokenAsync(context.Span.Start, - token => token.Parent.IsKind(SyntaxKind.LocalFunctionStatement), cancellationToken).ConfigureAwait(false); - if (identifier == default) - { - return; - } - if (context.Span.Length > 0 && - context.Span != identifier.Span) - { - return; - } - - var localFunction = (LocalFunctionStatementSyntax)identifier.Parent; - if (localFunction.Identifier != identifier) + var localFunction = await CodeRefactoringHelpers.TryGetSelectedNodeAsync(document, context.Span, cancellationToken).ConfigureAwait(false); + if (localFunction == default) { return; } @@ -66,6 +53,8 @@ public override async Task ComputeRefactoringsAsync(CodeRefactoringContext conte return; } + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + context.RegisterRefactoring(new MyCodeAction(CSharpFeaturesResources.Convert_to_method, c => UpdateDocumentAsync(root, document, parentBlock, localFunction, c))); } diff --git a/src/Features/Core/Portable/CodeRefactoringHelpers.cs b/src/Features/Core/Portable/CodeRefactoringHelpers.cs index 76f450a52e0fc..8e25a67d852bc 100644 --- a/src/Features/Core/Portable/CodeRefactoringHelpers.cs +++ b/src/Features/Core/Portable/CodeRefactoringHelpers.cs @@ -3,12 +3,89 @@ using System.Collections.Immutable; using System.Threading; using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Shared.Extensions; using Microsoft.CodeAnalysis.Text; namespace Microsoft.CodeAnalysis { internal static class CodeRefactoringHelpers { + /// + /// + /// Returns an instance of for refactoring given specified selection in document or null + /// if no such instance exists. + /// + /// + /// A instance is returned if: + /// - Selection is zero-width and inside/touching a Token with direct parent of type . + /// - Selection is zero-width and touching a Token whose ancestor ends/starts precisely on current selection . + /// - Token whose direct parent of type is selected. + /// - Whole node of a type is selected. + /// + /// + /// Note: this function strips all whitespace from both the beginning and the end of given . + /// The stripped version is then used to determine relevant . It also handles incomplete selections + /// of tokens gracefully. + /// + /// + public static async Task TryGetSelectedNodeAsync( + Document document, TextSpan selection, CancellationToken cancellationToken) where TSyntaxNode : SyntaxNode + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + var selectionStripped = await GetStrippedTextSpan(document, selection, cancellationToken).ConfigureAwait(false); + + var node = root.FindNode(selectionStripped, getInnermostNodeForTie: true); + SyntaxNode prevNode; + do + { + if (node is TSyntaxNode) + { + return (TSyntaxNode)node; + } + + prevNode = node; + node = node.Parent; + + } while (node != null && prevNode.FullWidth() == node.FullWidth()); + + // only consider what is direct selection touching when selection is empty + // prevents `[|C|] methodName(){}` from registering as relevant for method Node + if (!selection.IsEmpty) + { + return default; + } + + var tokenToLeft = await root.SyntaxTree.GetTouchingTokenToLeftAsync(selectionStripped.Start, cancellationToken).ConfigureAwait(false); + var leftNode = tokenToLeft.Parent; + do + { + // either touches a Token which parent is `TSyntaxNode` or is whose ancestor's span ends on selection + if (leftNode is TSyntaxNode) + { + return (TSyntaxNode)leftNode; + } + + leftNode = leftNode?.Parent; + } while (leftNode != null && leftNode.Span.End == selection.Start); + + + var tokenToRight = await root.SyntaxTree.GetTouchingTokenToRightOrInAsync(selectionStripped.Start, cancellationToken).ConfigureAwait(false); + var rightNode = tokenToRight.Parent; + do + { + // either touches a Token which parent is `TSyntaxNode` or is whose ancestor's span starts on selection + if (rightNode is TSyntaxNode) + { + return (TSyntaxNode)rightNode; + } + + rightNode = rightNode?.Parent; + } while (rightNode != null && rightNode.Span.Start == selection.Start); + + return default; + + } + public static Task RefactoringSelectionIsValidAsync( Document document, TextSpan selection, SyntaxNode node, CancellationToken cancellation) { @@ -99,14 +176,16 @@ public static async Task RefactoringPositionIsValidAsync( return true; } - private static async Task GetExpandedNodeSpan( - Document document, - SyntaxNode node, - CancellationToken cancellationToken) + private static Task GetExpandedNodeSpan(Document document, SyntaxNode node, CancellationToken cancellationToken) + { + return GetExpandedTextSpan(document, node.Span, cancellationToken); + } + + private static async Task GetExpandedTextSpan(Document document, TextSpan span, CancellationToken cancellationToken) { var sourceText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false); - var nodeStartLine = sourceText.Lines.GetLineFromPosition(node.SpanStart); + var nodeStartLine = sourceText.Lines.GetLineFromPosition(span.Start); // Enable vertical selections that catch the previous line break and perhaps some whitespace. if (nodeStartLine.LineNumber != 0) @@ -114,10 +193,10 @@ private static async Task GetExpandedNodeSpan( nodeStartLine = sourceText.Lines[nodeStartLine.LineNumber - 1]; } - var nodeEndLine = sourceText.Lines.GetLineFromPosition(node.Span.End); + var nodeEndLine = sourceText.Lines.GetLineFromPosition(span.End); - var start = node.SpanStart; - var end = node.Span.End; + var start = span.Start; + var end = span.End; while (start > nodeStartLine.Start && char.IsWhiteSpace(sourceText[start - 1])) { @@ -131,5 +210,38 @@ private static async Task GetExpandedNodeSpan( return TextSpan.FromBounds(start, end); } + + /// + /// Strips leading and trailing whitespace from . + /// + /// + /// Returns unchanged in case . + /// Returns empty Span with original in case it contains only whitespace. + /// + private static async Task GetStrippedTextSpan(Document document, TextSpan span, CancellationToken cancellationToken) + { + if (span.IsEmpty) + { + return span; + } + + var sourceText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false); + var start = span.Start; + var end = span.End; + + while (start < end && char.IsWhiteSpace(sourceText[end - 1])) + { + end--; + } + + while (start < end && char.IsWhiteSpace(sourceText[start])) + { + start++; + } + + return start == end + ? new TextSpan(start, 0) + : TextSpan.FromBounds(start, end); + } } } diff --git a/src/Workspaces/Core/Portable/Shared/Extensions/SyntaxTreeExtensions.cs b/src/Workspaces/Core/Portable/Shared/Extensions/SyntaxTreeExtensions.cs index 65b0bb2294c90..7c9ddc56c62ad 100644 --- a/src/Workspaces/Core/Portable/Shared/Extensions/SyntaxTreeExtensions.cs +++ b/src/Workspaces/Core/Portable/Shared/Extensions/SyntaxTreeExtensions.cs @@ -72,6 +72,70 @@ public static async Task GetTouchingTokenAsync( return default; } + /// + /// Gets a token that contains or which start edge touches . I.e returns token + /// directly to the right from or a token that encompasses . + /// If such Token doesn't exist a Kind = None Token is returned. + /// + public static async Task GetTouchingTokenToRightOrInAsync( + this SyntaxTree syntaxTree, + int position, + CancellationToken cancellationToken, + bool findInsideTrivia = false) + { + Contract.ThrowIfNull(syntaxTree); + + if (position >= syntaxTree.Length) + { + return default; + } + + var root = await syntaxTree.GetRootAsync(cancellationToken).ConfigureAwait(false); + + var token = root.FindToken(position, findInsideTrivia); + if (token.Span.Contains(position)) + { + return token; + } + + return default; + } + + /// + /// Gets a token which end edge touches specified . I.e returns token + /// directly to the left from or a token of Kind = None if the caret is + /// not the end of any Token. + /// + public static async Task GetTouchingTokenToLeftAsync( + this SyntaxTree syntaxTree, + int position, + CancellationToken cancellationToken, + bool findInsideTrivia = false) + { + Contract.ThrowIfNull(syntaxTree); + + if (position >= syntaxTree.Length) + { + return default; + } + + var root = await syntaxTree.GetRootAsync(cancellationToken).ConfigureAwait(false); + + var token = root.FindToken(position, findInsideTrivia); + if (token.Span.End == position) + { + return token; + } + + token = token.GetPreviousToken(); + if (token.Span.End == position) + { + return token; + } + + return default; + } + public static bool IsEntirelyHidden(this SyntaxTree tree, TextSpan span, CancellationToken cancellationToken) { if (!tree.HasHiddenRegions())