diff --git a/Directory.Build.props b/Directory.Build.props index a727412840a4..49cf8a2406d2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -140,6 +140,7 @@ $(MSBuildThisFileDirectory)src\Shared\ + $(MSBuildThisFileDirectory)src\Analyzers\Analyzers\src\ $(RepoRoot)src\submodules\googletest\ diff --git a/src/Analyzers/Analyzers/src/StartupSymbols.cs b/src/Analyzers/Analyzers/src/StartupSymbols.cs index 943527586267..b801a1c1ebf1 100644 --- a/src/Analyzers/Analyzers/src/StartupSymbols.cs +++ b/src/Analyzers/Analyzers/src/StartupSymbols.cs @@ -9,9 +9,9 @@ internal sealed class StartupSymbols { public StartupSymbols(Compilation compilation) { - IApplicationBuilder = compilation.GetTypeByMetadataName(SymbolNames.IApplicationBuilder.MetadataName); - IServiceCollection = compilation.GetTypeByMetadataName(SymbolNames.IServiceCollection.MetadataName); - MvcOptions = compilation.GetTypeByMetadataName(SymbolNames.MvcOptions.MetadataName); + IApplicationBuilder = compilation.GetTypeByMetadataName(SymbolNames.IApplicationBuilder.MetadataName)!; + IServiceCollection = compilation.GetTypeByMetadataName(SymbolNames.IServiceCollection.MetadataName)!; + MvcOptions = compilation.GetTypeByMetadataName(SymbolNames.MvcOptions.MetadataName)!; } public bool HasRequiredSymbols => IApplicationBuilder != null && IServiceCollection != null; diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs index e0143542bff0..95cd2552ae89 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs @@ -223,4 +223,13 @@ internal static class DiagnosticDescriptors DiagnosticSeverity.Warning, isEnabledByDefault: true, helpLinkUri: "https://aka.ms/aspnet/analyzers"); + + internal static readonly DiagnosticDescriptor IncorrectlyConfiguredProblemDetailsWriter = new( + "ASP0027", + new LocalizableResourceString(nameof(Resources.Analyzer_IncorrectlyConfiguredProblemDetailsWriter_Title), Resources.ResourceManager, typeof(Resources)), + new LocalizableResourceString(nameof(Resources.Analyzer_IncorrectlyConfiguredProblemDetailsWriter_Message), Resources.ResourceManager, typeof(Resources)), + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: "https://aka.ms/aspnet/analyzers"); } diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Microsoft.AspNetCore.App.Analyzers.csproj b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Microsoft.AspNetCore.App.Analyzers.csproj index 97439d9a3d20..88cebb986417 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Microsoft.AspNetCore.App.Analyzers.csproj +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Microsoft.AspNetCore.App.Analyzers.csproj @@ -32,4 +32,17 @@ + + + + + + + + + + + + + diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx index e983fd83ad25..b89a405ab354 100644 --- a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Resources.resx @@ -321,4 +321,10 @@ [Authorize] overridden by [AllowAnonymous] from farther away + + The custom IProblemDetailsWriter must be registered before calling AddControllers, AddControllersWithViews, AddMvc, or AddRazorPages + + + Custom IProblemDetailsWriter is incorrectly configured + \ No newline at end of file diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/MiddlewareAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/MiddlewareAnalyzer.cs new file mode 100644 index 000000000000..64a9065d26cd --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/MiddlewareAnalyzer.cs @@ -0,0 +1,47 @@ +// 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 Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Microsoft.AspNetCore.Analyzers.Startup; + +internal sealed class MiddlewareAnalyzer +{ + private readonly StartupAnalysisBuilder _context; + + public MiddlewareAnalyzer(StartupAnalysisBuilder context) + { + _context = context; + } + + public void AnalyzeConfigureMethod(OperationBlockStartAnalysisContext context) + { + var configureMethod = (IMethodSymbol)context.OwningSymbol; + var middleware = ImmutableArray.CreateBuilder(); + + // Note: this is a simple source-order implementation. We don't attempt perform data flow + // analysis in order to determine the actual order in which middleware are ordered. + // + // This can currently be confused by things like Map(...) + context.RegisterOperationAction(context => + { + // We're looking for usage of extension methods, so we need to look at the 'this' parameter + // rather than invocation.Instance. + if (context.Operation is IInvocationOperation invocation && + invocation.Instance == null && + invocation.Arguments.Length >= 1 && + SymbolEqualityComparer.Default.Equals(invocation.Arguments[0].Parameter?.Type, _context.StartupSymbols.IApplicationBuilder)) + { + middleware.Add(new MiddlewareItem(invocation)); + } + }, OperationKind.Invocation); + + context.RegisterOperationBlockEndAction(context => + { + _context.ReportAnalysis(new MiddlewareAnalysis(configureMethod, middleware.ToImmutable())); + }); + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/OptionsAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/OptionsAnalyzer.cs new file mode 100644 index 000000000000..fd442c8676e0 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/OptionsAnalyzer.cs @@ -0,0 +1,46 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Microsoft.AspNetCore.Analyzers.Startup; + +internal sealed class OptionsAnalyzer +{ + private readonly StartupAnalysisBuilder _context; + + public OptionsAnalyzer(StartupAnalysisBuilder context) + { + _context = context; + } + + public void AnalyzeConfigureServices(OperationBlockStartAnalysisContext context) + { + var configureServicesMethod = (IMethodSymbol)context.OwningSymbol; + var options = ImmutableArray.CreateBuilder(); + + context.RegisterOperationAction(context => + { + if (context.Operation is ISimpleAssignmentOperation operation && + operation.Value.ConstantValue.HasValue && + // For nullable types, it's possible for Value to be null when HasValue is true. + operation.Value.ConstantValue.Value != null && + operation.Target is IPropertyReferenceOperation property && + property.Property?.ContainingType?.Name != null && + property.Property.ContainingType.Name.EndsWith("Options", StringComparison.Ordinal)) + { + options.Add(new OptionsItem(property.Property, operation.Value.ConstantValue.Value)); + } + + }, OperationKind.SimpleAssignment); + + context.RegisterOperationBlockEndAction(context => + { + _context.ReportAnalysis(new OptionsAnalysis(configureServicesMethod, options.ToImmutable())); + }); + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/ProblemDetailsWriterAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/ProblemDetailsWriterAnalyzer.cs new file mode 100644 index 000000000000..83f2413cd4cd --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/ProblemDetailsWriterAnalyzer.cs @@ -0,0 +1,113 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Microsoft.AspNetCore.Analyzers.Startup; + +internal sealed class ProblemDetailsWriterAnalyzer +{ + private readonly StartupAnalysis _context; + + public ProblemDetailsWriterAnalyzer(StartupAnalysis context) + { + _context = context; + } + + public void AnalyzeSymbol(SymbolAnalysisContext context) + { + Debug.Assert(context.Symbol.Kind == SymbolKind.NamedType); + + var type = (INamedTypeSymbol)context.Symbol; + + var serviceAnalyses = _context.GetRelatedAnalyses(type); + if (serviceAnalyses == null) + { + return; + } + + foreach (var serviceAnalysis in serviceAnalyses) + { + var mvcServiceItems = serviceAnalysis.Services + .Where(IsMvcServiceCollectionExtension) + .ToArray(); + + if (mvcServiceItems.Length == 0) + { + continue; + } + + var problemDetailsWriterServiceItems = serviceAnalysis.Services + .Where(IsProblemDetailsWriterRegistration) + .ToArray(); + + if (problemDetailsWriterServiceItems.Length == 0) + { + continue; + } + + var mvcServiceTextSpans = mvcServiceItems.ToDictionary(x => x, x => x.Operation.Syntax.Span); + + foreach (var problemDetailsWriterServiceItem in problemDetailsWriterServiceItems) + { + var problemDetailsWriterServiceTextSpan = problemDetailsWriterServiceItem.Operation.Syntax.Span; + + foreach (var mvcServiceTextSpan in mvcServiceTextSpans) + { + var mvcService = mvcServiceTextSpan.Key; + var textSpan = mvcServiceTextSpan.Value; + + // Check if the IProblemDetailsWriter registration is after the MVC registration in the source. + if (problemDetailsWriterServiceTextSpan.CompareTo(textSpan) > 0) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.IncorrectlyConfiguredProblemDetailsWriter, + problemDetailsWriterServiceItem.Operation.Syntax.GetLocation(), + additionalLocations: [mvcService.Operation.Syntax.GetLocation()])); + + break; + } + } + } + } + } + + private static bool IsMvcServiceCollectionExtension(ServicesItem middlewareItem) + { + var methodName = middlewareItem.UseMethod.Name; + + if (string.Equals(methodName, SymbolNames.MvcServiceCollectionExtensions.AddControllersMethodName, StringComparison.Ordinal) + || string.Equals(methodName, SymbolNames.MvcServiceCollectionExtensions.AddControllersWithViewsMethodName, StringComparison.Ordinal) + || string.Equals(methodName, SymbolNames.MvcServiceCollectionExtensions.AddMvcMethodName, StringComparison.Ordinal) + || string.Equals(methodName, SymbolNames.MvcServiceCollectionExtensions.AddRazorPagesMethodName, StringComparison.Ordinal)) + { + return true; + } + + return false; + } + + private static bool IsProblemDetailsWriterRegistration(ServicesItem servicesItem) + { + var methodName = servicesItem.UseMethod.Name; + + if (string.Equals(methodName, SymbolNames.ServiceCollectionServiceExtensions.AddTransientMethodName, StringComparison.Ordinal) + || string.Equals(methodName, SymbolNames.ServiceCollectionServiceExtensions.AddScopedMethodName, StringComparison.Ordinal) + || string.Equals(methodName, SymbolNames.ServiceCollectionServiceExtensions.AddSingletonMethodName, StringComparison.Ordinal)) + { + var typeArguments = servicesItem.Operation.TargetMethod.TypeArguments; + + if (typeArguments.Length == 2 + && string.Equals(typeArguments[0].Name, SymbolNames.IProblemDetailsWriter.Name, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/ServicesAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/ServicesAnalyzer.cs new file mode 100644 index 000000000000..debab455e34c --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/ServicesAnalyzer.cs @@ -0,0 +1,43 @@ +// 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 Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Microsoft.AspNetCore.Analyzers.Startup; + +internal sealed class ServicesAnalyzer +{ + private readonly StartupAnalysisBuilder _context; + + public ServicesAnalyzer(StartupAnalysisBuilder context) + { + _context = context; + } + + public void AnalyzeConfigureServices(OperationBlockStartAnalysisContext context) + { + var configureServicesMethod = (IMethodSymbol)context.OwningSymbol; + var services = ImmutableArray.CreateBuilder(); + + context.RegisterOperationAction(context => + { + // We're looking for usage of extension methods, so we need to look at the 'this' parameter + // rather than invocation.Instance. + if (context.Operation is IInvocationOperation invocation && + invocation.Instance == null && + invocation.Arguments.Length >= 1 && + SymbolEqualityComparer.Default.Equals(invocation.Arguments[0].Parameter?.Type, _context.StartupSymbols.IServiceCollection)) + { + services.Add(new ServicesItem(invocation)); + } + }, OperationKind.Invocation); + + context.RegisterOperationBlockEndAction(context => + { + _context.ReportAnalysis(new ServicesAnalysis(configureServicesMethod, services.ToImmutable())); + }); + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/StartupAnalysisBuilder.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/StartupAnalysisBuilder.cs new file mode 100644 index 000000000000..e59b1cfe352c --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/StartupAnalysisBuilder.cs @@ -0,0 +1,94 @@ +// 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.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Analyzers.Startup; + +internal sealed class StartupAnalysisBuilder +{ + private readonly Dictionary> _analysesByType; + private readonly StartupAnalyzer _analyzer; + private readonly object _lock; + + public StartupAnalysisBuilder(StartupAnalyzer analyzer, StartupSymbols startupSymbols) + { + _analyzer = analyzer; + StartupSymbols = startupSymbols; + +#pragma warning disable RS1024 // Compare symbols correctly + _analysesByType = new Dictionary>(SymbolEqualityComparer.Default); +#pragma warning restore RS1024 // Compare symbols correctly + _lock = new object(); + } + + public StartupSymbols StartupSymbols { get; } + + public StartupAnalysis Build() + { + lock (_lock) + { + return new StartupAnalysis( + StartupSymbols, + _analysesByType.ToImmutableDictionary( + k => k.Key, + v => v.Value.ToImmutableArray(), + // Custom equality comparer as SymbolEqualityComparer.Default will upcast to ISymbol. + new NamedTypeSymbolEqualityComparer(SymbolEqualityComparer.Default))); + } + } + + public void ReportAnalysis(ServicesAnalysis analysis) + { + ReportAnalysisCore(analysis.StartupType, analysis); + _analyzer.OnServicesAnalysisCompleted(analysis); + } + + public void ReportAnalysis(OptionsAnalysis analysis) + { + ReportAnalysisCore(analysis.StartupType, analysis); + _analyzer.OnOptionsAnalysisCompleted(analysis); + } + + public void ReportAnalysis(MiddlewareAnalysis analysis) + { + ReportAnalysisCore(analysis.StartupType, analysis); + _analyzer.OnMiddlewareAnalysisCompleted(analysis); + } + + private void ReportAnalysisCore(INamedTypeSymbol type, object analysis) + { + lock (_lock) + { + if (!_analysesByType.TryGetValue(type, out var list)) + { + list = new List(); + _analysesByType.Add(type, list); + } + + list.Add(analysis); + } + } + + private class NamedTypeSymbolEqualityComparer : IEqualityComparer + { + private readonly SymbolEqualityComparer _baseComparer; + + public NamedTypeSymbolEqualityComparer(SymbolEqualityComparer baseComparer) + { + _baseComparer = baseComparer; + } + + public bool Equals(INamedTypeSymbol x, INamedTypeSymbol y) + { + return _baseComparer.Equals(x, y); + } + + public int GetHashCode(INamedTypeSymbol obj) + { + return _baseComparer.GetHashCode(obj); + } + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/StartupAnalyzer.Events.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/StartupAnalyzer.Events.cs new file mode 100644 index 000000000000..f03fc8b1ac0d --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/StartupAnalyzer.Events.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Microsoft.AspNetCore.Analyzers.Startup; + +// Events for testability. Allows us to unit test the data we gather from analysis. +public partial class StartupAnalyzer : DiagnosticAnalyzer +{ + internal event EventHandler? ConfigureServicesMethodFound; + + internal void OnConfigureServicesMethodFound(IMethodSymbol method) + { + ConfigureServicesMethodFound?.Invoke(this, method); + } + + internal event EventHandler? ServicesAnalysisCompleted; + + internal void OnServicesAnalysisCompleted(ServicesAnalysis analysis) + { + ServicesAnalysisCompleted?.Invoke(this, analysis); + } + + internal event EventHandler? OptionsAnalysisCompleted; + + internal void OnOptionsAnalysisCompleted(OptionsAnalysis analysis) + { + OptionsAnalysisCompleted?.Invoke(this, analysis); + } + + internal event EventHandler? ConfigureMethodFound; + + internal void OnConfigureMethodFound(IMethodSymbol method) + { + ConfigureMethodFound?.Invoke(this, method); + } + + internal event EventHandler? MiddlewareAnalysisCompleted; + + internal void OnMiddlewareAnalysisCompleted(MiddlewareAnalysis analysis) + { + MiddlewareAnalysisCompleted?.Invoke(this, analysis); + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/StartupAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/StartupAnalyzer.cs new file mode 100644 index 000000000000..4ab547323371 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/StartupAnalyzer.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Microsoft.AspNetCore.Analyzers.Startup; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public partial class StartupAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Create( + DiagnosticDescriptors.IncorrectlyConfiguredProblemDetailsWriter + ); + + public override void Initialize(AnalysisContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + private void OnCompilationStart(CompilationStartAnalysisContext context) + { + var symbols = new StartupSymbols(context.Compilation); + + // Don't run analyzer if ASP.NET Core types cannot be found + if (!symbols.HasRequiredSymbols) + { + return; + } + + var entryPoint = context.Compilation.GetEntryPoint(context.CancellationToken); + + context.RegisterSymbolStartAction(context => + { + var type = (INamedTypeSymbol)context.Symbol; + if (!StartupFacts.IsStartupClass(symbols, type) && !SymbolEqualityComparer.Default.Equals(entryPoint?.ContainingType, type)) + { + // Not a startup class, nothing to do. + return; + } + + // This analyzer fans out a bunch of jobs. The context will capture the results of doing analysis + // on the startup code, so that other analyzers that run later can examine them. + var builder = new StartupAnalysisBuilder(this, symbols); + + var services = new ServicesAnalyzer(builder); + var options = new OptionsAnalyzer(builder); + var middleware = new MiddlewareAnalyzer(builder); + + context.RegisterOperationBlockStartAction(context => + { + if (context.OwningSymbol.Kind != SymbolKind.Method) + { + return; + } + + var method = (IMethodSymbol)context.OwningSymbol; + var isConfigureServices = StartupFacts.IsConfigureServices(symbols, method); + if (isConfigureServices) + { + OnConfigureServicesMethodFound(method); + } + + // In the future we can consider looking at more methods, but for now limit to Main, implicit Main, and Configure* methods + var isMain = SymbolEqualityComparer.Default.Equals(entryPoint, context.OwningSymbol); + + if (isConfigureServices || isMain) + { + services.AnalyzeConfigureServices(context); + options.AnalyzeConfigureServices(context); + } + + var isConfigure = StartupFacts.IsConfigure(symbols, method); + if (isConfigure) + { + OnConfigureMethodFound(method); + } + if (isConfigure || isMain) + { + middleware.AnalyzeConfigureMethod(context); + } + }); + + // Run after analyses have had a chance to finish to add diagnostics. + context.RegisterSymbolEndAction(context => + { + var analysis = builder.Build(); + new ProblemDetailsWriterAnalyzer(analysis).AnalyzeSymbol(context); + }); + + }, SymbolKind.NamedType); + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/SymbolNames.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/SymbolNames.cs new file mode 100644 index 000000000000..9631965745c9 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Startup/SymbolNames.cs @@ -0,0 +1,32 @@ +// 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.Startup; + +internal static class SymbolNames +{ + public static class MvcServiceCollectionExtensions + { + public const string AddControllersMethodName = "AddControllers"; + + public const string AddControllersWithViewsMethodName = "AddControllersWithViews"; + + public const string AddMvcMethodName = "AddMvc"; + + public const string AddRazorPagesMethodName = "AddRazorPages"; + } + + public static class ServiceCollectionServiceExtensions + { + public const string AddTransientMethodName = "AddTransient"; + + public const string AddScopedMethodName = "AddScoped"; + + public const string AddSingletonMethodName = "AddSingleton"; + } + + public static class IProblemDetailsWriter + { + public const string Name = "IProblemDetailsWriter"; + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/CodeFixes.sln b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/CodeFixes.sln new file mode 100644 index 000000000000..f781fa9305fb --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/CodeFixes.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.App.CodeFixes", "Microsoft.AspNetCore.App.CodeFixes.csproj", "{F932DB57-A17C-4F2C-BC27-12AFEDBF09B4}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F932DB57-A17C-4F2C-BC27-12AFEDBF09B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F932DB57-A17C-4F2C-BC27-12AFEDBF09B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F932DB57-A17C-4F2C-BC27-12AFEDBF09B4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F932DB57-A17C-4F2C-BC27-12AFEDBF09B4}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D1B1613F-5749-4FB4-B736-E00351198B1E} + EndGlobalSection +EndGlobal diff --git a/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Startup/IncorrectlyConfiguredProblemDetailsWriterFixer.cs b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Startup/IncorrectlyConfiguredProblemDetailsWriterFixer.cs new file mode 100644 index 000000000000..c8cfcb4562a5 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Startup/IncorrectlyConfiguredProblemDetailsWriterFixer.cs @@ -0,0 +1,215 @@ +// 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.Collections.Immutable; +using System.Composition; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; + +namespace Microsoft.AspNetCore.Analyzers.Startup.Fixers; + +[ExportCodeFixProvider(LanguageNames.CSharp), Shared] +public sealed class IncorrectlyConfiguredProblemDetailsWriterFixer : CodeFixProvider +{ + public override ImmutableArray FixableDiagnosticIds { get; } = [DiagnosticDescriptors.IncorrectlyConfiguredProblemDetailsWriter.Id]; + + public sealed override FixAllProvider? GetFixAllProvider() + { + return FixAllProvider.Create(async (context, document, diagnostics) => + { + return await FixOrderOfAllProblemDetailsWriter(document, diagnostics, context.CancellationToken).ConfigureAwait(false); + }); + } + + public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root == null) + { + return; + } + + foreach (var diagnostic in context.Diagnostics) + { + if (CanFixOrderOfProblemDetailsWriter(diagnostic, root, out var problemDetailsWriter, out var mvcServiceCollectionExtension)) + { + const string title = "Fix order of ProblemDetailsWriter registration"; + + async Task CreateChangedDocument(CancellationToken cancellationToken) => + await FixOrderOfProblemDetailsWriter( + context.Document, + problemDetailsWriter, + mvcServiceCollectionExtension, + cancellationToken).ConfigureAwait(false); + + var codeAction = CodeAction.Create( + title, + CreateChangedDocument, + equivalenceKey: DiagnosticDescriptors.IncorrectlyConfiguredProblemDetailsWriter.Id); + + context.RegisterCodeFix(codeAction, diagnostic); + } + } + } + + private static bool CanFixOrderOfProblemDetailsWriter( + Diagnostic diagnostic, + SyntaxNode root, + [NotNullWhen(true)] out ExpressionStatementSyntax? problemDetailsWriterStatement, + [NotNullWhen(true)] out ExpressionStatementSyntax? mvcServiceCollectionExtensionStatement) + { + problemDetailsWriterStatement = null; + mvcServiceCollectionExtensionStatement = null; + + Debug.Assert(diagnostic.AdditionalLocations.Count == 1, + "Expected exactly one additional location for the MvcServiceCollectionExtension."); + + return diagnostic.AdditionalLocations.Count == 1 && + // Ensure that the ProblemDetailsWriter registration appears after the MvcServiceCollectionExtension in source. + diagnostic.Location.SourceSpan.CompareTo(diagnostic.AdditionalLocations[0].SourceSpan) > 0 && + TryGetInvocationExpressionStatement(diagnostic.Location, root, out problemDetailsWriterStatement) && + // Exclude ProblemDetailsWriter registrations that may be part of an invocation chain to avoid moving unrelated code. + !IsPotentiallyPartOfInvocationChain(problemDetailsWriterStatement) && + TryGetInvocationExpressionStatement(diagnostic.AdditionalLocations[0], root, out mvcServiceCollectionExtensionStatement); + } + + private static async Task FixOrderOfProblemDetailsWriter( + Document document, + ExpressionStatementSyntax problemDetailsWriterExpression, + ExpressionStatementSyntax mvcServiceCollectionExtensionExpression, + CancellationToken cancellationToken) + { + var groupedStatements = new Dictionary> + { + [mvcServiceCollectionExtensionExpression] = [problemDetailsWriterExpression], + }; + + return await MoveProblemDetailsWritersBeforeMvcExtensions(document, groupedStatements, cancellationToken).ConfigureAwait(false); + } + + private static async Task FixOrderOfAllProblemDetailsWriter( + Document document, + ImmutableArray diagnostics, + CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root == null) + { + return document; + } + + var groupedStatements = new Dictionary>(); + + foreach (var diagnostic in diagnostics) + { + if (!CanFixOrderOfProblemDetailsWriter(diagnostic, root, out var problemDetailsWriterStatement, out var mvcServiceCollectionExtensionStatement)) + { + continue; + } + + if (groupedStatements.TryGetValue(mvcServiceCollectionExtensionStatement, out var problemDetailsWriterStatements)) + { + problemDetailsWriterStatements.Add(problemDetailsWriterStatement); + } + else + { + groupedStatements[mvcServiceCollectionExtensionStatement] = [problemDetailsWriterStatement]; + } + } + + if (groupedStatements.Count == 0) + { + return document; + } + + // Maintain the relative source order of the ProblemDetailsWriter registrations. + var comparer = Comparer.Create((a, b) => a.Span.CompareTo(b.Span)); + + foreach (var group in groupedStatements) + { + groupedStatements[group.Key] = [.. group.Value.OrderBy(x => x, comparer)]; + } + + return await MoveProblemDetailsWritersBeforeMvcExtensions(document, groupedStatements, cancellationToken).ConfigureAwait(false); + } + + private static async Task MoveProblemDetailsWritersBeforeMvcExtensions( + Document document, + IDictionary> groupedStatements, + CancellationToken cancellationToken) + { + var documentEditor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); + + foreach (var group in groupedStatements) + { + var mvcServiceExtensionExpression = group.Key; + var registerProblemDetailsWriterExpressions = group.Value; + + foreach (var registerProblemDetailsWriterExpression in registerProblemDetailsWriterExpressions) + { + documentEditor.RemoveNode(registerProblemDetailsWriterExpression, SyntaxRemoveOptions.KeepNoTrivia); + } + + documentEditor.InsertBefore(mvcServiceExtensionExpression, registerProblemDetailsWriterExpressions); + } + + return documentEditor.GetChangedDocument(); + } + + private static bool TryGetInvocationExpressionStatement( + Location location, + SyntaxNode root, + [NotNullWhen(true)] out ExpressionStatementSyntax? expressionStatement) + { + expressionStatement = null; + + var node = root.FindNode(location.SourceSpan, getInnermostNodeForTie: true); + + if (node is not InvocationExpressionSyntax) + { + return false; + } + + var parentNode = node.Parent; + + while (parentNode != null) + { + if (parentNode is ExpressionStatementSyntax expressionStatementSyntax) + { + expressionStatement = expressionStatementSyntax; + return true; + } + + if (parentNode is not MemberAccessExpressionSyntax + && parentNode is not InvocationExpressionSyntax) + { + break; + } + + parentNode = parentNode.Parent; + } + + return false; + } + + private static bool IsPotentiallyPartOfInvocationChain(ExpressionStatementSyntax expressionStatement) + { + if (expressionStatement.Expression is InvocationExpressionSyntax invocationExpressionSyntax && + invocationExpressionSyntax.Expression is MemberAccessExpressionSyntax memberAccessExpression && + memberAccessExpression.Expression is IdentifierNameSyntax) + { + return false; + } + + return true; + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj b/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj index 8070e80c0b51..4257e9a7aae5 100644 --- a/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj +++ b/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj @@ -26,6 +26,11 @@ + + + + + diff --git a/src/Framework/AspNetCoreAnalyzers/test/Startup/ProblemDetailsWriterTests.cs b/src/Framework/AspNetCoreAnalyzers/test/Startup/ProblemDetailsWriterTests.cs new file mode 100644 index 000000000000..3c95d1630629 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/test/Startup/ProblemDetailsWriterTests.cs @@ -0,0 +1,427 @@ +// 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.Concurrent; +using Microsoft.AspNetCore.Analyzers.Startup.Fixers; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; + +namespace Microsoft.AspNetCore.Analyzers.Startup; + +public sealed class ProblemDetailsWriterTests +{ + public ProblemDetailsWriterTests() + { + StartupAnalyzer = new StartupAnalyzer(); + + Analyses = new ConcurrentBag(); + StartupAnalyzer.ServicesAnalysisCompleted += (sender, analysis) => Analyses.Add(analysis); + StartupAnalyzer.OptionsAnalysisCompleted += (sender, analysis) => Analyses.Add(analysis); + StartupAnalyzer.MiddlewareAnalysisCompleted += (sender, analysis) => Analyses.Add(analysis); + } + + internal StartupAnalyzer StartupAnalyzer { get; } + + internal ConcurrentBag Analyses { get; } + + [Theory] + [InlineData("AddControllers")] + [InlineData("AddControllersWithViews")] + [InlineData("AddMvc")] + [InlineData("AddRazorPages")] + public async Task StartupAnalyzer_ProblemDetailsWriter_AfterChainedMvcServiceCollectionsExtension_ReportsDiagnostic(string methodName) + { + // Arrange + var source = $@" +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +namespace Microsoft.AspNetCore.Analyzers.TestFiles.StartupAnalyzerTest +{{ + public class ProblemDetailsWriterRegistration + {{ + public void ConfigureServices(IServiceCollection services) + {{ + {{|#0:services.{methodName}()|}} + .AddViewLocalization(); + {{|#1:services.AddTransient()|}}; + }} + }} + {GetSampleProblemDetailsWriterSource()} +}}"; + + var diagnostic = new DiagnosticResult(DiagnosticDescriptors.IncorrectlyConfiguredProblemDetailsWriter) + .WithLocation(1) + .WithLocation(0); + + var fixedSource = $@" +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +namespace Microsoft.AspNetCore.Analyzers.TestFiles.StartupAnalyzerTest +{{ + public class ProblemDetailsWriterRegistration + {{ + public void ConfigureServices(IServiceCollection services) + {{ + services.AddTransient(); + services.{methodName}() + .AddViewLocalization(); + }} + }} + {GetSampleProblemDetailsWriterSource()} +}}"; + + // Act + Assert + await VerifyCodeFix(source, [diagnostic], fixedSource); + } + + [Theory] + [InlineData("AddControllers")] + [InlineData("AddControllersWithViews")] + [InlineData("AddMvc")] + [InlineData("AddRazorPages")] + public async Task StartupAnalyzer_ProblemDetailsWriter_AfterMvcServiceCollectionsExtension_ReportsDiagnostic(string methodName) + { + // Arrange + var source = $@" +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +namespace Microsoft.AspNetCore.Analyzers.TestFiles.StartupAnalyzerTest +{{ + public class ProblemDetailsWriterRegistration + {{ + public void ConfigureServices(IServiceCollection services) + {{ + {{|#0:services.{methodName}()|}}; + {{|#1:services.AddTransient()|}}; + }} + }} + {GetSampleProblemDetailsWriterSource()} +}}"; + + var diagnostic = new DiagnosticResult(DiagnosticDescriptors.IncorrectlyConfiguredProblemDetailsWriter) + .WithLocation(1) + .WithLocation(0); + + var fixedSource = $@" +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +namespace Microsoft.AspNetCore.Analyzers.TestFiles.StartupAnalyzerTest +{{ + public class ProblemDetailsWriterRegistration + {{ + public void ConfigureServices(IServiceCollection services) + {{ + services.AddTransient(); + services.{methodName}(); + }} + }} + {GetSampleProblemDetailsWriterSource()} +}}"; + + // Act + Assert + await VerifyCodeFix(source, [diagnostic], fixedSource); + } + + [Theory] + [InlineData("AddControllers")] + [InlineData("AddControllersWithViews")] + [InlineData("AddMvc")] + [InlineData("AddRazorPages")] + public async Task StartupAnalyzer_MultipleProblemDetailsWriters_AfterMvcServiceCollectionsExtension_ReportsDiagnostic(string methodName) + { + // Arrange + var source = $@" +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +namespace Microsoft.AspNetCore.Analyzers.TestFiles.StartupAnalyzerTest +{{ + public class ProblemDetailsWriterRegistration + {{ + public void ConfigureServices(IServiceCollection services) + {{ + {{|#0:services.{methodName}()|}}; + {{|#1:services.AddTransient()|}}; + {{|#2:services.AddTransient()|}}; + {{|#3:services.AddTransient()|}}; + }} + }} + {GetSampleProblemDetailsWriterSource("A")} + {GetSampleProblemDetailsWriterSource("B")} + {GetSampleProblemDetailsWriterSource("C")} +}}"; + + var diagnostics = new[] + { + new DiagnosticResult(DiagnosticDescriptors.IncorrectlyConfiguredProblemDetailsWriter) + .WithLocation(1) + .WithLocation(0), + new DiagnosticResult(DiagnosticDescriptors.IncorrectlyConfiguredProblemDetailsWriter) + .WithLocation(2) + .WithLocation(0), + new DiagnosticResult(DiagnosticDescriptors.IncorrectlyConfiguredProblemDetailsWriter) + .WithLocation(3) + .WithLocation(0) + }; + + var fixedSource = $@" +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +namespace Microsoft.AspNetCore.Analyzers.TestFiles.StartupAnalyzerTest +{{ + public class ProblemDetailsWriterRegistration + {{ + public void ConfigureServices(IServiceCollection services) + {{ + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.{methodName}(); + }} + }} + {GetSampleProblemDetailsWriterSource("A")} + {GetSampleProblemDetailsWriterSource("B")} + {GetSampleProblemDetailsWriterSource("C")} +}}"; + + // Act + Assert + await VerifyCodeFixAll(source, diagnostics, fixedSource); + } + + [Theory] + [InlineData("AddControllers")] + [InlineData("AddControllersWithViews")] + [InlineData("AddMvc")] + [InlineData("AddRazorPages")] + public async Task StartupAnalyzer_ProblemDetailsWriterRegistrationChained_AfterMvcServiceCollectionsExtension_ReportsDiagnosticButDoesNotFix(string methodName) + { + // Arrange + var source = $@" +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +namespace Microsoft.AspNetCore.Analyzers.TestFiles.StartupAnalyzerTest +{{ + public class ProblemDetailsWriterRegistration + {{ + public void ConfigureServices(IServiceCollection services) + {{ + {{|#0:services.{methodName}()|}}; + + {{|#1:services.AddTransient()|}} + .AddTransient(provider => Array.Empty()); + + {{|#2:services.AddTransient(provider => Array.Empty()) + .AddTransient()|}}; + + {{|#3:services.AddTransient(provider => Array.Empty()) + .AddTransient()|}} + .AddTransient(provider => Array.Empty()); + }} + }} + {GetSampleProblemDetailsWriterSource("A")} + {GetSampleProblemDetailsWriterSource("B")} + {GetSampleProblemDetailsWriterSource("C")} +}}"; + + var diagnostics = new[] + { + new DiagnosticResult(DiagnosticDescriptors.IncorrectlyConfiguredProblemDetailsWriter) + .WithLocation(1) + .WithLocation(0), + new DiagnosticResult(DiagnosticDescriptors.IncorrectlyConfiguredProblemDetailsWriter) + .WithLocation(2) + .WithLocation(0), + new DiagnosticResult(DiagnosticDescriptors.IncorrectlyConfiguredProblemDetailsWriter) + .WithLocation(3) + .WithLocation(0) + }; + + var fixedSource = $@" +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +namespace Microsoft.AspNetCore.Analyzers.TestFiles.StartupAnalyzerTest +{{ + public class ProblemDetailsWriterRegistration + {{ + public void ConfigureServices(IServiceCollection services) + {{ + {{|#0:services.{methodName}()|}}; + + {{|#1:services.AddTransient()|}} + .AddTransient(provider => Array.Empty()); + + {{|#2:services.AddTransient(provider => Array.Empty()) + .AddTransient()|}}; + + {{|#3:services.AddTransient(provider => Array.Empty()) + .AddTransient()|}} + .AddTransient(provider => Array.Empty()); + }} + }} + {GetSampleProblemDetailsWriterSource("A")} + {GetSampleProblemDetailsWriterSource("B")} + {GetSampleProblemDetailsWriterSource("C")} +}}"; + + // Act + Assert + await VerifyCodeFix(source, diagnostics, fixedSource); + } + + [Theory] + [InlineData("AddScoped")] + [InlineData("AddSingleton")] + public async Task StartupAnalyzer_ProblemDetailsWriter_OtherLifetimes_AfterMvcServiceCollectionsExtension_ReportsDiagnostic(string methodName) + { + // Arrange + var source = $@" +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +namespace Microsoft.AspNetCore.Analyzers.TestFiles.StartupAnalyzerTest +{{ + public class ProblemDetailsWriterRegistration + {{ + public void ConfigureServices(IServiceCollection services) + {{ + {{|#0:services.AddControllers()|}}; + {{|#1:services.{methodName}()|}}; + }} + }} + {GetSampleProblemDetailsWriterSource()} +}}"; + + var diagnostic = new DiagnosticResult(DiagnosticDescriptors.IncorrectlyConfiguredProblemDetailsWriter) + .WithLocation(1) + .WithLocation(0); + + var fixedSource = $@" +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +namespace Microsoft.AspNetCore.Analyzers.TestFiles.StartupAnalyzerTest +{{ + public class ProblemDetailsWriterRegistration + {{ + public void ConfigureServices(IServiceCollection services) + {{ + services.{methodName}(); + services.AddControllers(); + }} + }} + {GetSampleProblemDetailsWriterSource()} +}}"; + + // Act + Assert + await VerifyCodeFix(source, [diagnostic], fixedSource); + } + + [Theory] + [InlineData("AddControllers")] + [InlineData("AddControllersWithViews")] + [InlineData("AddMvc")] + [InlineData("AddRazorPages")] + public async Task StartupAnalyzer_ProblemDetailsWriter_BeforeMvcServiceCollectionsExtension_ReportsNoDiagnostics(string methodName) + { + // Arrange + var source = $@" +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +namespace Microsoft.AspNetCore.Analyzers.TestFiles.StartupAnalyzerTest +{{ + public class ProblemDetailsWriterRegistration + {{ + public void ConfigureServices(IServiceCollection services) + {{ + services.AddTransient(); + services.{methodName}(); + }} + }} + {GetSampleProblemDetailsWriterSource()} +}}"; + + // Act + await VerifyNoCodeFix(source); + + // Assert + var middlewareAnalysis = Analyses.OfType().First(); + Assert.NotEmpty(middlewareAnalysis.Services); + } + + private static string GetSampleProblemDetailsWriterSource(string suffix = null) + { + return $@" +public class SampleProblemDetailsWriter{suffix} : IProblemDetailsWriter +{{ + public bool CanWrite(ProblemDetailsContext context) + => context.HttpContext.Response.StatusCode == 400; + + public ValueTask WriteAsync(ProblemDetailsContext context) + {{ + var response = context.HttpContext.Response; + return new ValueTask(response.WriteAsJsonAsync(context.ProblemDetails)); + }} +}}"; + } + + private async Task VerifyNoCodeFix(string source) + { + await VerifyCodeFix(source, [], source); + } + + private async Task VerifyCodeFix(string source, DiagnosticResult[] diagnostics, string fixedSource) + { + var test = CreateAnalyzerTest(); + + test.TestCode = source; + test.FixedCode = fixedSource; + test.ExpectedDiagnostics.AddRange(diagnostics); + + await test.RunAsync(); + } + + private async Task VerifyCodeFixAll(string source, DiagnosticResult[] diagnostics, string fixedSource) + { + var test = CreateAnalyzerTest(); + + test.TestCode = source; + test.FixedCode = fixedSource; + test.ExpectedDiagnostics.AddRange(diagnostics); + test.NumberOfFixAllIterations = 1; + + await test.RunAsync(); + } + + private StartupCSharpAnalyzerTest CreateAnalyzerTest() + { + var test = new StartupCSharpAnalyzerTest(StartupAnalyzer, TestReferences.MetadataReferences) + { + ReferenceAssemblies = TestReferences.EmptyReferenceAssemblies + }; + + // Tests are just the Configure/ConfigureServices methods, no Main, so we need to mark the output as not console. + test.TestState.OutputKind = OutputKind.DynamicallyLinkedLibrary; + + return test; + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/test/Startup/StartupCSharpAnalyzerTest.cs b/src/Framework/AspNetCoreAnalyzers/test/Startup/StartupCSharpAnalyzerTest.cs new file mode 100644 index 000000000000..0b3ee1fb096b --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/test/Startup/StartupCSharpAnalyzerTest.cs @@ -0,0 +1,26 @@ +// 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 Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing.Verifiers; + +namespace Microsoft.AspNetCore.Analyzers.Startup; + +internal sealed class StartupCSharpAnalyzerTest : CSharpCodeFixTest + where TCodeFix : CodeFixProvider, new() +{ + public StartupCSharpAnalyzerTest(StartupAnalyzer analyzer, ImmutableArray metadataReferences) + { + StartupAnalyzer = analyzer; + TestState.OutputKind = OutputKind.WindowsApplication; + TestState.AdditionalReferences.AddRange(metadataReferences); + } + + public StartupAnalyzer StartupAnalyzer { get; } + + protected override IEnumerable GetDiagnosticAnalyzers() => new[] { StartupAnalyzer }; +}