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 |
| // Holding off on this test until configured correctly (fails on CI) | ||
| //[Theory] |
There was a problem hiding this comment.
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.
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
davidwengier
left a comment
There was a problem hiding this comment.
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.
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.
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.
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.
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
| } | ||
|
|
||
| var componentNode = owner.FirstAncestorOrSelf<MarkupElementSyntax>(); | ||
| var startComponentNode = owner.FirstAncestorOrSelf<MarkupElementSyntax>(); |
There was a problem hiding this comment.
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.
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?"
| // 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.
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.
Nit: This is navigating up the tree, through node1s 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.
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.
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.
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.
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.
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.
| using System.Text; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using ICSharpCode.Decompiler.CSharp.Syntax; |
There was a problem hiding this comment.
Why is this needed/desired?
| return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>(); | ||
| return true; | ||
| } | ||
| return context.Location.AbsoluteIndex > startElementNode.StartTag.Span.End && |
There was a problem hiding this comment.
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.
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.
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.
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.
| 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.
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.
Consider switching this to context.CodeDcoument.Source.Text
| } | ||
|
|
||
|
|
||
| private static void AddMultiPointSelectionToContext(ref RazorCodeActionContext context, TextSpan selectionSpan) |
There was a problem hiding this comment.
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 | ||
| }; |
| 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.
Should this return false instead of true?
| // 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.
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.
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.
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.
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(), |
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.
| // 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.
Worth adding a test that exercises this
There was a problem hiding this comment.
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.
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.
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.
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.
Does this mean we can't handle self-closing tags, like <Comp$$onent />?
There was a problem hiding this comment.
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.
| if (selectionStart == selectionEnd) | ||
| { | ||
| return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>(); | ||
| return null; |
There was a problem hiding this comment.
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.
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.
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.
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.
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.
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.
Why not just set the initial value of Range to this, above?
There was a problem hiding this comment.
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.
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?
davidwengier
left a comment
There was a problem hiding this comment.
Sounds lots a lot of comments have been actioned in future PRs, so going to approve so things can move forward
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: