Skip to content

Commit a73f80f

Browse files
committed
improvement(analyzer): enhance handling of public members in nested classes
Add new test cases to validate proper handling of public members within nested internal and public classes. Refactor `AT0001_PublicMemberShouldBeAnnotatedWithPublicAPIAnalyzer` to improve performance and readability by caching configuration for valid attributes. Streamline logic for effective public access checks and recursive attribute validation. Update `_usings.cs` to include `System.Collections.Concurrent` for the newly introduced caching mechanism.
1 parent 66b9675 commit a73f80f

3 files changed

Lines changed: 123 additions & 94 deletions

File tree

DecSm.Analyzers.PublicApiSurface.Tests/AT0001_PublicMemberShouldBeAnnotatedWithPublicAPIAnalyzerTests.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,43 @@ public static class MyClass
237237
await Verifier.VerifyAnalyzerAsync(text, Configure, expected);
238238
}
239239

240+
[Fact]
241+
public async Task PublicMemberInNestedInternalClass_NoDiagnostic()
242+
{
243+
const string text = """
244+
public class Outer
245+
{
246+
internal class Inner
247+
{
248+
public void MyMethod() { }
249+
}
250+
}
251+
""";
252+
253+
var expected = Verifier
254+
.Diagnostic()
255+
.WithSpan(1, 14, 1, 19)
256+
.WithArguments("Outer");
257+
258+
await Verifier.VerifyAnalyzerAsync(text, Configure, expected);
259+
}
260+
261+
[Fact]
262+
public async Task PublicMemberInPublicClassInInternalClass_NoDiagnostic()
263+
{
264+
const string text = """
265+
internal class Outer
266+
{
267+
public class Inner
268+
{
269+
public void MyMethod() { }
270+
}
271+
}
272+
""";
273+
274+
await Verifier.VerifyAnalyzerAsync(text, Configure);
275+
}
276+
240277
[Fact]
241278
public async Task CustomAttribute_WhenConfigured_NoDiagnostic()
242279
{

DecSm.Analyzers.PublicApiSurface/AT0001_PublicMemberShouldBeAnnotatedWithPublicAPIAnalyzer.cs

Lines changed: 85 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -37,132 +37,124 @@ public class DSPA0001_PublicMemberShouldBeAnnotatedWithPublicAPIAnalyzer : Diagn
3737

3838
public override void Initialize(AnalysisContext context)
3939
{
40-
// You must call this method to avoid analyzing generated code.
4140
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
42-
43-
// You must call this method to enable the Concurrent Execution.
4441
context.EnableConcurrentExecution();
4542

46-
// Register for symbol actions
47-
context.RegisterSymbolAction(AnalyzeSymbol,
48-
SymbolKind.NamedType,
49-
SymbolKind.Method,
50-
SymbolKind.Property,
51-
SymbolKind.Field,
52-
SymbolKind.Event);
43+
context.RegisterCompilationStartAction(compilationContext =>
44+
{
45+
var configCache = new ConcurrentDictionary<string, ImmutableHashSet<string>>(StringComparer.Ordinal);
46+
compilationContext.RegisterSymbolAction(symbolContext => AnalyzeSymbol(symbolContext, configCache),
47+
SymbolKind.NamedType,
48+
SymbolKind.Method,
49+
SymbolKind.Property,
50+
SymbolKind.Field,
51+
SymbolKind.Event);
52+
});
5353
}
5454

55-
private static void AnalyzeSymbol(SymbolAnalysisContext context)
55+
private static void AnalyzeSymbol(SymbolAnalysisContext context, ConcurrentDictionary<string, ImmutableHashSet<string>> configCache)
5656
{
5757
var symbol = context.Symbol;
5858

59-
// Only check public symbols
60-
if (symbol.DeclaredAccessibility != Accessibility.Public)
61-
return;
62-
63-
// If public, check if containing type is also public
64-
if (symbol.ContainingType?.DeclaredAccessibility is not Accessibility.Public and not null)
59+
if (symbol.IsImplicitlyDeclared)
6560
return;
6661

67-
var optionsProvider = context.Options.AnalyzerConfigOptionsProvider;
68-
69-
optionsProvider.GlobalOptions.TryGetValue("dotnet_code_quality.DecSm_Analyzers_ValidPublicApiAttributes",
70-
out var values);
62+
if (symbol is IMethodSymbol methodSymbol)
63+
{
64+
if (methodSymbol.MethodKind is MethodKind.Constructor or MethodKind.StaticConstructor or
65+
MethodKind.PropertyGet or MethodKind.PropertySet or
66+
MethodKind.EventAdd or MethodKind.EventRemove or MethodKind.EventRaise)
67+
return;
7168

72-
var options = GeneratorOptions.Parse(values);
69+
if (methodSymbol.IsOverride)
70+
return;
71+
}
7372

74-
var tree = symbol.Locations.FirstOrDefault()
75-
?.SourceTree;
73+
if (!IsEffectivelyPublic(symbol))
74+
return;
7675

77-
if (tree != null &&
78-
optionsProvider
79-
.GetOptions(tree)
80-
.TryGetValue("dotnet_code_quality.DecSm_Analyzers_ValidPublicApiAttributes", out var fileValues))
81-
options = GeneratorOptions.Parse(fileValues);
76+
var validAttributes = GetValidAttributes(context, configCache);
8277

83-
var validAttributes = options.ValidAttributes.ToImmutableHashSet();
78+
if (HasValidAttributeRecursive(symbol, validAttributes))
79+
return;
8480

85-
// Skip if the symbol itself has [PublicAPI]
86-
if (HasValidAttribute(symbol, validAttributes))
81+
if (symbol.Name == "" || validAttributes.Contains(symbol.Name))
8782
return;
8883

89-
// Skip if the containing type has [PublicAPI]
90-
var containingType = symbol.ContainingType;
84+
context.ReportDiagnostic(Diagnostic.Create(Rule, symbol.Locations[0], symbol.Name));
85+
}
9186

92-
while (containingType != null)
87+
private static bool IsEffectivelyPublic(ISymbol symbol)
88+
{
89+
var current = symbol;
90+
while (current != null)
9391
{
94-
if (HasValidAttribute(containingType, validAttributes))
95-
return;
92+
if (current.DeclaredAccessibility != Accessibility.Public)
93+
return false;
9694

97-
containingType = containingType.ContainingType;
95+
current = current.ContainingType;
9896
}
9997

100-
// Special case: Implicitly declared symbols (like default constructors)
101-
if (symbol.IsImplicitlyDeclared)
102-
return;
103-
104-
// Special case: .ctor methods
105-
if (symbol is { Kind: SymbolKind.Method, Name: ".ctor" })
106-
return;
98+
return true;
99+
}
107100

108-
// Special case: Nameless symbols
109-
// Also don't flag the Public API attributes themselves
110-
if (symbol.Name == "" ||
111-
IsValidAttributeName(symbol.Name, validAttributes) ||
112-
symbol.Name == "PublicAPI" ||
113-
symbol.Name == "PublicAPIAttribute")
114-
return;
101+
private static ImmutableHashSet<string> GetValidAttributes(SymbolAnalysisContext context, ConcurrentDictionary<string, ImmutableHashSet<string>> cache)
102+
{
103+
var optionsProvider = context.Options.AnalyzerConfigOptionsProvider;
104+
var tree = context.Symbol.Locations.Length > 0 ? context.Symbol.Locations[0].SourceTree : null;
115105

116-
if (symbol is IMethodSymbol methodSymbol)
106+
if (tree != null && optionsProvider.GetOptions(tree).TryGetValue("dotnet_code_quality.DecSm_Analyzers_ValidPublicApiAttributes", out var treeValues))
117107
{
118-
// Skip property/event accessors, as we check the property/event itself
119-
if (methodSymbol.MethodKind is MethodKind.PropertyGet
120-
or MethodKind.PropertySet
121-
or MethodKind.EventAdd
122-
or MethodKind.EventRemove
123-
or MethodKind.EventRaise)
124-
return;
125-
126-
if (methodSymbol.IsOverride)
127-
return;
108+
return ParseAndCache(treeValues, cache);
128109
}
129110

130-
context.ReportDiagnostic(Diagnostic.Create(Rule, symbol.Locations[0], symbol.Name));
111+
optionsProvider.GlobalOptions.TryGetValue("dotnet_code_quality.DecSm_Analyzers_ValidPublicApiAttributes", out var globalValues);
112+
return ParseAndCache(globalValues ?? string.Empty, cache);
131113
}
132114

133-
private static bool IsValidAttributeName(string name, ImmutableHashSet<string> attributeNames)
115+
private static ImmutableHashSet<string> ParseAndCache(string values, ConcurrentDictionary<string, ImmutableHashSet<string>> cache)
134116
{
135-
if (attributeNames.Contains(name))
136-
return true;
137-
138-
return name.EndsWith("Attribute", StringComparison.Ordinal) &&
139-
name.Length > 9 &&
140-
attributeNames.Contains(name.Substring(0, name.Length - 9));
117+
return cache.GetOrAdd(values, v =>
118+
{
119+
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.Ordinal);
120+
builder.Add("PublicAPI");
121+
builder.Add("PublicAPIAttribute");
122+
123+
if (!string.IsNullOrWhiteSpace(v))
124+
{
125+
var parts = v.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
126+
foreach (var part in parts)
127+
{
128+
var trimmed = part.Trim();
129+
if (string.IsNullOrWhiteSpace(trimmed))
130+
continue;
131+
132+
builder.Add(trimmed);
133+
if (!trimmed.EndsWith("Attribute", StringComparison.Ordinal))
134+
{
135+
builder.Add(trimmed + "Attribute");
136+
}
137+
}
138+
}
139+
140+
return builder.ToImmutable();
141+
});
141142
}
142143

143-
private static bool HasValidAttribute(ISymbol symbol, ImmutableHashSet<string> attributeNames) =>
144-
symbol
145-
.GetAttributes()
146-
.Any(attr => attr.AttributeClass != null && IsValidAttributeName(attr.AttributeClass.Name, attributeNames));
147-
}
144+
private static bool HasValidAttributeRecursive(ISymbol symbol, ImmutableHashSet<string> validAttributes)
145+
{
146+
var current = symbol;
147+
while (current != null)
148+
{
149+
foreach (var attribute in current.GetAttributes())
150+
{
151+
if (attribute.AttributeClass != null && validAttributes.Contains(attribute.AttributeClass.Name))
152+
return true;
153+
}
148154

149-
public sealed record GeneratorOptions(string[] ValidAttributes)
150-
{
151-
public string[] ValidAttributes { get; } = ValidAttributes;
155+
current = current.ContainingType;
156+
}
152157

153-
public static GeneratorOptions Parse(string? s)
154-
{
155-
if (string.IsNullOrWhiteSpace(s))
156-
return new(["PublicAPI"]);
157-
158-
var results = s!
159-
.Split(',')
160-
.Select(x => x.Trim())
161-
.Where(x => !string.IsNullOrWhiteSpace(x))
162-
.Append("PublicAPI")
163-
.Distinct()
164-
.ToArray();
165-
166-
return new(results);
158+
return false;
167159
}
168160
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
global using System;
2+
global using System.Collections.Concurrent;
23
global using System.Collections.Immutable;
3-
global using System.Linq;
44
global using Microsoft.CodeAnalysis;
55
global using Microsoft.CodeAnalysis.Diagnostics;

0 commit comments

Comments
 (0)