diff --git a/AspNetCore.sln b/AspNetCore.sln index 2e2f7d19d4a2da6510dfadc9b2c213b170b53963..2472df941390c8ebbb9ad77c3db7a582e9c7cb97 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1646,12 +1646,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wasm.Prerendered.Client", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wasm.Prerendered.Server", "src\Components\WebAssembly\testassets\Wasm.Prerendered.Server\Wasm.Prerendered.Server.csproj", "{6D365C86-3158-49F5-A21D-506C1E06E870}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.App.Analyzer", "src\Framework\Analyzer\src\Microsoft.AspNetCore.App.Analyzers.csproj", "{564CABB8-1B3F-4D9E-909D-260EF2B8614A}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Analyzer", "Analyzer", "{EE39397E-E4AF-4D3F-9B9C-D637F9222CDD}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.App.Analyzer.Test", "src\Framework\Analyzer\test\Microsoft.AspNetCore.App.Analyzers.Test.csproj", "{CF4CEC18-798D-46EC-B0A0-98D97496590F}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -7867,30 +7861,6 @@ Global {6D365C86-3158-49F5-A21D-506C1E06E870}.Release|x64.Build.0 = Release|Any CPU {6D365C86-3158-49F5-A21D-506C1E06E870}.Release|x86.ActiveCfg = Release|Any CPU {6D365C86-3158-49F5-A21D-506C1E06E870}.Release|x86.Build.0 = Release|Any CPU - {564CABB8-1B3F-4D9E-909D-260EF2B8614A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {564CABB8-1B3F-4D9E-909D-260EF2B8614A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {564CABB8-1B3F-4D9E-909D-260EF2B8614A}.Debug|x64.ActiveCfg = Debug|Any CPU - {564CABB8-1B3F-4D9E-909D-260EF2B8614A}.Debug|x64.Build.0 = Debug|Any CPU - {564CABB8-1B3F-4D9E-909D-260EF2B8614A}.Debug|x86.ActiveCfg = Debug|Any CPU - {564CABB8-1B3F-4D9E-909D-260EF2B8614A}.Debug|x86.Build.0 = Debug|Any CPU - {564CABB8-1B3F-4D9E-909D-260EF2B8614A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {564CABB8-1B3F-4D9E-909D-260EF2B8614A}.Release|Any CPU.Build.0 = Release|Any CPU - {564CABB8-1B3F-4D9E-909D-260EF2B8614A}.Release|x64.ActiveCfg = Release|Any CPU - {564CABB8-1B3F-4D9E-909D-260EF2B8614A}.Release|x64.Build.0 = Release|Any CPU - {564CABB8-1B3F-4D9E-909D-260EF2B8614A}.Release|x86.ActiveCfg = Release|Any CPU - {564CABB8-1B3F-4D9E-909D-260EF2B8614A}.Release|x86.Build.0 = Release|Any CPU - {CF4CEC18-798D-46EC-B0A0-98D97496590F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CF4CEC18-798D-46EC-B0A0-98D97496590F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CF4CEC18-798D-46EC-B0A0-98D97496590F}.Debug|x64.ActiveCfg = Debug|Any CPU - {CF4CEC18-798D-46EC-B0A0-98D97496590F}.Debug|x64.Build.0 = Debug|Any CPU - {CF4CEC18-798D-46EC-B0A0-98D97496590F}.Debug|x86.ActiveCfg = Debug|Any CPU - {CF4CEC18-798D-46EC-B0A0-98D97496590F}.Debug|x86.Build.0 = Debug|Any CPU - {CF4CEC18-798D-46EC-B0A0-98D97496590F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CF4CEC18-798D-46EC-B0A0-98D97496590F}.Release|Any CPU.Build.0 = Release|Any CPU - {CF4CEC18-798D-46EC-B0A0-98D97496590F}.Release|x64.ActiveCfg = Release|Any CPU - {CF4CEC18-798D-46EC-B0A0-98D97496590F}.Release|x64.Build.0 = Release|Any CPU - {CF4CEC18-798D-46EC-B0A0-98D97496590F}.Release|x86.ActiveCfg = Release|Any CPU - {CF4CEC18-798D-46EC-B0A0-98D97496590F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -8706,9 +8676,6 @@ Global {835A4E0F-A697-4B69-9736-3E99D163C4B9} = {48526D13-69E2-4409-A57B-C3FA3C64B4F7} {148A5B4F-C8A3-4468-92F6-51DB5641FB49} = {7D2B0799-A634-42AC-AE77-5D167BA51389} {6D365C86-3158-49F5-A21D-506C1E06E870} = {7D2B0799-A634-42AC-AE77-5D167BA51389} - {564CABB8-1B3F-4D9E-909D-260EF2B8614A} = {EE39397E-E4AF-4D3F-9B9C-D637F9222CDD} - {EE39397E-E4AF-4D3F-9B9C-D637F9222CDD} = {A4C26078-B6D8-4FD8-87A6-7C15A3482038} - {CF4CEC18-798D-46EC-B0A0-98D97496590F} = {EE39397E-E4AF-4D3F-9B9C-D637F9222CDD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/eng/Dependencies.props b/eng/Dependencies.props index 4f64075c07cbcd07c0574fe8f903084758c74a61..8a50fb9f218b5b5abc616560d1efa559a85cb2c3 100644 --- a/eng/Dependencies.props +++ b/eng/Dependencies.props @@ -56,6 +56,7 @@ and are generated based on the last package release. <LatestPackageReference Include="Microsoft.Extensions.Options" /> <LatestPackageReference Include="Microsoft.Extensions.Primitives" /> <LatestPackageReference Include="Microsoft.Win32.Registry" /> + <LatestPackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing.XUnit" /> <LatestPackageReference Include="System.Buffers" /> <LatestPackageReference Include="System.CodeDom" /> <LatestPackageReference Include="System.CommandLine.Experimental" /> diff --git a/eng/Versions.props b/eng/Versions.props index 81cb263a6b00aa4f3c606c85e3f5f90a61252c2b..00a10405a3e848d9b009e62878262b7e582c7e5d 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -188,6 +188,7 @@ <MicrosoftCodeAnalysisCSharpVersion>4.0.0-2.21354.7</MicrosoftCodeAnalysisCSharpVersion> <MicrosoftCodeAnalysisCSharpWorkspacesVersion>4.0.0-2.21354.7</MicrosoftCodeAnalysisCSharpWorkspacesVersion> <MicrosoftCodeAnalysisPublicApiAnalyzersVersion>3.3.0</MicrosoftCodeAnalysisPublicApiAnalyzersVersion> + <MicrosoftCodeAnalysisCSharpCodeFixTestingXUnitVersion>1.1.1-beta1.21413.1</MicrosoftCodeAnalysisCSharpCodeFixTestingXUnitVersion> <MicrosoftCssParserVersion>1.0.0-20200708.1</MicrosoftCssParserVersion> <MicrosoftIdentityModelLoggingVersion>6.10.0</MicrosoftIdentityModelLoggingVersion> <MicrosoftIdentityModelProtocolsOpenIdConnectVersion>6.10.0</MicrosoftIdentityModelProtocolsOpenIdConnectVersion> diff --git a/src/Framework/App.Ref/src/Microsoft.AspNetCore.App.Ref.csproj b/src/Framework/App.Ref/src/Microsoft.AspNetCore.App.Ref.csproj index 72fe921a71719075eef9df5a88fd02dd8f745c33..aff8d01e180e01e336854121cbac179200939997 100644 --- a/src/Framework/App.Ref/src/Microsoft.AspNetCore.App.Ref.csproj +++ b/src/Framework/App.Ref/src/Microsoft.AspNetCore.App.Ref.csproj @@ -63,7 +63,11 @@ This package is an internal implementation of the .NET Core SDK and is not meant <ItemGroup> <!-- Note: do not add _TransitiveExternalAspNetCoreAppReference to this list. This is intentionally not listed as a direct package reference. --> <Reference Include="@(AspNetCoreAppReference);@(AspNetCoreAppReferenceAndPackage);@(ExternalAspNetCoreAppReference)" /> - <ProjectReference Include="..\..\Analyzer\src\Microsoft.AspNetCore.App.Analyzers.csproj" + <ProjectReference Include="..\..\AspNetCoreAnalyzers\src\Analyzers\Microsoft.AspNetCore.App.Analyzers.csproj" + ReferenceOutputAssembly="false" + SkipGetTargetFrameworkProperties="true" + UndefineProperties="TargetFramework;TargetFrameworks;RuntimeIdentifier;PublishDir" /> + <ProjectReference Include="..\..\AspNetCoreAnalyzers\src\CodeFixes\Microsoft.AspNetCore.App.CodeFixes.csproj" ReferenceOutputAssembly="false" SkipGetTargetFrameworkProperties="true" UndefineProperties="TargetFramework;TargetFrameworks;RuntimeIdentifier;PublishDir" /> @@ -160,6 +164,7 @@ This package is an internal implementation of the .NET Core SDK and is not meant <RefPackContent Include="$(PkgMicrosoft_Internal_Runtime_AspNetCore_Transport)\$(AnalyzersPackagePath)**\*.*" PackagePath="$(AnalyzersPackagePath)" /> <RefPackContent Include="$(ArtifactsDir)bin\Microsoft.AspNetCore.App.Analyzers\$(Configuration)\netstandard2.0\Microsoft.AspNetCore.App.Analyzers.dll" PackagePath="$(AnalyzersPackagePath)dotnet/cs/" /> + <RefPackContent Include="$(ArtifactsDir)bin\Microsoft.AspNetCore.App.CodeFixes\$(Configuration)\netstandard2.0\Microsoft.AspNetCore.App.CodeFixes.dll" PackagePath="$(AnalyzersPackagePath)dotnet/cs/" /> <RefPackContent Include="@(AspNetCoreReferenceAssemblyPath)" PackagePath="$(RefAssemblyPackagePath)" /> <RefPackContent Include="@(AspNetCoreReferenceDocXml)" PackagePath="$(RefAssemblyPackagePath)" /> diff --git a/src/Framework/Analyzer/src/DelegateEndpoints/DelegateEndpointAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DelegateEndpointAnalyzer.cs similarity index 93% rename from src/Framework/Analyzer/src/DelegateEndpoints/DelegateEndpointAnalyzer.cs rename to src/Framework/AspNetCoreAnalyzers/src/Analyzers/DelegateEndpointAnalyzer.cs index aca8224bf9c71258d684a9b8fdcd87b1b1d9dd3c..927429ac5728a705d353e6c3dda1348d6e71241a 100644 --- a/src/Framework/Analyzer/src/DelegateEndpoints/DelegateEndpointAnalyzer.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DelegateEndpointAnalyzer.cs @@ -18,7 +18,8 @@ public partial class DelegateEndpointAnalyzer : DiagnosticAnalyzer { DiagnosticDescriptors.DoNotUseModelBindingAttributesOnDelegateEndpointParameters, DiagnosticDescriptors.DoNotReturnActionResultsFromMapActions, - DiagnosticDescriptors.DetectMisplacedLambdaAttribute + DiagnosticDescriptors.DetectMisplacedLambdaAttribute, + DiagnosticDescriptors.DetectMismatchedParameterOptionality }); public override void Initialize(AnalysisContext context) @@ -56,11 +57,13 @@ public partial class DelegateEndpointAnalyzer : DiagnosticAnalyzer DisallowMvcBindArgumentsOnParameters(in operationAnalysisContext, wellKnownTypes, invocation, lambda.Symbol); DisallowReturningActionResultFromMapMethods(in operationAnalysisContext, wellKnownTypes, invocation, lambda); DetectMisplacedLambdaAttribute(operationAnalysisContext, invocation, lambda); + DetectMismatchedParameterOptionality(in operationAnalysisContext, invocation, lambda.Symbol); } else if (delegateCreation.Target.Kind == OperationKind.MethodReference) { var methodReference = (IMethodReferenceOperation)delegateCreation.Target; DisallowMvcBindArgumentsOnParameters(in operationAnalysisContext, wellKnownTypes, invocation, methodReference.Method); + DetectMismatchedParameterOptionality(in operationAnalysisContext, invocation, methodReference.Method); var foundMethodReferenceBody = false; if (!methodReference.Method.DeclaringSyntaxReferences.IsEmpty) diff --git a/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DetectMismatchedParameterOptionality.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DetectMismatchedParameterOptionality.cs new file mode 100644 index 0000000000000000000000000000000000000000..8ccde050a5250b54b81380831673ad8463eda597 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DetectMismatchedParameterOptionality.cs @@ -0,0 +1,139 @@ +// 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.Linq; +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Microsoft.AspNetCore.Analyzers.DelegateEndpoints; + +public partial class DelegateEndpointAnalyzer : DiagnosticAnalyzer +{ + internal const string DetectMismatchedParameterOptionalityRuleId = "ASP0006"; + + private static void DetectMismatchedParameterOptionality( + in OperationAnalysisContext context, + IInvocationOperation invocation, + IMethodSymbol methodSymbol) + { + if (invocation.Arguments.Length < 2) + { + return; + } + + var value = invocation.Arguments[1].Value; + if (value.ConstantValue is not { HasValue: true } constant || + constant.Value is not string routeTemplate) + { + return; + } + + var allDeclarations = methodSymbol.GetAllMethodSymbolsOfPartialParts(); + foreach (var method in allDeclarations) + { + var parametersInArguments = method.Parameters; + var enumerator = new RouteTokenEnumerator(routeTemplate); + + while (enumerator.MoveNext()) + { + foreach (var parameter in parametersInArguments) + { + var paramName = parameter.Name; + // If this is not the methpd parameter associated with the route + // parameter then continue looking for it in the list + if (!enumerator.CurrentName.Equals(paramName.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + continue; + } + var argumentIsOptional = parameter.IsOptional || parameter.NullableAnnotation != NullableAnnotation.NotAnnotated; + var location = parameter.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax().GetLocation(); + var routeParamIsOptional = enumerator.CurrentQualifiers.IndexOf('?') > -1; + + if (!argumentIsOptional && routeParamIsOptional) + { + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.DetectMismatchedParameterOptionality, + location, + paramName)); + } + } + } + } + } + + internal ref struct RouteTokenEnumerator + { + private ReadOnlySpan<char> _routeTemplate; + + public RouteTokenEnumerator(string routeTemplateString) + { + _routeTemplate = routeTemplateString.AsSpan(); + CurrentName = default; + CurrentQualifiers = default; + } + + public ReadOnlySpan<char> CurrentName { get; private set; } + public ReadOnlySpan<char> CurrentQualifiers { get; private set; } + + public bool MoveNext() + { + if (_routeTemplate.IsEmpty) + { + return false; + } + + findStartBrace: + var startIndex = _routeTemplate.IndexOf('{'); + if (startIndex == -1) + { + return false; + } + + if (startIndex < _routeTemplate.Length - 1 && _routeTemplate[startIndex + 1] == '{') + { + // Escaped sequence + _routeTemplate = _routeTemplate.Slice(startIndex + 1); + goto findStartBrace; + } + + var tokenStart = startIndex + 1; + + findEndBrace: + var endIndex = IndexOf(_routeTemplate, tokenStart, '}'); + if (endIndex == -1) + { + return false; + } + if (endIndex < _routeTemplate.Length - 1 && _routeTemplate[endIndex + 1] == '}') + { + tokenStart = endIndex + 2; + goto findEndBrace; + } + + var token = _routeTemplate.Slice(startIndex + 1, endIndex - startIndex - 1); + var qualifier = token.IndexOfAny(new[] { ':', '=', '?' }); + CurrentName = qualifier == -1 ? token : token.Slice(0, qualifier); + CurrentQualifiers = qualifier == -1 ? null : token.Slice(qualifier); + + _routeTemplate = _routeTemplate.Slice(endIndex + 1); + return true; + } + } + + private static int IndexOf(ReadOnlySpan<char> span, int startIndex, char c) + { + for (var i = startIndex; i < span.Length; i++) + { + if (span[i] == c) + { + return i; + } + } + + return -1; + } +} \ No newline at end of file diff --git a/src/Framework/Analyzer/src/DelegateEndpoints/DetectMisplacedLambdaAttribute.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DetectMisplacedLambdaAttribute.cs similarity index 100% rename from src/Framework/Analyzer/src/DelegateEndpoints/DetectMisplacedLambdaAttribute.cs rename to src/Framework/AspNetCoreAnalyzers/src/Analyzers/DetectMisplacedLambdaAttribute.cs diff --git a/src/Framework/Analyzer/src/DelegateEndpoints/DiagnosticDescriptors.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs similarity index 79% rename from src/Framework/Analyzer/src/DelegateEndpoints/DiagnosticDescriptors.cs rename to src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs index 396ebef19efea0387d3bd22f25fb327ed350fa85..4cb94d5f26db16032f6c69fea7b5938bd405c3cd 100644 --- a/src/Framework/Analyzer/src/DelegateEndpoints/DiagnosticDescriptors.cs +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DiagnosticDescriptors.cs @@ -34,5 +34,14 @@ namespace Microsoft.AspNetCore.Analyzers.DelegateEndpoints DiagnosticSeverity.Warning, isEnabledByDefault: true, helpLinkUri: "https://aka.ms/aspnet/analyzers"); + + internal static readonly DiagnosticDescriptor DetectMismatchedParameterOptionality = new( + "ASP0006", + "Route parameter and argument optionality is mismatched", + "'{0}' argument should be annotated as optional or nullable to match route parameter", + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: "https://aka.ms/aspnet/analyzers"); } } diff --git a/src/Framework/Analyzer/src/DelegateEndpoints/DisallowMvcBindArgumentsOnParameters.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DisallowMvcBindArgumentsOnParameters.cs similarity index 100% rename from src/Framework/Analyzer/src/DelegateEndpoints/DisallowMvcBindArgumentsOnParameters.cs rename to src/Framework/AspNetCoreAnalyzers/src/Analyzers/DisallowMvcBindArgumentsOnParameters.cs diff --git a/src/Framework/Analyzer/src/DelegateEndpoints/DisallowReturningActionResultFromMapMethods.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/DisallowReturningActionResultFromMapMethods.cs similarity index 100% rename from src/Framework/Analyzer/src/DelegateEndpoints/DisallowReturningActionResultFromMapMethods.cs rename to src/Framework/AspNetCoreAnalyzers/src/Analyzers/DisallowReturningActionResultFromMapMethods.cs diff --git a/src/Framework/Analyzer/src/Microsoft.AspNetCore.App.Analyzers.csproj b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Microsoft.AspNetCore.App.Analyzers.csproj similarity index 83% rename from src/Framework/Analyzer/src/Microsoft.AspNetCore.App.Analyzers.csproj rename to src/Framework/AspNetCoreAnalyzers/src/Analyzers/Microsoft.AspNetCore.App.Analyzers.csproj index e8be6868895864b6278c8ab9c63b1212e2b3a2d7..12662dd9bc7db0502998bc31d367d5986e48b93a 100644 --- a/src/Framework/Analyzer/src/Microsoft.AspNetCore.App.Analyzers.csproj +++ b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/Microsoft.AspNetCore.App.Analyzers.csproj @@ -10,9 +10,10 @@ </PropertyGroup> <ItemGroup> - <Reference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" PrivateAssets="All" /> + <Reference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="All" /> <InternalsVisibleTo Include="Microsoft.AspNetCore.App.Analyzers.Test" /> + <InternalsVisibleTo Include="Microsoft.AspNetCore.App.CodeFixes" /> </ItemGroup> <ItemGroup> diff --git a/src/Framework/Analyzer/src/DelegateEndpoints/WellKnownTypes.cs b/src/Framework/AspNetCoreAnalyzers/src/Analyzers/WellKnownTypes.cs similarity index 100% rename from src/Framework/Analyzer/src/DelegateEndpoints/WellKnownTypes.cs rename to src/Framework/AspNetCoreAnalyzers/src/Analyzers/WellKnownTypes.cs diff --git a/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/DetectMismatchedParameterOptionalityFixer.cs b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/DetectMismatchedParameterOptionalityFixer.cs new file mode 100644 index 0000000000000000000000000000000000000000..0b4f97ec0fe232abdff764d79935240dde906851 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/DetectMismatchedParameterOptionalityFixer.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using System.Threading; +using System.Collections.Immutable; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Analyzers.DelegateEndpoints; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.Editing; + +namespace Microsoft.AspNetCore.Analyzers.DelegateEndpoints.Fixers; + +public class DetectMismatchedParameterOptionalityFixer : CodeFixProvider +{ + public override ImmutableArray<string> FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticDescriptors.DetectMismatchedParameterOptionality.Id); + + public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public sealed override Task RegisterCodeFixesAsync(CodeFixContext context) + { + foreach (var diagnostic in context.Diagnostics) + { + context.RegisterCodeFix( + CodeAction.Create("Fix mismatched route parameter and argument optionality", + cancellationToken => FixMismatchedParameterOptionality(diagnostic, context.Document, cancellationToken), + equivalenceKey: DiagnosticDescriptors.DetectMismatchedParameterOptionality.Id), + diagnostic); + } + + return Task.CompletedTask; + } + + private static async Task<Document> FixMismatchedParameterOptionality(Diagnostic diagnostic, Document document, CancellationToken cancellationToken) + { + DocumentEditor editor = await DocumentEditor.CreateAsync(document, cancellationToken); + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + + if (root == null) + { + return document; + } + + var param = root.FindNode(diagnostic.Location.SourceSpan); + if (param is ParameterSyntax { Type: { } parameterType } parameterSyntax) + { + var newParam = parameterSyntax.WithType(SyntaxFactory.NullableType(parameterType)); + editor.ReplaceNode(parameterSyntax, newParam); + } + + return editor.GetChangedDocument(); + } +} diff --git a/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Microsoft.AspNetCore.App.CodeFixes.csproj b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Microsoft.AspNetCore.App.CodeFixes.csproj new file mode 100644 index 0000000000000000000000000000000000000000..d70178504e713b6bfe86bfdc75defe9905b1010c --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/src/CodeFixes/Microsoft.AspNetCore.App.CodeFixes.csproj @@ -0,0 +1,17 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <Description>CSharp CodeFixes for ASP.NET Core.</Description> + <IsShippingPackage>false</IsShippingPackage> + <AddPublicApiAnalyzers>false</AddPublicApiAnalyzers> + <TargetFramework>netstandard2.0</TargetFramework> + <IncludeBuildOutput>false</IncludeBuildOutput> + <Nullable>Enable</Nullable> + <RootNamespace>Microsoft.AspNetCore.Analyzers</RootNamespace> + </PropertyGroup> + + <ItemGroup> + <Reference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" PrivateAssets="All" /> + <ProjectReference Include="..\Analyzers\Microsoft.AspNetCore.App.Analyzers.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/Framework/Analyzer/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj b/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj similarity index 73% rename from src/Framework/Analyzer/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj rename to src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj index 8434a97bbfc2509be58135bd5bd70a423325953b..6ec72c844de5a4bb1ebd00b43b280506a63563db 100644 --- a/src/Framework/Analyzer/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj +++ b/src/Framework/AspNetCoreAnalyzers/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj @@ -11,11 +11,13 @@ </ItemGroup> <ItemGroup> - <ProjectReference Include="..\src\Microsoft.AspNetCore.App.Analyzers.csproj" /> + <ProjectReference Include="..\src\Analyzers\Microsoft.AspNetCore.App.Analyzers.csproj" /> + <ProjectReference Include="..\src\CodeFixes\Microsoft.AspNetCore.App.CodeFixes.csproj" /> <ProjectReference Include="$(RepoRoot)src\Analyzers\Microsoft.AspNetCore.Analyzer.Testing\src\Microsoft.AspNetCore.Analyzer.Testing.csproj" /> <Reference Include="Microsoft.AspNetCore" /> <Reference Include="Microsoft.AspNetCore.Mvc" /> <Reference Include="Microsoft.AspNetCore.Http.Results" /> + <Reference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing.XUnit" /> </ItemGroup> </Project> diff --git a/src/Framework/AspNetCoreAnalyzers/test/MinimalActions/DetectMismatchedParameterOptionalityTest.cs b/src/Framework/AspNetCoreAnalyzers/test/MinimalActions/DetectMismatchedParameterOptionalityTest.cs new file mode 100644 index 0000000000000000000000000000000000000000..baa4841bb0cbb18e3e8b0bbcb52b1ec6577042a4 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/test/MinimalActions/DetectMismatchedParameterOptionalityTest.cs @@ -0,0 +1,396 @@ +// 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.Testing; +using Xunit; +using VerifyCS = Microsoft.AspNetCore.Analyzers.DelegateEndpoints.CSharpDelegateEndpointsCodeFixVerifier< + Microsoft.AspNetCore.Analyzers.DelegateEndpoints.DelegateEndpointAnalyzer, + Microsoft.AspNetCore.Analyzers.DelegateEndpoints.Fixers.DetectMismatchedParameterOptionalityFixer>; + +namespace Microsoft.AspNetCore.Analyzers.DelegateEndpoints; + +public partial class DetectMismatchedParameterOptionalityTest +{ + [Fact] + public async Task MatchingRequiredOptionality_CanBeFixed() + { + var source = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{name?}"", ({|#0:string name|}) => $""Hello {name}"");"; + + var fixedSource = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{name?}"", (string? name) => $""Hello {name}"");"; + + var expectedDiagnostics = new DiagnosticResult(DiagnosticDescriptors.DetectMismatchedParameterOptionality).WithArguments("name").WithLocation(0); + + await VerifyCS.VerifyCodeFixAsync(source, expectedDiagnostics, fixedSource); + } + + [Fact] + public async Task MatchingMultipleRequiredOptionality_CanBeFixed() + { + var source = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{name?}/{title?}"", ({|#0:string name|}, {|#1:string title|}) => $""Hello {name}, you are a {title}.""); +"; + var fixedSource = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{name?}/{title?}"", (string? name, string? title) => $""Hello {name}, you are a {title}.""); +"; + var expectedDiagnostics = new[] { + new DiagnosticResult(DiagnosticDescriptors.DetectMismatchedParameterOptionality).WithArguments("name").WithLocation(0), + new DiagnosticResult(DiagnosticDescriptors.DetectMismatchedParameterOptionality).WithArguments("title").WithLocation(1) + }; + + await VerifyCS.VerifyCodeFixAsync(source, expectedDiagnostics, fixedSource); + + } + + [Fact] + public async Task MatchingSingleRequiredOptionality_CanBeFixed() + { + var source = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{name?}/{title?}"", ({|#0:string name|}, string? title) => $""Hello {name}, you are a {title}.""); +"; + var fixedSource = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{name?}/{title?}"", (string? name, string? title) => $""Hello {name}, you are a {title}.""); +"; + var expectedDiagnostic = new DiagnosticResult(DiagnosticDescriptors.DetectMismatchedParameterOptionality).WithArguments("name").WithLocation(0); + + await VerifyCS.VerifyCodeFixAsync(source, expectedDiagnostic, fixedSource); + } + + [Fact] + public async Task MismatchedOptionalityInMethodGroup_CanBeFixed() + { + var source = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +string SayHello({|#0:string name|}, {|#1:string title|}) => $""Hello {name}, you are a {title}.""; +app.MapGet(""/hello/{name?}/{title?}"", SayHello); +"; + var fixedSource = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +string SayHello(string? name, string? title) => $""Hello {name}, you are a {title}.""; +app.MapGet(""/hello/{name?}/{title?}"", SayHello); +"; + + var expectedDiagnostics = new[] { + new DiagnosticResult(DiagnosticDescriptors.DetectMismatchedParameterOptionality).WithArguments("name").WithLocation(0), + new DiagnosticResult(DiagnosticDescriptors.DetectMismatchedParameterOptionality).WithArguments("title").WithLocation(1) + }; + + await VerifyCS.VerifyCodeFixAsync(source, expectedDiagnostics, fixedSource); + } + + [Fact] + public async Task MismatchedOptionalityInMethodGroupFromPartialMethod_CanBeFixed() + { + var source = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{name?}/{title?}"", ExternalImplementation.SayHello); + +public partial class ExternalImplementation +{ + public static partial string SayHello({|#0:string name|}, {|#1:string title|}); +} + +public partial class ExternalImplementation +{ + public static partial string SayHello({|#2:string name|}, {|#3:string title|}) + { + return $""Hello {name}, you are a {title}.""; + } +} +"; + var fixedSource = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{name?}/{title?}"", ExternalImplementation.SayHello); + +public partial class ExternalImplementation +{ + public static partial string SayHello(string? name, string? title); +} + +public partial class ExternalImplementation +{ + public static partial string SayHello(string? name, string? title) + { + return $""Hello {name}, you are a {title}.""; + } +} +"; + // Diagnostics are produced at both the declaration and definition syntax + // for partial method definitions to support the CodeFix correctly resolving both. + var expectedDiagnostics = new[] { + new DiagnosticResult(DiagnosticDescriptors.DetectMismatchedParameterOptionality).WithArguments("name").WithLocation(0), + new DiagnosticResult(DiagnosticDescriptors.DetectMismatchedParameterOptionality).WithArguments("title").WithLocation(1), + new DiagnosticResult(DiagnosticDescriptors.DetectMismatchedParameterOptionality).WithArguments("name").WithLocation(2), + new DiagnosticResult(DiagnosticDescriptors.DetectMismatchedParameterOptionality).WithArguments("title").WithLocation(3) + }; + + await VerifyCS.VerifyCodeFixAsync(source, expectedDiagnostics, fixedSource); + } + + [Fact] + public async Task MismatchedOptionalityInSeparateSource_CanBeFixed() + { + var usageSource = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{name?}/{title?}"", Helpers.SayHello); +"; + var source = @" +#nullable enable +using System; + +public static class Helpers +{ + public static string SayHello({|#0:string name|}, {|#1:string title|}) + { + return $""Hello {name}, you are a {title}.""; + } +}"; + var fixedSource = @" +#nullable enable +using System; + +public static class Helpers +{ + public static string SayHello(string? name, string? title) + { + return $""Hello {name}, you are a {title}.""; + } +}"; + + var expectedDiagnostics = new[] { + new DiagnosticResult(DiagnosticDescriptors.DetectMismatchedParameterOptionality).WithArguments("name").WithLocation(0), + new DiagnosticResult(DiagnosticDescriptors.DetectMismatchedParameterOptionality).WithArguments("title").WithLocation(1) + }; + + await VerifyCS.VerifyCodeFixAsync(source, expectedDiagnostics, fixedSource, usageSource); + } + + [Fact] + public async Task MatchingRequiredOptionality_DoesNotProduceDiagnostics() + { + // Arrange + var source = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{name}"", (string name) => $""Hello {name}""); +"; + + await VerifyCS.VerifyCodeFixAsync(source, source); + } + + [Fact] + public async Task ParameterFromRouteOrQuery_DoesNotProduceDiagnostics() + { + // Arrange + var source = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{name}"", (string name) => $""Hello {name}""); +"; + + await VerifyCS.VerifyCodeFixAsync(source, source); + } + + [Fact] + public async Task MatchingOptionality_DoesNotProduceDiagnostics() + { + // Arrange + var source = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{name?}"", (string? name) => $""Hello {name}""); +"; + + await VerifyCS.VerifyCodeFixAsync(source, source); + } + + [Fact] + public async Task RequiredRouteParamOptionalArgument_DoesNotProduceDiagnostics() + { + // Arrange + var source = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{name}"", (string? name) => $""Hello {name}""); +"; + + await VerifyCS.VerifyCodeFixAsync(source, source); + } + + [Fact] + public async Task OptionalRouteParamRequiredArgument_WithFromRoute_ProducesDiagnostics() + { + // Arrange + var source = @" +#nullable enable +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{Age?}"", ({|#0:[FromRoute] int age|}) => $""Age: {age}""); +"; + + var fixedSource = @" +#nullable enable +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{Age?}"", ([FromRoute] int? age) => $""Age: {age}""); +"; + + var expectedDiagnostic = new DiagnosticResult(DiagnosticDescriptors.DetectMismatchedParameterOptionality).WithArguments("age").WithLocation(0); + + await VerifyCS.VerifyCodeFixAsync(source, expectedDiagnostic, fixedSource); + } + + [Fact] + public async Task OptionalRouteParamRequiredArgument_WithRegexConstraint_ProducesDiagnostics() + { + // Arrange + var source = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{age:regex(^\\d{{3}}-\\d{{2}}-\\d{{4}}$)?}"", ({|#0:int age|}) => $""Age: {age}""); +"; + + var fixedSource = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{age:regex(^\\d{{3}}-\\d{{2}}-\\d{{4}}$)?}"", (int? age) => $""Age: {age}""); +"; + var expectedDiagnostic = new DiagnosticResult(DiagnosticDescriptors.DetectMismatchedParameterOptionality).WithArguments("age").WithLocation(0); + + await VerifyCS.VerifyCodeFixAsync(source, expectedDiagnostic, fixedSource); + } + + [Fact] + public async Task OptionalRouteParamRequiredArgument_WithTypeConstraint_ProducesDiagnostics() + { + // Arrange + var source = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{age:int?}"", ({|#0:int age|}) => $""Age: {age}""); +"; + + var fixedSource = @" +#nullable enable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{age:int?}"", (int? age) => $""Age: {age}""); +"; + + var expectedDiagnostic = new DiagnosticResult(DiagnosticDescriptors.DetectMismatchedParameterOptionality).WithArguments("age").WithLocation(0); + + await VerifyCS.VerifyCodeFixAsync(source, expectedDiagnostic, fixedSource); + } + + [Fact] + public async Task MatchingRequiredOptionality_WithDisabledNullability() + { + var source = @" +#nullable disable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{name?}"", (string name) => $""Hello {name}""); +"; + var fixedSource = @" +#nullable disable +using Microsoft.AspNetCore.Builder; + +var app = WebApplication.Create(); +app.MapGet(""/hello/{name?}"", (string name) => $""Hello {name}""); +"; + + await VerifyCS.VerifyCodeFixAsync(source, fixedSource); + } + + [Theory] + [InlineData("{id}", new[] { "id" }, new[] { "" })] + [InlineData("{category}/product/{group}", new[] { "category", "group" }, new[] { "", "" })] + [InlineData("{category:int}/product/{group:range(10, 20)}?", new[] { "category", "group" }, new[] { ":int", ":range(10, 20)" })] + [InlineData("{person:int}/{ssn:regex(^\\d{{3}}-\\d{{2}}-\\d{{4}}$)}", new[] { "person", "ssn" }, new[] { ":int", ":regex(^\\d{{3}}-\\d{{2}}-\\d{{4}}$)"})] + [InlineData("{area=Home}/{controller:required}/{id=0:int}", new[] { "area", "controller", "id" }, new[] { "=Home", ":required", "=0:int" })] + [InlineData("{category}/product/{group?}", new[] { "category", "group" }, new[] { "", "?"})] + [InlineData("{category}/{product}/{*sku}", new[] { "category", "product", "*sku" }, new[] { "", "", "" })] + [InlineData("{category}-product-{sku}", new[] { "category", "sku" }, new[] { "", "" } )] + [InlineData("category-{product}-sku", new[] { "product" }, new[] { "" } )] + [InlineData("{category}.{sku?}", new[] { "category", "sku" }, new[] { "", "?" })] + [InlineData("{category}.{product?}/{sku}", new[] { "category", "product", "sku" }, new[] { "", "?", "" })] + public void RouteTokenizer_Works_ForSimpleRouteTemplates(string template, string[] expectedNames, string[] expectedQualifiers) + { + // Arrange + var tokenizer = new DelegateEndpointAnalyzer.RouteTokenEnumerator(template); + var actualNames = new List<string>(); + var actualQualifiers = new List<string>(); + + // Act + while (tokenizer.MoveNext()) + { + actualNames.Add(tokenizer.CurrentName.ToString()); + actualQualifiers.Add(tokenizer.CurrentQualifiers.ToString()); + + } + + // Assert + Assert.Equal(expectedNames, actualNames); + Assert.Equal(expectedQualifiers, actualQualifiers); + } +} diff --git a/src/Framework/Analyzer/test/MinimalActions/DetectMisplacedLambdaAttributeTest.cs b/src/Framework/AspNetCoreAnalyzers/test/MinimalActions/DetectMisplacedLambdaAttributeTest.cs similarity index 100% rename from src/Framework/Analyzer/test/MinimalActions/DetectMisplacedLambdaAttributeTest.cs rename to src/Framework/AspNetCoreAnalyzers/test/MinimalActions/DetectMisplacedLambdaAttributeTest.cs diff --git a/src/Framework/Analyzer/test/MinimalActions/DisallowMvcBindArgumentsOnParametersTest.cs b/src/Framework/AspNetCoreAnalyzers/test/MinimalActions/DisallowMvcBindArgumentsOnParametersTest.cs similarity index 100% rename from src/Framework/Analyzer/test/MinimalActions/DisallowMvcBindArgumentsOnParametersTest.cs rename to src/Framework/AspNetCoreAnalyzers/test/MinimalActions/DisallowMvcBindArgumentsOnParametersTest.cs diff --git a/src/Framework/Analyzer/test/MinimalActions/DisallowReturningActionResultsFromMapMethodsTest.cs b/src/Framework/AspNetCoreAnalyzers/test/MinimalActions/DisallowReturningActionResultsFromMapMethodsTest.cs similarity index 100% rename from src/Framework/Analyzer/test/MinimalActions/DisallowReturningActionResultsFromMapMethodsTest.cs rename to src/Framework/AspNetCoreAnalyzers/test/MinimalActions/DisallowReturningActionResultsFromMapMethodsTest.cs diff --git a/src/Framework/Analyzer/test/TestDiagnosticAnalyzer.cs b/src/Framework/AspNetCoreAnalyzers/test/TestDiagnosticAnalyzer.cs similarity index 100% rename from src/Framework/Analyzer/test/TestDiagnosticAnalyzer.cs rename to src/Framework/AspNetCoreAnalyzers/test/TestDiagnosticAnalyzer.cs diff --git a/src/Framework/AspNetCoreAnalyzers/test/Verifiers/CSharpDelegateEndpointsAnalyzerVerifier.cs b/src/Framework/AspNetCoreAnalyzers/test/Verifiers/CSharpDelegateEndpointsAnalyzerVerifier.cs new file mode 100644 index 0000000000000000000000000000000000000000..2fa14f67bccb8e39a7c4b62df7e95d14f3c68ad1 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/test/Verifiers/CSharpDelegateEndpointsAnalyzerVerifier.cs @@ -0,0 +1,33 @@ +// 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.Globalization; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Testing.Verifiers; +using Xunit; + +namespace Microsoft.AspNetCore.Analyzers.DelegateEndpoints; + +public static class CSharpDelegateEndpointsAnalyzerVerifier<TAnalyzer> + where TAnalyzer : DelegateEndpointAnalyzer, new() +{ + public static DiagnosticResult Diagnostic(string diagnosticId = null) + => CSharpDelegateEndpointsAnalyzerVerifier<DelegateEndpointAnalyzer>.Diagnostic(diagnosticId); + + public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) + => new DiagnosticResult(descriptor); + + public static Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected) + { + var test = new Test { TestCode = source }; + test.ExpectedDiagnostics.AddRange(expected); + return test.RunAsync(); + } + + public class Test : CSharpCodeFixTest<TAnalyzer, EmptyCodeFixProvider, XUnitVerifier> { } +} \ No newline at end of file diff --git a/src/Framework/AspNetCoreAnalyzers/test/Verifiers/CSharpDelegateEndpointsCodeFixVerifier.cs b/src/Framework/AspNetCoreAnalyzers/test/Verifiers/CSharpDelegateEndpointsCodeFixVerifier.cs new file mode 100644 index 0000000000000000000000000000000000000000..2883bb2fd389f380a4131ca689d689053b5df801 --- /dev/null +++ b/src/Framework/AspNetCoreAnalyzers/test/Verifiers/CSharpDelegateEndpointsCodeFixVerifier.cs @@ -0,0 +1,85 @@ +// 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.Globalization; +using Microsoft.AspNetCore.Analyzers.DelegateEndpoints.Fixers; +using Microsoft.AspNetCore.Analyzer.Testing; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Testing.Verifiers; +using Xunit; + +namespace Microsoft.AspNetCore.Analyzers.DelegateEndpoints; + +public static class CSharpDelegateEndpointsCodeFixVerifier<TAnalyzer, TCodeFix> + where TAnalyzer : DelegateEndpointAnalyzer, new() + where TCodeFix : DetectMismatchedParameterOptionalityFixer, new() +{ + public static DiagnosticResult Diagnostic(string diagnosticId = null) + => CSharpCodeFixVerifier<TAnalyzer, TCodeFix, XUnitVerifier>.Diagnostic(diagnosticId); + + public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) + => new DiagnosticResult(descriptor); + + public static Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected) + { + var test = new CSharpDelegateEndpointsAnalyzerVerifier<TAnalyzer>.Test { TestCode = source }; + test.ExpectedDiagnostics.AddRange(expected); + return test.RunAsync(); + } + + public static Task VerifyCodeFixAsync(string source, string fixedSource) + => VerifyCodeFixAsync(source, DiagnosticResult.EmptyDiagnosticResults, fixedSource); + + public static Task VerifyCodeFixAsync(string source, DiagnosticResult expected, string fixedSource) + => VerifyCodeFixAsync(source, new[] { expected }, fixedSource); + + public static Task VerifyCodeFixAsync(string source, DiagnosticResult[] expected, string fixedSource) + => VerifyCodeFixAsync(source, expected, fixedSource, string.Empty); + + public static Task VerifyCodeFixAsync(string sources, DiagnosticResult[] expected, string fixedSources, string usageSource = "") + { + var test = new DelegateEndpointAnalyzerTest + { + TestState = + { + Sources = { sources, usageSource }, + // We need to set the output type to an exe to properly + // support top-level programs in the tests. Otherwise, + // the test infra will assume we are trying to build a library. + OutputKind = OutputKind.ConsoleApplication + }, + FixedState = + { + Sources = { fixedSources, usageSource } + } + }; + + test.TestState.ExpectedDiagnostics.AddRange(expected); + return test.RunAsync(); + } + + public class DelegateEndpointAnalyzerTest : CSharpCodeFixTest<TAnalyzer, TCodeFix, XUnitVerifier> + { + public DelegateEndpointAnalyzerTest() + { + // We populate the ReferenceAssemblies used in the tests with the locally-built AspNetCore + // assemblies that are referenced in a minimal app to ensure that there are no reference + // errors during the build. + ReferenceAssemblies = ReferenceAssemblies.Net.Net60.AddAssemblies(ImmutableArray.Create( + TrimAssemblyExtension(typeof(Microsoft.AspNetCore.Builder.WebApplication).Assembly.Location), + TrimAssemblyExtension(typeof(Microsoft.AspNetCore.Builder.DelegateEndpointRouteBuilderExtensions).Assembly.Location), + TrimAssemblyExtension(typeof(Microsoft.AspNetCore.Builder.IApplicationBuilder).Assembly.Location), + TrimAssemblyExtension(typeof(Microsoft.AspNetCore.Builder.IEndpointConventionBuilder).Assembly.Location), + TrimAssemblyExtension(typeof(Microsoft.Extensions.Hosting.IHost).Assembly.Location), + TrimAssemblyExtension(typeof(Microsoft.AspNetCore.Mvc.ModelBinding.IBinderTypeProviderMetadata).Assembly.Location), + TrimAssemblyExtension(typeof(Microsoft.AspNetCore.Mvc.BindAttribute).Assembly.Location))); + + string TrimAssemblyExtension(string fullPath) => fullPath.Replace(".dll", string.Empty); + } + } +} \ No newline at end of file diff --git a/src/Framework/Analyzer/test/xunit.runner.json b/src/Framework/AspNetCoreAnalyzers/test/xunit.runner.json similarity index 100% rename from src/Framework/Analyzer/test/xunit.runner.json rename to src/Framework/AspNetCoreAnalyzers/test/xunit.runner.json diff --git a/src/Framework/Framework.slnf b/src/Framework/Framework.slnf index 94e9163086f8dd2ac2962bdc004466eb35d65187..bce83882a62233cbf21a0cf7308f2cea8484b977 100644 --- a/src/Framework/Framework.slnf +++ b/src/Framework/Framework.slnf @@ -19,7 +19,8 @@ "src\\Extensions\\Features\\src\\Microsoft.Extensions.Features.csproj", "src\\FileProviders\\Embedded\\src\\Microsoft.Extensions.FileProviders.Embedded.csproj", "src\\FileProviders\\Manifest.MSBuildTask\\src\\Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.csproj", - "src\\Framework\\Analyzer\\src\\Microsoft.AspNetCore.App.Analyzers.csproj", + "src\\Framework\\AspNetCoreAnalyzers\\src\\Analyzers\\Microsoft.AspNetCore.App.Analyzers.csproj", + "src\\Framework\\AspNetCoreAnalyzers\\src\\CodeFixes\\Microsoft.AspNetCore.App.CodeFixes.csproj", "src\\Framework\\Analyzer\\test\\Microsoft.AspNetCore.App.Analyzers.Test.csproj", "src\\Framework\\App.Ref\\src\\Microsoft.AspNetCore.App.Ref.csproj", "src\\Framework\\App.Runtime\\src\\Microsoft.AspNetCore.App.Runtime.csproj", diff --git a/src/Shared/Roslyn/CodeAnalysisExtensions.cs b/src/Shared/Roslyn/CodeAnalysisExtensions.cs index 4239bfa15732941db7d6d03c1de367f80370566d..5d22c7753abb22f1464908673ff44dfd73e97ead 100644 --- a/src/Shared/Roslyn/CodeAnalysisExtensions.cs +++ b/src/Shared/Roslyn/CodeAnalysisExtensions.cs @@ -148,5 +148,26 @@ namespace Microsoft.CodeAnalysis typeSymbol = typeSymbol.BaseType; } } + + // Adapted from https://github.com/dotnet/roslyn/blob/929272/src/Workspaces/Core/Portable/Shared/Extensions/IMethodSymbolExtensions.cs#L61 + public static IEnumerable<IMethodSymbol> GetAllMethodSymbolsOfPartialParts(this IMethodSymbol method) + { + if (method.PartialDefinitionPart != null) + { + Debug.Assert(method.PartialImplementationPart == null && !SymbolEqualityComparer.Default.Equals(method.PartialDefinitionPart, method)); + yield return method; + yield return method.PartialDefinitionPart; + } + else if (method.PartialImplementationPart != null) + { + Debug.Assert(!SymbolEqualityComparer.Default.Equals(method.PartialImplementationPart, method)); + yield return method.PartialImplementationPart; + yield return method; + } + else + { + yield return method; + } + } } }