Skip to content

Commit 0d4d79b

Browse files
authored
Add CodeAction to simplify fully-qualified component tags (#12379)
## Implementation Complete: Add CodeAction to Simplify Fully-Qualified Component ✅ **All implementation tasks completed successfully** ### What was implemented: - [x] Created `SimplifyFullyQualifiedComponentCodeActionProvider` to detect fully-qualified component tags - [x] Created `SimplifyFullyQualifiedComponentCodeActionParams` model for the action parameters - [x] Created `SimplifyFullyQualifiedComponentCodeActionResolver` to perform simplification and add using directives - [x] Registered provider and resolver in both LSP and OOP service collections - [x] Added `SimplifyFullyQualifiedComponent` constant to LanguageServerConstants - [x] Added factory method in RazorCodeActionFactory - [x] Added localized string resource "Simplify fully qualified component" - [x] Created 15 comprehensive tests covering various scenarios - [x] All tests pass (15/15 SimplifyFullyQualifiedComponent + 110/110 CodeActions suite) - [x] No regressions in existing code actions - [x] Addressed all PR feedback ### Recent changes: - Fixed negative tests to not pass `codeActionName` parameter when `expected` is null - Removed null-conditional operator as TagHelperInfo.BindingResult cannot be null per recent framework changes - Added test `DoNotOfferOnLegacyRazorFile` to verify code action not offered on .cshtml files - Added test `DoNotOfferInCSharpCode` to verify code action not offered in C# code blocks - Fixed comment to correctly state 3 elements max (start tag, end tag, using directive) - Changed to use `TryGetSyntaxRoot()` instead of `GetSyntaxTree()` for cleaner code - Added comment explaining no capacity needed for PooledArrayBuilder - Fixed constructor consistency by adding parentheses to WorkspaceEdit - Added test to verify code action doesn't trigger when cursor is inside tag content - Changed to only trigger on `MarkupTagHelperStartTagSyntax` instead of `MarkupTagHelperElementSyntax` - Fixed `FindInnermostNode` to use `includeWhitespace: true` - Changed diagnostic range checking to use `TryGetAbsoluteIndex` - Improved using directive detection to use `GetRequiredTagHelpers()` - Simplified resolver implementation by removing unnecessary PooledArrayBuilder - Added test cases for self-closing tag without space and multiline components ### Features: ✅ Simplifies fully-qualified component tags (e.g., `Microsoft.AspNetCore.Components.Authorization.AuthorizeRouteView`) ✅ Adds `@using` directive only when needed ✅ Intelligently detects when namespace is already in scope ✅ Works with both self-closing tags and tags with content ✅ Handles multiline components with attributes ✅ Only offers when cursor is on the start tag, not on element content ✅ Only offers in Component (.razor) files, not Legacy (.cshtml) files ✅ Only offers in HTML context, not in C# code blocks ✅ Safely handles invalid diagnostic ranges from LSP clients ✅ Does not offer when diagnostics are present on the start tag ✅ Does not offer for simple (non-qualified) component names ### Test Coverage: 1. ✅ No existing using directive 2. ✅ With existing using directive 3. ✅ With start and end tags 4. ✅ With attributes 5. ✅ Do not offer on simple component 6. ✅ Do not offer on HTML tag 7. ✅ Do not offer when diagnostic present 8. ✅ Nested namespace support 9. ✅ Multiple occurrences (only selected one simplified) 10. ✅ Self-closing without space 11. ✅ Multiline with attributes spread across multiple lines 12. ✅ Namespace already in scope via imports 13. ✅ Do not offer when cursor is inside tag content 14. ✅ Do not offer on Legacy (.cshtml) files 15. ✅ Do not offer in C# code blocks Fixes #4522 <!-- START COPILOT CODING AGENT SUFFIX --> <details> <summary>Original prompt</summary> > > ---- > > *This section details on the original issue you should resolve* > > <issue_title>Add CodeAction to simplify fully-qualified Component </issue_title> > <issue_description>## Summarization > There should be a CodeAction available to simplify the Component name for a fully qualified component tag, either when there is already an `@using` directive in the file, or if there isn't the code action should insert one. > > eg given: > ``` > <Microsoft.AspNetCore.Components.Authorization.AuthorizeRouteView /> > ``` > > There should be a code action to "Simplify component tag" which results in the following document: > ``` > @using Microsoft.AspNetCore.Components.Authorization > <AuthorizeRouteView /> > ``` > > If the using already exists, it should be left alone. eg given: > > ``` > @using Microsoft.AspNetCore.Components.Authorization > <Microsoft.AspNetCore.Components.Authorization.AuthorizeRouteView /> > ``` > > Then the "Simplify component tag" code action should result in the following document: > ``` > @using Microsoft.AspNetCore.Components.Authorization > <AuthorizeRouteView /> > ``` > > If there are any diagnostics present on the start tag then we shouldn't offer. > </issue_description> > > <agent_instructions>Please place tests in a new file in https://github.com/dotnet/razor/tree/main/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions</agent_instructions> > > ## Comments on the Issue (you are @copilot in this section) > > <comments> > </comments> > </details> Fixes #4522 <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs.
2 parents 262b31a + 6297300 commit 0d4d79b

File tree

23 files changed

+783
-8
lines changed

23 files changed

+783
-8
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ public static void AddCodeActionsServices(this IServiceCollection services)
162162
services.AddSingleton<IRazorCodeActionResolver, WrapAttributesCodeActionResolver>();
163163
services.AddSingleton<IRazorCodeActionProvider, SimplifyTagToSelfClosingCodeActionProvider>();
164164
services.AddSingleton<IRazorCodeActionResolver, SimplifyTagToSelfClosingCodeActionResolver>();
165+
services.AddSingleton<IRazorCodeActionProvider, SimplifyFullyQualifiedComponentCodeActionProvider>();
166+
services.AddSingleton<IRazorCodeActionResolver, SimplifyFullyQualifiedComponentCodeActionResolver>();
165167

166168
// Html Code actions
167169
services.AddSingleton<IHtmlCodeActionProvider, HtmlCodeActionProvider>();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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 SimplifyFullyQualifiedComponentCodeActionParams
9+
{
10+
[JsonPropertyName("namespace")]
11+
public string Namespace { get; set; } = string.Empty;
12+
13+
[JsonPropertyName("componentName")]
14+
public string ComponentName { get; set; } = string.Empty;
15+
16+
[JsonPropertyName("startTagSpanStart")]
17+
public int StartTagSpanStart { get; set; }
18+
19+
[JsonPropertyName("startTagSpanEnd")]
20+
public int StartTagSpanEnd { get; set; }
21+
22+
[JsonPropertyName("endTagSpanStart")]
23+
public int EndTagSpanStart { get; set; }
24+
25+
[JsonPropertyName("endTagSpanEnd")]
26+
public int EndTagSpanEnd { get; set; }
27+
}

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
@@ -21,6 +21,7 @@ internal static class RazorCodeActionFactory
2121
private readonly static Guid s_generateAsyncMethodTelemetryId = new("9058ca47-98e2-4f11-bf7c-a16a444dd939");
2222
private readonly static Guid s_promoteUsingDirectiveTelemetryId = new("751f9012-e37b-444a-9211-b4ebce91d96e");
2323
private readonly static Guid s_wrapAttributesTelemetryId = new("1df50ba6-4ed1-40d8-8fe2-1c4c1b08e8b5");
24+
private readonly static Guid s_simplifyFullyQualifiedComponentTelemetryId = new("f8640324-2037-49fd-9697-2227690c33c3");
2425

2526
public static RazorVSInternalCodeAction CreateWrapAttributes(RazorCodeActionResolutionParams resolutionParams)
2627
=> new RazorVSInternalCodeAction
@@ -196,4 +197,17 @@ public static RazorVSInternalCodeAction CreateAsyncGenerateMethod(VSTextDocument
196197
};
197198
return codeAction;
198199
}
200+
201+
public static RazorVSInternalCodeAction CreateSimplifyFullyQualifiedComponent(RazorCodeActionResolutionParams resolutionParams)
202+
{
203+
var data = JsonSerializer.SerializeToElement(resolutionParams);
204+
var codeAction = new RazorVSInternalCodeAction()
205+
{
206+
Title = SR.Simplify_Fully_Qualified_Component_Title,
207+
Data = data,
208+
TelemetryId = s_simplifyFullyQualifiedComponentTelemetryId,
209+
Name = LanguageServerConstants.CodeActions.SimplifyFullyQualifiedComponent,
210+
};
211+
return codeAction;
212+
}
199213
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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.Collections.Immutable;
5+
using System.Linq;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using Microsoft.AspNetCore.Razor.Language;
9+
using Microsoft.AspNetCore.Razor.Language.Syntax;
10+
using Microsoft.AspNetCore.Razor.Threading;
11+
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
12+
using Microsoft.CodeAnalysis.Razor.Protocol;
13+
14+
namespace Microsoft.CodeAnalysis.Razor.CodeActions.Razor;
15+
16+
internal class SimplifyFullyQualifiedComponentCodeActionProvider : IRazorCodeActionProvider
17+
{
18+
public Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken)
19+
{
20+
if (context.HasSelection)
21+
{
22+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
23+
}
24+
25+
// Make sure we're in the right kind and part of file
26+
if (!FileKinds.IsComponent(context.CodeDocument.FileKind))
27+
{
28+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
29+
}
30+
31+
if (context.LanguageKind != RazorLanguageKind.Html)
32+
{
33+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
34+
}
35+
36+
if (!context.CodeDocument.TryGetSyntaxRoot(out var syntaxRoot))
37+
{
38+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
39+
}
40+
41+
// Find the tag at the cursor position, if it's on the start tag (name portion) or end tag only.
42+
var owner = syntaxRoot.FindInnermostNode(context.StartAbsoluteIndex, includeWhitespace: true) switch
43+
{
44+
MarkupTagHelperStartTagSyntax startTag when startTag.Name.Span.Contains(context.StartAbsoluteIndex) => startTag.Parent,
45+
MarkupTagHelperEndTagSyntax endTag => endTag.Parent,
46+
_ => null
47+
};
48+
49+
if (owner is not MarkupTagHelperElementSyntax markupElementSyntax)
50+
{
51+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
52+
}
53+
54+
// If there are any diagnostics on the start tag, we shouldn't offer
55+
if (HasDiagnosticsOnStartTag(markupElementSyntax, context))
56+
{
57+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
58+
}
59+
60+
// Check whether the element represents a fully qualified component
61+
if (!IsFullyQualifiedComponent(markupElementSyntax, out var @namespace, out var componentName))
62+
{
63+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
64+
}
65+
66+
// Create the action params
67+
var actionParams = new SimplifyFullyQualifiedComponentCodeActionParams
68+
{
69+
Namespace = @namespace,
70+
ComponentName = componentName,
71+
StartTagSpanStart = markupElementSyntax.StartTag.Name.SpanStart,
72+
StartTagSpanEnd = markupElementSyntax.StartTag.Name.Span.End,
73+
EndTagSpanStart = markupElementSyntax.EndTag?.Name.SpanStart ?? -1,
74+
EndTagSpanEnd = markupElementSyntax.EndTag?.Name.Span.End ?? -1,
75+
};
76+
77+
var resolutionParams = new RazorCodeActionResolutionParams()
78+
{
79+
TextDocument = context.Request.TextDocument,
80+
Action = LanguageServerConstants.CodeActions.SimplifyFullyQualifiedComponent,
81+
Language = RazorLanguageKind.Razor,
82+
DelegatedDocumentUri = context.DelegatedDocumentUri,
83+
Data = actionParams,
84+
};
85+
86+
var codeAction = RazorCodeActionFactory.CreateSimplifyFullyQualifiedComponent(resolutionParams);
87+
return Task.FromResult<ImmutableArray<RazorVSInternalCodeAction>>([codeAction]);
88+
}
89+
90+
private static bool HasDiagnosticsOnStartTag(MarkupTagHelperElementSyntax element, RazorCodeActionContext context)
91+
{
92+
if (context.Request.Context.Diagnostics is null)
93+
{
94+
return false;
95+
}
96+
97+
var startTagSpan = element.StartTag.Span;
98+
foreach (var diagnostic in context.Request.Context.Diagnostics)
99+
{
100+
if (diagnostic.Range is null)
101+
{
102+
continue;
103+
}
104+
105+
if (!context.SourceText.TryGetAbsoluteIndex(diagnostic.Range.Start, out var diagnosticStart) ||
106+
!context.SourceText.TryGetAbsoluteIndex(diagnostic.Range.End, out var diagnosticEnd))
107+
{
108+
continue;
109+
}
110+
111+
// Check if diagnostic overlaps with the start tag
112+
if (diagnosticStart < startTagSpan.End && diagnosticEnd > startTagSpan.Start)
113+
{
114+
return true;
115+
}
116+
}
117+
118+
return false;
119+
}
120+
121+
private static bool IsFullyQualifiedComponent(MarkupTagHelperElementSyntax element, out string @namespace, out string componentName)
122+
{
123+
@namespace = string.Empty;
124+
componentName = string.Empty;
125+
126+
var descriptors = element.TagHelperInfo.BindingResult.Descriptors;
127+
var boundTagHelper = descriptors.FirstOrDefault(static d => d.Kind == TagHelperKind.Component);
128+
if (boundTagHelper is null)
129+
{
130+
return false;
131+
}
132+
133+
// Check if this is a fully qualified name match
134+
if (!boundTagHelper.IsFullyQualifiedNameMatch)
135+
{
136+
return false;
137+
}
138+
139+
var fullyQualifiedName = boundTagHelper.Name;
140+
141+
// Extract the namespace and component name
142+
var lastDotIndex = fullyQualifiedName.LastIndexOf('.');
143+
if (lastDotIndex < 0)
144+
{
145+
return false;
146+
}
147+
148+
@namespace = fullyQualifiedName[..lastDotIndex];
149+
componentName = fullyQualifiedName[(lastDotIndex + 1)..];
150+
return true;
151+
}
152+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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.AspNetCore.Razor.Language;
8+
using Microsoft.AspNetCore.Razor.PooledObjects;
9+
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
10+
using Microsoft.CodeAnalysis.Razor.Formatting;
11+
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
12+
using Microsoft.CodeAnalysis.Razor.Protocol;
13+
14+
namespace Microsoft.CodeAnalysis.Razor.CodeActions.Razor;
15+
16+
internal class SimplifyFullyQualifiedComponentCodeActionResolver : IRazorCodeActionResolver
17+
{
18+
public string Action => LanguageServerConstants.CodeActions.SimplifyFullyQualifiedComponent;
19+
20+
public async Task<WorkspaceEdit?> ResolveAsync(DocumentContext documentContext, JsonElement data, RazorFormattingOptions options, CancellationToken cancellationToken)
21+
{
22+
if (data.ValueKind == JsonValueKind.Undefined)
23+
{
24+
return null;
25+
}
26+
27+
var actionParams = JsonSerializer.Deserialize<SimplifyFullyQualifiedComponentCodeActionParams>(data.GetRawText());
28+
if (actionParams is null)
29+
{
30+
return null;
31+
}
32+
33+
var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
34+
var text = codeDocument.Source.Text;
35+
36+
var codeDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier() { DocumentUri = new(documentContext.Uri) };
37+
38+
// Check if we need to add a using directive.
39+
// We check the tag helpers available in the document to see if the simple component name
40+
// can already be used without qualification. This would be the case if the namespace is
41+
// already imported via a @using directive in this file or an _Imports.razor file.
42+
var needsUsing = true;
43+
var tagHelpers = codeDocument.GetRequiredTagHelperContext().TagHelpers;
44+
45+
// Look through all tag helpers to find one that matches our component and can be used
46+
// with the simple (non-fully-qualified) name. The presence of such a tag helper indicates
47+
// that the namespace is already in scope.
48+
foreach (var tagHelper in tagHelpers)
49+
{
50+
// We need a component tag helper that:
51+
// 1. Is not a fully qualified name match (can be used with simple name)
52+
// 2. Would match the unqualified tag name we'll be transforming to
53+
// 3. Is from the same namespace we would add a using for
54+
if (tagHelper.Kind == TagHelperKind.Component &&
55+
!tagHelper.IsFullyQualifiedNameMatch &&
56+
tagHelper.TagMatchingRules is [{ TagName: { } matchingTagName }] &&
57+
matchingTagName == actionParams.ComponentName &&
58+
tagHelper.TypeNamespace == actionParams.Namespace)
59+
{
60+
// Found it - the namespace is already in scope
61+
needsUsing = false;
62+
break;
63+
}
64+
}
65+
66+
// Build the tag simplification edits (at the original positions in the document)
67+
// No capacity needed - tagEdits will never contain more than 3 elements (start tag, end tag, and using directive)
68+
using var tagEdits = new PooledArrayBuilder<SumType<TextEdit, AnnotatedTextEdit>>();
69+
70+
// Replace the fully qualified name with the simple component name in end tag first (if it exists)
71+
// The end tag edit must come before the start tag edit, as clients may not re-order them
72+
if (actionParams.EndTagSpanStart >= 0 && actionParams.EndTagSpanEnd >= 0)
73+
{
74+
var endTagRange = text.GetRange(actionParams.EndTagSpanStart, actionParams.EndTagSpanEnd);
75+
tagEdits.Add(new TextEdit
76+
{
77+
NewText = actionParams.ComponentName,
78+
Range = endTagRange,
79+
});
80+
}
81+
82+
// Replace the fully qualified name with the simple component name in start tag
83+
var startTagRange = text.GetRange(actionParams.StartTagSpanStart, actionParams.StartTagSpanEnd);
84+
tagEdits.Add(new TextEdit
85+
{
86+
NewText = actionParams.ComponentName,
87+
Range = startTagRange,
88+
});
89+
90+
// Add using directive if needed (at the top of the file)
91+
// This must come after the tag edits because the using directive will be inserted at the top,
92+
// which would change line numbers for subsequent edits
93+
if (needsUsing)
94+
{
95+
var addUsingEdit = AddUsingsHelper.CreateAddUsingTextEdit(actionParams.Namespace, codeDocument);
96+
tagEdits.Add(addUsingEdit);
97+
}
98+
99+
return new WorkspaceEdit()
100+
{
101+
DocumentChanges = new TextDocumentEdit[]
102+
{
103+
new TextDocumentEdit()
104+
{
105+
TextDocument = codeDocumentIdentifier,
106+
Edits = tagEdits.ToArray()
107+
}
108+
}
109+
};
110+
}
111+
}

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

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

6060
public const string WrapAttributes = nameof(WrapAttributes);
6161

62+
public const string SimplifyFullyQualifiedComponent = nameof(SimplifyFullyQualifiedComponent);
63+
6264
/// <summary>
6365
/// Remaps without formatting the resolved code action edit
6466
/// </summary>

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Resources/SR.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,9 @@
214214
<data name="Simplify_Tag_To_SelfClosing_Title" xml:space="preserve">
215215
<value>Simplify tag to self-closing</value>
216216
</data>
217+
<data name="Simplify_Fully_Qualified_Component_Title" xml:space="preserve">
218+
<value>Simplify fully qualified component</value>
219+
</data>
217220
<data name="IncompatibleProject_NotAnAdditionalFile" xml:space="preserve">
218221
<value>The Razor editor utilizes the Razor Source Generator, which requires *.razor and *.cshtml files to be AdditionalFiles in the project. {0} appears to come from '{1}', which has Razor documents but this file is not an AdditionalFile, so the editing experience will be limited. No more messages will be logged for this project.</value>
219222
</data>

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Resources/xlf/SR.cs.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Resources/xlf/SR.de.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Resources/xlf/SR.es.xlf

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)