Skip to content

#11793 Simplify tag to self-closing code action #11802

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

Open
wants to merge 7 commits into
base: release/dev17.14
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,19 @@ public bool Any()
return Node != null;
}

public bool Any(Func<TNode, bool> predicate)
{
foreach (var node in this)
{
if (predicate(node))
{
return true;
}
}

return false;
}

public SyntaxList<TNode> Where(Func<TNode, bool> predicate)
{
using var _ = SyntaxListBuilderPool.GetPooledBuilder<TNode>(out var builder);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ public static void AddCodeActionsServices(this IServiceCollection services)
services.AddSingleton<IRazorCodeActionResolver, PromoteUsingCodeActionResolver>();
services.AddSingleton<IRazorCodeActionProvider, WrapAttributesCodeActionProvider>();
services.AddSingleton<IRazorCodeActionResolver, WrapAttributesCodeActionResolver>();
services.AddSingleton<IRazorCodeActionProvider, SimplifyTagToSelfClosingCodeActionProvider>();
services.AddSingleton<IRazorCodeActionResolver, SimplifyTagToSelfClosingCodeActionResolver>();

// Html Code actions
services.AddSingleton<IHtmlCodeActionProvider, HtmlCodeActionProvider>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Text.Json.Serialization;

namespace Microsoft.CodeAnalysis.Razor.CodeActions.Models;

internal sealed class SimplifyTagToSelfClosingCodeActionParams
{
[JsonPropertyName("startTagCloseAngleIndex")]
public int StartTagCloseAngleIndex { get; set; }

[JsonPropertyName("endTagCloseAngleIndex")]
public int EndTagCloseAngleIndex { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal static class RazorCodeActionFactory
private readonly static Guid s_createComponentFromTagTelemetryId = new("a28e0baa-a4d5-4953-a817-1db586035841");
private readonly static Guid s_createExtractToCodeBehindTelemetryId = new("f63167f7-fdc6-450f-8b7b-b240892f4a27");
private readonly static Guid s_createExtractToComponentTelemetryId = new("af67b0a3-f84b-4808-97a7-b53e85b22c64");
private readonly static Guid s_simplifyComponentTelemetryId = new("2207f68c-419e-4baa-8493-2e7769e5c91d");
private readonly static Guid s_generateMethodTelemetryId = new("c14fa003-c752-45fc-bb29-3a123ae5ecef");
private readonly static Guid s_generateAsyncMethodTelemetryId = new("9058ca47-98e2-4f11-bf7c-a16a444dd939");
private readonly static Guid s_promoteUsingDirectiveTelemetryId = new("751f9012-e37b-444a-9211-b4ebce91d96e");
Expand Down Expand Up @@ -112,6 +113,19 @@ public static RazorVSInternalCodeAction CreateExtractToComponent(RazorCodeAction
return codeAction;
}

public static RazorVSInternalCodeAction CreateSimplifyTagToSelfClosing(RazorCodeActionResolutionParams resolutionParams)
{
var data = JsonSerializer.SerializeToElement(resolutionParams);
var codeAction = new RazorVSInternalCodeAction()
{
Title = SR.Simplify_Tag_To_SelfClosing_Title,
Data = data,
TelemetryId = s_simplifyComponentTelemetryId,
Name = LanguageServerConstants.CodeActions.SimplifyTagToSelfClosingAction,
};
return codeAction;
}

public static RazorVSInternalCodeAction CreateGenerateMethod(VSTextDocumentIdentifier textDocument, Uri? delegatedDocumentUri, string methodName, string? eventParameterType)
{
var @params = new GenerateMethodCodeActionParams
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// 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.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Components;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.Threading;
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.VisualStudio.LanguageServer.Protocol;

namespace Microsoft.CodeAnalysis.Razor.CodeActions.Razor;

internal class SimplifyTagToSelfClosingCodeActionProvider : IRazorCodeActionProvider
{
public Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken)
{
if (context.HasSelection)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

// Make sure we're in the right kind and part of file
if (!FileKinds.IsComponent(context.CodeDocument.FileKind))
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

if (context.LanguageKind != RazorLanguageKind.Html)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

// Caret must be inside a markup element
if (context.ContainsDiagnostic(ComponentDiagnosticFactory.UnexpectedMarkupElement.Id) ||
context.ContainsDiagnostic(ComponentDiagnosticFactory.UnexpectedClosingTag.Id))
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

var syntaxTree = context.CodeDocument.GetSyntaxTree();
if (syntaxTree?.Root is null)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

var owner = syntaxTree.Root.FindInnermostNode(context.StartAbsoluteIndex, includeWhitespace: false)?.FirstAncestorOrSelf<MarkupTagHelperElementSyntax>();
if (owner is not MarkupTagHelperElementSyntax markupElementSyntax)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

// Check whether the code action is applicable to the element
if (!IsApplicableTo(markupElementSyntax))
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

// Provide code action to simplify
var actionParams = new SimplifyTagToSelfClosingCodeActionParams
{
StartTagCloseAngleIndex = markupElementSyntax.StartTag.CloseAngle.SpanStart,
EndTagCloseAngleIndex = markupElementSyntax.EndTag.CloseAngle.EndPosition,
};

var resolutionParams = new RazorCodeActionResolutionParams()
{
TextDocument = context.Request.TextDocument,
Action = LanguageServerConstants.CodeActions.SimplifyTagToSelfClosingAction,
Language = RazorLanguageKind.Razor,
DelegatedDocumentUri = context.DelegatedDocumentUri,
Data = actionParams,
};

var codeAction = RazorCodeActionFactory.CreateSimplifyTagToSelfClosing(resolutionParams);
return Task.FromResult<ImmutableArray<RazorVSInternalCodeAction>>([codeAction]);
}

internal static bool IsApplicableTo(MarkupTagHelperElementSyntax markupElementSyntax)
{
// Check whether the element is self-closing
if (markupElementSyntax is not
{
EndTag.CloseAngle.IsMissing: false,
StartTag: { ForwardSlash: null, CloseAngle.IsMissing: false },
TagHelperInfo.BindingResult.Descriptors: [.. var descriptors]
})
{
return false;
}

// Check whether the element has any non-whitespace content
if (markupElementSyntax is { Body: { } body } && body.Any(static n => !n.ContainsOnlyWhitespace()))
{
return false;
}

// Get symbols for the markup element
var boundTagHelper = descriptors.FirstOrDefault(static d => d.IsComponentTagHelper);
if (boundTagHelper == null)
{
return false;
}

// Check whether the Component must have children
foreach (var attribute in boundTagHelper.BoundAttributes)
{
// Parameter is not required
if (attribute is { IsEditorRequired: false })
{
continue;
}

// Parameter is not a `RenderFragment` or `RenderFragment<T>`
if (!attribute.IsChildContentProperty())
{
continue;
}

// Parameter is not set or bound as an attribute
if (!markupElementSyntax.TagHelperInfo!.BindingResult.Attributes.Any(a =>
RazorSyntaxFacts.TryGetComponentParameterNameFromFullAttributeName(a.Key, out var componentParameterName, out var directiveAttributeParameter) &&
componentParameterName.SequenceEqual(attribute.Name) &&
directiveAttributeParameter is { IsEmpty: true } or "get"
))
{
return false;
}
}

return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
using Microsoft.CodeAnalysis.Razor.Formatting;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.VisualStudio.LanguageServer.Protocol;

namespace Microsoft.CodeAnalysis.Razor.CodeActions.Razor;

internal class SimplifyTagToSelfClosingCodeActionResolver : IRazorCodeActionResolver
{
public string Action => LanguageServerConstants.CodeActions.SimplifyTagToSelfClosingAction;

public async Task<WorkspaceEdit?> ResolveAsync(DocumentContext documentContext, JsonElement data, RazorFormattingOptions options, CancellationToken cancellationToken)
{
if (data.ValueKind == JsonValueKind.Undefined)
{
return null;
}

var actionParams = JsonSerializer.Deserialize<SimplifyTagToSelfClosingCodeActionParams>(data.GetRawText());
if (actionParams is null)
{
return null;
}

var componentDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);

var text = componentDocument.Source.Text;
var removeRange = text.GetRange(actionParams.StartTagCloseAngleIndex, actionParams.EndTagCloseAngleIndex);

var documentChanges = new TextDocumentEdit[]
{
new TextDocumentEdit
{
TextDocument = new OptionalVersionedTextDocumentIdentifier { Uri = documentContext.Uri },
Edits =
[
new TextEdit
{
NewText = " />",
Range = removeRange,
}
],
}
};

return new WorkspaceEdit
{
DocumentChanges = documentChanges,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ public static class CodeActions

public const string ExtractToNewComponentAction = "ExtractToNewComponent";

public const string SimplifyTagToSelfClosingAction = "SimplifyTagToSelfClosing";

public const string CreateComponentFromTag = "CreateComponentFromTag";

public const string AddUsing = "AddUsing";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// 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.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.AspNetCore.Razor.Language;
Expand Down Expand Up @@ -116,6 +117,43 @@ static TextSpan CalculateFullSpan(MarkupTextLiteralSyntax attributeName, MarkupT
}
}

/// <summary>
/// For example given "&lt;Goo @bi$$nd-Value:after="val" /&gt;", it would return the span from "V" to "e".
/// </summary>
public static bool TryGetComponentParameterNameFromFullAttributeName(string fullAttributeName, out ReadOnlySpan<char> componentParameterName, out ReadOnlySpan<char> directiveAttributeParameter)
{
componentParameterName = fullAttributeName.AsSpan();
directiveAttributeParameter = default;
if (componentParameterName.IsEmpty)
{
return false;
}

// Parse @bind directive
if (componentParameterName[0] == '@')
{
// Trim `@` transition
componentParameterName = componentParameterName[1..];

// Check for and trim `bind-` directive prefix
if (!componentParameterName.StartsWith("bind-", StringComparison.Ordinal))
{
return false;
}

componentParameterName = componentParameterName["bind-".Length..];

// Trim directive parameter name, if any
if (componentParameterName.LastIndexOf(':') is int colonIndex and > 0)
{
directiveAttributeParameter = componentParameterName[(colonIndex + 1)..];
componentParameterName = componentParameterName[..colonIndex];
}
}

return true;
}

public static CSharpCodeBlockSyntax? TryGetCSharpCodeFromCodeBlock(RazorSyntaxNode node)
{
if (node is CSharpCodeBlockSyntax block &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,4 +211,7 @@
<data name="Unsupported_razor_project_info_version_encountered" xml:space="preserve">
<value>Unsupported razor project info version encounted.</value>
</data>
<data name="Simplify_Tag_To_SelfClosing_Title" xml:space="preserve">
<value>Simplify tag to self-closing</value>
Copy link
Member

Choose a reason for hiding this comment

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

@phil-allen-msft: Does this need any word-smithing?

</data>
</root>

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading