-
Notifications
You must be signed in to change notification settings - Fork 196
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merge features/extract-to-component to main #11019
Changes from 71 commits
9ba71db
7e3ef75
74956e1
bce001c
60d5881
8ab2133
c63e51c
09435b4
921aef1
afc61ea
6d8e44c
2908e6c
9d7a565
371b09a
8725265
6f56d75
142c3ed
ef995cd
a81a177
ea0ac53
0deb915
46932b3
a939694
a814ca8
8ba59f2
cd6b24a
55669a7
8ee84de
eba1b01
6656f7a
31282c8
04aa349
8dbd672
8a7d0bb
7d3c78a
842b162
4bab4dd
4297e6f
f1923ed
0471dbf
1b6e035
cba6cf1
cf33fd7
019d6a4
dcee923
6e8d2aa
17479d4
2c4b709
5bab548
83703f6
33c66f4
b170f2a
5b7e0c6
3d9614c
d83031b
1c239a1
cd677c7
851fb45
be3686f
be1f0d7
ea556b9
b776c60
17dbcdb
e060ce0
fccbbfc
da1b19b
95123dc
f07da46
fa3b2bf
c873dc6
94407a0
d64e2b3
a49c67d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
// Copyright (c) .NET Foundation. All rights reserved. | ||
// Licensed under the MIT license. See License.txt in the project root for license information. | ||
|
||
using System; | ||
using System.Collections.Generic; | ||
using System.Collections.Immutable; | ||
using System.Text.Json.Serialization; | ||
|
||
namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; | ||
|
||
internal sealed class ExtractToComponentCodeActionParams | ||
{ | ||
[JsonPropertyName("uri")] | ||
public required Uri Uri { get; set; } | ||
|
||
[JsonPropertyName("start")] | ||
public int Start { get; set; } | ||
|
||
[JsonPropertyName("end")] | ||
public int End { get; set; } | ||
|
||
[JsonPropertyName("namespace")] | ||
public required string Namespace { get; set; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,233 @@ | ||
// Copyright (c) .NET Foundation. All rights reserved. | ||
// Licensed under the MIT license. See License.txt in the project root for license information. | ||
|
||
using System; | ||
using System.Collections.Generic; | ||
using System.Collections.Immutable; | ||
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() : IRazorCodeActionProvider | ||
{ | ||
public Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken) | ||
{ | ||
if (!context.SupportsFileCreation) | ||
{ | ||
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>(); | ||
} | ||
|
||
if (!FileKinds.IsComponent(context.CodeDocument.GetFileKind())) | ||
{ | ||
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>(); | ||
} | ||
|
||
var syntaxTree = context.CodeDocument.GetSyntaxTree(); | ||
if (syntaxTree?.Root is null) | ||
{ | ||
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>(); | ||
} | ||
|
||
if (!TryGetNamespace(context.CodeDocument, out var @namespace)) | ||
{ | ||
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>(); | ||
} | ||
|
||
var (startNode, endNode) = GetStartAndEndElements(context, syntaxTree); | ||
if (startNode is null || endNode is null) | ||
{ | ||
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>(); | ||
} | ||
|
||
var actionParams = CreateActionParams(context, startNode, endNode, @namespace); | ||
|
||
var resolutionParams = new RazorCodeActionResolutionParams() | ||
{ | ||
Action = LanguageServerConstants.CodeActions.ExtractToNewComponentAction, | ||
Language = LanguageServerConstants.CodeActions.Languages.Razor, | ||
Data = actionParams, | ||
}; | ||
|
||
var codeAction = RazorCodeActionFactory.CreateExtractToComponent(resolutionParams); | ||
return Task.FromResult<ImmutableArray<RazorVSInternalCodeAction>>([codeAction]); | ||
} | ||
|
||
private static (SyntaxNode? Start, SyntaxNode? End) GetStartAndEndElements(RazorCodeActionContext context, RazorSyntaxTree syntaxTree) | ||
{ | ||
var owner = syntaxTree.Root.FindInnermostNode(context.StartLocation.AbsoluteIndex, includeWhitespace: true); | ||
if (owner is null) | ||
{ | ||
return (null, null); | ||
} | ||
|
||
var startElementNode = owner.FirstAncestorOrSelf<SyntaxNode>(IsBlockNode); | ||
|
||
if (startElementNode is null || LocationInvalid(context.StartLocation, startElementNode)) | ||
{ | ||
return (null, null); | ||
} | ||
|
||
var endElementNode = context.StartLocation == context.EndLocation | ||
? startElementNode | ||
: GetEndElementNode(context, syntaxTree); | ||
|
||
return (startElementNode, endElementNode); | ||
|
||
static bool LocationInvalid(SourceLocation location, SyntaxNode node) | ||
{ | ||
// Make sure to test for cases where selection | ||
// is inside of a markup tag such as <p>hello$ there</p> | ||
if (node is MarkupElementSyntax markupElement) | ||
{ | ||
return location.AbsoluteIndex > markupElement.StartTag.Span.End && | ||
location.AbsoluteIndex < markupElement.EndTag.SpanStart; | ||
} | ||
|
||
return !node.Span.Contains(location.AbsoluteIndex); | ||
} | ||
} | ||
|
||
private static SyntaxNode? GetEndElementNode(RazorCodeActionContext context, RazorSyntaxTree syntaxTree) | ||
{ | ||
var endOwner = syntaxTree.Root.FindInnermostNode(context.EndLocation.AbsoluteIndex, includeWhitespace: true); | ||
if (endOwner is null) | ||
{ | ||
return null; | ||
} | ||
|
||
// Correct selection to include the current node if the selection ends immediately after a closing tag. | ||
if (endOwner is MarkupTextLiteralSyntax | ||
&& endOwner.ContainsOnlyWhitespace() | ||
&& endOwner.TryGetPreviousSibling(out var previousSibling)) | ||
{ | ||
endOwner = previousSibling; | ||
} | ||
|
||
return endOwner.FirstAncestorOrSelf<SyntaxNode>(IsBlockNode); | ||
} | ||
|
||
private static bool IsBlockNode(SyntaxNode node) | ||
=> node.Kind is | ||
SyntaxKind.MarkupElement or | ||
SyntaxKind.MarkupTagHelperElement or | ||
SyntaxKind.CSharpCodeBlock; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is probably okay for now, but the Razor syntax tree is annoying and I don't feel super strongly about it, though, because it's very much in the "if it hurts when you do that, don't do that" category IMO. Assuming the editor implements undo correctly anyway :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yea... I wanted to keep it open until we find cases where it's hurting too much. Then add more tests and improve :) |
||
|
||
private static ExtractToComponentCodeActionParams CreateActionParams( | ||
RazorCodeActionContext context, | ||
SyntaxNode startNode, | ||
SyntaxNode endNode, | ||
string @namespace) | ||
{ | ||
var selectionSpan = AreSiblings(startNode, endNode) | ||
? TextSpan.FromBounds(startNode.Span.Start, endNode.Span.End) | ||
: GetEncompassingTextSpan(startNode, endNode); | ||
|
||
return new ExtractToComponentCodeActionParams | ||
{ | ||
Uri = context.Request.TextDocument.Uri, | ||
Start = selectionSpan.Start, | ||
End = selectionSpan.End, | ||
Namespace = @namespace | ||
}; | ||
} | ||
|
||
private static TextSpan GetEncompassingTextSpan(SyntaxNode startNode, SyntaxNode endNode) | ||
{ | ||
// Find a valid node that encompasses both the start and the end to | ||
// become the selection. | ||
var commonAncestor = endNode.Span.Contains(startNode.Span) | ||
? endNode | ||
: startNode; | ||
|
||
while (commonAncestor is MarkupElementSyntax or | ||
MarkupTagHelperAttributeSyntax or | ||
MarkupBlockSyntax) | ||
{ | ||
if (commonAncestor.Span.Contains(startNode.Span) && | ||
commonAncestor.Span.Contains(endNode.Span)) | ||
{ | ||
break; | ||
} | ||
|
||
commonAncestor = commonAncestor.Parent; | ||
} | ||
|
||
// If walking up the tree was required then make sure to reduce | ||
// selection back down to minimal nodes needed. | ||
// For example: | ||
// <div> | ||
// {|result:<span> | ||
// {|selection:<p>Some text</p> | ||
// </span> | ||
// <span> | ||
// <p>More text</p> | ||
// </span> | ||
// <span> | ||
// </span>|}|} | ||
// </div> | ||
if (commonAncestor != startNode && | ||
commonAncestor != endNode) | ||
{ | ||
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 TextSpan.FromBounds(modifiedStart.Span.Start, modifiedEnd.Span.End); | ||
} | ||
} | ||
|
||
// Fallback to extracting the nearest common ancestor span | ||
return commonAncestor.Span; | ||
} | ||
|
||
private static bool AreSiblings(SyntaxNode? node1, SyntaxNode? node2) | ||
{ | ||
if (node1 is null) | ||
{ | ||
return false; | ||
} | ||
|
||
if (node2 is null) | ||
{ | ||
return false; | ||
} | ||
|
||
return 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); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't there be code here that doesn't offer the action if there is C# code in the selection? Or are we just going with the approach of offering to do something even if its not great?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe the latter, but @leslierichardson95 what do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's fine if that is the determination. Though would love to see tests that have it. In particular I'm curious about things like:
or:
or the very exciting
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yea, I left your comment open on tests. Adding more today for sure
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Last time we chatted, I think we decided on the latter of showing the option which I'm still ok with as long as we still have telemetry attached that lets us generally know what the user was trying to accomplish.