-
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
Select range feature #10621
Select range feature #10621
Conversation
Is it possible to merge the other PR, to make this one easier? Or is that still blocked on something? |
FWIW, the build failures can be avoided by building at the command-line with |
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.
// Holding off on this test until configured correctly (fails on CI) | ||
//[Theory] |
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.
You don't need to comment-out tests, just add a Skip
to the [Theory]
[Theory(Skip = "Holding off on this test until configured correctly (fails on CI)")]
//[InlineData(""" | ||
//<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> | ||
//""")] | ||
//[InlineData(""" | ||
//<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> | ||
//""")] | ||
//[InlineData(""" | ||
//<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> | ||
//""")] |
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.
This [InlineData]
is far too bulky, IMO. Please consider one of the following:
- Switch over to
[MemberData]
. There are plenty of examples in the repo of using[MemberData]
rather than[InlineData]
. - Create three separate
[Fact]
methods that call this code with different data. (You'd want to switch this to a private method in this case.) @davidwengier has been writing a lot of tests in this style for co-hosting. Here's an example.
For tests like this one, I'll often go with the second approach. It can be helpful when a test fails to clearly see what it's input was, without having to go dig through an array of member data.
11ec212
to
5631864
Compare
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.
Thank you for rebasing! I'm a little unsure of exactly what all of the logic is doing though, and whether it's all necessary. Would love to see some more tests and perhaps a few more comments explaining things.
if (selectionEnd is not null && selectionEnd.Line < selectionStart.Line || | ||
(selectionEnd is not null && selectionEnd.Line == selectionStart.Line && selectionEnd.Character < selectionStart.Character)) | ||
{ | ||
(selectionEnd, selectionStart) = (selectionStart, selectionEnd); | ||
} |
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.
Does this ever actually happen? I'm surprised the LSP client would ever send us a backwards range
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.
Good catch, indeed the LSP client does not send a backwards range
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>(); | ||
} | ||
|
||
endComponentNode = endOwner.FirstAncestorOrSelf<MarkupElementSyntax>(); |
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'm not quite sure how this node ends up being helpful. I understand endOwner
is the node at the end of the selection, but looking up the tree for a markup element seems surprising. Can you maybe add a comment to illustrate?
For example, given a selection (between [|
and |]
of:
[|<div>
<div>
</div>]]
</div>
It seems like startComponentNode
would be the first div, and endComponentNode
would be the second div. If the selection is extended to last closing div tag, then I think both startComponentNode
and endComponentNode
would be the same.
if (startComponentNode is null) | ||
{ | ||
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>(); | ||
} |
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.
Probably should move this to above the previous bit of logic, as there is no point finding an end node if there is no start node
@@ -54,17 +57,55 @@ public Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(RazorCodeAct | |||
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>(); | |||
} | |||
|
|||
var componentNode = owner.FirstAncestorOrSelf<MarkupElementSyntax>(); | |||
var startComponentNode = owner.FirstAncestorOrSelf<MarkupElementSyntax>(); |
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.
Nit on naming of this, and endComponentNode
, they're not necessarily components, just elements.
// If component @ start of the selection includes a parent element of the component @ end of selection, then proceed as usual. | ||
// If not, limit extraction to end of component @ end of selection (in the simplest case) | ||
var selectionStartHasParentElement = endComponentNode.Ancestors().Any(node => node == startComponentNode); | ||
actionParams.ExtractEnd = selectionStartHasParentElement ? actionParams.ExtractEnd : endComponentNode.Span.End; |
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.
Okay, I think this bit of logic answers my comment above, and explains what the endComponentNode
is for. I wonder if this can be moved to that block to make things clearer, and maybe even have the whole thing extracted to a separate method?
A method that essentially answers the question "Given this start node and selection end, what is the best end position to use for extraction?"
@@ -100,4 +160,64 @@ private static bool TryGetNamespace(RazorCodeDocument codeDocument, [NotNullWhen | |||
// and causing compiler errors. Avoid offering this refactoring if we can't accurately get a | |||
// good namespace to extract to | |||
=> codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out @namespace); | |||
|
|||
public (SyntaxNode? Start, SyntaxNode? End) FindContainingSiblingPair(SyntaxNode startNode, SyntaxNode endNode) |
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.
This should be private, and hopefully static too
return (startContainingNode, endContainingNode); | ||
} | ||
|
||
public SyntaxNode? FindLowestCommonAncestor(SyntaxNode node1, SyntaxNode node2) |
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.
Nit: This is navigating up the tree, through node1
s parents, so it is probably better being called FindHighestCommonAncestor
. Having said that, "ancestor" implies that anyway, so its really FindNearestCommonAncestor
This should be private, and hopefully static too
var startSpan = startNode.Span; | ||
var endSpan = endNode.Span; | ||
|
||
foreach (var child in lowestCommonAncestor.ChildNodes().Where(node => node.Kind == SyntaxKind.MarkupElement)) |
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.
Given this starts from a common ancestor, doesn't that mean by definition it will contain both the start and end spans, and therefore this loop will only ever run one iteration, and always return both endContainingNode
and startContaininedNode
as being the same node as lowestCommonAncestor
?
I might be missing something though. Again, so illustrative comments would be helpful.
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.
The ChildNodes()
method won't include the ancestor itself AFAIK. The loop runs exactly 3 iterations: the first one finds startContainingNode
, then the second one finds endContainingNode
, then the last iteration checks that both are non-null and returns. Trying to see how to optimize this.
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.
The ChildNodes() method won't include the ancestor itself AFAIK.
Derp, of course 🤦♂️
The loop runs exactly 3 iterations
Sounds like more test cases might help there too. If you can confidently say that the first iteration finds the start node, and the second finds the end node, it sounds like a very specific case where both nodes are directly next to each other. What would happen in this situation?
<common>
[|<start>
</start>
<middle>
</middle>
<end>
</end>|]
</common>
At least by me typing that out, I now understand more what the logic is trying to do :)
"""; | ||
TestFileMarkupParser.GetPosition(contents, out contents, out var cursorPosition); | ||
|
||
var request = new VSCodeActionParams() |
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.
Consider extracting this common code in the tests out to a helper method
var request = new VSCodeActionParams() | ||
{ | ||
TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, | ||
Range = new Range(), |
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.
Given this is passing in an empty range, does that mean this test isn't actually testing the change in this PR?
Would like to see a bunch more tests for the various scenarios that the logic handles. With a helper method it should be hopefully straight foward. The test markup syntax already supports [|
and |]
as selection markers too.
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.
+1 to this more tests would be good
@@ -9,14 +9,18 @@ | |||
using System.Text; | |||
using System.Threading; | |||
using System.Threading.Tasks; | |||
using ICSharpCode.Decompiler.CSharp.Syntax; |
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.
Why is this needed/desired?
} | ||
return context.Location.AbsoluteIndex > startElementNode.StartTag.Span.End && |
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.
tiny nit: Add a blank line between }
and return
, which is consistent with the bulk of this file.
} | ||
|
||
var endOwner = syntaxTree.Root.FindInnermostNode(endLocation.Value.AbsoluteIndex, true); | ||
|
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.
tiny nit: Consider removing this blank line, which is consistent with the style you used above.
{ | ||
return null; | ||
} | ||
return location; |
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.
tiny nit: Consider adding a blank line between }
and return
|
||
private static SourceLocation? GetEndLocation(Position selectionEnd, RazorCodeDocument codeDocument, ILogger logger) | ||
{ | ||
if (!selectionEnd.TryGetSourceLocation(codeDocument.GetSourceText(), logger, out var location)) |
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.
Note: GetSourceText()
has been removed in main. You can avoid that merge conflict by changing this to codeDocument.Source.Text
.
var startSpan = startNode.Span; | ||
var endSpan = endNode.Span; | ||
|
||
foreach (var child in nearestCommonAncestor.ChildNodes().Where(node => node.Kind == SyntaxKind.MarkupElement)) |
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.
foreach (var child in nearestCommonAncestor.ChildNodes().Where(node => node.Kind == SyntaxKind.MarkupElement)) | |
foreach (var child in nearestCommonAncestor.ChildNodes().Where(static node => node.Kind == SyntaxKind.MarkupElement)) |
{ | ||
throw new ArgumentException("The path is not rooted.", nameof(path)); | ||
} | ||
// Add check for rooted path in the future, currently having issues in platforms other than Windows. |
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.
If you filed an issue for this, please link it here.
|
||
private static void AddMultiPointSelectionToContext(ref RazorCodeActionContext context, TextSpan selectionSpan) | ||
{ | ||
var sourceText = context.CodeDocument.GetSourceText(); |
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.
Consider switching this to context.CodeDcoument.Source.Text
@@ -98,4 +248,21 @@ private static RazorCodeActionContext CreateRazorCodeActionContext(VSCodeActionP | |||
|
|||
return context; | |||
} | |||
|
|||
|
|||
private static void AddMultiPointSelectionToContext(ref RazorCodeActionContext context, TextSpan selectionSpan) |
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.
Why is context
passed as ref
? Based on the code below, it doesn't need to be.
var startLinePosition = sourceText.Lines.GetLinePosition(selectionSpan.Start); | ||
var startPosition = new Position(startLinePosition.Line, startLinePosition.Character); | ||
|
||
var endLinePosition = sourceText.Lines.GetLinePosition(selectionSpan.End); | ||
var endPosition = new Position(endLinePosition.Line, endLinePosition.Character); | ||
|
||
context.Request.Range = new Range | ||
{ | ||
Start = startPosition, | ||
End = endPosition | ||
}; |
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.
private static bool IsInsideProperHtmlContent(RazorCodeActionContext context, MarkupElementSyntax startElementNode) | ||
{ | ||
// If the provider executes before the user/completion inserts an end tag, the below return fails | ||
if (startElementNode.EndTag.IsMissing) |
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.
Should this return false instead of true?
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.
Yes
// If the start element is an ancestor, keep the original end; otherwise, use the end of the end element | ||
actionParams.ExtractEnd = selectionStartHasParentElement ? actionParams.ExtractEnd : endElementNode.Span.End; |
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.
nit: I just find this easier to read
// If the start element is an ancestor, keep the original end; otherwise, use the end of the end element | |
actionParams.ExtractEnd = selectionStartHasParentElement ? actionParams.ExtractEnd : endElementNode.Span.End; | |
// If the start element is an ancestor, keep the original end; otherwise, use the end of the end element | |
if (!selectionStartHasParentElement) | |
{ | |
actionParams.ExtractEnd = endElementNode.Span.End; | |
} |
} | ||
|
||
// Correct selection to include the current node if the selection ends immediately after a closing tag. | ||
if (string.IsNullOrWhiteSpace(endOwner.ToFullString()) && endOwner.TryGetPreviousSibling(out var previousSibling)) |
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 think you're detecting cases where MarkupTextLiteral
is only whitespace? It's worth doing a syntax type check to reduce creating a new string when not needed
} | ||
|
||
var actionParams = new ExtractToNewComponentCodeActionParams() | ||
return endOwner?.FirstAncestorOrSelf<MarkupElementSyntax>(); |
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.
nit: endOwner should not be null on this line, no need for ?.
|
||
var codeAction = RazorCodeActionFactory.CreateExtractToNewComponent(resolutionParams); | ||
// Check if the start element is an ancestor of the end element | ||
var selectionStartHasParentElement = endElementNode.Ancestors().Any(node => node == startElementNode); |
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.
nit: clarifying the name
var selectionStartHasParentElement = endElementNode.Ancestors().Any(node => node == startElementNode); | |
var startNodeContainsEndNode = endElementNode.Ancestors().Any(node => node == startElementNode); |
var request = new VSCodeActionParams() | ||
{ | ||
TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, | ||
Range = new Range(), |
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.
+1 to this more tests would be good
5707fd9
to
04aa349
Compare
// If the start element is an ancestor, keep the original end; otherwise, use the end of the end element | ||
if (!startNodeContainsEndNode) | ||
{ | ||
actionParams.ExtractEnd = endElementNode.Span.End; | ||
} |
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.
// If the start element is an ancestor, keep the original end; otherwise, use the end of the end element | |
if (!startNodeContainsEndNode) | |
{ | |
actionParams.ExtractEnd = endElementNode.Span.End; | |
} | |
// If the start element is an ancestor, keep the original end; otherwise, use the end of the end element | |
if (startNodeContainsEndNode) | |
{ | |
actionParams.ExtractEnd = endElementNode.Span.End; | |
return; | |
} |
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.
Worth adding a test that exercises this
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.
Tests on this have been added in upcoming PR: #10760
// Selected text ends here <span></span> | ||
// </div> | ||
// In this case, we need to find the smallest set of complete elements that covers the entire selection. | ||
if (!startNodeContainsEndNode) |
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.
If you return early you can remove this check
|
||
var actionParams = CreateInitialActionParams(context, startElementNode, @namespace); | ||
|
||
if (IsMultiPointSelection(context.Request.Range)) |
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.
Why does a multi-point selection need special handling? Surely we need to find the right nodes to process either way?
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.
True! This is fixed in later PR's but I'll go ahead and make this correction.
private static bool IsInsideProperHtmlContent(RazorCodeActionContext context, MarkupElementSyntax startElementNode) | ||
{ | ||
// If the provider executes before the user/completion inserts an end tag, the below return fails | ||
if (startElementNode.EndTag.IsMissing) |
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.
Does this mean we can't handle self-closing tags, like <Comp$$onent />
?
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.
Nope, this check corrects for a case where the provider executes while in the middle of typing an end tag, or right after inserting a starting tag (in those few seconds where the completion doesn't insert its corresponding end tag yet).
Anyway, this is removed in the latest PR where instead of doing this check I check for error diagnostics on a markupSyntaxNode
.
{ | ||
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>(); | ||
return null; |
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.
Why is this returning null here? Given something like:
<di$$v>
</div>
Thats a single point range, where the start tag is <div>
, why would the end tag be null, and not the </div>
?
This relates to my previous comment about why "multi-point" selection is handled differently
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.
You're right, endElementNode should not be null, the new PR accounts for this and also handles processing selections differently, in general. Now I don't know if I should bring all of those changes into this branch or just wait for the new PR's to merge 😅
} | ||
|
||
// Correct selection to include the current node if the selection ends immediately after a closing tag. | ||
if (endOwner is MarkupTextLiteralSyntax && string.IsNullOrWhiteSpace(endOwner.ToFullString()) && endOwner.TryGetPreviousSibling(out var previousSibling)) |
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.
There is a ContainsOnlyWhitespace()
extension method that should be callable here, rather than realising the entire end tag as a string.
} | ||
|
||
if (!TryGetNamespace(context.CodeDocument, out var @namespace)) | ||
var endLocation = GetEndLocation(selectionEnd, context.CodeDocument); |
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 don't think we need GetEndLocation
to exist, this can just be var endAbsoluteIndex = context.SourceText.GetRequiredAbsoluteIndex(selectionEnd)
.
// </span> | ||
// Selected text ends here <span></span> | ||
// </div> | ||
// In this case, we need to find the smallest set of complete elements that covers the entire selection. |
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 love this comment <3
BUT... it would be clearer to just write things as we would for test input, with the same markers, and also indicate what the expected resulting selection would be. We all need to be used to reading that syntax anyway.
eg (and correct me if my guess is wrong)
// <div>
// {|result:<span>
// {|selection:<p>Some text</p>
// </span>
// <span>
// <p>More text</p>
// </span>
// <span></span>|}|}
// </div>
} | ||
} | ||
|
||
private static bool IsMultiPointSelection(Range range) => range.Start != range.End; |
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.
Honestly, I'd get rid of this method. It's harder for me to reason about what "Multi point" means, than to just read the start and end checks
var context = CreateRazorCodeActionContext(request, location, documentPath, contents); | ||
|
||
var lineSpan = context.SourceText.GetLinePositionSpan(selectionSpan); | ||
request.Range = VsLspFactory.CreateRange(lineSpan); |
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.
Why not just set the initial value of Range
to this, above?
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.
lineSpan
depends on context
-> request
so I have to initialize it first
Assert.Equal(selectionSpan.Start, actionParams.ExtractStart); | ||
Assert.Equal(selectionSpan.End, actionParams.ExtractEnd); |
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 seems odd that this is the only test that seems to actually validate anything about the response. Is this what you were talking about with future PRs having better tests?
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.
Yup! I mentioned tests in upcoming PR's here
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.
Sounds lots a lot of comments have been actioned in future PRs, so going to approve so things can move forward
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.
f1923ed
into
dotnet:features/extract-to-component
Summary of the changes
Users can now extract a selected range that spans multiple sibling elements, along with their parent (or not), depending on the case.
Fixes: