Skip to content

Commit

Permalink
Merge features/extract-to-component to main (#11019)
Browse files Browse the repository at this point in the history
New PR because apparently the branch is restricted so I can't handle
merge conflicts...

Adding work from @marcarro into main 🥳
  • Loading branch information
ryzngard authored Oct 18, 2024
2 parents 9561e53 + a49c67d commit 5269c1c
Show file tree
Hide file tree
Showing 38 changed files with 1,539 additions and 39 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(
}

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ static bool TryGetOwner(RazorCodeActionContext context, [NotNullWhen(true)] out
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.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,16 +161,22 @@ public void ApplyCapabilities(VSInternalServerCapabilities serverCapabilities, V
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);
Expand All @@ -180,7 +186,7 @@ public void ApplyCapabilities(VSInternalServerCapabilities serverCapabilities, V

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)
Expand Down
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
Expand Up @@ -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 [];
Expand All @@ -44,7 +44,7 @@ public async Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(RazorC
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(RazorCodeAct
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.");
Expand Down Expand Up @@ -84,7 +84,7 @@ public Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(RazorCodeAct
}

// 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>();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ internal sealed class ExtractToCodeBehindCodeActionResolver(
return null;
}

var codeBehindPath = GenerateCodeBehindPath(path);
var codeBehindPath = FileUtilities.GenerateUniquePath(path, $"{Path.GetExtension(path)}.cs");

// VS Code in Windows expects path to start with '/'
var updatedCodeBehindPath = _languageServerFeatureOptions.ReturnCodeActionAndRenamePathsWithPrefixedSlash && !codeBehindPath.StartsWith("/")
Expand Down Expand Up @@ -111,33 +111,6 @@ internal sealed class ExtractToCodeBehindCodeActionResolver(
};
}

/// <summary>
/// Generate a file path with adjacent to our input path that has the
/// correct codebehind extension, using numbers to differentiate from
/// any collisions.
/// </summary>
/// <param name="path">The origin file path.</param>
/// <returns>A non-existent file path with the same base name and a codebehind extension.</returns>
private static string GenerateCodeBehindPath(string path)
{
var baseFileName = Path.GetFileNameWithoutExtension(path);
var extension = Path.GetExtension(path);
var directoryName = Path.GetDirectoryName(path).AssumeNotNull();

var n = 0;
string codeBehindPath;
do
{
var identifier = n > 0 ? n.ToString(CultureInfo.InvariantCulture) : string.Empty; // Make it look nice

codeBehindPath = Path.Combine(directoryName, $"{baseFileName}{identifier}{extension}.cs");
n++;
}
while (File.Exists(codeBehindPath));

return codeBehindPath;
}

private async Task<string> GenerateCodeBehindClassAsync(IProjectSnapshot project, Uri codeBehindUri, string className, string namespaceName, string contents, RazorCodeDocument razorCodeDocument, CancellationToken cancellationToken)
{
using var _ = StringBuilderPool.GetPooledObject(out var builder);
Expand Down
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;

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

0 comments on commit 5269c1c

Please sign in to comment.