Skip to content
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

Merged

Conversation

marcarro
Copy link
Contributor

@marcarro marcarro commented Jul 15, 2024

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:

@marcarro marcarro requested a review from a team as a code owner July 15, 2024 20:14
@davidwengier
Copy link
Contributor

Is it possible to merge the other PR, to make this one easier? Or is that still blocked on something?

@DustinCampbell
Copy link
Member

FWIW, the build failures can be avoided by building at the command-line with build.cmd before creating your PR or pushing changes.

Copy link
Member

@DustinCampbell DustinCampbell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this PR must merge after #10578.

I added a couple of comments to the unit test below, but I can't review new code in this PR because it's hard to see what changes come from #10578 or deviate from it.

Comment on lines 125 to 126
// Holding off on this test until configured correctly (fails on CI)
//[Theory]
Copy link
Member

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)")]

Comment on lines 127 to 162
//[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>
//""")]
Copy link
Member

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:

  1. Switch over to [MemberData]. There are plenty of examples in the repo of using [MemberData] rather than [InlineData].
  2. 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.

Copy link
Contributor

@davidwengier davidwengier left a 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.

Comment on lines 67 to 71
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);
}
Copy link
Contributor

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

Copy link
Contributor Author

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>();
Copy link
Contributor

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.

Comment on lines 101 to 122
if (startComponentNode is null)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}
Copy link
Contributor

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>();
Copy link
Contributor

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.

Comment on lines 128 to 131
// 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;
Copy link
Contributor

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)
Copy link
Contributor

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)
Copy link
Contributor

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 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))
Copy link
Contributor

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.

Copy link
Contributor Author

@marcarro marcarro Jul 25, 2024

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.

Copy link
Contributor

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()
Copy link
Contributor

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(),
Copy link
Contributor

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.

Copy link
Contributor

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

@marcarro marcarro requested a review from a team as a code owner July 25, 2024 17:11
@@ -9,14 +9,18 @@
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using ICSharpCode.Decompiler.CSharp.Syntax;
Copy link
Member

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 &&
Copy link
Member

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

Copy link
Member

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;
Copy link
Member

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))
Copy link
Member

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))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.
Copy link
Member

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();
Copy link
Member

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)
Copy link
Member

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.

Comment on lines 256 to 272
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
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: You don't have the new extension methods or VsLspFactory that merged to main with #10670 (and will be updated eventually with #10682), but you should be able to replace this code with a single helper once you have the latest main.

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)
Copy link
Contributor

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

Comment on lines 173 to 174
// 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;
Copy link
Contributor

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

Suggested change
// 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))
Copy link
Contributor

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>();
Copy link
Contributor

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: clarifying the name

Suggested change
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(),
Copy link
Contributor

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

Comment on lines 173 to 177
// 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;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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;
}

Copy link
Contributor

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

Copy link
Contributor Author

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)
Copy link
Contributor

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))
Copy link
Contributor

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?

Copy link
Contributor Author

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)
Copy link
Contributor

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 />?

Copy link
Contributor Author

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;
Copy link
Contributor

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

Copy link
Contributor Author

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))
Copy link
Contributor

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);
Copy link
Contributor

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.
Copy link
Contributor

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;
Copy link
Contributor

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);
Copy link
Contributor

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?

Copy link
Contributor Author

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

Comment on lines +227 to +228
Assert.Equal(selectionSpan.Start, actionParams.ExtractStart);
Assert.Equal(selectionSpan.End, actionParams.ExtractEnd);
Copy link
Contributor

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?

Copy link
Contributor Author

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

Copy link
Contributor

@davidwengier davidwengier left a 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

Copy link
Contributor

@ryzngard ryzngard left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:shipit:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants