diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToNewComponentCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToNewComponentCodeActionProvider.cs index 6c3b3e8ee38..6ae53437ba7 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToNewComponentCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToNewComponentCodeActionProvider.cs @@ -12,11 +12,15 @@ using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Components; using Microsoft.AspNetCore.Razor.Language.Extensions; +using Microsoft.AspNetCore.Razor.Language.Intermediate; using Microsoft.AspNetCore.Razor.Language.Syntax; using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; using Microsoft.AspNetCore.Razor.Threading; using Microsoft.CodeAnalysis.Razor.Logging; using Microsoft.CodeAnalysis.Razor.Workspaces; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor; @@ -47,51 +51,149 @@ public Task> ProvideAsync(RazorCodeAct return SpecializedTasks.EmptyImmutableArray(); } + var (startElementNode, endElementNode) = GetStartAndEndElements(context, syntaxTree, _logger); + + // Make sure the selection starts on an element tag + if (startElementNode is null) + { + return SpecializedTasks.EmptyImmutableArray(); + } + + if (!TryGetNamespace(context.CodeDocument, out var @namespace)) + { + return SpecializedTasks.EmptyImmutableArray(); + } + + var actionParams = CreateInitialActionParams(context, startElementNode, @namespace); + + ProcessSelection(startElementNode, endElementNode, actionParams); + + var resolutionParams = new RazorCodeActionResolutionParams() + { + Action = LanguageServerConstants.CodeActions.ExtractToNewComponentAction, + Language = LanguageServerConstants.CodeActions.Languages.Razor, + Data = actionParams, + }; + + var codeAction = RazorCodeActionFactory.CreateExtractToNewComponent(resolutionParams); + return Task.FromResult>([codeAction]); + } + + private static (MarkupElementSyntax? Start, MarkupElementSyntax? End) GetStartAndEndElements(RazorCodeActionContext context, RazorSyntaxTree syntaxTree, ILogger logger) + { var owner = syntaxTree.Root.FindInnermostNode(context.Location.AbsoluteIndex, includeWhitespace: true); if (owner is null) { - _logger.LogWarning($"Owner should never be null."); - return SpecializedTasks.EmptyImmutableArray(); + logger.LogWarning($"Owner should never be null."); + return (null, null); + } + + var startElementNode = owner.FirstAncestorOrSelf(); + if (startElementNode is null || IsInsideProperHtmlContent(context, startElementNode)) + { + return (null, null); + } + + var endElementNode = GetEndElementNode(context, syntaxTree); + return (startElementNode, endElementNode); + } + + private static bool IsInsideProperHtmlContent(RazorCodeActionContext context, MarkupElementSyntax startElementNode) + { + // If the provider executes before the user/completion inserts an end tag, the below return fails + if (startElementNode.EndTag.IsMissing) + { + return true; } - var componentNode = owner.FirstAncestorOrSelf(); + return context.Location.AbsoluteIndex > startElementNode.StartTag.Span.End && + context.Location.AbsoluteIndex < startElementNode.EndTag.SpanStart; + } - // Make sure we've found tag - if (componentNode is null) + private static MarkupElementSyntax? GetEndElementNode(RazorCodeActionContext context, RazorSyntaxTree syntaxTree) + { + var selectionStart = context.Request.Range.Start; + var selectionEnd = context.Request.Range.End; + if (selectionStart == selectionEnd) { - return SpecializedTasks.EmptyImmutableArray(); + return null; } - // Do not provide code action if the cursor is inside proper html content (i.e. page text) - if (context.Location.AbsoluteIndex > componentNode.StartTag.Span.End && - context.Location.AbsoluteIndex < componentNode.EndTag.SpanStart) + var endAbsoluteIndex = context.SourceText.GetRequiredAbsoluteIndex(selectionEnd); + var endOwner = syntaxTree.Root.FindInnermostNode(endAbsoluteIndex, true); + if (endOwner is null) { - return SpecializedTasks.EmptyImmutableArray(); + return null; } - if (!TryGetNamespace(context.CodeDocument, out var @namespace)) + // Correct selection to include the current node if the selection ends immediately after a closing tag. + if (endOwner is MarkupTextLiteralSyntax && endOwner.ContainsOnlyWhitespace() && endOwner.TryGetPreviousSibling(out var previousSibling)) { - return SpecializedTasks.EmptyImmutableArray(); + endOwner = previousSibling; } - var actionParams = new ExtractToNewComponentCodeActionParams() + return endOwner.FirstAncestorOrSelf(); + } + + private static ExtractToNewComponentCodeActionParams CreateInitialActionParams(RazorCodeActionContext context, MarkupElementSyntax startElementNode, string @namespace) + { + return new ExtractToNewComponentCodeActionParams { Uri = context.Request.TextDocument.Uri, - ExtractStart = componentNode.Span.Start, - ExtractEnd = componentNode.Span.End, + ExtractStart = startElementNode.Span.Start, + ExtractEnd = startElementNode.Span.End, Namespace = @namespace }; + } - var resolutionParams = new RazorCodeActionResolutionParams() + /// + /// Processes a multi-point selection to determine the correct range for extraction. + /// + /// The starting element of the selection. + /// The ending element of the selection, if it exists. + /// The parameters for the extraction action, which will be updated. + private static void ProcessSelection(MarkupElementSyntax startElementNode, MarkupElementSyntax? endElementNode, ExtractToNewComponentCodeActionParams actionParams) + { + // If there's no end element, we can't process a multi-point selection + if (endElementNode is null) { - Action = LanguageServerConstants.CodeActions.ExtractToNewComponentAction, - Language = LanguageServerConstants.CodeActions.Languages.Razor, - Data = actionParams, - }; + return; + } - var codeAction = RazorCodeActionFactory.CreateExtractToNewComponent(resolutionParams); + var startNodeContainsEndNode = endElementNode.Ancestors().Any(node => node == startElementNode); - return Task.FromResult>([codeAction]); + // If the start element is an ancestor, keep the original end; otherwise, use the end of the end element + if (startNodeContainsEndNode) + { + actionParams.ExtractEnd = startElementNode.Span.End; + return; + } + + // If the start element is not an ancestor of the end element, we need to find a common parent + // This conditional handles cases where the user's selection spans across different levels of the DOM. + // For example: + //
+ // {|result: + // {|selection:

Some text

+ //
+ // + //

More text

+ //
+ // + // |}|} + //
+ // In this case, we need to find the smallest set of complete elements that covers the entire selection. + + // Find the closest containing sibling pair that encompasses both the start and end elements + var (extractStart, extractEnd) = FindContainingSiblingPair(startElementNode, endElementNode); + + // If we found a valid containing pair, update the extraction range + if (extractStart is not null && extractEnd is not null) + { + actionParams.ExtractStart = extractStart.Span.Start; + actionParams.ExtractEnd = extractEnd.Span.End; + } + // Note: If we don't find a valid pair, we keep the original extraction range } private static bool TryGetNamespace(RazorCodeDocument codeDocument, [NotNullWhen(returnValue: true)] out string? @namespace) @@ -100,4 +202,59 @@ private static bool TryGetNamespace(RazorCodeDocument codeDocument, [NotNullWhen // and causing compiler errors. Avoid offering this refactoring if we can't accurately get a // good namespace to extract to => codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out @namespace); + + private static (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(SyntaxNode startNode, SyntaxNode endNode) + { + // Find the lowest common ancestor of both nodes + var nearestCommonAncestor = FindNearestCommonAncestor(startNode, endNode); + if (nearestCommonAncestor == null) + { + return (null, null); + } + + SyntaxNode? startContainingNode = null; + SyntaxNode? endContainingNode = null; + + // Pre-calculate the spans for comparison + var startSpan = startNode.Span; + var endSpan = endNode.Span; + + foreach (var child in nearestCommonAncestor.ChildNodes().Where(static node => node.Kind == SyntaxKind.MarkupElement)) + { + var childSpan = child.Span; + + if (startContainingNode == null && childSpan.Contains(startSpan)) + { + startContainingNode = child; + if (endContainingNode is not null) + break; // Exit if we've found both + } + + if (childSpan.Contains(endSpan)) + { + endContainingNode = child; + if (startContainingNode is not null) + break; // Exit if we've found both + } + } + + return (startContainingNode, endContainingNode); + } + + private static SyntaxNode? FindNearestCommonAncestor(SyntaxNode node1, SyntaxNode node2) + { + var current = node1; + + while (current.Kind == SyntaxKind.MarkupElement && current is not null) + { + if (current.Span.Contains(node2.Span)) + { + return current; + } + + current = current.Parent; + } + + return null; + } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToNewComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToNewComponentCodeActionResolver.cs index 8b176aeb891..7cc6dfce792 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToNewComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToNewComponentCodeActionResolver.cs @@ -23,6 +23,7 @@ using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.VisualStudio.LanguageServer.Protocol; using Newtonsoft.Json.Linq; +using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor; diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToNewComponentCodeActionProviderTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToNewComponentCodeActionProviderTest.cs index 120a93192a6..185f34b0b70 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToNewComponentCodeActionProviderTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToNewComponentCodeActionProviderTest.cs @@ -2,55 +2,60 @@ // Licensed under the MIT license. See License.txt in the project root for license information. using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Components; using Microsoft.AspNetCore.Razor.Language.Extensions; using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; +using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor; using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; -using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.CodeAnalysis.Testing; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; using Moq; using Xunit; using Xunit.Abstractions; +using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; namespace Microsoft.AspNetCore.Razor.LanguageServer.Test.CodeActions.Razor; public class ExtractToNewComponentCodeActionProviderTest(ITestOutputHelper testOutput) : LanguageServerTestBase(testOutput) { - [Fact (Skip = "Not fully set up yet")] + [Fact] public async Task Handle_InvalidFileKind() { // Arrange var documentPath = "c:/Test.razor"; var contents = """ - @page "/test" -
-

This is my title!

-

This is my paragraph!

- -
-

This is my other paragraph!

- Alternate Text + @page "/" + + Home + +
+
+

Div a title

+

Div $$a par

+
+
+

Div b title

+

Div b par

- @$$code {} + +

Hello, world!

+ + Welcome to your new app. """; TestFileMarkupParser.GetPosition(contents, out contents, out var cursorPosition); var request = new VSCodeActionParams() { TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, - Range = new Range(), + Range = VsLspFactory.DefaultRange, Context = new VSInternalCodeActionContext() }; @@ -67,6 +72,162 @@ public async Task Handle_InvalidFileKind() Assert.Empty(commandOrCodeActionContainer); } + [Fact] + public async Task Handle_SinglePointSelection_ReturnsNotEmpty() + { + // Arrange + var documentPath = "c:/Test.razor"; + var contents = """ + @page "/" + + Home + +
+ <$$div> +

Div a title

+

Div a par

+
+
+

Div b title

+

Div b par

+
+
+ +

Hello, world!

+ + Welcome to your new app. + """; + TestFileMarkupParser.GetPosition(contents, out contents, out var cursorPosition); + + var request = new VSCodeActionParams() + { + TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, + Range = VsLspFactory.DefaultRange, + Context = new VSInternalCodeActionContext() + }; + + var location = new SourceLocation(cursorPosition, -1, -1); + var context = CreateRazorCodeActionContext(request, location, documentPath, contents, supportsFileCreation: true); + + var provider = new ExtractToNewComponentCodeActionProvider(LoggerFactory); + + // Act + var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); + + // Assert + Assert.NotEmpty(commandOrCodeActionContainer); + } + + [Fact] + public async Task Handle_MultiPointSelection_ReturnsNotEmpty() + { + // Arrange + var documentPath = "c:/Test.razor"; + var contents = """ + @page "/" + + Home + +
+ [|
+ $$

Div a title

+

Div a par

+
+
+

Div b title

+

Div b par

+ +
+ +

Hello, world!

+ + Welcome to your new app. + """; + TestFileMarkupParser.GetPositionAndSpan(contents, out contents, out var cursorPosition, out var selectionSpan); + + var request = new VSCodeActionParams() + { + TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, + Range = VsLspFactory.DefaultRange, + Context = new VSInternalCodeActionContext() + }; + + var location = new SourceLocation(cursorPosition, -1, -1); + var context = CreateRazorCodeActionContext(request, location, documentPath, contents); + + var lineSpan = context.SourceText.GetLinePositionSpan(selectionSpan); + request.Range = VsLspFactory.CreateRange(lineSpan); + + var provider = new ExtractToNewComponentCodeActionProvider(LoggerFactory); + + // Act + var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); + + // Assert + Assert.NotEmpty(commandOrCodeActionContainer); + var codeAction = Assert.Single(commandOrCodeActionContainer); + var razorCodeActionResolutionParams = ((JsonElement)codeAction.Data!).Deserialize(); + Assert.NotNull(razorCodeActionResolutionParams); + var actionParams = ((JsonElement)razorCodeActionResolutionParams.Data).Deserialize(); + Assert.NotNull(actionParams); + } + + [Fact] + public async Task Handle_MultiPointSelection_WithEndAfterElement_ReturnsCurrentElement() + { + // Arrange + var documentPath = "c:/Test.razor"; + var contents = """ + @page "/" + + Home + +
+ [|$$
+

Div a title

+

Div a par

+
+
+

Div b title

+

Div b par

+
|] +
+ +

Hello, world!

+ + Welcome to your new app. + """; + TestFileMarkupParser.GetPositionAndSpan(contents, out contents, out var cursorPosition, out var selectionSpan); + + var request = new VSCodeActionParams() + { + TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, + Range = VsLspFactory.DefaultRange, + Context = new VSInternalCodeActionContext() + }; + + var location = new SourceLocation(cursorPosition, -1, -1); + var context = CreateRazorCodeActionContext(request, location, documentPath, contents); + + var lineSpan = context.SourceText.GetLinePositionSpan(selectionSpan); + request.Range = VsLspFactory.CreateRange(lineSpan); + + var provider = new ExtractToNewComponentCodeActionProvider(LoggerFactory); + + // Act + var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); + + // Assert + Assert.NotEmpty(commandOrCodeActionContainer); + var codeAction = Assert.Single(commandOrCodeActionContainer); + var razorCodeActionResolutionParams = ((JsonElement)codeAction.Data!).Deserialize(); + Assert.NotNull(razorCodeActionResolutionParams); + var actionParams = ((JsonElement)razorCodeActionResolutionParams.Data).Deserialize(); + Assert.NotNull(actionParams); + Assert.Equal(selectionSpan.Start, actionParams.ExtractStart); + Assert.Equal(selectionSpan.End, actionParams.ExtractEnd); + } + private static RazorCodeActionContext CreateRazorCodeActionContext(VSCodeActionParams request, SourceLocation location, string filePath, string text, bool supportsFileCreation = true) => CreateRazorCodeActionContext(request, location, filePath, text, relativePath: filePath, supportsFileCreation: supportsFileCreation); @@ -84,13 +245,13 @@ private static RazorCodeActionContext CreateRazorCodeActionContext(VSCodeActionP codeDocument.SetFileKind(FileKinds.Component); codeDocument.SetCodeGenerationOptions(RazorCodeGenerationOptions.Create(o => { - o.RootNamespace = "ExtractToCodeBehindTest"; + o.RootNamespace = "ExtractToComponentTest"; })); codeDocument.SetSyntaxTree(syntaxTree); var documentSnapshot = Mock.Of(document => document.GetGeneratedOutputAsync() == Task.FromResult(codeDocument) && - document.GetTextAsync() == Task.FromResult(codeDocument.GetSourceText()), MockBehavior.Strict); + document.GetTextAsync() == Task.FromResult(codeDocument.Source.Text), MockBehavior.Strict); var sourceText = SourceText.From(text); diff --git a/src/Shared/Microsoft.AspNetCore.Razor.Utilities.Shared/FileUtilities.cs b/src/Shared/Microsoft.AspNetCore.Razor.Utilities.Shared/FileUtilities.cs index c93a6e35fd2..829fb250647 100644 --- a/src/Shared/Microsoft.AspNetCore.Razor.Utilities.Shared/FileUtilities.cs +++ b/src/Shared/Microsoft.AspNetCore.Razor.Utilities.Shared/FileUtilities.cs @@ -1,7 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT license. See License.txt in the project root for license information. -using System; using System.Globalization; using System.IO; @@ -19,10 +18,8 @@ internal static class FileUtilities /// A non-existent file path with a name in the specified format and a corresponding extension. public static string GenerateUniquePath(string path, string extension) { - if (!Path.IsPathRooted(path)) - { - throw new ArgumentException("The path is not rooted.", nameof(path)); - } + // Add check for rooted path in the future, currently having issues in platforms other than Windows. + // See: https://github.com/dotnet/razor/issues/10684 var directoryName = Path.GetDirectoryName(path).AssumeNotNull(); var baseFileName = Path.GetFileNameWithoutExtension(path);