Skip to content

Commit 58aea48

Browse files
authored
Add analyzer for detecting mismatched endpoint parameter optionality
1 parent 1c4b1d9 commit 58aea48

11 files changed

+752
-1
lines changed

eng/Dependencies.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ and are generated based on the last package release.
5656
<LatestPackageReference Include="Microsoft.Extensions.Options" />
5757
<LatestPackageReference Include="Microsoft.Extensions.Primitives" />
5858
<LatestPackageReference Include="Microsoft.Win32.Registry" />
59+
<LatestPackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing.XUnit" />
5960
<LatestPackageReference Include="System.Buffers" />
6061
<LatestPackageReference Include="System.CodeDom" />
6162
<LatestPackageReference Include="System.CommandLine.Experimental" />

eng/Versions.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
<MicrosoftExtensionsPrimitivesVersion>6.0.0-rc.2.21452.13</MicrosoftExtensionsPrimitivesVersion>
109109
<MicrosoftWin32SystemEventsVersion>6.0.0-rc.2.21452.13</MicrosoftWin32SystemEventsVersion>
110110
<MicrosoftInternalRuntimeAspNetCoreTransportVersion>6.0.0-rc.2.21452.13</MicrosoftInternalRuntimeAspNetCoreTransportVersion>
111+
<MicrosoftCodeAnalysisCSharpCodeFixTestingXUnitVersion>1.1.1-beta1.21413.1</MicrosoftCodeAnalysisCSharpCodeFixTestingXUnitVersion>
111112
<SystemDiagnosticsDiagnosticSourceVersion>6.0.0-rc.2.21452.13</SystemDiagnosticsDiagnosticSourceVersion>
112113
<SystemDiagnosticsEventLogVersion>6.0.0-rc.2.21452.13</SystemDiagnosticsEventLogVersion>
113114
<SystemDirectoryServicesProtocolsVersion>6.0.0-rc.2.21452.13</SystemDirectoryServicesProtocolsVersion>

src/Framework/Analyzer/src/DelegateEndpoints/DelegateEndpointAnalyzer.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ public partial class DelegateEndpointAnalyzer : DiagnosticAnalyzer
1818
{
1919
DiagnosticDescriptors.DoNotUseModelBindingAttributesOnDelegateEndpointParameters,
2020
DiagnosticDescriptors.DoNotReturnActionResultsFromMapActions,
21-
DiagnosticDescriptors.DetectMisplacedLambdaAttribute
21+
DiagnosticDescriptors.DetectMisplacedLambdaAttribute,
22+
DiagnosticDescriptors.DetectMismatchedParameterOptionality
2223
});
2324

2425
public override void Initialize(AnalysisContext context)
@@ -56,11 +57,13 @@ public override void Initialize(AnalysisContext context)
5657
DisallowMvcBindArgumentsOnParameters(in operationAnalysisContext, wellKnownTypes, invocation, lambda.Symbol);
5758
DisallowReturningActionResultFromMapMethods(in operationAnalysisContext, wellKnownTypes, invocation, lambda);
5859
DetectMisplacedLambdaAttribute(operationAnalysisContext, invocation, lambda);
60+
DetectMismatchedParameterOptionality(in operationAnalysisContext, invocation, lambda.Symbol);
5961
}
6062
else if (delegateCreation.Target.Kind == OperationKind.MethodReference)
6163
{
6264
var methodReference = (IMethodReferenceOperation)delegateCreation.Target;
6365
DisallowMvcBindArgumentsOnParameters(in operationAnalysisContext, wellKnownTypes, invocation, methodReference.Method);
66+
DetectMismatchedParameterOptionality(in operationAnalysisContext, invocation, methodReference.Method);
6467

6568
var foundMethodReferenceBody = false;
6669
if (!methodReference.Method.DeclaringSyntaxReferences.IsEmpty)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Linq;
6+
using System.Threading;
7+
using System.Collections.Immutable;
8+
using System.Threading.Tasks;
9+
using System.Collections.Generic;
10+
using Microsoft.CodeAnalysis;
11+
using Microsoft.CodeAnalysis.CSharp.Syntax;
12+
using Microsoft.CodeAnalysis.CodeFixes;
13+
using Microsoft.CodeAnalysis.CodeActions;
14+
15+
namespace Microsoft.AspNetCore.Analyzers.DelegateEndpoints;
16+
17+
public partial class DelegateEndpointFixer : CodeFixProvider
18+
{
19+
public override ImmutableArray<string> FixableDiagnosticIds { get; } = ImmutableArray.Create(DiagnosticDescriptors.DetectMismatchedParameterOptionality.Id);
20+
21+
public sealed override FixAllProvider GetFixAllProvider()
22+
{
23+
return WellKnownFixAllProviders.BatchFixer;
24+
}
25+
26+
public sealed override Task RegisterCodeFixesAsync(CodeFixContext context)
27+
{
28+
foreach (var diagnostic in context.Diagnostics)
29+
{
30+
switch (diagnostic.Id)
31+
{
32+
case DelegateEndpointAnalyzer.DetectMismatchedParameterOptionalityRuleId:
33+
context.RegisterCodeFix(
34+
CodeAction.Create("Fix mismatched parameter optionality", cancellationToken => FixMismatchedParameterOptionality(context, cancellationToken), equivalenceKey: DiagnosticDescriptors.DetectMismatchedParameterOptionality.Id),
35+
diagnostic);
36+
break;
37+
default:
38+
break;
39+
}
40+
}
41+
42+
return Task.CompletedTask;
43+
}
44+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Linq;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using System.Collections.Generic;
9+
using Microsoft.CodeAnalysis.CSharp;
10+
using Microsoft.CodeAnalysis;
11+
using Microsoft.CodeAnalysis.CSharp.Syntax;
12+
using Microsoft.CodeAnalysis.CodeFixes;
13+
using Microsoft.CodeAnalysis.Operations;
14+
using Microsoft.CodeAnalysis.Editing;
15+
16+
namespace Microsoft.AspNetCore.Analyzers.DelegateEndpoints;
17+
18+
public partial class DelegateEndpointFixer : CodeFixProvider
19+
{
20+
private static async Task<Document> FixMismatchedParameterOptionality(CodeFixContext context, CancellationToken cancellationToken)
21+
{
22+
DocumentEditor editor = await DocumentEditor.CreateAsync(context.Document, cancellationToken);
23+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
24+
foreach (var diagnostic in context.Diagnostics)
25+
{
26+
var param = root.FindNode(diagnostic.Location.SourceSpan);
27+
if (param != null && param is ParameterSyntax parameterSyntax)
28+
{
29+
if (parameterSyntax.Type != null)
30+
{
31+
var newParam = parameterSyntax.WithType(SyntaxFactory.NullableType(parameterSyntax.Type));
32+
editor.ReplaceNode(parameterSyntax, newParam);
33+
}
34+
}
35+
}
36+
return editor.GetChangedDocument();
37+
}
38+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Linq;
6+
using System.Collections.Generic;
7+
using Microsoft.CodeAnalysis;
8+
using Microsoft.CodeAnalysis.CSharp.Syntax;
9+
using Microsoft.CodeAnalysis.Diagnostics;
10+
using Microsoft.CodeAnalysis.Operations;
11+
12+
namespace Microsoft.AspNetCore.Analyzers.DelegateEndpoints;
13+
14+
public partial class DelegateEndpointAnalyzer : DiagnosticAnalyzer
15+
{
16+
internal const string DetectMismatchedParameterOptionalityRuleId = "ASP0006";
17+
18+
private static void DetectMismatchedParameterOptionality(
19+
in OperationAnalysisContext context,
20+
IInvocationOperation invocation,
21+
IMethodSymbol methodSymbol)
22+
{
23+
var value = invocation.Arguments[1].Value;
24+
if (value.ConstantValue is not { HasValue: true } constant ||
25+
constant.Value is not string routeTemplate)
26+
{
27+
return;
28+
}
29+
30+
var parametersInArguments = methodSymbol.Parameters;
31+
var parametersInRoute = GetParametersFromRoute(routeTemplate);
32+
33+
foreach (var parameter in parametersInArguments)
34+
{
35+
var isOptional = parameter.IsOptional || parameter.NullableAnnotation != NullableAnnotation.NotAnnotated;
36+
var location = parameter.DeclaringSyntaxReferences.SingleOrDefault()?.GetSyntax().GetLocation();
37+
var paramName = parameter.Name;
38+
var parameterFound = parametersInRoute.TryGetValue(paramName, out var routeParam);
39+
40+
if (!isOptional && parameterFound && routeParam.IsOptional)
41+
{
42+
context.ReportDiagnostic(Diagnostic.Create(
43+
DiagnosticDescriptors.DetectMismatchedParameterOptionality,
44+
location,
45+
paramName));
46+
}
47+
}
48+
}
49+
50+
private static IDictionary<string, RouteParameter> GetParametersFromRoute(string routeTemplate)
51+
{
52+
var enumerator = new RouteTokenEnumerator(routeTemplate);
53+
Dictionary<string, RouteParameter> result = new(StringComparer.OrdinalIgnoreCase);
54+
while (enumerator.MoveNext())
55+
{
56+
var isOptional = enumerator.CurrentQualifiers.IndexOf('?') > -1;
57+
result.Add(
58+
enumerator.CurrentName.ToString(),
59+
new RouteParameter(enumerator.CurrentName.ToString(), isOptional));
60+
}
61+
return result;
62+
}
63+
64+
internal ref struct RouteTokenEnumerator
65+
{
66+
private ReadOnlySpan<char> _routeTemplate;
67+
68+
public RouteTokenEnumerator(string routeTemplateString)
69+
{
70+
_routeTemplate = routeTemplateString.AsSpan();
71+
CurrentName = default;
72+
CurrentQualifiers = default;
73+
}
74+
75+
public ReadOnlySpan<char> CurrentName { get; private set; }
76+
public ReadOnlySpan<char> CurrentQualifiers { get; private set; }
77+
78+
public bool MoveNext()
79+
{
80+
if (_routeTemplate.IsEmpty)
81+
{
82+
return false;
83+
}
84+
85+
findStartBrace:
86+
var startIndex = _routeTemplate.IndexOf('{');
87+
if (startIndex == -1)
88+
{
89+
return false;
90+
}
91+
92+
if (startIndex < _routeTemplate.Length - 1 && _routeTemplate[startIndex + 1] == '{')
93+
{
94+
// Escaped sequence
95+
_routeTemplate = _routeTemplate.Slice(startIndex + 1);
96+
goto findStartBrace;
97+
}
98+
99+
var tokenStart = startIndex + 1;
100+
101+
findEndBrace:
102+
var endIndex = IndexOf(_routeTemplate, tokenStart, '}');
103+
if (endIndex == -1)
104+
{
105+
return false;
106+
}
107+
if (endIndex < _routeTemplate.Length - 1 && _routeTemplate[endIndex + 1] == '}')
108+
{
109+
tokenStart = endIndex + 2;
110+
goto findEndBrace;
111+
}
112+
113+
var token = _routeTemplate.Slice(startIndex + 1, endIndex - startIndex - 1);
114+
var qualifier = token.IndexOfAny(new[] { ':', '=', '?' });
115+
CurrentName = qualifier == -1 ? token : token.Slice(0, qualifier);
116+
CurrentQualifiers = qualifier == -1 ? null : token.Slice(qualifier);
117+
118+
_routeTemplate = _routeTemplate.Slice(endIndex + 1);
119+
return true;
120+
}
121+
}
122+
123+
private static int IndexOf(ReadOnlySpan<char> span, int startIndex, char c)
124+
{
125+
for (var i = startIndex; i < span.Length; i++)
126+
{
127+
if (span[i] == c)
128+
{
129+
return i;
130+
}
131+
}
132+
133+
return -1;
134+
}
135+
136+
internal record RouteParameter(string Name, bool IsOptional);
137+
}

src/Framework/Analyzer/src/DelegateEndpoints/DiagnosticDescriptors.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,14 @@ internal static class DiagnosticDescriptors
3434
DiagnosticSeverity.Warning,
3535
isEnabledByDefault: true,
3636
helpLinkUri: "https://aka.ms/aspnet/analyzers");
37+
38+
internal static readonly DiagnosticDescriptor DetectMismatchedParameterOptionality = new(
39+
"ASP0006",
40+
"Parameter optionality is mismatched",
41+
"'{0}' argument should be annotated as optional to match route parameter",
42+
"Usage",
43+
DiagnosticSeverity.Warning,
44+
isEnabledByDefault: true,
45+
helpLinkUri: "https://aka.ms/aspnet/analyzers");
3746
}
3847
}

src/Framework/Analyzer/test/Microsoft.AspNetCore.App.Analyzers.Test.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<Reference Include="Microsoft.AspNetCore" />
1717
<Reference Include="Microsoft.AspNetCore.Mvc" />
1818
<Reference Include="Microsoft.AspNetCore.Http.Results" />
19+
<Reference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing.XUnit" />
1920
</ItemGroup>
2021

2122
</Project>

0 commit comments

Comments
 (0)