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
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace Creedengo.Core.Analyzers;

/// <summary>GCI6605 fixer: Replace Any with Exists on List&lt;T&gt;.</summary>
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UseExistsInsteadOfAnyFixer)), Shared]
public sealed class UseExistsInsteadOfAnyFixer : CodeFixProvider
{
/// <inheritdoc/>
public override ImmutableArray<string> FixableDiagnosticIds => _fixableDiagnosticIds;
private static readonly ImmutableArray<string> _fixableDiagnosticIds = ImmutableArray.Create(UseExistsInsteadOfAny.Descriptor.Id);

/// <inheritdoc/>
[ExcludeFromCodeCoverage]
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;

/// <inheritdoc/>
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
if (context.Diagnostics.FirstOrDefault() is not { } diagnostic ||
await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false) is not { } root ||
root.FindNode(context.Span, getInnermostNodeForTie: true) is not SimpleNameSyntax { Identifier.Text: "Any" } anyName)
{
return;
}

context.RegisterCodeFix(CodeAction.Create(
"Use Exists instead of Any",
ct => ReplaceAnyWithExistsAsync(context.Document, anyName),
equivalenceKey: "Use Exists instead of Any"),
diagnostic);
}

private static async Task<Document> ReplaceAnyWithExistsAsync(Document document, SimpleNameSyntax anyName)
{
var existsIdentifier = SyntaxFactory.IdentifierName("Exists")
.WithLeadingTrivia(anyName.GetLeadingTrivia())
.WithTrailingTrivia(anyName.GetTrailingTrivia());

return await document.WithUpdatedRoot(anyName, existsIdentifier).ConfigureAwait(false);
}
}
107 changes: 107 additions & 0 deletions src/Creedengo.Core/Analyzers/GCI6605.UseExistsInsteadOfAny.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
namespace Creedengo.Core.Analyzers;

/// <summary>GCI6605: Use List.Exists instead of LINQ Any with a predicate.</summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class UseExistsInsteadOfAny : DiagnosticAnalyzer
{
private static readonly ImmutableArray<SyntaxKind> SyntaxKinds = ImmutableArray.Create(SyntaxKind.InvocationExpression);

/// <summary>The diagnostic descriptor.</summary>
public static DiagnosticDescriptor Descriptor { get; } = Rule.CreateDescriptor(
id: Rule.Ids.GCI6605_UseExistsInsteadOfAny,
title: "Use Exists instead of Any",
message: "Use 'Exists' instead of 'Any' for improved performance on List<T>",
category: Rule.Categories.Performance,
severity: DiagnosticSeverity.Warning,
description: "List<T>.Exists(predicate) is more performant than the LINQ extension method Enumerable.Any(predicate) for List<T> because it avoids enumerator allocation and interface dispatch overhead.");

/// <inheritdoc/>
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => _supportedDiagnostics;
private static readonly ImmutableArray<DiagnosticDescriptor> _supportedDiagnostics = ImmutableArray.Create(Descriptor);

/// <inheritdoc/>
public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.RegisterCompilationStartAction(static startContext =>
{
var enumerableType = startContext.Compilation.GetTypeByMetadataName("System.Linq.Enumerable");
if (enumerableType is null) return;

var listType = startContext.Compilation.GetTypeByMetadataName("System.Collections.Generic.List`1");
var expressionType = startContext.Compilation.GetTypeByMetadataName("System.Linq.Expressions.Expression`1");

startContext.RegisterSyntaxNodeAction(
nodeContext => AnalyzeInvocation(nodeContext, enumerableType, listType, expressionType),
SyntaxKinds);
});
}

private static void AnalyzeInvocation(
SyntaxNodeAnalysisContext context,
INamedTypeSymbol enumerableType,
INamedTypeSymbol? listType,
INamedTypeSymbol? expressionType)
{
var invocation = (InvocationExpressionSyntax)context.Node;

// Must be a member access like source.Any(predicate)
if (invocation.Expression is not MemberAccessExpressionSyntax { Name.Identifier.Text: "Any" } memberAccess)
return;

// Resolve the method symbol — must be System.Linq.Enumerable.Any with the predicate parameter
if (context.SemanticModel.GetSymbolInfo(invocation).Symbol is not IMethodSymbol method ||
!method.IsExtensionMethod ||
method.Parameters.Length != 1 || // Reduced extension method: only the predicate parameter (excludes parameterless Any())
!SymbolEqualityComparer.Default.Equals(method.ContainingType, enumerableType))
{
return;
}

// Receiver type must be List<T> or derive from it
var receiverType = context.SemanticModel.GetTypeInfo(memberAccess.Expression).Type;
if (!IsList(receiverType, listType))
return;

// Exclude Expression<TDelegate> contexts (e.g. Entity Framework, NoSQL drivers)
if (IsInsideExpressionTree(context.SemanticModel, invocation, expressionType))
return;

context.ReportDiagnostic(Diagnostic.Create(Descriptor, memberAccess.Name.GetLocation()));
}

private static bool IsList(ITypeSymbol? type, INamedTypeSymbol? listType)
{
if (type is null || listType is null) return false;

var current = type;
while (current is not null)
{
if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, listType))
return true;
current = current.BaseType;
}
return false;
}

private static bool IsInsideExpressionTree(SemanticModel semanticModel, SyntaxNode node, INamedTypeSymbol? expressionType)
{
if (expressionType is null) return false;

for (var current = node.Parent; current is not null; current = current.Parent)
{
if (current is not LambdaExpressionSyntax and not AnonymousMethodExpressionSyntax)
continue;

var typeInfo = semanticModel.GetTypeInfo(current);
var convertedType = typeInfo.ConvertedType;
if (convertedType is INamedTypeSymbol namedType &&
SymbolEqualityComparer.Default.Equals(namedType.OriginalDefinition, expressionType))
{
return true;
}
}
return false;
}
}
1 change: 1 addition & 0 deletions src/Creedengo.Core/Models/Rule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public static class Ids
public const string GCI2508_RemoveUselessToStringCall = "GCI2508";
public const string GCI2333_RemoveRedundantToCharArrayCall = "GCI2333";
public const string GCI98_UseThenByInsteadOfOrderBy = "GCI98";
public const string GCI6605_UseExistsInsteadOfAny = "GCI6605";
}

/// <summary>Creates a diagnostic descriptor.</summary>
Expand Down
15 changes: 15 additions & 0 deletions src/Creedengo.Sandbox/S6605Sandbox.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Text;

namespace Creedengo.Sandbox;

internal class S6605Sandbox
{

public bool M1(List<int> ages) => ages.Any(a => IsUnder30(a));
public bool M2(List<int> ages) => ages.Exists(a => IsUnder30(a));

private bool IsUnder30(int age) => age < 30;

}
Loading