diff --git a/src/Analyzers/Microsoft.AspNetCore.Analyzer.Testing/src/DiagnosticProject.cs b/src/Analyzers/Microsoft.AspNetCore.Analyzer.Testing/src/DiagnosticProject.cs index 381e38557ccc..ebafe1002dba 100644 --- a/src/Analyzers/Microsoft.AspNetCore.Analyzer.Testing/src/DiagnosticProject.cs +++ b/src/Analyzers/Microsoft.AspNetCore.Analyzer.Testing/src/DiagnosticProject.cs @@ -28,7 +28,7 @@ public class DiagnosticProject private static readonly ICompilationAssemblyResolver _assemblyResolver = new AppBaseCompilationAssemblyResolver(); private static readonly Dictionary _solutionCache = new Dictionary(); - public static Project Create(Assembly testAssembly, string[] sources, Func workspaceFactory = null, Type analyzerReference = null) + public static Project Create(Assembly testAssembly, string[] sources, Func workspaceFactory = null, Type[] analyzerReferences = null) { Solution solution; lock (_solutionCache) @@ -50,11 +50,14 @@ public static Project Create(Assembly testAssembly, string[] sources, Func +/// This fixer uses Roslyn's AspNetCoreAddPackageCodeAction to support providing a code fix for a missing +/// package based on APIs defined in that package that are called in user code. This fixer is particularly +/// helpful for providing guidance to users on how to add a missing package when they are using an extension +/// method on well-known types like `IServiceCollection` and `IApplicationBuilder`. +/// +/// +/// This class is not sealed to support mocking of the virtual method `TryCreateCodeActionAsync` in unit tests. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AddPackageFixer)), Shared] +public class AddPackageFixer : CodeFixProvider +{ + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root == null) + { + return; + } + + var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel == null) + { + return; + } + + var wellKnownTypes = WellKnownTypes.GetOrCreate(semanticModel.Compilation); + var wellKnownExtensionMethodCache = ExtensionMethodsCache.ConstructFromWellKnownTypes(wellKnownTypes); + + // Diagnostics are already filtered by FixableDiagnosticIds values. + foreach (var diagnostic in context.Diagnostics) + { + var location = diagnostic.Location.SourceSpan; + var node = root.FindNode(location); + if (node == null) + { + return; + } + var methodName = node is IdentifierNameSyntax identifier ? identifier.Identifier.Text : null; + if (methodName == null) + { + return; + } + + if (node.Parent is not MemberAccessExpressionSyntax) + { + return; + } + + var symbol = semanticModel.GetSymbolInfo(((MemberAccessExpressionSyntax)node.Parent).Expression).Symbol; + var symbolType = symbol switch + { + IMethodSymbol methodSymbol => methodSymbol.ReturnType, + IPropertySymbol propertySymbol => propertySymbol.Type, + ILocalSymbol localSymbol => localSymbol.Type, + _ => null + }; + + if (symbolType == null) + { + return; + } + + var targetThisAndExtensionMethod = new ThisAndExtensionMethod(symbolType, methodName); + if (wellKnownExtensionMethodCache.TryGetValue(targetThisAndExtensionMethod, out var packageSourceAndNamespace)) + { + var position = diagnostic.Location.SourceSpan.Start; + var packageInstallData = new AspNetCoreInstallPackageData( + packageSource: null, + packageName: packageSourceAndNamespace.packageName, + packageVersionOpt: null, + packageNamespaceName: packageSourceAndNamespace.namespaceName); + var codeAction = await TryCreateCodeActionAsync( + context.Document, + position, + packageInstallData, + context.CancellationToken); + + if (codeAction != null) + { + context.RegisterCodeFix(codeAction, diagnostic); + } + } + + } + } + + public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + /// 'IServiceCollection' does not contain a definition for 'AddOpenApi' and no accessible extension method 'AddOpenApi' accepting + /// a first argument of type 'IServiceCollection' could be found (are you missing a using directive or an assembly reference?). + /// + public override ImmutableArray FixableDiagnosticIds { get; } = ["CS1061"]; + + internal virtual async Task TryCreateCodeActionAsync( + Document document, + int position, + AspNetCoreInstallPackageData packageInstallData, + CancellationToken cancellationToken) + { + var codeAction = await AspNetCoreAddPackageCodeAction.TryCreateCodeActionAsync( + document, + position, + packageInstallData, + cancellationToken); + + return codeAction; + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Dependencies/ExtensionMethodsCache.cs b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Dependencies/ExtensionMethodsCache.cs new file mode 100644 index 000000000000..e50fc22c119b --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Dependencies/ExtensionMethodsCache.cs @@ -0,0 +1,24 @@ +// 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.Generic; +using Microsoft.AspNetCore.Analyzers; +using Microsoft.AspNetCore.App.Analyzers.Infrastructure; + +internal static class ExtensionMethodsCache +{ + public static Dictionary ConstructFromWellKnownTypes(WellKnownTypes wellKnownTypes) + { + return new() + { + { + new(wellKnownTypes.Get(WellKnownTypeData.WellKnownType.Microsoft_Extensions_DependencyInjection_IServiceCollection), "AddOpenApi"), + new("Microsoft.AspNetCore.OpenApi", "Microsoft.Extensions.DependencyInjection") + }, + { + new(wellKnownTypes.Get(WellKnownTypeData.WellKnownType.Microsoft_AspNetCore_Builder_WebApplication), "MapOpenApi"), + new("Microsoft.AspNetCore.OpenApi", "Microsoft.AspNetCore.Builder") + } + }; + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Dependencies/ExtensionMethodsCompletionProvider.cs b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Dependencies/ExtensionMethodsCompletionProvider.cs new file mode 100644 index 000000000000..d5ec910472a1 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Dependencies/ExtensionMethodsCompletionProvider.cs @@ -0,0 +1,118 @@ +// 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.Generic; +using System.Composition; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.App.Analyzers.Infrastructure; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Completion; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage; + +/// +/// This completion provider expands the completion list of target symbols defined in the +/// ExtensionMethodsCache to include extension methods that can be invoked on the target +/// type that are defined in auxillary packages. This completion provider is designed to be +/// used in conjunction with the `AddPackageFixer` to recommend adding the missing packages +/// extension methods are defined in. +/// +[ExportCompletionProvider(nameof(ExtensionMethodsCompletionProvider), LanguageNames.CSharp)] +[Shared] +public sealed class ExtensionMethodsCompletionProvider : CompletionProvider +{ + public override async Task ProvideCompletionsAsync(CompletionContext context) + { + if (!context.Document.SupportsSemanticModel) + { + return; + } + + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root == null) + { + return; + } + + var span = context.CompletionListSpan; + var token = root.FindToken(span.Start); + if (token.Parent == null) + { + return; + } + + var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel == null) + { + return; + } + + var wellKnownTypes = WellKnownTypes.GetOrCreate(semanticModel.Compilation); + var wellKnownExtensionMethodCache = ExtensionMethodsCache.ConstructFromWellKnownTypes(wellKnownTypes); + + // We find the nearest member access expression to the adjacent expression to resolve the + // target type of the extension method that the user is invoking. For example, `app.` should + // allow us to resolve to a `WebApplication` instance and `builder.Services.Add` should resolve + // to an `IServiceCollection`. + var nearestMemberAccessExpression = FindNearestMemberAccessExpression(token.Parent); + if (nearestMemberAccessExpression is not null && nearestMemberAccessExpression is MemberAccessExpressionSyntax memberAccess) + { + var symbol = semanticModel.GetSymbolInfo(memberAccess.Expression); + var symbolType = symbol.Symbol switch + { + IMethodSymbol methodSymbol => methodSymbol.ReturnType, + IPropertySymbol propertySymbol => propertySymbol.Type, + ILocalSymbol localSymbol => localSymbol.Type, + _ => null + }; + + var matchingExtensionMethods = wellKnownExtensionMethodCache.Where(pair => IsMatchingExtensionMethod(pair, symbolType, token)); + foreach (var item in matchingExtensionMethods) + { + context.CompletionListSpan = span; + context.AddItem(CompletionItem.Create( + displayText: item.Key.ExtensionMethod, + sortText: item.Key.ExtensionMethod, + filterText: item.Key.ExtensionMethod + )); + } + } + } + + private static SyntaxNode? FindNearestMemberAccessExpression(SyntaxNode? node) + { + var current = node; + while (current != null) + { + if (current?.IsKind(SyntaxKind.SimpleMemberAccessExpression) ?? false) + { + return current; + } + + current = current?.Parent; + } + + return null; + } + + private static bool IsMatchingExtensionMethod( + KeyValuePair pair, + ISymbol? symbolType, + SyntaxToken token) + { + if (symbolType is null) + { + return false; + } + + // If the token that we are parsing is some sort of identifier, this indicates that the user + // has triggered a completion with characters already inserted into the invocation (e.g. `builder.Services.Ad$$). + // In this case, we only want to provide completions that match the characters that have been inserted. + var isIdentifierToken = token.IsKind(SyntaxKind.IdentifierName) || token.IsKind(SyntaxKind.IdentifierToken); + return SymbolEqualityComparer.Default.Equals(pair.Key.ThisType, symbolType) && + (!isIdentifierToken || pair.Key.ExtensionMethod.Contains(token.ValueText)); + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Dependencies/PackageSourceAndNamespace.cs b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Dependencies/PackageSourceAndNamespace.cs new file mode 100644 index 000000000000..032abaf96ab5 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Dependencies/PackageSourceAndNamespace.cs @@ -0,0 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Analyzers; + +internal record struct PackageSourceAndNamespace(string packageName, string namespaceName); diff --git a/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Dependencies/ThisAndExtensionMethod.cs b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Dependencies/ThisAndExtensionMethod.cs new file mode 100644 index 000000000000..4e87db00617f --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Dependencies/ThisAndExtensionMethod.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Analyzers; + +internal readonly struct ThisAndExtensionMethod(ITypeSymbol thisType, string extensionMethod) +{ + public ITypeSymbol ThisType { get; } = thisType; + public string ExtensionMethod { get; } = extensionMethod; + + public override bool Equals(object obj) + { + return obj is ThisAndExtensionMethod other && + SymbolEqualityComparer.Default.Equals(ThisType, other.ThisType) && + ExtensionMethod == other.ExtensionMethod; + } + + public override int GetHashCode() + { + return HashCode.Combine(SymbolEqualityComparer.Default.GetHashCode(ThisType), ExtensionMethod); + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Microsoft.AspNetCore.App.CodeFixes.csproj b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Microsoft.AspNetCore.App.CodeFixes.csproj index 0275894e5719..dcf278570a22 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Microsoft.AspNetCore.App.CodeFixes.csproj +++ b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Microsoft.AspNetCore.App.CodeFixes.csproj @@ -22,4 +22,12 @@ + + + + + + + + diff --git a/src/Framework/AspNetCoreAnalyzers/test/Dependencies/AddPackageTests.cs b/src/Framework/AspNetCoreAnalyzers/test/Dependencies/AddPackageTests.cs new file mode 100644 index 000000000000..81dc77c7b8dc --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/test/Dependencies/AddPackageTests.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.ExternalAccess.AspNetCore.AddPackage; +using Moq; +using VerifyCS = Microsoft.AspNetCore.Analyzers.Verifiers.CSharpCodeFixVerifier< + Microsoft.AspNetCore.Analyzers.WebApplicationBuilder.WebApplicationBuilderAnalyzer, + Microsoft.AspNetCore.Analyzers.Dependencies.AddPackagesTest.MockAddPackageFixer>; + +namespace Microsoft.AspNetCore.Analyzers.Dependencies; + +/// +/// These tests don't assert the fix is applied, since it takes a dependency on the internal +/// VS-specific `PackageInstallerService`. However, the fixer is invoked in these codepaths +/// so we can validate that the symbol resolution and checks function correctly. +/// +public class AddPackagesTest +{ + [Fact] + public async Task CanFixMissingExtensionMethodForDI() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.{|CS1061:AddOpenApi|}(); +"; + + // Assert + MockAddPackageFixer.Invoked = false; + await VerifyCS.VerifyCodeFixAsync(source, source); + Assert.True(MockAddPackageFixer.Invoked); + } + + [Fact] + public async Task CanFixMissingExtensionMethodForBuilder() + { + // Arrange + var source = @" +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var app = WebApplication.Create(); + +app.{|CS1061:MapOpenApi|}(); +"; + + // Assert + MockAddPackageFixer.Invoked = false; + await VerifyCS.VerifyCodeFixAsync(source, source); + Assert.True(MockAddPackageFixer.Invoked); + } + + public class MockAddPackageFixer : AddPackageFixer + { + /// + /// This static property allows us to verify that the fixer was + /// able to successfully resolve the symbol and call into the + /// package install APIs. This is a workaround for the fact that + /// the package install APIs are not readily mockable. Note: this + /// is not intended for use across test classes. + /// + internal static bool Invoked { get; set; } + + internal override Task TryCreateCodeActionAsync( + Document document, + int position, + AspNetCoreInstallPackageData packageInstallData, + CancellationToken cancellationToken) + { + Invoked = true; + Assert.Equal("Microsoft.AspNetCore.OpenApi", packageInstallData.PackageName); + return Task.FromResult(null); + } + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/test/Dependencies/ExtensionMethodsCompletionProviderTests.cs b/src/Framework/AspNetCoreAnalyzers/test/Dependencies/ExtensionMethodsCompletionProviderTests.cs new file mode 100644 index 000000000000..9ea06af21652 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/test/Dependencies/ExtensionMethodsCompletionProviderTests.cs @@ -0,0 +1,162 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage.Infrastructure; +using Microsoft.AspNetCore.Analyzers.WebApplicationBuilder; +using Microsoft.CodeAnalysis.Completion; + +namespace Microsoft.AspNetCore.Analyzers.Dependencies; + +public partial class ExtensionMethodsCompletionProviderTests +{ + private TestDiagnosticAnalyzerRunner Runner { get; } = new(new WebApplicationBuilderAnalyzer()); + + public static object[][] CompletionTriggers => + [ + [CompletionTrigger.Invoke], + [null] + ]; + + [Theory] + [MemberData(nameof(CompletionTriggers))] + public async Task ProvidesAddOpenApiCompletion(CompletionTrigger trigger) + { + // Arrange & Act + var result = await GetCompletionsAndServiceAsync(@" +using Microsoft.AspNetCore.Builder; + +class Program +{ + static void Main() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.$$ + } +} +", trigger); + + // Assert + Assert.True(result.ShouldTriggerCompletion); + Assert.Contains(result.Completions.ItemsList, item => item.DisplayText == "AddOpenApi"); + } + + [Theory] + [MemberData(nameof(CompletionTriggers))] + public async Task ProvidesAddOpenApiCompletionWithPartialToken(CompletionTrigger trigger) + { + // Arrange & Act + var result = await GetCompletionsAndServiceAsync(@" +using Microsoft.AspNetCore.Builder; + +class Program +{ + static void Main() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.[|Ad$$|] + } +} +", trigger); + + // Assert + Assert.True(result.ShouldTriggerCompletion); + Assert.Contains(result.Completions.ItemsList, item => item.DisplayText == "AddOpenApi"); + } + + [Theory] + [MemberData(nameof(CompletionTriggers))] + public async Task DoesNotProvideCompletionIfNoStringMatchForServices(CompletionTrigger trigger) + { + // Arrange & Act + var result = await GetCompletionsAndServiceAsync(@" +using Microsoft.AspNetCore.Builder; + +class Program +{ + static void Main() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.[|Confi$$|] + } +} +", trigger); + + // Assert + Assert.True(result.ShouldTriggerCompletion); + Assert.DoesNotContain(result.Completions.ItemsList, item => item.DisplayText == "AddOpenApi"); + } + + [Theory] + [MemberData(nameof(CompletionTriggers))] + public async Task ProvidesMapOpenApiCompletion(CompletionTrigger trigger) + { + // Arrange & Act + var result = await GetCompletionsAndServiceAsync(@" +using Microsoft.AspNetCore.Builder; + +class Program +{ + static void Main() + { + var app = WebApplication.Create(); + app.$$ + } +} +", trigger); + + // Assert + Assert.True(result.ShouldTriggerCompletion); + Assert.Contains(result.Completions.ItemsList, item => item.DisplayText == "MapOpenApi"); + } + + [Theory] + [MemberData(nameof(CompletionTriggers))] + public async Task ProvidesMapOpenApiCompletionWithPartialToken(CompletionTrigger trigger) + { + // Arrange & Act + var result = await GetCompletionsAndServiceAsync(@" +using Microsoft.AspNetCore.Builder; + +class Program +{ + static void Main() + { + var app = WebApplication.Create(); + app.[|Ma$$|] + } +} +", trigger); + + // Assert + Assert.True(result.ShouldTriggerCompletion); + Assert.Contains(result.Completions.ItemsList, item => item.DisplayText == "MapOpenApi"); + } + + [Theory] + [MemberData(nameof(CompletionTriggers))] + public async Task DoesNotProvideCompletionIfNoStringMatchForWebApplication(CompletionTrigger trigger) + { + // Arrange & Act + var result = await GetCompletionsAndServiceAsync(@" +using Microsoft.AspNetCore.Builder; + +class Program +{ + static void Main() + { + var app = WebApplication.Create(); + app.[|Use$$|] + } +} +", trigger); + + // Assert + Assert.True(result.ShouldTriggerCompletion); + Assert.DoesNotContain(result.Completions.ItemsList, item => item.DisplayText == "MapOpenApi"); + } + + private Task GetCompletionsAndServiceAsync(string source, CompletionTrigger? completionTrigger = null) + { + return CompletionTestHelpers.GetCompletionsAndServiceAsync(Runner, source, completionTrigger); + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/test/TestDiagnosticAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/test/TestDiagnosticAnalyzer.cs index 44a15625ef9d..8300b2232690 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/TestDiagnosticAnalyzer.cs +++ b/src/Framework/AspNetCoreAnalyzers/test/TestDiagnosticAnalyzer.cs @@ -151,7 +151,7 @@ public static Project CreateProjectWithReferencesInBinDir(Assembly testAssembly, Func createWorkspace = CreateWorkspace; - var project = DiagnosticProject.Create(testAssembly, source, createWorkspace, typeof(RoutePatternClassifier)); + var project = DiagnosticProject.Create(testAssembly, source, createWorkspace, [typeof(RoutePatternClassifier), typeof(ExtensionMethodsCompletionProvider)]); foreach (var assembly in Directory.EnumerateFiles(AppContext.BaseDirectory, "*.dll")) { if (!project.MetadataReferences.Any(c => string.Equals(Path.GetFileNameWithoutExtension(c.Display), Path.GetFileNameWithoutExtension(assembly), StringComparison.OrdinalIgnoreCase))) diff --git a/src/Shared/RoslynUtils/WellKnownTypeData.cs b/src/Shared/RoslynUtils/WellKnownTypeData.cs index 6cd79a6c388c..10e62baef94e 100644 --- a/src/Shared/RoslynUtils/WellKnownTypeData.cs +++ b/src/Shared/RoslynUtils/WellKnownTypeData.cs @@ -112,7 +112,8 @@ public enum WellKnownType Microsoft_AspNetCore_Authorization_AuthorizeAttribute, Microsoft_Extensions_DependencyInjection_PolicyServiceCollectionExtensions, Microsoft_Extensions_DependencyInjection_FromKeyedServicesAttribute, - Microsoft_AspNetCore_Authorization_AuthorizationOptions + Microsoft_AspNetCore_Authorization_AuthorizationOptions, + Microsoft_Extensions_DependencyInjection_IServiceCollection } public static string[] WellKnownTypeNames = new[] @@ -222,6 +223,7 @@ public enum WellKnownType "Microsoft.AspNetCore.Authorization.AuthorizeAttribute", "Microsoft.Extensions.DependencyInjection.PolicyServiceCollectionExtensions", "Microsoft.Extensions.DependencyInjection.FromKeyedServicesAttribute", - "Microsoft.AspNetCore.Authorization.AuthorizationOptions" + "Microsoft.AspNetCore.Authorization.AuthorizationOptions", + "Microsoft.Extensions.DependencyInjection.IServiceCollection", }; }