From e060ce0e58e6de1b2b5bbf23072b59526d6d3ae2 Mon Sep 17 00:00:00 2001 From: Andrew Hall Date: Mon, 14 Oct 2024 17:02:21 -0700 Subject: [PATCH] Some Cleanup For ExtractToComponent (#11008) Clean up some of the extract to component code. Move more work to the resolver, use pooled objects where applicable, and try to make the provider cleaner when creating the ExtractToComponentCodeActionParams --- .../CSharp/DefaultCSharpCodeActionProvider.cs | 2 +- .../TypeAccessibilityCodeActionProvider.cs | 2 +- .../CodeActions/CodeActionEndpoint.cs | 12 +- .../ExtractToComponentCodeActionParams.cs | 14 +- ...omponentAccessibilityCodeActionProvider.cs | 4 +- .../ExtractToCodeBehindCodeActionProvider.cs | 4 +- .../ExtractToComponentCodeActionProvider.cs | 277 +++++----------- .../ExtractToComponentCodeActionResolver.cs | 104 +++--- .../Razor/GenerateMethodCodeActionProvider.cs | 2 +- .../CodeActions/RazorCodeActionContext.cs | 3 +- .../DefaultCSharpCodeActionProviderTest.cs | 10 +- ...TypeAccessibilityCodeActionProviderTest.cs | 10 +- .../CodeActionEndToEndTest.NetFx.cs | 6 +- .../Html/DefaultHtmlCodeActionProviderTest.cs | 10 +- ...nentAccessibilityCodeActionProviderTest.cs | 10 +- ...tractToCodeBehindCodeActionProviderTest.cs | 10 +- ...xtractToComponentCodeActionProviderTest.cs | 303 ++++++++++-------- 17 files changed, 376 insertions(+), 407 deletions(-) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/DefaultCSharpCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/DefaultCSharpCodeActionProvider.cs index 429d8aff10..92fc6052c5 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/DefaultCSharpCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/DefaultCSharpCodeActionProvider.cs @@ -67,7 +67,7 @@ internal sealed class DefaultCSharpCodeActionProvider(LanguageServerFeatureOptio } var tree = context.CodeDocument.GetSyntaxTree(); - var node = tree.Root.FindInnermostNode(context.Location.AbsoluteIndex); + var node = tree.Root.FindInnermostNode(context.StartLocation.AbsoluteIndex); var isInImplicitExpression = node?.AncestorsAndSelf().Any(n => n is CSharpImplicitExpressionSyntax) ?? false; var allowList = isInImplicitExpression diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/TypeAccessibilityCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/TypeAccessibilityCodeActionProvider.cs index 679eb58c84..b8850127a6 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/TypeAccessibilityCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/TypeAccessibilityCodeActionProvider.cs @@ -215,7 +215,7 @@ internal sealed class TypeAccessibilityCodeActionProvider : ICSharpCodeActionPro return false; } - owner = syntaxTree.Root.FindInnermostNode(context.Location.AbsoluteIndex); + owner = syntaxTree.Root.FindInnermostNode(context.StartLocation.AbsoluteIndex); if (owner is null) { Debug.Fail("Owner should never be null."); diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CodeActionEndpoint.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CodeActionEndpoint.cs index 1914b6f87d..fb228ec091 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CodeActionEndpoint.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CodeActionEndpoint.cs @@ -158,16 +158,22 @@ internal sealed class CodeActionEndpoint( request.Range = vsCodeActionContext.SelectionRange; } - if (!sourceText.TryGetSourceLocation(request.Range.Start, out var location)) + if (!sourceText.TryGetSourceLocation(request.Range.Start, out var startLocation)) { return null; } + if (!sourceText.TryGetSourceLocation(request.Range.End, out var endLocation)) + { + endLocation = startLocation; + } + var context = new RazorCodeActionContext( request, documentSnapshot, codeDocument, - location, + startLocation, + endLocation, sourceText, _languageServerFeatureOptions.SupportsFileManipulation, _supportsCodeActionResolve); @@ -177,7 +183,7 @@ internal sealed class CodeActionEndpoint( private async Task> GetDelegatedCodeActionsAsync(DocumentContext documentContext, RazorCodeActionContext context, Guid correlationId, CancellationToken cancellationToken) { - var languageKind = context.CodeDocument.GetLanguageKind(context.Location.AbsoluteIndex, rightAssociative: false); + var languageKind = context.CodeDocument.GetLanguageKind(context.StartLocation.AbsoluteIndex, rightAssociative: false); // No point delegating if we're in a Razor context if (languageKind == RazorLanguageKind.Razor) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToComponentCodeActionParams.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToComponentCodeActionParams.cs index 3834355e6c..70ef4ca50b 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToComponentCodeActionParams.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToComponentCodeActionParams.cs @@ -3,26 +3,22 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Text.Json.Serialization; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; -// NOTE: As mentioned before, these have changed in future PRs, where much of the Provider logic was moved to the resolver. -// The last three properties are not used in the current implementation. internal sealed class ExtractToComponentCodeActionParams { [JsonPropertyName("uri")] public required Uri Uri { get; set; } - [JsonPropertyName("extractStart")] - public int ExtractStart { get; set; } + [JsonPropertyName("start")] + public int Start { get; set; } - [JsonPropertyName("extractEnd")] - public int ExtractEnd { get; set; } + [JsonPropertyName("end")] + public int End { get; set; } [JsonPropertyName("namespace")] public required string Namespace { get; set; } - - [JsonPropertyName("usingDirectives")] - public required List usingDirectives { get; set; } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ComponentAccessibilityCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ComponentAccessibilityCodeActionProvider.cs index 8a551f300b..cf88e1adaa 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ComponentAccessibilityCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ComponentAccessibilityCodeActionProvider.cs @@ -27,7 +27,7 @@ internal sealed class ComponentAccessibilityCodeActionProvider : IRazorCodeActio public async Task> ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken) { // Locate cursor - var node = context.CodeDocument.GetSyntaxTree().Root.FindInnermostNode(context.Location.AbsoluteIndex); + var node = context.CodeDocument.GetSyntaxTree().Root.FindInnermostNode(context.StartLocation.AbsoluteIndex); if (node is null) { return []; @@ -44,7 +44,7 @@ internal sealed class ComponentAccessibilityCodeActionProvider : IRazorCodeActio return []; } - if (context.Location.AbsoluteIndex < startTag.SpanStart) + if (context.StartLocation.AbsoluteIndex < startTag.SpanStart) { // Cursor is before the start tag, so we shouldn't show a light bulb. This can happen // in cases where the cursor is in whitespace at the beginning of the document diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToCodeBehindCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToCodeBehindCodeActionProvider.cs index 297ac7c24c..8631bfab4f 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToCodeBehindCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToCodeBehindCodeActionProvider.cs @@ -42,7 +42,7 @@ internal sealed class ExtractToCodeBehindCodeActionProvider(ILoggerFactory logge return SpecializedTasks.EmptyImmutableArray(); } - var owner = syntaxTree.Root.FindInnermostNode(context.Location.AbsoluteIndex); + var owner = syntaxTree.Root.FindInnermostNode(context.StartLocation.AbsoluteIndex); if (owner is null) { _logger.LogWarning($"Owner should never be null."); @@ -84,7 +84,7 @@ internal sealed class ExtractToCodeBehindCodeActionProvider(ILoggerFactory logge } // Do not provide code action if the cursor is inside the code block - if (context.Location.AbsoluteIndex > csharpCodeBlockNode.SpanStart) + if (context.StartLocation.AbsoluteIndex > csharpCodeBlockNode.SpanStart) { return SpecializedTasks.EmptyImmutableArray(); } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs index d6d8967308..92e331acdc 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionProvider.cs @@ -8,20 +8,21 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; +using ICSharpCode.Decompiler.CSharp.Syntax; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Syntax; using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; +using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.AspNetCore.Razor.Threading; using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor; -internal sealed class ExtractToComponentCodeActionProvider(ILoggerFactory loggerFactory) : IRazorCodeActionProvider +internal sealed class ExtractToComponentCodeActionProvider() : IRazorCodeActionProvider { - private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); - public Task> ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken) { if (context is null) @@ -46,39 +47,18 @@ internal sealed class ExtractToComponentCodeActionProvider(ILoggerFactory logger } // Make sure the selection starts on an element tag - var (startElementNode, endElementNode) = GetStartAndEndElements(context, syntaxTree, _logger); - if (startElementNode is null) + var (startNode, endNode) = GetStartAndEndElements(context, syntaxTree); + if (startNode is null || endNode is null) { return SpecializedTasks.EmptyImmutableArray(); } - if (endElementNode is null) - { - endElementNode = startElementNode; - } - if (!TryGetNamespace(context.CodeDocument, out var @namespace)) { return SpecializedTasks.EmptyImmutableArray(); } - var actionParams = CreateInitialActionParams(context, startElementNode, @namespace); - - ProcessSelection(startElementNode, endElementNode, actionParams); - - var utilityScanRoot = FindNearestCommonAncestor(startElementNode, endElementNode) ?? startElementNode; - - // The new component usings are going to be a subset of the usings in the source razor file. - var usingStrings = syntaxTree.Root.DescendantNodes().Where(node => node.IsUsingDirective(out var _)).Select(node => node.ToFullString().TrimEnd()); - - // Get only the namespace after the "using" keyword. - var usingNamespaceStrings = usingStrings.Select(usingString => usingString.Substring("using ".Length)); - - AddUsingDirectivesInRange(utilityScanRoot, - usingNamespaceStrings, - actionParams.ExtractStart, - actionParams.ExtractEnd, - actionParams); + var actionParams = CreateActionParams(context, startNode, endNode, @namespace); var resolutionParams = new RazorCodeActionResolutionParams() { @@ -91,49 +71,36 @@ internal sealed class ExtractToComponentCodeActionProvider(ILoggerFactory logger return Task.FromResult>([codeAction]); } - private static (MarkupElementSyntax? Start, MarkupElementSyntax? End) GetStartAndEndElements(RazorCodeActionContext context, RazorSyntaxTree syntaxTree, ILogger logger) + private static (MarkupElementSyntax? Start, MarkupElementSyntax? End) GetStartAndEndElements(RazorCodeActionContext context, RazorSyntaxTree syntaxTree) { - var owner = syntaxTree.Root.FindInnermostNode(context.Location.AbsoluteIndex, includeWhitespace: true); + var owner = syntaxTree.Root.FindInnermostNode(context.StartLocation.AbsoluteIndex, includeWhitespace: true); if (owner is null) { - logger.LogWarning($"Owner should never be null."); return (null, null); } var startElementNode = owner.FirstAncestorOrSelf(); - if (startElementNode is null || IsInsideProperHtmlContent(context, startElementNode)) + if (startElementNode is not { EndTag: not null } || LocationOutsideNode(context.StartLocation, startElementNode)) { return (null, null); } - var endElementNode = GetEndElementNode(context, syntaxTree); + var endElementNode = context.StartLocation == context.EndLocation + ? startElementNode + : GetEndElementNode(context, syntaxTree); return (startElementNode, endElementNode); } - private static bool IsInsideProperHtmlContent(RazorCodeActionContext context, MarkupElementSyntax startElementNode) + private static bool LocationOutsideNode(SourceLocation location, MarkupElementSyntax node) { - // If the provider executes before the user/completion inserts an end tag, the below return fails - if (startElementNode.EndTag.IsMissing) - { - return true; - } - - return context.Location.AbsoluteIndex > startElementNode.StartTag.Span.End && - context.Location.AbsoluteIndex < startElementNode.EndTag.SpanStart; + return location.AbsoluteIndex > node.StartTag.Span.End && + location.AbsoluteIndex < node.EndTag.SpanStart; } private static MarkupElementSyntax? GetEndElementNode(RazorCodeActionContext context, RazorSyntaxTree syntaxTree) { - var selectionStart = context.Request.Range.Start; - var selectionEnd = context.Request.Range.End; - if (selectionStart == selectionEnd) - { - return null; - } - - var endAbsoluteIndex = context.SourceText.GetRequiredAbsoluteIndex(selectionEnd); - var endOwner = syntaxTree.Root.FindInnermostNode(endAbsoluteIndex, true); + var endOwner = syntaxTree.Root.FindInnermostNode(context.EndLocation.AbsoluteIndex, includeWhitespace: true); if (endOwner is null) { return null; @@ -148,43 +115,48 @@ internal sealed class ExtractToComponentCodeActionProvider(ILoggerFactory logger return endOwner.FirstAncestorOrSelf(); } - private static ExtractToComponentCodeActionParams CreateInitialActionParams(RazorCodeActionContext context, MarkupElementSyntax startElementNode, string @namespace) + private static ExtractToComponentCodeActionParams CreateActionParams( + RazorCodeActionContext context, + MarkupElementSyntax startNode, + MarkupElementSyntax endNode, + string @namespace) { + var selectionSpan = AreSiblings(startNode, endNode) + ? TextSpanFromNodes(startNode, endNode) + : GetEncompassingTextSpan(startNode, endNode); + return new ExtractToComponentCodeActionParams { Uri = context.Request.TextDocument.Uri, - ExtractStart = startElementNode.Span.Start, - ExtractEnd = startElementNode.Span.End, - Namespace = @namespace, - usingDirectives = [] + Start = selectionSpan.Start, + End = selectionSpan.End, + Namespace = @namespace }; } - /// - /// 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, ExtractToComponentCodeActionParams actionParams) + private static TextSpan GetEncompassingTextSpan(MarkupElementSyntax startNode, MarkupElementSyntax endNode) { - // If there's no end element, we can't process a multi-point selection - if (endElementNode is null) + // Find a valid node that encompasses both the start and the end to + // become the selection. + SyntaxNode commonAncestor = endNode.Span.Contains(startNode.Span) + ? endNode + : startNode; + + while (commonAncestor is MarkupElementSyntax or + MarkupTagHelperAttributeSyntax or + MarkupBlockSyntax) { - return; + if (commonAncestor.Span.Contains(startNode.Span) && + commonAncestor.Span.Contains(endNode.Span)) + { + break; + } + + commonAncestor = commonAncestor.Parent; } - var startNodeContainsEndNode = endElementNode.Ancestors().Any(node => node == startElementNode); - - // 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. + // If walking up the tree was required then make sure to reduce + // selection back down to minimal nodes needed. // For example: //
// {|result: @@ -196,134 +168,47 @@ internal sealed class ExtractToComponentCodeActionProvider(ILoggerFactory logger // // |}|} //
- // 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) + if (commonAncestor != startNode && + commonAncestor != endNode) { - actionParams.ExtractStart = extractStart.Span.Start; - actionParams.ExtractEnd = extractEnd.Span.End; + SyntaxNode? modifiedStart = null, modifiedEnd = null; + foreach (var child in commonAncestor.ChildNodes().Where(static node => node.Kind == SyntaxKind.MarkupElement)) + { + if (child.Span.Contains(startNode.Span)) + { + modifiedStart = child; + if (modifiedEnd is not null) + break; // Exit if we've found both + } + + if (child.Span.Contains(endNode.Span)) + { + modifiedEnd = child; + if (modifiedStart is not null) + break; // Exit if we've found both + } + } + + if (modifiedStart is not null && modifiedEnd is not null) + { + return TextSpanFromNodes(modifiedStart, modifiedEnd); + } } - // Note: If we don't find a valid pair, we keep the original extraction range + + // Fallback to extracting the nearest common ancestor span + return commonAncestor.Span; } + private static TextSpan TextSpanFromNodes(SyntaxNode start, SyntaxNode end) + => new(start.Span.Start, end.Span.End - start.Span.Start); + + private static bool AreSiblings(SyntaxNode node1, SyntaxNode node2) + => node1.Parent == node2.Parent; + private static bool TryGetNamespace(RazorCodeDocument codeDocument, [NotNullWhen(returnValue: true)] out string? @namespace) // If the compiler can't provide a computed namespace it will fallback to "__GeneratedComponent" or // similar for the NamespaceNode. This would end up with extracting to a wrong namespace // 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 is 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 is 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 is MarkupElementSyntax or - MarkupTagHelperAttributeSyntax or - MarkupBlockSyntax && - current is not null) - { - if (current.Span.Contains(node2.Span)) - { - return current; - } - - current = current.Parent; - } - - return null; - } - - private static void AddUsingDirectivesInRange(SyntaxNode root, IEnumerable usingsInSourceRazor, int extractStart, int extractEnd, ExtractToComponentCodeActionParams actionParams) - { - var components = new HashSet(); - var extractSpan = new TextSpan(extractStart, extractEnd - extractStart); - - foreach (var node in root.DescendantNodes().Where(node => extractSpan.Contains(node.Span))) - { - if (node is MarkupTagHelperElementSyntax { TagHelperInfo: { } tagHelperInfo }) - { - AddUsingFromTagHelperInfo(tagHelperInfo, components, usingsInSourceRazor, actionParams); - } - } - } - - private static void AddUsingFromTagHelperInfo(TagHelperInfo tagHelperInfo, HashSet components, IEnumerable usingsInSourceRazor, ExtractToComponentCodeActionParams actionParams) - { - foreach (var descriptor in tagHelperInfo.BindingResult.Descriptors) - { - if (descriptor is null) - { - continue; - } - - var typeNamespace = descriptor.GetTypeNamespace(); - - // Since the using directive at the top of the file may be relative and not absolute, - // we need to generate all possible partial namespaces from `typeNamespace`. - - // Potentially, the alternative could be to ask if the using namespace at the top is a substring of `typeNamespace`. - // The only potential edge case is if there are very similar namespaces where one - // is a substring of the other, but they're actually different (e.g., "My.App" and "My.Apple"). - - // Generate all possible partial namespaces from `typeNamespace`, from least to most specific - // (assuming that the user writes absolute `using` namespaces most of the time) - - // This is a bit inefficient because at most 'k' string operations are performed (k = parts in the namespace), - // for each potential using directive. - - var parts = typeNamespace.Split('.'); - for (var i = 0; i < parts.Length; i++) - { - var partialNamespace = string.Join(".", parts.Skip(i)); - - if (components.Add(partialNamespace) && usingsInSourceRazor.Contains(partialNamespace)) - { - actionParams.usingDirectives.Add($"@using {partialNamespace}"); - break; - } - } - } - } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs index 4cff9dc9a9..9d374e07dd 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToComponentCodeActionResolver.cs @@ -3,36 +3,30 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; -using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Syntax; using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; -using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; +using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; using Microsoft.CodeAnalysis.Razor; using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; using Microsoft.CodeAnalysis.Razor.Workspaces; -using Microsoft.CodeAnalysis.Razor.Protocol; +using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; -using Newtonsoft.Json.Linq; using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor; -internal sealed class ExtractToComponentCodeActionResolver - ( +internal sealed class ExtractToComponentCodeActionResolver( IDocumentContextFactory documentContextFactory, LanguageServerFeatureOptions languageServerFeatureOptions) : IRazorCodeActionResolver { - private readonly IDocumentContextFactory _documentContextFactory = documentContextFactory; private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions; @@ -62,82 +56,75 @@ internal sealed class ExtractToComponentCodeActionResolver return null; } - if (!FileKinds.IsComponent(componentDocument.GetFileKind())) - { - return null; - } - - var path = FilePathNormalizer.Normalize(actionParams.Uri.GetAbsoluteOrUNCPath()); - var directoryName = Path.GetDirectoryName(path).AssumeNotNull(); - var templatePath = Path.Combine(directoryName, "Component"); - var componentPath = FileUtilities.GenerateUniquePath(templatePath, ".razor"); - - // VS Code in Windows expects path to start with '/' - var updatedComponentPath = _languageServerFeatureOptions.ReturnCodeActionAndRenamePathsWithPrefixedSlash && !componentPath.StartsWith('/') - ? '/' + componentPath - : componentPath; - - var newComponentUri = new UriBuilder - { - Scheme = Uri.UriSchemeFile, - Path = updatedComponentPath, - Host = string.Empty, - }.Uri; - var text = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false); if (text is null) { return null; } + var path = FilePathNormalizer.Normalize(actionParams.Uri.GetAbsoluteOrUNCPath()); + var directoryName = Path.GetDirectoryName(path).AssumeNotNull(); + var templatePath = Path.Combine(directoryName, "Component.razor"); + var componentPath = FileUtilities.GenerateUniquePath(templatePath, ".razor"); var componentName = Path.GetFileNameWithoutExtension(componentPath); - var newComponentContent = string.Empty; - newComponentContent += string.Join(Environment.NewLine, actionParams.usingDirectives); - if (actionParams.usingDirectives.Count > 0) + // VS Code in Windows expects path to start with '/' + componentPath = _languageServerFeatureOptions.ReturnCodeActionAndRenamePathsWithPrefixedSlash && !componentPath.StartsWith('/') + ? '/' + componentPath + : componentPath; + + var newComponentUri = new UriBuilder { - newComponentContent += Environment.NewLine + Environment.NewLine; // Ensure there's a newline after the dependencies if any exist. + Scheme = Uri.UriSchemeFile, + Path = componentPath, + Host = string.Empty, + }.Uri; + + using var _ = StringBuilderPool.GetPooledObject(out var builder); + + var syntaxTree = componentDocument.GetSyntaxTree(); + foreach (var usingDirective in GetUsingsInDocument(syntaxTree)) + { + builder.AppendLine(usingDirective); } - newComponentContent += text.GetSubTextString(new CodeAnalysis.Text.TextSpan(actionParams.ExtractStart, actionParams.ExtractEnd - actionParams.ExtractStart)).Trim(); + var extractedText = text.GetSubTextString(new TextSpan(actionParams.Start, actionParams.End - actionParams.Start)).Trim(); + builder.Append(extractedText); - var start = componentDocument.Source.Text.Lines.GetLinePosition(actionParams.ExtractStart); - var end = componentDocument.Source.Text.Lines.GetLinePosition(actionParams.ExtractEnd); + var start = componentDocument.Source.Text.Lines.GetLinePosition(actionParams.Start); + var end = componentDocument.Source.Text.Lines.GetLinePosition(actionParams.End); var removeRange = new Range { Start = new Position(start.Line, start.Character), End = new Position(end.Line, end.Character) }; - var componentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = actionParams.Uri }; - var newComponentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = newComponentUri }; - var documentChanges = new SumType[] { new CreateFile { Uri = newComponentUri }, new TextDocumentEdit { - TextDocument = componentDocumentIdentifier, - Edits = new[] - { + TextDocument = new OptionalVersionedTextDocumentIdentifier { Uri = actionParams.Uri }, + Edits = + [ new TextEdit { NewText = $"<{componentName} />", Range = removeRange, } - }, + ], }, new TextDocumentEdit { - TextDocument = newComponentDocumentIdentifier, - Edits = new[] - { + TextDocument = new OptionalVersionedTextDocumentIdentifier { Uri = newComponentUri }, + Edits = + [ new TextEdit { - NewText = newComponentContent, + NewText = builder.ToString(), Range = new Range { Start = new Position(0, 0), End = new Position(0, 0) }, } - }, + ], } }; @@ -146,4 +133,19 @@ internal sealed class ExtractToComponentCodeActionResolver DocumentChanges = documentChanges, }; } + + private static IEnumerable GetUsingsInDocument(RazorSyntaxTree syntaxTree) + => syntaxTree + .Root + .ChildNodes() + .Select(node => + { + if (node.IsUsingDirective(out var _)) + { + return node.ToFullString().Trim(); + } + + return null; + }) + .WhereNotNull(); } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/GenerateMethodCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/GenerateMethodCodeActionProvider.cs index 366b544d78..bd048ecfc5 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/GenerateMethodCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/GenerateMethodCodeActionProvider.cs @@ -30,7 +30,7 @@ internal sealed class GenerateMethodCodeActionProvider : IRazorCodeActionProvide } var syntaxTree = context.CodeDocument.GetSyntaxTree(); - var owner = syntaxTree.Root.FindToken(context.Location.AbsoluteIndex).Parent.AssumeNotNull(); + var owner = syntaxTree.Root.FindToken(context.StartLocation.AbsoluteIndex).Parent.AssumeNotNull(); if (IsGenerateEventHandlerValid(owner, out var methodName, out var eventName)) { diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/RazorCodeActionContext.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/RazorCodeActionContext.cs index 0fc4c9e384..26160e318b 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/RazorCodeActionContext.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/RazorCodeActionContext.cs @@ -13,7 +13,8 @@ internal sealed record class RazorCodeActionContext( VSCodeActionParams Request, IDocumentSnapshot DocumentSnapshot, RazorCodeDocument CodeDocument, - SourceLocation Location, + SourceLocation StartLocation, + SourceLocation EndLocation, SourceText SourceText, bool SupportsFileCreation, bool SupportsCodeActionResolve); diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CSharp/DefaultCSharpCodeActionProviderTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CSharp/DefaultCSharpCodeActionProviderTest.cs index 2f66858691..6414d60ca4 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CSharp/DefaultCSharpCodeActionProviderTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CSharp/DefaultCSharpCodeActionProviderTest.cs @@ -339,7 +339,15 @@ $$Path; var sourceText = SourceText.From(text); - var context = new RazorCodeActionContext(request, documentSnapshot, codeDocument, location, sourceText, supportsFileCreation, supportsCodeActionResolve); + var context = new RazorCodeActionContext( + request, + documentSnapshot, + codeDocument, + location, + location, + sourceText, + supportsFileCreation, + supportsCodeActionResolve); return context; } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CSharp/TypeAccessibilityCodeActionProviderTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CSharp/TypeAccessibilityCodeActionProviderTest.cs index 86d1b8239e..91afbb2983 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CSharp/TypeAccessibilityCodeActionProviderTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CSharp/TypeAccessibilityCodeActionProviderTest.cs @@ -470,7 +470,15 @@ public class TypeAccessibilityCodeActionProviderTest(ITestOutputHelper testOutpu var sourceText = SourceText.From(text); - var context = new RazorCodeActionContext(request, documentSnapshot, codeDocument, location, sourceText, supportsFileCreation, supportsCodeActionResolve); + var context = new RazorCodeActionContext( + request, + documentSnapshot, + codeDocument, + location, + location, + sourceText, + supportsFileCreation, + supportsCodeActionResolve); return context; } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs index df0443e570..fa103bd162 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CodeActionEndToEndTest.NetFx.cs @@ -1043,7 +1043,7 @@ public class CodeActionEndToEndTest(ITestOutputHelper testOutput) : SingleServer input, expectedRazorComponent, ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider()], codeActionResolversCreator: CreateExtractComponentCodeActionResolver); } @@ -1076,7 +1076,7 @@ public class CodeActionEndToEndTest(ITestOutputHelper testOutput) : SingleServer input, expectedRazorComponent, ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider()], codeActionResolversCreator: CreateExtractComponentCodeActionResolver); } @@ -1111,7 +1111,7 @@ public class CodeActionEndToEndTest(ITestOutputHelper testOutput) : SingleServer input, expectedRazorComponent, ExtractToComponentTitle, - razorCodeActionProviders: [new ExtractToComponentCodeActionProvider(LoggerFactory)], + razorCodeActionProviders: [new ExtractToComponentCodeActionProvider()], codeActionResolversCreator: CreateExtractComponentCodeActionResolver); } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Html/DefaultHtmlCodeActionProviderTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Html/DefaultHtmlCodeActionProviderTest.cs index 8bcc81013b..56f407f421 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Html/DefaultHtmlCodeActionProviderTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Html/DefaultHtmlCodeActionProviderTest.cs @@ -158,7 +158,15 @@ public class DefaultHtmlCodeActionProviderTest(ITestOutputHelper testOutput) : L var sourceText = SourceText.From(text); - var context = new RazorCodeActionContext(request, documentSnapshot, codeDocument, location, sourceText, supportsFileCreation, supportsCodeActionResolve); + var context = new RazorCodeActionContext( + request, + documentSnapshot, + codeDocument, + location, + location, + sourceText, + supportsFileCreation, + supportsCodeActionResolve); return context; } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ComponentAccessibilityCodeActionProviderTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ComponentAccessibilityCodeActionProviderTest.cs index 2a0e033c4b..e4cec854cc 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ComponentAccessibilityCodeActionProviderTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ComponentAccessibilityCodeActionProviderTest.cs @@ -470,7 +470,15 @@ public class ComponentAccessibilityCodeActionProviderTest(ITestOutputHelper test var sourceText = SourceText.From(text); - var context = new RazorCodeActionContext(request, documentSnapshot, codeDocument, location, sourceText, supportsFileCreation, SupportsCodeActionResolve: true); + var context = new RazorCodeActionContext( + request, + documentSnapshot, + codeDocument, + location, + location, + sourceText, + supportsFileCreation, + SupportsCodeActionResolve: true); return context; } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToCodeBehindCodeActionProviderTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToCodeBehindCodeActionProviderTest.cs index 698d462b31..d59f0c5260 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToCodeBehindCodeActionProviderTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToCodeBehindCodeActionProviderTest.cs @@ -401,7 +401,15 @@ public class ExtractToCodeBehindCodeActionProviderTest(ITestOutputHelper testOut var sourceText = SourceText.From(text); - var context = new RazorCodeActionContext(request, documentSnapshot, codeDocument, location, sourceText, supportsFileCreation, SupportsCodeActionResolve: true); + var context = new RazorCodeActionContext( + request, + documentSnapshot, + codeDocument, + location, + location, + sourceText, + supportsFileCreation, + SupportsCodeActionResolve: true); return context; } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs index 7a8463b362..e64e5bef73 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToComponentCodeActionProviderTest.cs @@ -38,7 +38,7 @@ public class ExtractToComponentCodeActionProviderTest(ITestOutputHelper testOutp

Div a title

-

Div $$a par

+

Div [||]a par

Div b title

@@ -50,7 +50,7 @@ public class ExtractToComponentCodeActionProviderTest(ITestOutputHelper testOutp Welcome to your new app. """; - TestFileMarkupParser.GetPosition(contents, out contents, out var cursorPosition); + TestFileMarkupParser.GetSpan(contents, out contents, out var selectionSpan); var request = new VSCodeActionParams() { @@ -59,11 +59,10 @@ public class ExtractToComponentCodeActionProviderTest(ITestOutputHelper testOutp Context = new VSInternalCodeActionContext() }; - var location = new SourceLocation(cursorPosition, -1, -1); - var context = CreateRazorCodeActionContext(request, location, documentPath, contents); + var context = CreateRazorCodeActionContext(request, selectionSpan, documentPath, contents); context.CodeDocument.SetFileKind(FileKinds.Legacy); - var provider = new ExtractToComponentCodeActionProvider(LoggerFactory); + var provider = new ExtractToComponentCodeActionProvider(); // Act var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); @@ -83,7 +82,7 @@ public class ExtractToComponentCodeActionProviderTest(ITestOutputHelper testOutp Home
- <$$div> + <[||]div>

Div a title

Div a par

@@ -97,7 +96,7 @@ public class ExtractToComponentCodeActionProviderTest(ITestOutputHelper testOutp Welcome to your new app. """; - TestFileMarkupParser.GetPosition(contents, out contents, out var cursorPosition); + TestFileMarkupParser.GetSpan(contents, out contents, out var selectionSpan); var request = new VSCodeActionParams() { @@ -106,10 +105,9 @@ public class ExtractToComponentCodeActionProviderTest(ITestOutputHelper testOutp Context = new VSInternalCodeActionContext() }; - var location = new SourceLocation(cursorPosition, -1, -1); - var context = CreateRazorCodeActionContext(request, location, documentPath, contents, supportsFileCreation: true); + var context = CreateRazorCodeActionContext(request, selectionSpan, documentPath, contents, supportsFileCreation: true); - var provider = new ExtractToComponentCodeActionProvider(LoggerFactory); + var provider = new ExtractToComponentCodeActionProvider(); // Act var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); @@ -118,119 +116,6 @@ public class ExtractToComponentCodeActionProviderTest(ITestOutputHelper testOutp 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 ExtractToComponentCodeActionProvider(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 "/" - @namespace MarketApp.Pages.Product.Home - - namespace MarketApp.Pages.Product.Home - - 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 ExtractToComponentCodeActionProvider(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); - } - [Fact] public async Task Handle_InProperMarkup_ReturnsEmpty() { @@ -244,7 +129,7 @@ public class ExtractToComponentCodeActionProviderTest(ITestOutputHelper testOutp

Div a title

-

Div $$a par

+

Div [||]a par

Div b title

@@ -256,7 +141,7 @@ public class ExtractToComponentCodeActionProviderTest(ITestOutputHelper testOutp Welcome to your new app. """; - TestFileMarkupParser.GetPosition(contents, out contents, out var cursorPosition); + TestFileMarkupParser.GetSpan(contents, out contents, out var selectionSpan); var request = new VSCodeActionParams() { @@ -265,10 +150,13 @@ public class ExtractToComponentCodeActionProviderTest(ITestOutputHelper testOutp Context = new VSInternalCodeActionContext() }; - var location = new SourceLocation(cursorPosition, -1, -1); - var context = CreateRazorCodeActionContext(request, location, documentPath, contents); + var context = CreateRazorCodeActionContext( + request, + selectionSpan, + documentPath, + contents); - var provider = new ExtractToComponentCodeActionProvider(LoggerFactory); + var provider = new ExtractToComponentCodeActionProvider(); // Act var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); @@ -277,11 +165,117 @@ public class ExtractToComponentCodeActionProviderTest(ITestOutputHelper testOutp Assert.Empty(commandOrCodeActionContainer); } - private static RazorCodeActionContext CreateRazorCodeActionContext(VSCodeActionParams request, SourceLocation location, string filePath, string text, bool supportsFileCreation = true) - => CreateRazorCodeActionContext(request, location, filePath, text, relativePath: filePath, supportsFileCreation: supportsFileCreation); + [Fact] + public Task Handle_MultiPointSelection_ReturnsNotEmpty() + => TestSelectionStartAndCursorAsync(""" + @page "/" - private static RazorCodeActionContext CreateRazorCodeActionContext(VSCodeActionParams request, SourceLocation location, string filePath, string text, string? relativePath, bool supportsFileCreation = true) + Home + +
+ [|
+ |]

Div a title

+

Div a par

+
$$ +
+

Div b title

+

Div b par

+
+
+ +

Hello, world!

+ + Welcome to your new app. + """); + + [Fact] + public Task Handle_MultiPointSelection_WithEndAfterElement() + => TestSelectionStartAndCursorAsync(""" + @page "/" + @namespace MarketApp.Pages.Product.Home + + namespace MarketApp.Pages.Product.Home + + Home + +
+ [|
+

Div a title

+

Div a par

+
+
+

Div b title

+

Div b par

+
$$|] +
+ +

Hello, world!

+ + Welcome to your new app. + """); + + [Fact] + public Task Handle_MultiPointSelection_WithEndInsideSiblingElement() + => TestSelectionStartAndCursorAsync(""" + @page "/" + @namespace MarketApp.Pages.Product.Home + + namespace MarketApp.Pages.Product.Home + + Home + +
+ [|
+

Div a title

+

Div a par

+
+
+

Div b title

|] +

Div b par

+
$$ +
+ +

Hello, world!

+ + Welcome to your new app. + """); + + [Fact] + public Task Handle_MultiPointSelection_WithEndInsideElement() + => TestSelectionStartAndCursorAsync(""" + @page "/" + @namespace MarketApp.Pages.Product.Home + + namespace MarketApp.Pages.Product.Home + + Home + +
+ [|
+

Div a title

+

Div a par

|] +
$$ +
+

Div b title

+

Div b par

+
+
+ +

Hello, world!

+ + Welcome to your new app. + """); + + private static RazorCodeActionContext CreateRazorCodeActionContext( + VSCodeActionParams request, + TextSpan selectionSpan, + string filePath, + string text, + string? relativePath = null, + bool supportsFileCreation = true) { + relativePath ??= filePath; + var sourceDocument = RazorSourceDocument.Create(text, RazorSourceDocumentProperties.Create(filePath, relativePath)); var options = RazorParserOptions.Create(o => { @@ -303,8 +297,53 @@ public class ExtractToComponentCodeActionProviderTest(ITestOutputHelper testOutp var sourceText = SourceText.From(text); - var context = new RazorCodeActionContext(request, documentSnapshot, codeDocument, location, sourceText, supportsFileCreation, SupportsCodeActionResolve: true); + var context = new RazorCodeActionContext( + request, + documentSnapshot, + codeDocument, + new SourceLocation(selectionSpan.Start, -1, -1), + new SourceLocation(selectionSpan.End, -1, -1), + sourceText, + supportsFileCreation, + SupportsCodeActionResolve: true); return context; } + + /// + /// Tests the contents where the expected start/end are marked by '[|' and '$$' + /// + private async Task TestSelectionStartAndCursorAsync(string contents) + { + // Arrange + var documentPath = "c:/Test.razor"; + 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 context = CreateRazorCodeActionContext(request, selectionSpan, documentPath, contents); + + var lineSpan = context.SourceText.GetLinePositionSpan(selectionSpan); + request.Range = VsLspFactory.CreateRange(lineSpan); + + var provider = new ExtractToComponentCodeActionProvider(); + + // 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.Start); + Assert.Equal(cursorPosition, actionParams.End); + } }