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

Merged
merged 22 commits into from
Jul 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e15fe5b
fuse on by default for 17.14 (#11426)
Jan 29, 2025
0424172
Update dependencies from https://github.com/dotnet/arcade build 20250…
dotnet-maestro[bot] Feb 19, 2025
632a3f3
[release/dev17.14] Update dependencies from dotnet/arcade (#11515)
akhera99 Feb 20, 2025
6e4b433
Merge remote-tracking branch 'upstream/main' into merge_main_to_17_14
akhera99 Feb 25, 2025
bfd5764
Snap main to 17.14 P3 (#11549)
akhera99 Feb 25, 2025
efda00b
Move static accessor into seperate method so we don't JIT it unless w…
chsienki Feb 26, 2025
1874a91
[release/dev17.14] Fix extra JITing (#11559)
akhera99 Feb 26, 2025
a51562b
Move cohost options to explicit class (#11566)
github-actions[bot] Feb 27, 2025
10596b2
Snap 17.14p3 (merge `main` commits to `release/dev17.14`) (#11688)
jjonescz Apr 1, 2025
e5361d0
Fuse/fix debugging info (#11658)
chsienki Apr 2, 2025
4ccba78
Add back serialization tests
DustinCampbell Apr 2, 2025
4a69788
[release/dev17.14] Add serialization tests back (#11695)
DustinCampbell Apr 2, 2025
4fb2a6b
#11793 Simplify tag to self-closing code action
Peter-Juhasz Apr 28, 2025
0102275
Improvements:
Peter-Juhasz Apr 30, 2025
83bca1d
Simplify `IsChildContentProperty` call
Peter-Juhasz Apr 30, 2025
152b091
Simplify descriptor access on markup element
Peter-Juhasz May 4, 2025
7acb7ec
Move parsing attribute name to syntax facts
Peter-Juhasz May 4, 2025
f7df559
Fix build warnings
Peter-Juhasz May 4, 2025
91f6d31
Review findings
Peter-Juhasz May 12, 2025
e92382f
Merge remote-tracking branch 'upstream/main' into simplifytagtoselfcl…
davidwengier Jun 11, 2025
d648574
Simplify self-closing tag check
davidwengier Jun 11, 2025
cc5272c
Merge remote-tracking branch 'upstream/main' into simplifytagtoselfcl…
davidwengier Jul 1, 2025
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 @@ -440,6 +440,34 @@ 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 builder = new PooledArrayBuilder<TNode>(Count);

foreach (var node in this)
{
if (predicate(node))
{
builder.Add(node);
}
}

return builder.ToList();
}

// for debugging
#pragma warning disable IDE0051 // Remove unused private members
private TNode[] Nodes => [.. this];
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 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

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 @@ -15,6 +15,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 @@ -111,6 +112,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,137 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

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;

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)
{
// If there is no end tag, then the element is either already self-closing, or invalid. Either way, don't offer.
if (markupElementSyntax.EndTag is null)
{
return false;
}

if (markupElementSyntax is not { 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,57 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

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;

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 { DocumentUri= new(documentContext.Uri) },
Edits =
[
new TextEdit
{
NewText = " />",
Range = removeRange,
}
],
}
};

return new WorkspaceEdit
{
DocumentChanges = documentChanges,
};
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Generic;
using System.Collections.Immutable;
Expand Down
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 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

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
Loading
Loading