Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8404c74
Initial plan
Copilot Oct 28, 2025
a7d3b05
Add AddUsingsCodeActionProvider to offer @using directives for fully …
Copilot Oct 28, 2025
f793980
Refactor AddUsingsCodeActionProvider to avoid duplicate namespace ext…
Copilot Oct 28, 2025
4f69d7f
Implement UnboundDirectiveAttributeAddUsingCodeActionProvider for dir…
Copilot Oct 28, 2025
3b52a62
Remove incorrect AddUsingsCodeActionProvider and add UnboundDirective…
Copilot Oct 28, 2025
98fbf03
Remove redundant namespace check and clarify .Component suffix usage
Copilot Oct 28, 2025
328584c
Allow opting out of default imports in tests
davidwengier Oct 29, 2025
0a5c30b
Fix provider to look for MarkupAttributeBlockSyntax instead of Markup…
Copilot Oct 29, 2025
345c10d
Fix attribute name matching - use Name property directly and strip '@…
Copilot Oct 29, 2025
38d9cb0
Address code review feedback - extract method, use GetTagHelpers(), k…
Copilot Oct 29, 2025
c9a3e72
Fix namespace extraction to handle generic types and improve heuristics
Copilot Oct 29, 2025
d3bf9ec
Fix test expectations for @bind tests
Copilot Oct 29, 2025
e057c87
Fix implementation, unskip tests, and remove failing test
davidwengier Nov 3, 2025
47767f6
Ensure action is only offered on the name portion of the attribute
davidwengier Nov 3, 2025
e4bb3a3
Address code review feedback: fix cursor position check, add WorkItem…
Copilot Nov 3, 2025
e302b42
Remove incorrectly re-added Skip attribute from AddUsing_BindWithPara…
Copilot Nov 3, 2025
03b62c1
Use TryGetTagHelpers instead of GetTagHelpers with null check
Copilot Nov 3, 2025
97011f7
Use ReadOnlySpan<char> to avoid string allocations in attribute name …
Copilot Nov 3, 2025
6d3dffa
Use extension method syntax for SequenceEqual instead of static metho…
Copilot Nov 3, 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 @@ -153,6 +153,7 @@ public static void AddCodeActionsServices(this IServiceCollection services)
services.AddSingleton<IRazorCodeActionResolver, ExtractToComponentCodeActionResolver>();
services.AddSingleton<IRazorCodeActionProvider, ComponentAccessibilityCodeActionProvider>();
services.AddSingleton<IRazorCodeActionResolver, CreateComponentCodeActionResolver>();
services.AddSingleton<IRazorCodeActionProvider, UnboundDirectiveAttributeAddUsingCodeActionProvider>();
services.AddSingleton<IRazorCodeActionResolver, AddUsingsCodeActionResolver>();
services.AddSingleton<IRazorCodeActionProvider, GenerateMethodCodeActionProvider>();
services.AddSingleton<IRazorCodeActionResolver, GenerateMethodCodeActionResolver>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,22 +62,26 @@ internal static bool TryCreateAddUsingResolutionParams(string fullyQualifiedName
return false;
}

resolutionParams = CreateAddUsingResolutionParams(@namespace, textDocument, additionalEdit, delegatedDocumentUri);
return true;
}

internal static RazorCodeActionResolutionParams CreateAddUsingResolutionParams(string @namespace, VSTextDocumentIdentifier textDocument, TextDocumentEdit? additionalEdit, Uri? delegatedDocumentUri)
{
var actionParams = new AddUsingsCodeActionParams
{
Namespace = @namespace,
AdditionalEdit = additionalEdit
};

resolutionParams = new RazorCodeActionResolutionParams
return new RazorCodeActionResolutionParams
{
TextDocument = textDocument,
Action = LanguageServerConstants.CodeActions.AddUsing,
Language = RazorLanguageKind.Razor,
DelegatedDocumentUri = delegatedDocumentUri,
Data = actionParams,
};

return true;
}

// Internal for testing
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// 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.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.Threading;
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
using Microsoft.CodeAnalysis.Razor.CodeActions.Razor;
using Microsoft.CodeAnalysis.Razor.Workspaces;

namespace Microsoft.CodeAnalysis.Razor.CodeActions;

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

// Only work in component files
if (!FileKinds.IsComponent(context.CodeDocument.FileKind))
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

if (!context.CodeDocument.TryGetSyntaxRoot(out var syntaxRoot))
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

// Find the node at the cursor position
var owner = syntaxRoot.FindInnermostNode(context.StartAbsoluteIndex, includeWhitespace: false);
if (owner is null)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

// Find a regular markup attribute (not a tag helper attribute) that starts with '@'
// Unbound directive attributes are just regular attributes that happen to start with '@'
var attributeBlock = owner.FirstAncestorOrSelf<MarkupAttributeBlockSyntax>();
if (attributeBlock is null)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

// Make sure the cursor is actually on the name part, since the attribute block is the whole attribute, including
// value and even some whitespace
if (!attributeBlock.Name.Span.Contains(context.StartAbsoluteIndex))
Copy link
Contributor

Choose a reason for hiding this comment

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

Contains

@davidwengier Do we normally not include the end position in these tests?

Copy link
Member

Choose a reason for hiding this comment

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

The first guard clause in this checks for a selection, and doesn't offer the action if there is one, so end and start will always be the same. I don't know if that is best practice for code actions, but I think most, if not all, of the Razor ones do it.

Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry, that wasn't clear of me, I meant the end position of the Name.span. This will trigger if the caret is here

<a []@foo="test" />

but not here

<a @foo[] = "test" />

Copy link
Member

Choose a reason for hiding this comment

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

Oh, nice catch, thanks!

{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

// Try to find the missing namespace for this directive attribute
if (!TryGetMissingDirectiveAttributeNamespace(context.CodeDocument, attributeBlock, out var missingNamespace))
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

// Create the code action
var resolutionParams = AddUsingsCodeActionResolver.CreateAddUsingResolutionParams(
missingNamespace,
context.Request.TextDocument,
additionalEdit: null,
context.DelegatedDocumentUri);

var addUsingCodeAction = RazorCodeActionFactory.CreateAddComponentUsing(
missingNamespace,
newTagName: null,
resolutionParams);

return Task.FromResult<ImmutableArray<RazorVSInternalCodeAction>>([addUsingCodeAction]);
}

private static bool TryGetMissingDirectiveAttributeNamespace(
RazorCodeDocument codeDocument,
MarkupAttributeBlockSyntax attributeBlock,
[NotNullWhen(true)] out string? missingNamespace)
{
missingNamespace = null;

// Check if this is a directive attribute (starts with '@')
var attributeName = attributeBlock.Name.GetContent();
if (attributeName is not ['@', ..])
{
return false;
}

// Get all tag helpers, not just those in scope, since we want to suggest adding a using
var tagHelpers = codeDocument.GetTagHelpers();
if (tagHelpers is null)
{
return false;
}

// For attributes with parameters (e.g., @bind:after), extract just the base attribute name
var baseAttributeName = attributeName;
var colonIndex = attributeName.IndexOf(':');
if (colonIndex > 0)
{
baseAttributeName = attributeName[..colonIndex];
}

// Search for matching bound attribute descriptors in all available tag helpers
foreach (var tagHelper in tagHelpers)
{
if (!tagHelper.IsAttributeDescriptor())
{
continue;
}

foreach (var boundAttribute in tagHelper.BoundAttributes)
{
// No need to worry about multiple matches, because Razor syntax has no way to disambiguate anyway.
// Currently only compiler can create directive attribute tag helpers anyway.
if (boundAttribute.IsDirectiveAttribute &&
boundAttribute.Name == baseAttributeName)
{
if (boundAttribute.Parent.TypeNamespace is { } typeNamespace)
{
missingNamespace = typeNamespace;
return true;
}

// This is unexpected, but if for some reason we can't find a namespace, there is no point looking further
break;
}
}
}

return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ internal sealed class OOPSimplifyFullyQualifiedComponentCodeActionProvider : Sim
[method: ImportingConstructor]
internal sealed class OOPComponentAccessibilityCodeActionProvider(IFileSystem fileSystem) : ComponentAccessibilityCodeActionProvider(fileSystem);

[Export(typeof(IRazorCodeActionProvider)), Shared]
internal sealed class OOPUnboundDirectiveAttributeAddUsingCodeActionProvider : UnboundDirectiveAttributeAddUsingCodeActionProvider;

[Export(typeof(IRazorCodeActionProvider)), Shared]
internal sealed class OOPGenerateMethodCodeActionProvider : GenerateMethodCodeActionProvider;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ protected abstract TextDocument CreateProjectAndRazorDocument(
string? documentFilePath = null,
(string fileName, string contents)[]? additionalFiles = null,
bool inGlobalNamespace = false,
bool miscellaneousFile = false);
bool miscellaneousFile = false,
bool addDefaultImports = true);

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

return CreateProjectAndRazorDocument(remoteWorkspace, projectId, miscellaneousFile, documentId, documentFilePath, contents, additionalFiles, inGlobalNamespace);
return CreateProjectAndRazorDocument(remoteWorkspace, projectId, miscellaneousFile, documentId, documentFilePath, contents, additionalFiles, inGlobalNamespace, addDefaultImports);
}

protected static TextDocument CreateProjectAndRazorDocument(CodeAnalysis.Workspace workspace, ProjectId projectId, bool miscellaneousFile, DocumentId documentId, string documentFilePath, string contents, (string fileName, string contents)[]? additionalFiles, bool inGlobalNamespace)
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)
{
return AddProjectAndRazorDocument(workspace.CurrentSolution, TestProjectData.SomeProject.FilePath, projectId, miscellaneousFile, documentId, documentFilePath, contents, additionalFiles, inGlobalNamespace);
return AddProjectAndRazorDocument(workspace.CurrentSolution, TestProjectData.SomeProject.FilePath, projectId, miscellaneousFile, documentId, documentFilePath, contents, additionalFiles, inGlobalNamespace, addDefaultImports);
}

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)
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)
{
var builder = new RazorProjectBuilder(projectId);

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

builder.AddAdditionalDocument(
if (addDefaultImports)
{
builder.AddAdditionalDocument(
filePath: TestProjectData.SomeProjectComponentImportFile1.FilePath,
text: SourceText.From("""
@using Microsoft.AspNetCore.Components
Expand All @@ -211,11 +215,12 @@ @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
"""));
builder.AddAdditionalDocument(
builder.AddAdditionalDocument(
filePath: TestProjectData.SomeProjectImportFile.FilePath,
text: SourceText.From("""
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
"""));
}

if (additionalFiles is not null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,11 @@ protected override TextDocument CreateProjectAndRazorDocument(
string? documentFilePath = null,
(string fileName, string contents)[]? additionalFiles = null,
bool inGlobalNamespace = false,
bool miscellaneousFile = false)
bool miscellaneousFile = false,
bool addDefaultImports = true)
{
var remoteWorkspace = RemoteWorkspaceProvider.Instance.GetWorkspace();
var remoteDocument = base.CreateProjectAndRazorDocument(remoteWorkspace, contents, fileKind, documentFilePath, additionalFiles, inGlobalNamespace, miscellaneousFile);
var remoteDocument = base.CreateProjectAndRazorDocument(remoteWorkspace, contents, fileKind, documentFilePath, additionalFiles, inGlobalNamespace, miscellaneousFile, addDefaultImports);

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

private TextDocument CreateLocalProjectAndRazorDocument(
Expand All @@ -125,9 +127,10 @@ private TextDocument CreateLocalProjectAndRazorDocument(
string documentFilePath,
string contents,
(string fileName, string contents)[]? additionalFiles,
bool inGlobalNamespace)
bool inGlobalNamespace,
bool addDefaultImports)
{
var razorDocument = CreateProjectAndRazorDocument(LocalWorkspace, projectId, miscellaneousFile, documentId, documentFilePath, contents, additionalFiles, inGlobalNamespace);
var razorDocument = CreateProjectAndRazorDocument(LocalWorkspace, projectId, miscellaneousFile, documentId, documentFilePath, contents, additionalFiles, inGlobalNamespace, addDefaultImports);

// If we're creating remote and local workspaces, then we'll return the local document, and have to allow
// the remote service invoker to map from the local solution to the remote one.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public async Task HoverRequest_MultipleProjects_ReturnsResults()
var projectId = ProjectId.CreateNewId(debugName: TestProjectData.SomeProject.DisplayName);
var documentFilePath = TestProjectData.AnotherProjectComponentFile1.FilePath;
var documentId = DocumentId.CreateNewId(projectId, debugName: documentFilePath);
var otherDocument = AddProjectAndRazorDocument(document.Project.Solution, TestProjectData.AnotherProject.FilePath, projectId, miscellaneousFile: false, documentId, documentFilePath, otherInput.Text, additionalFiles: null, inGlobalNamespace: false);
var otherDocument = AddProjectAndRazorDocument(document.Project.Solution, TestProjectData.AnotherProject.FilePath, projectId, miscellaneousFile: false, documentId, documentFilePath, otherInput.Text, additionalFiles: null, inGlobalNamespace: false, addDefaultImports: true);

// Make sure we have the document from our new fork
document = otherDocument.Project.Solution.GetAdditionalDocument(document.Id).AssumeNotNull();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ protected override TextDocument CreateProjectAndRazorDocument(
string? documentFilePath = null,
(string fileName, string contents)[]? additionalFiles = null,
bool inGlobalNamespace = false,
bool miscellaneousFile = false)
bool miscellaneousFile = false,
bool addDefaultImports = true)
{
return CreateProjectAndRazorDocument(LocalWorkspace, contents, fileKind, documentFilePath, additionalFiles, inGlobalNamespace, miscellaneousFile);
return CreateProjectAndRazorDocument(LocalWorkspace, contents, fileKind, documentFilePath, additionalFiles, inGlobalNamespace, miscellaneousFile, addDefaultImports);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ private protected async Task VerifyCodeActionAsync(
RazorFileKind? fileKind = null,
string? documentFilePath = null,
(string filePath, string contents)[]? additionalFiles = null,
(Uri fileUri, string contents)[]? additionalExpectedFiles = null)
(Uri fileUri, string contents)[]? additionalExpectedFiles = null,
bool addDefaultImports = true)
{
var document = CreateRazorDocument(input, fileKind, documentFilePath, additionalFiles);
var document = CreateRazorDocument(input, fileKind, documentFilePath, additionalFiles, addDefaultImports: addDefaultImports);

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

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

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

return CreateProjectAndRazorDocument(input.Text, fileKind, documentFilePath, additionalFiles: additionalFiles);
return CreateProjectAndRazorDocument(input.Text, fileKind, documentFilePath, additionalFiles: additionalFiles, addDefaultImports: addDefaultImports);
}

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