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
This commit is contained in:
Andrew Hall 2024-10-14 17:02:21 -07:00 коммит произвёл GitHub
Родитель 17dbcdb70f
Коммит e060ce0e58
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
17 изменённых файлов: 376 добавлений и 407 удалений

Просмотреть файл

@ -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

Просмотреть файл

@ -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.");

Просмотреть файл

@ -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<ImmutableArray<RazorVSInternalCodeAction>> 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)

Просмотреть файл

@ -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<string> usingDirectives { get; set; }
}

Просмотреть файл

@ -27,7 +27,7 @@ internal sealed class ComponentAccessibilityCodeActionProvider : IRazorCodeActio
public async Task<ImmutableArray<RazorVSInternalCodeAction>> 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

Просмотреть файл

@ -42,7 +42,7 @@ internal sealed class ExtractToCodeBehindCodeActionProvider(ILoggerFactory logge
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}
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<RazorVSInternalCodeAction>();
}

Просмотреть файл

@ -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<ExtractToComponentCodeActionProvider>();
public Task<ImmutableArray<RazorVSInternalCodeAction>> 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<RazorVSInternalCodeAction>();
}
if (endElementNode is null)
{
endElementNode = startElementNode;
}
if (!TryGetNamespace(context.CodeDocument, out var @namespace))
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}
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<ImmutableArray<RazorVSInternalCodeAction>>([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<MarkupElementSyntax>();
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<MarkupElementSyntax>();
}
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
};
}
/// <summary>
/// Processes a multi-point selection to determine the correct range for extraction.
/// </summary>
/// <param name="startElementNode">The starting element of the selection.</param>
/// <param name="endElementNode">The ending element of the selection, if it exists.</param>
/// <param name="actionParams">The parameters for the extraction action, which will be updated.</param>
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:
// <div>
// {|result:<span>
@ -196,134 +168,47 @@ internal sealed class ExtractToComponentCodeActionProvider(ILoggerFactory logger
// <span>
// </span>|}|}
// </div>
// 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<string> usingsInSourceRazor, int extractStart, int extractEnd, ExtractToComponentCodeActionParams actionParams)
{
var components = new HashSet<string>();
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<string> components, IEnumerable<string> 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;
}
}
}
}
}

Просмотреть файл

@ -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<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>[]
{
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<string> GetUsingsInDocument(RazorSyntaxTree syntaxTree)
=> syntaxTree
.Root
.ChildNodes()
.Select(node =>
{
if (node.IsUsingDirective(out var _))
{
return node.ToFullString().Trim();
}
return null;
})
.WhereNotNull();
}

Просмотреть файл

@ -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))
{

Просмотреть файл

@ -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);

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -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;
}

Просмотреть файл

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

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -38,7 +38,7 @@ public class ExtractToComponentCodeActionProviderTest(ITestOutputHelper testOutp
<div id="parent">
<div>
<h1>Div a title</h1>
<p>Div $$a par</p>
<p>Div [||]a par</p>
</div>
<div>
<h1>Div b title</h1>
@ -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
<PageTitle>Home</PageTitle>
<div id="parent">
<$$div>
<[||]div>
<h1>Div a title</h1>
<p>Div a par</p>
</div>
@ -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 "/"
<PageTitle>Home</PageTitle>
<div id="parent">
[|<div>
$$<h1>Div a title</h1>
<p>Div a par</p>
</div>
<div>
<h1>Div b title</h1>
<p>Div b par</p>
</div|]>
</div>
<h1>Hello, world!</h1>
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<RazorCodeActionResolutionParams>();
Assert.NotNull(razorCodeActionResolutionParams);
var actionParams = ((JsonElement)razorCodeActionResolutionParams.Data).Deserialize<ExtractToComponentCodeActionParams>();
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
<PageTitle>Home</PageTitle>
<div id="parent">
[|$$<div>
<h1>Div a title</h1>
<p>Div a par</p>
</div>
<div>
<h1>Div b title</h1>
<p>Div b par</p>
</div>|]
</div>
<h1>Hello, world!</h1>
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<RazorCodeActionResolutionParams>();
Assert.NotNull(razorCodeActionResolutionParams);
var actionParams = ((JsonElement)razorCodeActionResolutionParams.Data).Deserialize<ExtractToComponentCodeActionParams>();
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 id="parent">
<div>
<h1>Div a title</h1>
<p>Div $$a par</p>
<p>Div [||]a par</p>
</div>
<div>
<h1>Div b title</h1>
@ -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)
<PageTitle>Home</PageTitle>
<div id="parent">
[|<div>
|]<h1>Div a title</h1>
<p>Div a par</p>
</div>$$
<div>
<h1>Div b title</h1>
<p>Div b par</p>
</div>
</div>
<h1>Hello, world!</h1>
Welcome to your new app.
""");
[Fact]
public Task Handle_MultiPointSelection_WithEndAfterElement()
=> TestSelectionStartAndCursorAsync("""
@page "/"
@namespace MarketApp.Pages.Product.Home
namespace MarketApp.Pages.Product.Home
<PageTitle>Home</PageTitle>
<div id="parent">
[|<div>
<h1>Div a title</h1>
<p>Div a par</p>
</div>
<div>
<h1>Div b title</h1>
<p>Div b par</p>
</div>$$|]
</div>
<h1>Hello, world!</h1>
Welcome to your new app.
""");
[Fact]
public Task Handle_MultiPointSelection_WithEndInsideSiblingElement()
=> TestSelectionStartAndCursorAsync("""
@page "/"
@namespace MarketApp.Pages.Product.Home
namespace MarketApp.Pages.Product.Home
<PageTitle>Home</PageTitle>
<div id="parent">
[|<div>
<h1>Div a title</h1>
<p>Div a par</p>
</div>
<div>
<h1>Div b title</h1>|]
<p>Div b par</p>
</div>$$
</div>
<h1>Hello, world!</h1>
Welcome to your new app.
""");
[Fact]
public Task Handle_MultiPointSelection_WithEndInsideElement()
=> TestSelectionStartAndCursorAsync("""
@page "/"
@namespace MarketApp.Pages.Product.Home
namespace MarketApp.Pages.Product.Home
<PageTitle>Home</PageTitle>
<div id="parent">
[|<div>
<h1>Div a title</h1>
<p>Div a par</p>|]
</div>$$
<div>
<h1>Div b title</h1>
<p>Div b par</p>
</div>
</div>
<h1>Hello, world!</h1>
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;
}
/// <summary>
/// Tests the contents where the expected start/end are marked by '[|' and '$$'
/// </summary>
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<RazorCodeActionResolutionParams>();
Assert.NotNull(razorCodeActionResolutionParams);
var actionParams = ((JsonElement)razorCodeActionResolutionParams.Data).Deserialize<ExtractToComponentCodeActionParams>();
Assert.NotNull(actionParams);
Assert.Equal(selectionSpan.Start, actionParams.Start);
Assert.Equal(cursorPosition, actionParams.End);
}
}