Skip to content

Commit a468975

Browse files
Copilotandrewlock
andauthored
Extend ToStringAnalyzer to detect enum usage in string interpolation (#198)
* Initial plan * WIP: Add string interpolation support to ToStringAnalyzer (debugging test issues) Co-authored-by: andrewlock <18755388+andrewlock@users.noreply.github.com> * Remove debug code and attempt verbatim string test syntax Co-authored-by: andrewlock <18755388+andrewlock@users.noreply.github.com> * Tweaks * Fix copilot's silliness * PR comment suggestions: support alignment * Add additional test --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: andrewlock <18755388+andrewlock@users.noreply.github.com> Co-authored-by: Andrew Lock <andrewlock.net@gmail.com>
1 parent 4c4279d commit a468975

4 files changed

Lines changed: 464 additions & 31 deletions

File tree

src/NetEscapades.EnumGenerators/Diagnostics/ToStringAnalyzer.cs

Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Collections.Generic;
23
using System.Collections.Immutable;
34
using Microsoft.CodeAnalysis;
@@ -58,6 +59,10 @@ public override void Initialize(AnalysisContext context)
5859
ctx.RegisterSyntaxNodeAction(
5960
c => AnalyzeInvocation(c, enumExtensionsAttr, externalEnumTypes),
6061
SyntaxKind.InvocationExpression);
62+
63+
ctx.RegisterSyntaxNodeAction(
64+
c => AnalyzeInterpolation(c, enumExtensionsAttr, externalEnumTypes),
65+
SyntaxKind.Interpolation);
6166
});
6267
}
6368

@@ -131,38 +136,94 @@ private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context, INamedT
131136
return;
132137
}
133138

134-
// Check if the enum has the [EnumExtensions] attribute or is referenced in EnumExtensions<T>
135-
bool hasEnumExtensionsAttribute = false;
136-
137-
// First check if the enum itself has the attribute
138-
foreach (var attributeData in receiverType.GetAttributes())
139+
if (!IsEnumWithExtensions(receiverType, enumExtensionsAttr, externalEnumTypes))
139140
{
140-
if (SymbolEqualityComparer.Default.Equals(
141-
attributeData.AttributeClass,
142-
enumExtensionsAttr))
143-
{
144-
hasEnumExtensionsAttribute = true;
145-
break;
146-
}
141+
return;
147142
}
148143

149-
// If not, check if it's in the external enum types (EnumExtensions<T>)
150-
if (!hasEnumExtensionsAttribute && receiverType is INamedTypeSymbol namedType)
144+
// Report the diagnostic
145+
var diagnostic = Diagnostic.Create(
146+
Rule,
147+
memberAccess.Name.GetLocation(),
148+
receiverType.Name);
149+
150+
context.ReportDiagnostic(diagnostic);
151+
}
152+
153+
private static void AnalyzeInterpolation(SyntaxNodeAnalysisContext context, INamedTypeSymbol enumExtensionsAttr, HashSet<INamedTypeSymbol> externalEnumTypes)
154+
{
155+
var interpolation = (InterpolationSyntax)context.Node;
156+
157+
// Get the expression inside the interpolation
158+
var expression = interpolation.Expression;
159+
160+
// Get the type of the expression using GetSymbolInfo first, then fall back to GetTypeInfo
161+
var symbolInfo = context.SemanticModel.GetSymbolInfo(expression);
162+
163+
var expressionType = symbolInfo.Symbol switch
164+
{
165+
ILocalSymbol localSymbol => localSymbol.Type,
166+
IFieldSymbol fieldSymbol => fieldSymbol.Type,
167+
IPropertySymbol propertySymbol => propertySymbol.Type,
168+
IParameterSymbol parameterSymbol => parameterSymbol.Type,
169+
IMethodSymbol methodSymbol => methodSymbol.ReturnType,
170+
_ => context.SemanticModel.GetTypeInfo(expression).Type
171+
};
172+
173+
if (expressionType is null || expressionType.TypeKind != TypeKind.Enum)
151174
{
152-
hasEnumExtensionsAttribute = externalEnumTypes.Contains(namedType);
175+
return;
153176
}
154177

155-
if (!hasEnumExtensionsAttribute)
178+
// Check if there's a format clause (e.g., :g, :G, :x)
179+
if (interpolation.FormatClause is not null)
180+
{
181+
var formatString = interpolation.FormatClause.FormatStringToken.Text;
182+
183+
// Check if the format string is compatible with ToStringFast()
184+
// Only "", "g", and "G" are compatible (empty is when there's no format clause)
185+
if (!string.IsNullOrEmpty(formatString) &&
186+
!string.Equals(formatString, "G", StringComparison.Ordinal) &&
187+
!string.Equals(formatString, "g", StringComparison.Ordinal))
188+
{
189+
return;
190+
}
191+
}
192+
193+
if (!IsEnumWithExtensions(expressionType, enumExtensionsAttr, externalEnumTypes))
156194
{
157195
return;
158196
}
159197

160-
// Report the diagnostic
198+
// Report the diagnostic on the expression itself
161199
var diagnostic = Diagnostic.Create(
162200
Rule,
163-
memberAccess.Name.GetLocation(),
164-
receiverType.Name);
201+
expression.GetLocation(),
202+
expressionType.Name);
165203

166204
context.ReportDiagnostic(diagnostic);
167205
}
206+
207+
private static bool IsEnumWithExtensions(
208+
ITypeSymbol receiverType,
209+
INamedTypeSymbol enumExtensionsAttr,
210+
HashSet<INamedTypeSymbol> externalEnumTypes)
211+
{
212+
// Check if the enum has the [EnumExtensions] attribute or is referenced in EnumExtensions<T>
213+
// First check if the enum itself has the attribute
214+
foreach (var attributeData in receiverType.GetAttributes())
215+
{
216+
if (SymbolEqualityComparer.Default.Equals(
217+
attributeData.AttributeClass,
218+
enumExtensionsAttr))
219+
{
220+
return true;
221+
}
222+
}
223+
224+
// If not, check if it's in the external enum types (EnumExtensions<T>)
225+
return receiverType is INamedTypeSymbol namedType
226+
&& externalEnumTypes.Contains(namedType);
227+
}
228+
168229
}

src/NetEscapades.EnumGenerators/Diagnostics/ToStringCodeFixProvider.cs

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,20 +30,32 @@ public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
3030
var diagnostic = context.Diagnostics.First();
3131
var diagnosticSpan = diagnostic.Location.SourceSpan;
3232

33-
// Find the ToString identifier
34-
var identifierNode = root.FindToken(diagnosticSpan.Start).Parent;
35-
if (identifierNode is not IdentifierNameSyntax identifierName)
33+
// Find the node at the diagnostic location
34+
var node = root.FindNode(diagnosticSpan);
35+
36+
// Check if this is an interpolation case
37+
if (node.Parent is InterpolationSyntax interpolation)
3638
{
37-
return;
39+
// Register a code action for interpolation replacement
40+
context.RegisterCodeFix(
41+
CodeAction.Create(
42+
title: Title,
43+
createChangedDocument: c =>
44+
ReplaceInterpolationWithToStringFast(context.Document, interpolation, c),
45+
equivalenceKey: Title),
46+
context.Diagnostics);
47+
}
48+
// Check if this is a ToString invocation (original case)
49+
else if (node is IdentifierNameSyntax identifierName)
50+
{
51+
// Register a code action for ToString() replacement
52+
context.RegisterCodeFix(
53+
CodeAction.Create(
54+
title: Title,
55+
createChangedDocument: c => ReplaceToStringWithToStringFast(context.Document, identifierName, c),
56+
equivalenceKey: Title),
57+
context.Diagnostics);
3858
}
39-
40-
// Register a code action that will invoke the fix
41-
context.RegisterCodeFix(
42-
CodeAction.Create(
43-
title: Title,
44-
createChangedDocument: c => ReplaceToStringWithToStringFast(context.Document, identifierName, c),
45-
equivalenceKey: Title),
46-
context.Diagnostics);
4759
}
4860

4961
private static async Task<Document> ReplaceToStringWithToStringFast(
@@ -90,4 +102,43 @@ private static async Task<Document> ReplaceToStringWithToStringFast(
90102

91103
return document.WithSyntaxRoot(newRoot);
92104
}
105+
106+
private static async Task<Document> ReplaceInterpolationWithToStringFast(
107+
Document document,
108+
InterpolationSyntax interpolation,
109+
CancellationToken cancellationToken)
110+
{
111+
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
112+
if (root is null)
113+
{
114+
return document;
115+
}
116+
117+
// Create a member access expression: expression.ToStringFast()
118+
var expression = interpolation.Expression;
119+
var toStringFastMemberAccess = SyntaxFactory.MemberAccessExpression(
120+
SyntaxKind.SimpleMemberAccessExpression,
121+
expression,
122+
SyntaxFactory.IdentifierName("ToStringFast"));
123+
124+
// Create an invocation expression: expression.ToStringFast()
125+
var toStringFastInvocation = SyntaxFactory.InvocationExpression(
126+
toStringFastMemberAccess,
127+
SyntaxFactory.ArgumentList());
128+
129+
// Create a new interpolation with the invocation and no format clause
130+
var newInterpolation = SyntaxFactory.Interpolation(toStringFastInvocation)
131+
.WithLeadingTrivia(interpolation.GetLeadingTrivia())
132+
.WithTrailingTrivia(interpolation.GetTrailingTrivia());
133+
134+
if (interpolation.AlignmentClause is { } alignment)
135+
{
136+
newInterpolation = newInterpolation.WithAlignmentClause(alignment);
137+
}
138+
139+
// Replace the old interpolation with the new one
140+
var newRoot = root.ReplaceNode(interpolation, newInterpolation);
141+
142+
return document.WithSyntaxRoot(newRoot);
143+
}
93144
}

tests/NetEscapades.EnumGenerators.IntegrationTests/AnalyzerTests.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ public void Neeg004Testing()
1818
_ = EnumInSystem.First.ToString(format: "g");
1919
_ = EnumInSystem.First.ToString(format: null); // no error
2020
_ = DateTimeKind.Local.ToString();
21+
_ = $"Some value: {test} <-";
22+
_ = $"Some value: {test:G} <-";
23+
_ = $"Some value: {EnumInSystem.First} <-";
24+
_ = $"Some value: {EnumInSystem.First:G} <-";
2125
#pragma warning restore NEEG004
2226
}
2327
}

0 commit comments

Comments
 (0)