Skip to content

Commit c0765e1

Browse files
authored
Add UnboundDirectiveAttributeAddUsingCodeActionProvider for directive attributes (#12404)
Part of #9747 Offer `@using` directives for unbound directive attributes.
2 parents 076ec28 + 6d3dffa commit c0765e1

File tree

10 files changed

+319
-23
lines changed

10 files changed

+319
-23
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ public static void AddCodeActionsServices(this IServiceCollection services)
153153
services.AddSingleton<IRazorCodeActionResolver, ExtractToComponentCodeActionResolver>();
154154
services.AddSingleton<IRazorCodeActionProvider, ComponentAccessibilityCodeActionProvider>();
155155
services.AddSingleton<IRazorCodeActionResolver, CreateComponentCodeActionResolver>();
156+
services.AddSingleton<IRazorCodeActionProvider, UnboundDirectiveAttributeAddUsingCodeActionProvider>();
156157
services.AddSingleton<IRazorCodeActionResolver, AddUsingsCodeActionResolver>();
157158
services.AddSingleton<IRazorCodeActionProvider, GenerateMethodCodeActionProvider>();
158159
services.AddSingleton<IRazorCodeActionResolver, GenerateMethodCodeActionResolver>();

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,22 +62,26 @@ internal static bool TryCreateAddUsingResolutionParams(string fullyQualifiedName
6262
return false;
6363
}
6464

65+
resolutionParams = CreateAddUsingResolutionParams(@namespace, textDocument, additionalEdit, delegatedDocumentUri);
66+
return true;
67+
}
68+
69+
internal static RazorCodeActionResolutionParams CreateAddUsingResolutionParams(string @namespace, VSTextDocumentIdentifier textDocument, TextDocumentEdit? additionalEdit, Uri? delegatedDocumentUri)
70+
{
6571
var actionParams = new AddUsingsCodeActionParams
6672
{
6773
Namespace = @namespace,
6874
AdditionalEdit = additionalEdit
6975
};
7076

71-
resolutionParams = new RazorCodeActionResolutionParams
77+
return new RazorCodeActionResolutionParams
7278
{
7379
TextDocument = textDocument,
7480
Action = LanguageServerConstants.CodeActions.AddUsing,
7581
Language = RazorLanguageKind.Razor,
7682
DelegatedDocumentUri = delegatedDocumentUri,
7783
Data = actionParams,
7884
};
79-
80-
return true;
8185
}
8286

8387
// Internal for testing
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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.Immutable;
6+
using System.Diagnostics.CodeAnalysis;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using Microsoft.AspNetCore.Razor.Language;
10+
using Microsoft.AspNetCore.Razor.Language.Syntax;
11+
using Microsoft.AspNetCore.Razor.Threading;
12+
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
13+
using Microsoft.CodeAnalysis.Razor.CodeActions.Razor;
14+
using Microsoft.CodeAnalysis.Razor.Workspaces;
15+
16+
namespace Microsoft.CodeAnalysis.Razor.CodeActions;
17+
18+
internal class UnboundDirectiveAttributeAddUsingCodeActionProvider : 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+
// Only work in component files
28+
if (!FileKinds.IsComponent(context.CodeDocument.FileKind))
29+
{
30+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
31+
}
32+
33+
if (!context.CodeDocument.TryGetSyntaxRoot(out var syntaxRoot))
34+
{
35+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
36+
}
37+
38+
// Find the node at the cursor position
39+
var owner = syntaxRoot.FindInnermostNode(context.StartAbsoluteIndex, includeWhitespace: false);
40+
if (owner is null)
41+
{
42+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
43+
}
44+
45+
// Find a regular markup attribute (not a tag helper attribute) that starts with '@'
46+
// Unbound directive attributes are just regular attributes that happen to start with '@'
47+
var attributeBlock = owner.FirstAncestorOrSelf<MarkupAttributeBlockSyntax>();
48+
if (attributeBlock is null)
49+
{
50+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
51+
}
52+
53+
// Make sure the cursor is actually on the name part, since the attribute block is the whole attribute, including
54+
// value and even some whitespace
55+
var nameSpan = attributeBlock.Name.Span;
56+
if (context.StartAbsoluteIndex < nameSpan.Start || context.StartAbsoluteIndex > nameSpan.End)
57+
{
58+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
59+
}
60+
61+
// Try to find the missing namespace for this directive attribute
62+
if (!TryGetMissingDirectiveAttributeNamespace(context.CodeDocument, attributeBlock, out var missingNamespace))
63+
{
64+
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
65+
}
66+
67+
// Create the code action
68+
var resolutionParams = AddUsingsCodeActionResolver.CreateAddUsingResolutionParams(
69+
missingNamespace,
70+
context.Request.TextDocument,
71+
additionalEdit: null,
72+
context.DelegatedDocumentUri);
73+
74+
var addUsingCodeAction = RazorCodeActionFactory.CreateAddComponentUsing(
75+
missingNamespace,
76+
newTagName: null,
77+
resolutionParams);
78+
79+
return Task.FromResult<ImmutableArray<RazorVSInternalCodeAction>>([addUsingCodeAction]);
80+
}
81+
82+
private static bool TryGetMissingDirectiveAttributeNamespace(
83+
RazorCodeDocument codeDocument,
84+
MarkupAttributeBlockSyntax attributeBlock,
85+
[NotNullWhen(true)] out string? missingNamespace)
86+
{
87+
missingNamespace = null;
88+
89+
// Check if this is a directive attribute (starts with '@')
90+
var attributeName = attributeBlock.Name.GetContent();
91+
if (attributeName is not ['@', ..])
92+
{
93+
return false;
94+
}
95+
96+
// Get all tag helpers, not just those in scope, since we want to suggest adding a using
97+
if (!codeDocument.TryGetTagHelpers(out var tagHelpers))
98+
{
99+
return false;
100+
}
101+
102+
// For attributes with parameters (e.g., @bind:after), extract just the base attribute name
103+
var baseAttributeName = attributeName.AsSpan();
104+
var colonIndex = baseAttributeName.IndexOf(':');
105+
if (colonIndex > 0)
106+
{
107+
baseAttributeName = baseAttributeName[..colonIndex];
108+
}
109+
110+
// Search for matching bound attribute descriptors in all available tag helpers
111+
foreach (var tagHelper in tagHelpers)
112+
{
113+
if (!tagHelper.IsAttributeDescriptor())
114+
{
115+
continue;
116+
}
117+
118+
foreach (var boundAttribute in tagHelper.BoundAttributes)
119+
{
120+
// No need to worry about multiple matches, because Razor syntax has no way to disambiguate anyway.
121+
// Currently only compiler can create directive attribute tag helpers anyway.
122+
if (boundAttribute.IsDirectiveAttribute &&
123+
boundAttribute.Name.AsSpan().SequenceEqual(baseAttributeName))
124+
{
125+
if (boundAttribute.Parent.TypeNamespace is { } typeNamespace)
126+
{
127+
missingNamespace = typeNamespace;
128+
return true;
129+
}
130+
131+
// This is unexpected, but if for some reason we can't find a namespace, there is no point looking further
132+
break;
133+
}
134+
}
135+
}
136+
137+
return false;
138+
}
139+
}

src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/CodeActions/RemoteServices.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ internal sealed class OOPSimplifyFullyQualifiedComponentCodeActionProvider : Sim
5656
[method: ImportingConstructor]
5757
internal sealed class OOPComponentAccessibilityCodeActionProvider(IFileSystem fileSystem) : ComponentAccessibilityCodeActionProvider(fileSystem);
5858

59+
[Export(typeof(IRazorCodeActionProvider)), Shared]
60+
internal sealed class OOPUnboundDirectiveAttributeAddUsingCodeActionProvider : UnboundDirectiveAttributeAddUsingCodeActionProvider;
61+
5962
[Export(typeof(IRazorCodeActionProvider)), Shared]
6063
internal sealed class OOPGenerateMethodCodeActionProvider : GenerateMethodCodeActionProvider;
6164

src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Cohosting/CohostTestBase.cs

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@ protected abstract TextDocument CreateProjectAndRazorDocument(
152152
string? documentFilePath = null,
153153
(string fileName, string contents)[]? additionalFiles = null,
154154
bool inGlobalNamespace = false,
155-
bool miscellaneousFile = false);
155+
bool miscellaneousFile = false,
156+
bool addDefaultImports = true);
156157

157158
protected TextDocument CreateProjectAndRazorDocument(
158159
CodeAnalysis.Workspace remoteWorkspace,
@@ -161,7 +162,8 @@ protected TextDocument CreateProjectAndRazorDocument(
161162
string? documentFilePath = null,
162163
(string fileName, string contents)[]? additionalFiles = null,
163164
bool inGlobalNamespace = false,
164-
bool miscellaneousFile = false)
165+
bool miscellaneousFile = false,
166+
bool addDefaultImports = true)
165167
{
166168
// Using IsLegacy means null == component, so easier for test authors
167169
var isComponent = fileKind != RazorFileKind.Legacy;
@@ -173,15 +175,15 @@ protected TextDocument CreateProjectAndRazorDocument(
173175
var projectId = ProjectId.CreateNewId(debugName: TestProjectData.SomeProject.DisplayName);
174176
var documentId = DocumentId.CreateNewId(projectId, debugName: documentFilePath);
175177

176-
return CreateProjectAndRazorDocument(remoteWorkspace, projectId, miscellaneousFile, documentId, documentFilePath, contents, additionalFiles, inGlobalNamespace);
178+
return CreateProjectAndRazorDocument(remoteWorkspace, projectId, miscellaneousFile, documentId, documentFilePath, contents, additionalFiles, inGlobalNamespace, addDefaultImports);
177179
}
178180

179-
protected static TextDocument CreateProjectAndRazorDocument(CodeAnalysis.Workspace workspace, ProjectId projectId, bool miscellaneousFile, DocumentId documentId, string documentFilePath, string contents, (string fileName, string contents)[]? additionalFiles, bool inGlobalNamespace)
181+
protected static TextDocument CreateProjectAndRazorDocument(CodeAnalysis.Workspace workspace, ProjectId projectId, bool miscellaneousFile, DocumentId documentId, string documentFilePath, string contents, (string fileName, string contents)[]? additionalFiles, bool inGlobalNamespace, bool addDefaultImports)
180182
{
181-
return AddProjectAndRazorDocument(workspace.CurrentSolution, TestProjectData.SomeProject.FilePath, projectId, miscellaneousFile, documentId, documentFilePath, contents, additionalFiles, inGlobalNamespace);
183+
return AddProjectAndRazorDocument(workspace.CurrentSolution, TestProjectData.SomeProject.FilePath, projectId, miscellaneousFile, documentId, documentFilePath, contents, additionalFiles, inGlobalNamespace, addDefaultImports);
182184
}
183185

184-
protected static TextDocument AddProjectAndRazorDocument(Solution solution, [DisallowNull] string? projectFilePath, ProjectId projectId, bool miscellaneousFile, DocumentId documentId, string documentFilePath, string contents, (string fileName, string contents)[]? additionalFiles, bool inGlobalNamespace)
186+
protected static TextDocument AddProjectAndRazorDocument(Solution solution, [DisallowNull] string? projectFilePath, ProjectId projectId, bool miscellaneousFile, DocumentId documentId, string documentFilePath, string contents, (string fileName, string contents)[]? additionalFiles, bool inGlobalNamespace, bool addDefaultImports)
185187
{
186188
var builder = new RazorProjectBuilder(projectId);
187189

@@ -202,7 +204,9 @@ protected static TextDocument AddProjectAndRazorDocument(Solution solution, [Dis
202204
builder.RootNamespace = TestProjectData.SomeProject.RootNamespace;
203205
}
204206

205-
builder.AddAdditionalDocument(
207+
if (addDefaultImports)
208+
{
209+
builder.AddAdditionalDocument(
206210
filePath: TestProjectData.SomeProjectComponentImportFile1.FilePath,
207211
text: SourceText.From("""
208212
@using Microsoft.AspNetCore.Components
@@ -211,11 +215,12 @@ @using Microsoft.AspNetCore.Components.Forms
211215
@using Microsoft.AspNetCore.Components.Routing
212216
@using Microsoft.AspNetCore.Components.Web
213217
"""));
214-
builder.AddAdditionalDocument(
218+
builder.AddAdditionalDocument(
215219
filePath: TestProjectData.SomeProjectImportFile.FilePath,
216220
text: SourceText.From("""
217221
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
218222
"""));
223+
}
219224

220225
if (additionalFiles is not null)
221226
{

src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostEndpointTestBase.cs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,11 @@ protected override TextDocument CreateProjectAndRazorDocument(
9797
string? documentFilePath = null,
9898
(string fileName, string contents)[]? additionalFiles = null,
9999
bool inGlobalNamespace = false,
100-
bool miscellaneousFile = false)
100+
bool miscellaneousFile = false,
101+
bool addDefaultImports = true)
101102
{
102103
var remoteWorkspace = RemoteWorkspaceProvider.Instance.GetWorkspace();
103-
var remoteDocument = base.CreateProjectAndRazorDocument(remoteWorkspace, contents, fileKind, documentFilePath, additionalFiles, inGlobalNamespace, miscellaneousFile);
104+
var remoteDocument = base.CreateProjectAndRazorDocument(remoteWorkspace, contents, fileKind, documentFilePath, additionalFiles, inGlobalNamespace, miscellaneousFile, addDefaultImports);
104105

105106
// In this project we simulate remote services running OOP by creating a different workspace with a different
106107
// set of services to represent the devenv Roslyn side of things. We don't have any actual solution syncing set
@@ -114,7 +115,8 @@ protected override TextDocument CreateProjectAndRazorDocument(
114115
remoteDocument.FilePath.AssumeNotNull(),
115116
contents,
116117
additionalFiles,
117-
inGlobalNamespace);
118+
inGlobalNamespace,
119+
addDefaultImports);
118120
}
119121

120122
private TextDocument CreateLocalProjectAndRazorDocument(
@@ -125,9 +127,10 @@ private TextDocument CreateLocalProjectAndRazorDocument(
125127
string documentFilePath,
126128
string contents,
127129
(string fileName, string contents)[]? additionalFiles,
128-
bool inGlobalNamespace)
130+
bool inGlobalNamespace,
131+
bool addDefaultImports)
129132
{
130-
var razorDocument = CreateProjectAndRazorDocument(LocalWorkspace, projectId, miscellaneousFile, documentId, documentFilePath, contents, additionalFiles, inGlobalNamespace);
133+
var razorDocument = CreateProjectAndRazorDocument(LocalWorkspace, projectId, miscellaneousFile, documentId, documentFilePath, contents, additionalFiles, inGlobalNamespace, addDefaultImports);
131134

132135
// If we're creating remote and local workspaces, then we'll return the local document, and have to allow
133136
// the remote service invoker to map from the local solution to the remote one.

src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/RetryProjectTest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ public async Task HoverRequest_MultipleProjects_ReturnsResults()
7272
var projectId = ProjectId.CreateNewId(debugName: TestProjectData.SomeProject.DisplayName);
7373
var documentFilePath = TestProjectData.AnotherProjectComponentFile1.FilePath;
7474
var documentId = DocumentId.CreateNewId(projectId, debugName: documentFilePath);
75-
var otherDocument = AddProjectAndRazorDocument(document.Project.Solution, TestProjectData.AnotherProject.FilePath, projectId, miscellaneousFile: false, documentId, documentFilePath, otherInput.Text, additionalFiles: null, inGlobalNamespace: false);
75+
var otherDocument = AddProjectAndRazorDocument(document.Project.Solution, TestProjectData.AnotherProject.FilePath, projectId, miscellaneousFile: false, documentId, documentFilePath, otherInput.Text, additionalFiles: null, inGlobalNamespace: false, addDefaultImports: true);
7676

7777
// Make sure we have the document from our new fork
7878
document = otherDocument.Project.Solution.GetAdditionalDocument(document.Id).AssumeNotNull();

src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/CohostEndpointTestBase.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,9 @@ protected override TextDocument CreateProjectAndRazorDocument(
8787
string? documentFilePath = null,
8888
(string fileName, string contents)[]? additionalFiles = null,
8989
bool inGlobalNamespace = false,
90-
bool miscellaneousFile = false)
90+
bool miscellaneousFile = false,
91+
bool addDefaultImports = true)
9192
{
92-
return CreateProjectAndRazorDocument(LocalWorkspace, contents, fileKind, documentFilePath, additionalFiles, inGlobalNamespace, miscellaneousFile);
93+
return CreateProjectAndRazorDocument(LocalWorkspace, contents, fileKind, documentFilePath, additionalFiles, inGlobalNamespace, miscellaneousFile, addDefaultImports);
9394
}
9495
}

src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Endpoints/Shared/CodeActions/CohostCodeActionsEndpointTestBase.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@ private protected async Task VerifyCodeActionAsync(
3636
RazorFileKind? fileKind = null,
3737
string? documentFilePath = null,
3838
(string filePath, string contents)[]? additionalFiles = null,
39-
(Uri fileUri, string contents)[]? additionalExpectedFiles = null)
39+
(Uri fileUri, string contents)[]? additionalExpectedFiles = null,
40+
bool addDefaultImports = true)
4041
{
41-
var document = CreateRazorDocument(input, fileKind, documentFilePath, additionalFiles);
42+
var document = CreateRazorDocument(input, fileKind, documentFilePath, additionalFiles, addDefaultImports: addDefaultImports);
4243

4344
var codeAction = await VerifyCodeActionRequestAsync(document, input, codeActionName, childActionIndex, expectOffer: expected is not null);
4445

@@ -55,7 +56,7 @@ private protected async Task VerifyCodeActionAsync(
5556
await VerifyCodeActionResultAsync(document, workspaceEdit, expected, additionalExpectedFiles);
5657
}
5758

58-
private protected TextDocument CreateRazorDocument(TestCode input, RazorFileKind? fileKind = null, string? documentFilePath = null, (string filePath, string contents)[]? additionalFiles = null)
59+
private protected TextDocument CreateRazorDocument(TestCode input, RazorFileKind? fileKind = null, string? documentFilePath = null, (string filePath, string contents)[]? additionalFiles = null, bool addDefaultImports = true)
5960
{
6061
var fileSystem = (RemoteFileSystem)OOPExportProvider.GetExportedValue<IFileSystem>();
6162
fileSystem.GetTestAccessor().SetFileSystem(new TestFileSystem(additionalFiles));
@@ -73,7 +74,7 @@ private protected TextDocument CreateRazorDocument(TestCode input, RazorFileKind
7374
return options;
7475
});
7576

76-
return CreateProjectAndRazorDocument(input.Text, fileKind, documentFilePath, additionalFiles: additionalFiles);
77+
return CreateProjectAndRazorDocument(input.Text, fileKind, documentFilePath, additionalFiles: additionalFiles, addDefaultImports: addDefaultImports);
7778
}
7879

7980
private async Task<CodeAction?> VerifyCodeActionRequestAsync(TextDocument document, TestCode input, string codeActionName, int childActionIndex, bool expectOffer)

0 commit comments

Comments
 (0)