Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
@@ -0,0 +1,81 @@
// 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.Linq;
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;

namespace Microsoft.CodeAnalysis.Razor.CodeActions;

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

// Make sure we're in a Razor or component file
if (!FileKinds.IsComponent(context.CodeDocument.FileKind) && !FileKinds.IsLegacy(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>();
}

// Check if we're in a fully qualified component tag
if (owner.FirstAncestorOrSelf<MarkupTagHelperElementSyntax>() is { } markupTagHelperElement)
{
var startTag = markupTagHelperElement.StartTag;
if (startTag is not null &&
startTag.Name.Content.Contains('.') &&
startTag.Name.Span.Contains(context.StartAbsoluteIndex))
{
var fullyQualifiedName = startTag.Name.Content;

// Check if this matches a tag helper
var descriptors = markupTagHelperElement.TagHelperInfo.BindingResult.Descriptors;
var boundTagHelper = descriptors.FirstOrDefault(static d => d.Kind == TagHelperKind.Component);

if (boundTagHelper is not null && boundTagHelper.IsFullyQualifiedNameMatch)
{
// Create the add using code action
if (AddUsingsCodeActionResolver.TryCreateAddUsingResolutionParams(
fullyQualifiedName,
context.Request.TextDocument,
additionalEdit: null,
context.DelegatedDocumentUri,
out var extractedNamespace,
out var resolutionParams))
{
// Extract component name for the title
var lastDotIndex = fullyQualifiedName.LastIndexOf('.');
var componentName = lastDotIndex > 0 ? fullyQualifiedName[(lastDotIndex + 1)..] : null;

var addUsingCodeAction = RazorCodeActionFactory.CreateAddComponentUsing(extractedNamespace, componentName, resolutionParams);
return Task.FromResult<ImmutableArray<RazorVSInternalCodeAction>>([addUsingCodeAction]);
}
}
}
}

return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// 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.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Legacy;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.Threading;
using Microsoft.CodeAnalysis.Razor.CodeActions.Models;
using Microsoft.CodeAnalysis.Razor.CodeActions.Razor;

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 the directive attribute ancestor
var directiveAttribute = owner.FirstAncestorOrSelf<MarkupTagHelperDirectiveAttributeSyntax>();
if (directiveAttribute?.TagHelperAttributeInfo is not { } attributeInfo)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

// Check if it's an unbound directive attribute
if (attributeInfo.Bound || !attributeInfo.IsDirectiveAttribute)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

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

// Check if the namespace is already imported
var syntaxTree = context.CodeDocument.GetSyntaxTree();
if (syntaxTree is not null)
{
var existingUsings = syntaxTree.EnumerateUsingDirectives()
.SelectMany(d => d.DescendantNodes())
.Select(n => n.GetChunkGenerator())
.OfType<AddImportChunkGenerator>()
.Where(g => !g.IsStatic)
.Select(g => g.ParsedNamespace)
.ToImmutableArray();

if (existingUsings.Contains(missingNamespace))
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}
}

// Create the code action
if (AddUsingsCodeActionResolver.TryCreateAddUsingResolutionParams(
missingNamespace + ".Dummy", // Dummy type name to extract namespace
context.Request.TextDocument,
additionalEdit: null,
context.DelegatedDocumentUri,
out var extractedNamespace,
out var resolutionParams))
{
var addUsingCodeAction = RazorCodeActionFactory.CreateAddComponentUsing(
extractedNamespace,
newTagName: null,
resolutionParams);

// Set high priority and order to show prominently
addUsingCodeAction.Priority = VSInternalPriorityLevel.High;
addUsingCodeAction.Order = -999;

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

return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

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

var tagHelperContext = codeDocument.GetRequiredTagHelperContext();
var attributeName = attributeInfo.Name;

// For attributes with parameters, extract just the attribute name
if (attributeInfo.ParameterName is not null)
{
var colonIndex = attributeName.IndexOf(':');
if (colonIndex >= 0)
{
attributeName = attributeName[..colonIndex];
}
}

// Search for matching bound attribute descriptors
foreach (var tagHelper in tagHelperContext.TagHelpers)
{
foreach (var boundAttribute in tagHelper.BoundAttributes)
{
if (boundAttribute.Name == attributeName)
{
// Extract namespace from the type name
var typeName = boundAttribute.TypeName;

// Apply heuristics to determine the namespace
if (typeName.Contains(".Web.") || typeName.EndsWith(".Web.EventHandlers"))
{
missingNamespace = "Microsoft.AspNetCore.Components.Web";
return true;
}
else if (typeName.Contains(".Forms."))
{
missingNamespace = "Microsoft.AspNetCore.Components.Forms";
return true;
}
else
{
// Extract namespace from type name (everything before the last dot)
var lastDotIndex = typeName.LastIndexOf('.');
if (lastDotIndex > 0)
{
missingNamespace = typeName[..lastDotIndex];
return true;
}
}
}
}
}

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
Loading