Skip to content

Commit e56dfe2

Browse files
#11793 Simplify tag to self-closing code action (#11802)
* #11793 Simplify tag to self-closing code action * Improvements: - code style - clarify naming on params model - better type check for RenderFragment - recognize binding get/set syntax * Simplify `IsChildContentProperty` call * Simplify descriptor access on markup element * Move parsing attribute name to syntax facts * Fix build warnings * Review findings * Simplify self-closing tag check The previous check was invalid now that tokens are structs, and making it valid was ugly and hard to understand, so decided to simplify. Added a test for good measure. Co-authored-by: David Wengier <[email protected]>
1 parent a00d8fe commit e56dfe2

File tree

26 files changed

+771
-32
lines changed

26 files changed

+771
-32
lines changed

src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/Syntax/SyntaxList`1.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,34 @@ public bool Any()
440440
return Node != null;
441441
}
442442

443+
public bool Any(Func<TNode, bool> predicate)
444+
{
445+
foreach (var node in this)
446+
{
447+
if (predicate(node))
448+
{
449+
return true;
450+
}
451+
}
452+
453+
return false;
454+
}
455+
456+
public SyntaxList<TNode> Where(Func<TNode, bool> predicate)
457+
{
458+
using var builder = new PooledArrayBuilder<TNode>(Count);
459+
460+
foreach (var node in this)
461+
{
462+
if (predicate(node))
463+
{
464+
builder.Add(node);
465+
}
466+
}
467+
468+
return builder.ToList();
469+
}
470+
443471
// for debugging
444472
#pragma warning disable IDE0051 // Remove unused private members
445473
private TNode[] Nodes => [.. this];

src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ public static void AddCodeActionsServices(this IServiceCollection services)
156156
services.AddSingleton<IRazorCodeActionResolver, PromoteUsingCodeActionResolver>();
157157
services.AddSingleton<IRazorCodeActionProvider, WrapAttributesCodeActionProvider>();
158158
services.AddSingleton<IRazorCodeActionResolver, WrapAttributesCodeActionResolver>();
159+
services.AddSingleton<IRazorCodeActionProvider, SimplifyTagToSelfClosingCodeActionProvider>();
160+
services.AddSingleton<IRazorCodeActionResolver, SimplifyTagToSelfClosingCodeActionResolver>();
159161

160162
// Html Code actions
161163
services.AddSingleton<IHtmlCodeActionProvider, HtmlCodeActionProvider>();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Text.Json.Serialization;
5+
6+
namespace Microsoft.CodeAnalysis.Razor.CodeActions.Models;
7+
8+
internal sealed class SimplifyTagToSelfClosingCodeActionParams
9+
{
10+
[JsonPropertyName("startTagCloseAngleIndex")]
11+
public int StartTagCloseAngleIndex { get; set; }
12+
13+
[JsonPropertyName("endTagCloseAngleIndex")]
14+
public int EndTagCloseAngleIndex { get; set; }
15+
}

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/CodeActions/Razor/RazorCodeActionFactory.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ internal static class RazorCodeActionFactory
1515
private readonly static Guid s_createComponentFromTagTelemetryId = new("a28e0baa-a4d5-4953-a817-1db586035841");
1616
private readonly static Guid s_createExtractToCodeBehindTelemetryId = new("f63167f7-fdc6-450f-8b7b-b240892f4a27");
1717
private readonly static Guid s_createExtractToComponentTelemetryId = new("af67b0a3-f84b-4808-97a7-b53e85b22c64");
18+
private readonly static Guid s_simplifyComponentTelemetryId = new("2207f68c-419e-4baa-8493-2e7769e5c91d");
1819
private readonly static Guid s_generateMethodTelemetryId = new("c14fa003-c752-45fc-bb29-3a123ae5ecef");
1920
private readonly static Guid s_generateAsyncMethodTelemetryId = new("9058ca47-98e2-4f11-bf7c-a16a444dd939");
2021
private readonly static Guid s_promoteUsingDirectiveTelemetryId = new("751f9012-e37b-444a-9211-b4ebce91d96e");
@@ -111,6 +112,19 @@ public static RazorVSInternalCodeAction CreateExtractToComponent(RazorCodeAction
111112
return codeAction;
112113
}
113114

115+
public static RazorVSInternalCodeAction CreateSimplifyTagToSelfClosing(RazorCodeActionResolutionParams resolutionParams)
116+
{
117+
var data = JsonSerializer.SerializeToElement(resolutionParams);
118+
var codeAction = new RazorVSInternalCodeAction()
119+
{
120+
Title = SR.Simplify_Tag_To_SelfClosing_Title,
121+
Data = data,
122+
TelemetryId = s_simplifyComponentTelemetryId,
123+
Name = LanguageServerConstants.CodeActions.SimplifyTagToSelfClosingAction,
124+
};
125+
return codeAction;
126+
}
127+
114128
public static RazorVSInternalCodeAction CreateGenerateMethod(VSTextDocumentIdentifier textDocument, Uri? delegatedDocumentUri, string methodName, string? eventParameterType)
115129
{
116130
var @params = new GenerateMethodCodeActionParams
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Collections.Immutable;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using Microsoft.AspNetCore.Razor.Language;
10+
using Microsoft.AspNetCore.Razor.Language.Components;
11+
using Microsoft.AspNetCore.Razor.Language.Syntax;
12+
using Microsoft.AspNetCore.Razor.Threading;
13+
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
14+
using Microsoft.CodeAnalysis.Razor.Protocol;
15+
16+
namespace Microsoft.CodeAnalysis.Razor.CodeActions.Razor;
17+
18+
internal class SimplifyTagToSelfClosingCodeActionProvider : IRazorCodeActionProvider
19+
{
20+
public Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken)
21+
{
22+
if (context.HasSelection)
23+
{
24+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
25+
}
26+
27+
// Make sure we're in the right kind and part of file
28+
if (!FileKinds.IsComponent(context.CodeDocument.FileKind))
29+
{
30+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
31+
}
32+
33+
if (context.LanguageKind != RazorLanguageKind.Html)
34+
{
35+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
36+
}
37+
38+
// Caret must be inside a markup element
39+
if (context.ContainsDiagnostic(ComponentDiagnosticFactory.UnexpectedMarkupElement.Id) ||
40+
context.ContainsDiagnostic(ComponentDiagnosticFactory.UnexpectedClosingTag.Id))
41+
{
42+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
43+
}
44+
45+
var syntaxTree = context.CodeDocument.GetSyntaxTree();
46+
if (syntaxTree?.Root is null)
47+
{
48+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
49+
}
50+
51+
var owner = syntaxTree.Root.FindInnermostNode(context.StartAbsoluteIndex, includeWhitespace: false)?.FirstAncestorOrSelf<MarkupTagHelperElementSyntax>();
52+
if (owner is not MarkupTagHelperElementSyntax markupElementSyntax)
53+
{
54+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
55+
}
56+
57+
// Check whether the code action is applicable to the element
58+
if (!IsApplicableTo(markupElementSyntax))
59+
{
60+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
61+
}
62+
63+
// Provide code action to simplify
64+
var actionParams = new SimplifyTagToSelfClosingCodeActionParams
65+
{
66+
StartTagCloseAngleIndex = markupElementSyntax.StartTag.CloseAngle.SpanStart,
67+
EndTagCloseAngleIndex = markupElementSyntax.EndTag.CloseAngle.EndPosition,
68+
};
69+
70+
var resolutionParams = new RazorCodeActionResolutionParams()
71+
{
72+
TextDocument = context.Request.TextDocument,
73+
Action = LanguageServerConstants.CodeActions.SimplifyTagToSelfClosingAction,
74+
Language = RazorLanguageKind.Razor,
75+
DelegatedDocumentUri = context.DelegatedDocumentUri,
76+
Data = actionParams,
77+
};
78+
79+
var codeAction = RazorCodeActionFactory.CreateSimplifyTagToSelfClosing(resolutionParams);
80+
return Task.FromResult<ImmutableArray<RazorVSInternalCodeAction>>([codeAction]);
81+
}
82+
83+
internal static bool IsApplicableTo(MarkupTagHelperElementSyntax markupElementSyntax)
84+
{
85+
// If there is no end tag, then the element is either already self-closing, or invalid. Either way, don't offer.
86+
if (markupElementSyntax.EndTag is null)
87+
{
88+
return false;
89+
}
90+
91+
if (markupElementSyntax is not { TagHelperInfo.BindingResult.Descriptors: [.. var descriptors] })
92+
{
93+
return false;
94+
}
95+
96+
// Check whether the element has any non-whitespace content
97+
if (markupElementSyntax is { Body: { } body } && body.Any(static n => !n.ContainsOnlyWhitespace()))
98+
{
99+
return false;
100+
}
101+
102+
// Get symbols for the markup element
103+
var boundTagHelper = descriptors.FirstOrDefault(static d => d.IsComponentTagHelper);
104+
if (boundTagHelper == null)
105+
{
106+
return false;
107+
}
108+
109+
// Check whether the Component must have children
110+
foreach (var attribute in boundTagHelper.BoundAttributes)
111+
{
112+
// Parameter is not required
113+
if (attribute is { IsEditorRequired: false })
114+
{
115+
continue;
116+
}
117+
118+
// Parameter is not a `RenderFragment` or `RenderFragment<T>`
119+
if (!attribute.IsChildContentProperty())
120+
{
121+
continue;
122+
}
123+
124+
// Parameter is not set or bound as an attribute
125+
if (!markupElementSyntax.TagHelperInfo!.BindingResult.Attributes.Any(a =>
126+
RazorSyntaxFacts.TryGetComponentParameterNameFromFullAttributeName(a.Key, out var componentParameterName, out var directiveAttributeParameter) &&
127+
componentParameterName.SequenceEqual(attribute.Name) &&
128+
directiveAttributeParameter is { IsEmpty: true } or "get"
129+
))
130+
{
131+
return false;
132+
}
133+
}
134+
135+
return true;
136+
}
137+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Text.Json;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
8+
using Microsoft.CodeAnalysis.Razor.Formatting;
9+
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
10+
using Microsoft.CodeAnalysis.Razor.Protocol;
11+
12+
namespace Microsoft.CodeAnalysis.Razor.CodeActions.Razor;
13+
14+
internal class SimplifyTagToSelfClosingCodeActionResolver : IRazorCodeActionResolver
15+
{
16+
public string Action => LanguageServerConstants.CodeActions.SimplifyTagToSelfClosingAction;
17+
18+
public async Task<WorkspaceEdit?> ResolveAsync(DocumentContext documentContext, JsonElement data, RazorFormattingOptions options, CancellationToken cancellationToken)
19+
{
20+
if (data.ValueKind == JsonValueKind.Undefined)
21+
{
22+
return null;
23+
}
24+
25+
var actionParams = JsonSerializer.Deserialize<SimplifyTagToSelfClosingCodeActionParams>(data.GetRawText());
26+
if (actionParams is null)
27+
{
28+
return null;
29+
}
30+
31+
var componentDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
32+
33+
var text = componentDocument.Source.Text;
34+
var removeRange = text.GetRange(actionParams.StartTagCloseAngleIndex, actionParams.EndTagCloseAngleIndex);
35+
36+
var documentChanges = new TextDocumentEdit[]
37+
{
38+
new TextDocumentEdit
39+
{
40+
TextDocument = new OptionalVersionedTextDocumentIdentifier { DocumentUri= new(documentContext.Uri) },
41+
Edits =
42+
[
43+
new TextEdit
44+
{
45+
NewText = " />",
46+
Range = removeRange,
47+
}
48+
],
49+
}
50+
};
51+
52+
return new WorkspaceEdit
53+
{
54+
DocumentChanges = documentChanges,
55+
};
56+
}
57+
}

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Completion/DirectiveAttributeEventParameterCompletionItemProvider.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
2-
// Licensed under the MIT license. See License.txt in the project root for license information.
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Collections.Generic;
55
using System.Collections.Immutable;

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/LanguageServerConstants.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ public static class CodeActions
4343

4444
public const string ExtractToNewComponentAction = "ExtractToNewComponent";
4545

46+
public const string SimplifyTagToSelfClosingAction = "SimplifyTagToSelfClosing";
47+
4648
public const string CreateComponentFromTag = "CreateComponentFromTag";
4749

4850
public const string AddUsing = "AddUsing";

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorSyntaxFacts.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System;
45
using System.Diagnostics.CodeAnalysis;
56
using System.Linq;
67
using Microsoft.AspNetCore.Razor.Language;
@@ -116,6 +117,43 @@ static TextSpan CalculateFullSpan(MarkupTextLiteralSyntax attributeName, MarkupT
116117
}
117118
}
118119

120+
/// <summary>
121+
/// For example given "&lt;Goo @bi$$nd-Value:after="val" /&gt;", it would return the span from "V" to "e".
122+
/// </summary>
123+
public static bool TryGetComponentParameterNameFromFullAttributeName(string fullAttributeName, out ReadOnlySpan<char> componentParameterName, out ReadOnlySpan<char> directiveAttributeParameter)
124+
{
125+
componentParameterName = fullAttributeName.AsSpan();
126+
directiveAttributeParameter = default;
127+
if (componentParameterName.IsEmpty)
128+
{
129+
return false;
130+
}
131+
132+
// Parse @bind directive
133+
if (componentParameterName[0] == '@')
134+
{
135+
// Trim `@` transition
136+
componentParameterName = componentParameterName[1..];
137+
138+
// Check for and trim `bind-` directive prefix
139+
if (!componentParameterName.StartsWith("bind-", StringComparison.Ordinal))
140+
{
141+
return false;
142+
}
143+
144+
componentParameterName = componentParameterName["bind-".Length..];
145+
146+
// Trim directive parameter name, if any
147+
if (componentParameterName.LastIndexOf(':') is int colonIndex and > 0)
148+
{
149+
directiveAttributeParameter = componentParameterName[(colonIndex + 1)..];
150+
componentParameterName = componentParameterName[..colonIndex];
151+
}
152+
}
153+
154+
return true;
155+
}
156+
119157
public static CSharpCodeBlockSyntax? TryGetCSharpCodeFromCodeBlock(RazorSyntaxNode node)
120158
{
121159
if (node is CSharpCodeBlockSyntax block &&

0 commit comments

Comments
 (0)