Skip to content

Commit e558b05

Browse files
authored
Add codefixer and completion provider to install OpenAPI package from extension methods (#55963)
* Add analyzer to install OpenAPI package from extension methods * Use Ordinal comparison for extension method names * Use records struct for comparison * Remove unneeded source include * Remove unrequired using * Check that code action was invoked in tests * More feedback * Add completion provider for extension methods in OpenAPI packages * Fix up reference and add comments * Fix up ThisAndExtensionMethod.GetHashCode
1 parent 333f1de commit e558b05

File tree

11 files changed

+564
-8
lines changed

11 files changed

+564
-8
lines changed

src/Analyzers/Microsoft.AspNetCore.Analyzer.Testing/src/DiagnosticProject.cs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public class DiagnosticProject
2828
private static readonly ICompilationAssemblyResolver _assemblyResolver = new AppBaseCompilationAssemblyResolver();
2929
private static readonly Dictionary<Assembly, Solution> _solutionCache = new Dictionary<Assembly, Solution>();
3030

31-
public static Project Create(Assembly testAssembly, string[] sources, Func<Workspace> workspaceFactory = null, Type analyzerReference = null)
31+
public static Project Create(Assembly testAssembly, string[] sources, Func<Workspace> workspaceFactory = null, Type[] analyzerReferences = null)
3232
{
3333
Solution solution;
3434
lock (_solutionCache)
@@ -50,11 +50,14 @@ public static Project Create(Assembly testAssembly, string[] sources, Func<Works
5050
}
5151
}
5252

53-
if (analyzerReference != null)
53+
if (analyzerReferences != null)
5454
{
55-
solution = solution.AddAnalyzerReference(
56-
projectId,
57-
new AnalyzerFileReference(analyzerReference.Assembly.Location, AssemblyLoader.Instance));
55+
foreach (var analyzerReference in analyzerReferences)
56+
{
57+
solution = solution.AddAnalyzerReference(
58+
projectId,
59+
new AnalyzerFileReference(analyzerReference.Assembly.Location, AssemblyLoader.Instance));
60+
}
5861
}
5962

6063
_solutionCache.Add(testAssembly, solution);
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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.Composition;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using Microsoft.AspNetCore.App.Analyzers.Infrastructure;
9+
using Microsoft.CodeAnalysis;
10+
using Microsoft.CodeAnalysis.CodeActions;
11+
using Microsoft.CodeAnalysis.CodeFixes;
12+
using Microsoft.CodeAnalysis.CSharp.Syntax;
13+
using Microsoft.CodeAnalysis.ExternalAccess.AspNetCore.AddPackage;
14+
15+
namespace Microsoft.AspNetCore.Analyzers.Dependencies;
16+
17+
/// <summary>
18+
/// This fixer uses Roslyn's AspNetCoreAddPackageCodeAction to support providing a code fix for a missing
19+
/// package based on APIs defined in that package that are called in user code. This fixer is particularly
20+
/// helpful for providing guidance to users on how to add a missing package when they are using an extension
21+
/// method on well-known types like `IServiceCollection` and `IApplicationBuilder`.
22+
/// </summary>
23+
/// <remarks>
24+
/// This class is not sealed to support mocking of the virtual method `TryCreateCodeActionAsync` in unit tests.
25+
/// </remarks>
26+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AddPackageFixer)), Shared]
27+
public class AddPackageFixer : CodeFixProvider
28+
{
29+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
30+
{
31+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
32+
if (root == null)
33+
{
34+
return;
35+
}
36+
37+
var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
38+
if (semanticModel == null)
39+
{
40+
return;
41+
}
42+
43+
var wellKnownTypes = WellKnownTypes.GetOrCreate(semanticModel.Compilation);
44+
var wellKnownExtensionMethodCache = ExtensionMethodsCache.ConstructFromWellKnownTypes(wellKnownTypes);
45+
46+
// Diagnostics are already filtered by FixableDiagnosticIds values.
47+
foreach (var diagnostic in context.Diagnostics)
48+
{
49+
var location = diagnostic.Location.SourceSpan;
50+
var node = root.FindNode(location);
51+
if (node == null)
52+
{
53+
return;
54+
}
55+
var methodName = node is IdentifierNameSyntax identifier ? identifier.Identifier.Text : null;
56+
if (methodName == null)
57+
{
58+
return;
59+
}
60+
61+
if (node.Parent is not MemberAccessExpressionSyntax)
62+
{
63+
return;
64+
}
65+
66+
var symbol = semanticModel.GetSymbolInfo(((MemberAccessExpressionSyntax)node.Parent).Expression).Symbol;
67+
var symbolType = symbol switch
68+
{
69+
IMethodSymbol methodSymbol => methodSymbol.ReturnType,
70+
IPropertySymbol propertySymbol => propertySymbol.Type,
71+
ILocalSymbol localSymbol => localSymbol.Type,
72+
_ => null
73+
};
74+
75+
if (symbolType == null)
76+
{
77+
return;
78+
}
79+
80+
var targetThisAndExtensionMethod = new ThisAndExtensionMethod(symbolType, methodName);
81+
if (wellKnownExtensionMethodCache.TryGetValue(targetThisAndExtensionMethod, out var packageSourceAndNamespace))
82+
{
83+
var position = diagnostic.Location.SourceSpan.Start;
84+
var packageInstallData = new AspNetCoreInstallPackageData(
85+
packageSource: null,
86+
packageName: packageSourceAndNamespace.packageName,
87+
packageVersionOpt: null,
88+
packageNamespaceName: packageSourceAndNamespace.namespaceName);
89+
var codeAction = await TryCreateCodeActionAsync(
90+
context.Document,
91+
position,
92+
packageInstallData,
93+
context.CancellationToken);
94+
95+
if (codeAction != null)
96+
{
97+
context.RegisterCodeFix(codeAction, diagnostic);
98+
}
99+
}
100+
101+
}
102+
}
103+
104+
public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
105+
106+
/// <example>
107+
/// 'IServiceCollection' does not contain a definition for 'AddOpenApi' and no accessible extension method 'AddOpenApi' accepting
108+
/// a first argument of type 'IServiceCollection' could be found (are you missing a using directive or an assembly reference?).
109+
/// </example>
110+
public override ImmutableArray<string> FixableDiagnosticIds { get; } = ["CS1061"];
111+
112+
internal virtual async Task<CodeAction?> TryCreateCodeActionAsync(
113+
Document document,
114+
int position,
115+
AspNetCoreInstallPackageData packageInstallData,
116+
CancellationToken cancellationToken)
117+
{
118+
var codeAction = await AspNetCoreAddPackageCodeAction.TryCreateCodeActionAsync(
119+
document,
120+
position,
121+
packageInstallData,
122+
cancellationToken);
123+
124+
return codeAction;
125+
}
126+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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.Generic;
5+
using Microsoft.AspNetCore.Analyzers;
6+
using Microsoft.AspNetCore.App.Analyzers.Infrastructure;
7+
8+
internal static class ExtensionMethodsCache
9+
{
10+
public static Dictionary<ThisAndExtensionMethod, PackageSourceAndNamespace> ConstructFromWellKnownTypes(WellKnownTypes wellKnownTypes)
11+
{
12+
return new()
13+
{
14+
{
15+
new(wellKnownTypes.Get(WellKnownTypeData.WellKnownType.Microsoft_Extensions_DependencyInjection_IServiceCollection), "AddOpenApi"),
16+
new("Microsoft.AspNetCore.OpenApi", "Microsoft.Extensions.DependencyInjection")
17+
},
18+
{
19+
new(wellKnownTypes.Get(WellKnownTypeData.WellKnownType.Microsoft_AspNetCore_Builder_WebApplication), "MapOpenApi"),
20+
new("Microsoft.AspNetCore.OpenApi", "Microsoft.AspNetCore.Builder")
21+
}
22+
};
23+
}
24+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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.Generic;
5+
using System.Composition;
6+
using System.Linq;
7+
using System.Threading.Tasks;
8+
using Microsoft.AspNetCore.App.Analyzers.Infrastructure;
9+
using Microsoft.CodeAnalysis;
10+
using Microsoft.CodeAnalysis.Completion;
11+
using Microsoft.CodeAnalysis.CSharp;
12+
using Microsoft.CodeAnalysis.CSharp.Syntax;
13+
14+
namespace Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage;
15+
16+
/// <summary>
17+
/// This completion provider expands the completion list of target symbols defined in the
18+
/// ExtensionMethodsCache to include extension methods that can be invoked on the target
19+
/// type that are defined in auxillary packages. This completion provider is designed to be
20+
/// used in conjunction with the `AddPackageFixer` to recommend adding the missing packages
21+
/// extension methods are defined in.
22+
/// </summary>
23+
[ExportCompletionProvider(nameof(ExtensionMethodsCompletionProvider), LanguageNames.CSharp)]
24+
[Shared]
25+
public sealed class ExtensionMethodsCompletionProvider : CompletionProvider
26+
{
27+
public override async Task ProvideCompletionsAsync(CompletionContext context)
28+
{
29+
if (!context.Document.SupportsSemanticModel)
30+
{
31+
return;
32+
}
33+
34+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
35+
if (root == null)
36+
{
37+
return;
38+
}
39+
40+
var span = context.CompletionListSpan;
41+
var token = root.FindToken(span.Start);
42+
if (token.Parent == null)
43+
{
44+
return;
45+
}
46+
47+
var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
48+
if (semanticModel == null)
49+
{
50+
return;
51+
}
52+
53+
var wellKnownTypes = WellKnownTypes.GetOrCreate(semanticModel.Compilation);
54+
var wellKnownExtensionMethodCache = ExtensionMethodsCache.ConstructFromWellKnownTypes(wellKnownTypes);
55+
56+
// We find the nearest member access expression to the adjacent expression to resolve the
57+
// target type of the extension method that the user is invoking. For example, `app.` should
58+
// allow us to resolve to a `WebApplication` instance and `builder.Services.Add` should resolve
59+
// to an `IServiceCollection`.
60+
var nearestMemberAccessExpression = FindNearestMemberAccessExpression(token.Parent);
61+
if (nearestMemberAccessExpression is not null && nearestMemberAccessExpression is MemberAccessExpressionSyntax memberAccess)
62+
{
63+
var symbol = semanticModel.GetSymbolInfo(memberAccess.Expression);
64+
var symbolType = symbol.Symbol switch
65+
{
66+
IMethodSymbol methodSymbol => methodSymbol.ReturnType,
67+
IPropertySymbol propertySymbol => propertySymbol.Type,
68+
ILocalSymbol localSymbol => localSymbol.Type,
69+
_ => null
70+
};
71+
72+
var matchingExtensionMethods = wellKnownExtensionMethodCache.Where(pair => IsMatchingExtensionMethod(pair, symbolType, token));
73+
foreach (var item in matchingExtensionMethods)
74+
{
75+
context.CompletionListSpan = span;
76+
context.AddItem(CompletionItem.Create(
77+
displayText: item.Key.ExtensionMethod,
78+
sortText: item.Key.ExtensionMethod,
79+
filterText: item.Key.ExtensionMethod
80+
));
81+
}
82+
}
83+
}
84+
85+
private static SyntaxNode? FindNearestMemberAccessExpression(SyntaxNode? node)
86+
{
87+
var current = node;
88+
while (current != null)
89+
{
90+
if (current?.IsKind(SyntaxKind.SimpleMemberAccessExpression) ?? false)
91+
{
92+
return current;
93+
}
94+
95+
current = current?.Parent;
96+
}
97+
98+
return null;
99+
}
100+
101+
private static bool IsMatchingExtensionMethod(
102+
KeyValuePair<ThisAndExtensionMethod, PackageSourceAndNamespace> pair,
103+
ISymbol? symbolType,
104+
SyntaxToken token)
105+
{
106+
if (symbolType is null)
107+
{
108+
return false;
109+
}
110+
111+
// If the token that we are parsing is some sort of identifier, this indicates that the user
112+
// has triggered a completion with characters already inserted into the invocation (e.g. `builder.Services.Ad$$).
113+
// In this case, we only want to provide completions that match the characters that have been inserted.
114+
var isIdentifierToken = token.IsKind(SyntaxKind.IdentifierName) || token.IsKind(SyntaxKind.IdentifierToken);
115+
return SymbolEqualityComparer.Default.Equals(pair.Key.ThisType, symbolType) &&
116+
(!isIdentifierToken || pair.Key.ExtensionMethod.Contains(token.ValueText));
117+
}
118+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
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+
namespace Microsoft.AspNetCore.Analyzers;
5+
6+
internal record struct PackageSourceAndNamespace(string packageName, string namespaceName);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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 Microsoft.CodeAnalysis;
5+
6+
namespace Microsoft.AspNetCore.Analyzers;
7+
8+
internal readonly struct ThisAndExtensionMethod(ITypeSymbol thisType, string extensionMethod)
9+
{
10+
public ITypeSymbol ThisType { get; } = thisType;
11+
public string ExtensionMethod { get; } = extensionMethod;
12+
13+
public override bool Equals(object obj)
14+
{
15+
return obj is ThisAndExtensionMethod other &&
16+
SymbolEqualityComparer.Default.Equals(ThisType, other.ThisType) &&
17+
ExtensionMethod == other.ExtensionMethod;
18+
}
19+
20+
public override int GetHashCode()
21+
{
22+
return HashCode.Combine(SymbolEqualityComparer.Default.GetHashCode(ThisType), ExtensionMethod);
23+
}
24+
}

src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Microsoft.AspNetCore.App.CodeFixes.csproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,12 @@
2222
<ProjectReference Include="..\Analyzers\Microsoft.AspNetCore.App.Analyzers.csproj" />
2323
</ItemGroup>
2424

25+
<ItemGroup>
26+
<InternalsVisibleTo Include="Microsoft.AspNetCore.App.Analyzers.Test" />
27+
</ItemGroup>
28+
29+
<ItemGroup>
30+
<Compile Include="$(RepoRoot)\src\Shared\HashCode.cs" />
31+
</ItemGroup>
32+
2533
</Project>

0 commit comments

Comments
 (0)