Skip to content

Commit 93997b7

Browse files
Add support for comment syntax to route detector (dotnet#45185)
Co-authored-by: Safia Abdalla <[email protected]>
1 parent a3a37ce commit 93997b7

File tree

12 files changed

+383
-25
lines changed

12 files changed

+383
-25
lines changed

src/Framework/AspNetCoreAnalyzers/samples/WebAppSample/Pages/Index.cshtml.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
14
using Microsoft.AspNetCore.Mvc;
25
using Microsoft.AspNetCore.Mvc.RazorPages;
36

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Collections.Generic;
5+
using System.Collections.Immutable;
6+
using System.Diagnostics.CodeAnalysis;
7+
using System.Linq;
8+
using System.Text.RegularExpressions;
9+
10+
namespace Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage.Infrastructure;
11+
12+
// Copied from https://github.com/dotnet/roslyn/blob/9fee6f5461baae5152c956c3c3024ca15b85feb9/src/Features/Core/Portable/EmbeddedLanguages/EmbeddedLanguageCommentDetector.cs
13+
14+
/// <summary>
15+
/// Helps match patterns of the form: language=name,option1,option2,option3
16+
/// <para/>
17+
/// All matching is case insensitive, with spaces allowed between the punctuation. Option values are
18+
/// returned as strings.
19+
/// <para/>
20+
/// Option names are the values from the TOptions enum.
21+
/// </summary>
22+
internal readonly struct EmbeddedLanguageCommentDetector
23+
{
24+
private readonly Regex _regex;
25+
26+
public EmbeddedLanguageCommentDetector(ImmutableArray<string> identifiers)
27+
{
28+
var namePortion = string.Join("|", identifiers.Select(n => $"({Regex.Escape(n)})"));
29+
_regex = new Regex($@"^((//)|(')|(/\*))\s*lang(uage)?\s*=\s*(?<identifier>{namePortion})\b((\s*,\s*)(?<option>[a-zA-Z]+))*",
30+
RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase | RegexOptions.Compiled);
31+
}
32+
33+
public bool TryMatch(
34+
string text,
35+
[NotNullWhen(true)] out string? identifier,
36+
[NotNullWhen(true)] out IEnumerable<string>? options)
37+
{
38+
var match = _regex.Match(text);
39+
identifier = null;
40+
options = null;
41+
if (!match.Success)
42+
{
43+
return false;
44+
}
45+
46+
identifier = match.Groups["identifier"].Value;
47+
var optionGroup = match.Groups["option"];
48+
options = optionGroup.Captures.OfType<Capture>().Select(c => c.Value);
49+
return true;
50+
}
51+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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.Collections.Generic;
6+
using System.Linq;
7+
using System.Reflection;
8+
9+
namespace Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage.Infrastructure;
10+
11+
// Copied from https://github.com/dotnet/roslyn/blob/9fee6f5461baae5152c956c3c3024ca15b85feb9/src/Features/Core/Portable/EmbeddedLanguages/EmbeddedLanguageCommentOptions.cs
12+
13+
/// <summary>
14+
/// Helps match patterns of the form: language=name,option1,option2,option3
15+
/// <para/>
16+
/// All matching is case insensitive, with spaces allowed between the punctuation. Option values will be or'ed
17+
/// together to produce final options value. If an unknown option is encountered, processing will stop with
18+
/// whatever value has accumulated so far.
19+
/// <para/>
20+
/// Option names are the values from the TOptions enum.
21+
/// </summary>
22+
internal static class EmbeddedLanguageCommentOptions<TOptions> where TOptions : struct, Enum
23+
{
24+
private static readonly Dictionary<string, TOptions> s_nameToOption =
25+
typeof(TOptions).GetTypeInfo().DeclaredFields
26+
.Where(f => f.FieldType == typeof(TOptions))
27+
.ToDictionary(f => f.Name, f => (TOptions)f.GetValue(null)!, StringComparer.OrdinalIgnoreCase);
28+
29+
public static bool TryGetOptions(IEnumerable<string> captures, out TOptions options)
30+
{
31+
options = default;
32+
33+
foreach (var capture in captures)
34+
{
35+
if (!s_nameToOption.TryGetValue(capture, out var specificOption))
36+
{
37+
// hit something we don't understand. bail out. that will help ensure
38+
// users don't have weird behavior just because they misspelled something.
39+
// instead, they will know they need to fix it up.
40+
return false;
41+
}
42+
43+
options = CombineOptions(options, specificOption);
44+
}
45+
46+
return true;
47+
}
48+
49+
private static TOptions CombineOptions(TOptions options, TOptions specificOption)
50+
{
51+
var int1 = (int)(object)options;
52+
var int2 = (int)(object)specificOption;
53+
return (TOptions)(object)(int1 | int2);
54+
}
55+
}

src/Framework/AspNetCoreAnalyzers/src/Analyzers/RouteEmbeddedLanguage/Infrastructure/RouteStringSyntaxDetector.cs

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Collections.Generic;
45
using System.Collections.Immutable;
56
using System.Diagnostics.CodeAnalysis;
67
using System.Linq;
@@ -13,14 +14,18 @@ namespace Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage.Infrastructure;
1314

1415
internal static class RouteStringSyntaxDetector
1516
{
16-
public static bool IsRouteStringSyntaxToken(SyntaxToken token, SemanticModel semanticModel, CancellationToken cancellationToken)
17+
private static readonly EmbeddedLanguageCommentDetector _commentDetector = new(ImmutableArray.Create("Route"));
18+
19+
public static bool IsRouteStringSyntaxToken(SyntaxToken token, SemanticModel semanticModel, CancellationToken cancellationToken, out RouteOptions options)
1720
{
21+
options = default;
22+
1823
if (!IsAnyStringLiteral(token.RawKind))
1924
{
2025
return false;
2126
}
2227

23-
if (!TryGetStringFormat(token, semanticModel, cancellationToken, out var identifier))
28+
if (!TryGetStringFormat(token, semanticModel, cancellationToken, out var identifier, out var stringOptions))
2429
{
2530
return false;
2631
}
@@ -30,6 +35,11 @@ public static bool IsRouteStringSyntaxToken(SyntaxToken token, SemanticModel sem
3035
return false;
3136
}
3237

38+
if (stringOptions != null)
39+
{
40+
return EmbeddedLanguageCommentOptions<RouteOptions>.TryGetOptions(stringOptions, out options);
41+
}
42+
3343
return true;
3444
}
3545

@@ -43,14 +53,26 @@ private static bool IsAnyStringLiteral(int rawKind)
4353
rawKind == (int)SyntaxKind.Utf8MultiLineRawStringLiteralToken;
4454
}
4555

46-
private static bool TryGetStringFormat(SyntaxToken token, SemanticModel semanticModel, CancellationToken cancellationToken, [NotNullWhen(true)] out string identifier)
56+
private static bool TryGetStringFormat(
57+
SyntaxToken token,
58+
SemanticModel semanticModel,
59+
CancellationToken cancellationToken,
60+
[NotNullWhen(true)] out string identifier,
61+
[NotNullWhen(true)] out IEnumerable<string>? options)
4762
{
63+
options = null;
64+
4865
if (token.Parent is not LiteralExpressionSyntax)
4966
{
5067
identifier = null;
5168
return false;
5269
}
5370

71+
if (HasLanguageComment(token, out identifier, out options))
72+
{
73+
return true;
74+
}
75+
5476
var container = token.TryFindContainer();
5577
if (container is null)
5678
{
@@ -117,6 +139,84 @@ private static bool TryGetStringFormat(SyntaxToken token, SemanticModel semantic
117139
return false;
118140
}
119141

142+
private static bool HasLanguageComment(
143+
SyntaxToken token,
144+
[NotNullWhen(true)] out string? identifier,
145+
[NotNullWhen(true)] out IEnumerable<string>? options)
146+
{
147+
if (HasLanguageComment(token.GetPreviousToken().TrailingTrivia, out identifier, out options))
148+
{
149+
return true;
150+
}
151+
152+
for (var node = token.Parent; node != null; node = node.Parent)
153+
{
154+
if (HasLanguageComment(node.GetLeadingTrivia(), out identifier, out options))
155+
{
156+
return true;
157+
}
158+
// Stop walking up once we hit a statement. We don't need/want statements higher up the parent chain to
159+
// have any impact on this token.
160+
if (IsStatement(node))
161+
{
162+
break;
163+
}
164+
}
165+
166+
return false;
167+
}
168+
169+
private static bool HasLanguageComment(
170+
SyntaxTriviaList list,
171+
[NotNullWhen(true)] out string? identifier,
172+
[NotNullWhen(true)] out IEnumerable<string>? options)
173+
{
174+
foreach (var trivia in list)
175+
{
176+
if (HasLanguageComment(trivia, out identifier, out options))
177+
{
178+
return true;
179+
}
180+
}
181+
182+
identifier = null;
183+
options = null;
184+
return false;
185+
}
186+
187+
private static bool HasLanguageComment(
188+
SyntaxTrivia trivia,
189+
[NotNullWhen(true)] out string? identifier,
190+
[NotNullWhen(true)] out IEnumerable<string>? options)
191+
{
192+
if (IsRegularComment(trivia))
193+
{
194+
// Note: ToString on SyntaxTrivia is non-allocating. It will just return the
195+
// underlying text that the trivia is already pointing to.
196+
var text = trivia.ToString();
197+
if (_commentDetector.TryMatch(text, out identifier, out options))
198+
{
199+
return true;
200+
}
201+
}
202+
203+
identifier = null;
204+
options = null;
205+
return false;
206+
}
207+
208+
public static bool IsStatement([NotNullWhen(true)] SyntaxNode? node)
209+
=> node is StatementSyntax;
210+
211+
public static bool IsRegularComment(this SyntaxTrivia trivia)
212+
=> trivia.IsSingleOrMultiLineComment() || trivia.IsShebangDirective();
213+
214+
public static bool IsSingleOrMultiLineComment(this SyntaxTrivia trivia)
215+
=> trivia.IsKind(SyntaxKind.MultiLineCommentTrivia) || trivia.IsKind(SyntaxKind.SingleLineCommentTrivia);
216+
217+
public static bool IsShebangDirective(this SyntaxTrivia trivia)
218+
=> trivia.IsKind(SyntaxKind.ShebangDirectiveTrivia);
219+
120220
public static bool IsEqualsValueOfPropertyDeclaration(SyntaxNode? node)
121221
=> node?.Parent is PropertyDeclarationSyntax propertyDeclaration && propertyDeclaration.Initializer == node;
122222

src/Framework/AspNetCoreAnalyzers/src/Analyzers/RouteEmbeddedLanguage/Infrastructure/RouteStringSyntaxDetectorDocument.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ namespace Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage.Infrastructure;
1313
/// </summary>
1414
internal static class RouteStringSyntaxDetectorDocument
1515
{
16-
internal static async ValueTask<(bool success, SyntaxToken token, SemanticModel? model)> TryGetStringSyntaxTokenAtPositionAsync(
16+
internal static async ValueTask<(bool success, SyntaxToken token, SemanticModel? model, RouteOptions options)> TryGetStringSyntaxTokenAtPositionAsync(
1717
Document document, int position, CancellationToken cancellationToken)
1818
{
1919
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
@@ -29,11 +29,11 @@ internal static class RouteStringSyntaxDetectorDocument
2929
return default;
3030
}
3131

32-
if (!RouteStringSyntaxDetector.IsRouteStringSyntaxToken(token, semanticModel, cancellationToken))
32+
if (!RouteStringSyntaxDetector.IsRouteStringSyntaxToken(token, semanticModel, cancellationToken, out var options))
3333
{
3434
return default;
3535
}
3636

37-
return (true, token, semanticModel);
37+
return (true, token, semanticModel, options);
3838
}
3939
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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+
6+
namespace Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage;
7+
8+
[Flags]
9+
internal enum RouteOptions
10+
{
11+
/// <summary>
12+
/// HTTP route. Used to match endpoints for Minimal API, MVC, SignalR, gRPC, etc.
13+
/// </summary>
14+
Http = 0,
15+
/// <summary>
16+
/// Component route. Used to match Razor components for Blazor.
17+
/// </summary>
18+
Component = 1,
19+
}

src/Framework/AspNetCoreAnalyzers/src/Analyzers/RouteEmbeddedLanguage/RoutePatternAnalyzer.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ private void Analyze(
5252
else
5353
{
5454
var token = child.AsToken();
55-
if (!RouteStringSyntaxDetector.IsRouteStringSyntaxToken(token, context.SemanticModel, cancellationToken))
55+
if (!RouteStringSyntaxDetector.IsRouteStringSyntaxToken(token, context.SemanticModel, cancellationToken, out var _))
5656
{
5757
continue;
5858
}
@@ -175,7 +175,7 @@ private record struct InsertPoint(ISymbol ExistingParameter, bool Before);
175175

176176
public override void Initialize(AnalysisContext context)
177177
{
178-
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
178+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.ReportDiagnostics);
179179
context.EnableConcurrentExecution();
180180

181181
context.RegisterSemanticModelAction(Analyze);

0 commit comments

Comments
 (0)