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); + } }