Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions src/NetEscapades.EnumGenerators/Diagnostics/ToStringAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace NetEscapades.EnumGenerators.Diagnostics;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ToStringAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = "NEEG004";
public static readonly DiagnosticDescriptor Rule = new(
#pragma warning disable RS2008 // Enable Analyzer Release Tracking
id: DiagnosticId,
#pragma warning restore RS2008
title: "Use ToStringFast() instead of ToString()",
messageFormat: "Use ToStringFast() instead of ToString() for better performance on enum '{0}'",
category: "Usage",
defaultSeverity: DiagnosticSeverity.Info,
isEnabledByDefault: true);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
=> ImmutableArray.Create(Rule);

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterCompilationStartAction(ctx =>
{
var enumExtensionsAttr =
ctx.Compilation.GetTypeByMetadataName(Attributes.EnumExtensionsAttribute);
var externalEnumExtensionsAttr =
ctx.Compilation.GetTypeByMetadataName(Attributes.ExternalEnumExtensionsAttribute);

if (enumExtensionsAttr is null)
{
return;
}

// Collect all enum types that have EnumExtensions<T> attributes
var externalEnumTypes = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
if (externalEnumExtensionsAttr is not null)
{
foreach (var attribute in ctx.Compilation.Assembly.GetAttributes())
{
if (attribute.AttributeClass is { IsGenericType: true } attrClass &&
SymbolEqualityComparer.Default.Equals(attrClass.ConstructedFrom, externalEnumExtensionsAttr) &&
attrClass.TypeArguments is [INamedTypeSymbol { TypeKind: TypeKind.Enum } enumType])
{
externalEnumTypes.Add(enumType);
}
}
}

ctx.RegisterSyntaxNodeAction(
c => AnalyzeInvocation(c, enumExtensionsAttr, externalEnumTypes),
SyntaxKind.InvocationExpression);
});
}

private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context, INamedTypeSymbol enumExtensionsAttr, HashSet<INamedTypeSymbol> externalEnumTypes)
{
var invocation = (InvocationExpressionSyntax)context.Node;

// Check if this is a member access expression (e.g., value.ToString())
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
{
return;
}

// Check if there are too many arguments)
if (invocation.ArgumentList.Arguments.Count > 1)
{
return;
}

// Check if the method name is "ToString"
if (memberAccess.Name.Identifier.Text != "ToString")
{
return;
}

// Get the symbol information for the invocation
var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation);
if (symbolInfo.Symbol is not IMethodSymbol methodSymbol)
{
return;
}

// Verify this is the ToString() method from System.Object or System.Enum
// We handle format specifiers, so accept ToString() with 0 or 1 parameters
if (methodSymbol.Name != "ToString" ||
methodSymbol.Parameters.Length > 1 ||
(methodSymbol.ContainingType.SpecialType != SpecialType.System_Object &&
methodSymbol.ContainingType.SpecialType != SpecialType.System_Enum))
{
return;
}

// If there's a format parameter, check if it's compatible with ToStringFast()
// ToStringFast() is equivalent to ToString() with no args or ToString("G")/ToString("g")/ToString("")
if (invocation.ArgumentList.Arguments.Count > 0)
{
var argument = invocation.ArgumentList.Arguments[0];
var constantValue = context.SemanticModel.GetConstantValue(argument.Expression);

// If we can't determine the value at compile time, don't suggest replacement
// If it's not a string (e.g., it's an IFormatProvider), don't suggest replacement
if (!constantValue.HasValue || constantValue.Value is not string formatString)
{
return;
}

// Check if the format string is compatible with ToStringFast()
// Only "", "G", and "g" are compatible
if (!string.IsNullOrEmpty(formatString) &&
!string.Equals(formatString, "G", StringComparison.Ordinal) &&
!string.Equals(formatString, "g", StringComparison.Ordinal))
{
return;
}
}

// Get the type of the receiver (the thing before .ToString())
var receiverType = context.SemanticModel.GetTypeInfo(memberAccess.Expression).Type;
if (receiverType is null || receiverType.TypeKind != TypeKind.Enum)
{
return;
}

// Check if the enum has the [EnumExtensions] attribute or is referenced in EnumExtensions<T>
bool hasEnumExtensionsAttribute = false;

// First check if the enum itself has the attribute
foreach (var attributeData in receiverType.GetAttributes())
{
if (SymbolEqualityComparer.Default.Equals(
attributeData.AttributeClass,
enumExtensionsAttr))
{
hasEnumExtensionsAttribute = true;
break;
}
}

// If not, check if it's in the external enum types (EnumExtensions<T>)
if (!hasEnumExtensionsAttribute && receiverType is INamedTypeSymbol namedType)
{
hasEnumExtensionsAttribute = externalEnumTypes.Contains(namedType);
}

if (!hasEnumExtensionsAttribute)
{
return;
}

// Report the diagnostic
var diagnostic = Diagnostic.Create(
Rule,
memberAccess.Name.GetLocation(),
receiverType.Name);

context.ReportDiagnostic(diagnostic);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System.Collections.Immutable;
using System.Composition;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace NetEscapades.EnumGenerators.Diagnostics;

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ToStringCodeFixProvider)), Shared]
public class ToStringCodeFixProvider : CodeFixProvider
{
private const string Title = "Replace with ToStringFast()";

public sealed override ImmutableArray<string> FixableDiagnosticIds
=> ImmutableArray.Create(ToStringAnalyzer.DiagnosticId);

public sealed override FixAllProvider GetFixAllProvider()
=> WellKnownFixAllProviders.BatchFixer;

public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
if (root is null)
{
return;
}

var diagnostic = context.Diagnostics.First();
var diagnosticSpan = diagnostic.Location.SourceSpan;

// Find the ToString identifier
var identifierNode = root.FindToken(diagnosticSpan.Start).Parent;
if (identifierNode is not IdentifierNameSyntax identifierName)
{
return;
}

// Register a code action that will invoke the fix
context.RegisterCodeFix(
CodeAction.Create(
title: Title,
createChangedDocument: c => ReplaceToStringWithToStringFast(context.Document, identifierName, c),
equivalenceKey: Title),
context.Diagnostics);
}

private static async Task<Document> ReplaceToStringWithToStringFast(
Document document,
IdentifierNameSyntax identifierName,
CancellationToken cancellationToken)
{
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
if (root is null)
{
return document;
}

// Find the invocation expression to replace arguments as well
var invocationExpression = identifierName.Parent?.Parent as InvocationExpressionSyntax;
if (invocationExpression is null)
{
return document;
}

// Create the new identifier with "ToStringFast"
var newIdentifier = SyntaxFactory.IdentifierName("ToStringFast")
.WithTriviaFrom(identifierName);

// Create new member access with the new identifier
var memberAccess = identifierName.Parent as MemberAccessExpressionSyntax;
if (memberAccess is null)
{
return document;
}

var newMemberAccess = memberAccess.WithName(newIdentifier);

// Create new invocation with empty argument list
var newArgumentList = SyntaxFactory.ArgumentList()
.WithTrailingTrivia(invocationExpression.ArgumentList.GetTrailingTrivia());

var newInvocation = invocationExpression
.WithExpression(newMemberAccess)
.WithArgumentList(newArgumentList);

// Replace the old invocation with the new one
var newRoot = root.ReplaceNode(invocationExpression, newInvocation);

return document.WithSyntaxRoot(newRoot);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#### .NET Coding Conventions ####
[*.{cs,vb}]

dotnet_diagnostic.NEEG001.severity = error
dotnet_diagnostic.NEEG002.severity = error
dotnet_diagnostic.NEEG003.severity = error
dotnet_diagnostic.NEEG004.severity = error
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System;
using Xunit;

namespace NetEscapades.EnumGenerators.Benchmarks;

public class AnalyzerTests
{
// These method calls should all be flagged by the analyzer
[Fact]
public void Neeg004Testing()
{
#pragma warning disable NEEG004
var test = EnumInSystem.First;
_ = test.ToString();
_ = EnumInSystem.First.ToString();
_ = EnumInSystem.First.ToString("G");
_ = EnumInSystem.First.ToString("x"); // no error
_ = EnumInSystem.First.ToString(format: "g");
_ = EnumInSystem.First.ToString(format: null); // no error
_ = DateTimeKind.Local.ToString();
#pragma warning restore NEEG004
}
}
2 changes: 2 additions & 0 deletions tests/NetEscapades.EnumGenerators.IntegrationTests/Enums.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;

#pragma warning disable NEEG003

[assembly:NetEscapades.EnumGenerators.EnumExtensions<DateTimeKind>()]

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot when checking if an enum is valid to be replaced with ToStringFast(), we need to check if it's referenced in an EnumExtensions<T> attribute as well. Add tests and update the implementation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit b291228. The analyzer now checks for EnumExtensions<T> assembly attributes and collects all external enum types during compilation start. Added 8 new tests covering external enum scenarios (DateTimeKind, FileShare) with various format specifiers. All 198 tests pass.

[assembly:NetEscapades.EnumGenerators.EnumExtensions<System.IO.FileShare>()]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.0.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" Condition="'$(TargetFramework)' == 'net48'" />
Expand Down
Loading
Loading