From e841cfd49c1573f186cdb1c7f957090694e73647 Mon Sep 17 00:00:00 2001 From: Thomas GICQUEL Date: Wed, 20 May 2026 11:27:40 +0200 Subject: [PATCH 1/2] add S6605 - Use Exists Instead of Any --- .../GCI6605.UseExistsInsteadOfAny.Fixer.cs | 42 ++++ .../GCI6605.UseExistsInsteadOfAny.cs | 107 +++++++++ src/Creedengo.Core/Models/Rule.cs | 1 + src/Creedengo.Sandbox/S6605Sandbox.cs | 15 ++ .../GCI6605.UseExistsInsteadOfAny.Tests.cs | 221 ++++++++++++++++++ 5 files changed, 386 insertions(+) create mode 100644 src/Creedengo.Core/Analyzers/GCI6605.UseExistsInsteadOfAny.Fixer.cs create mode 100644 src/Creedengo.Core/Analyzers/GCI6605.UseExistsInsteadOfAny.cs create mode 100644 src/Creedengo.Sandbox/S6605Sandbox.cs create mode 100644 src/Creedengo.Tests/Tests/GCI6605.UseExistsInsteadOfAny.Tests.cs diff --git a/src/Creedengo.Core/Analyzers/GCI6605.UseExistsInsteadOfAny.Fixer.cs b/src/Creedengo.Core/Analyzers/GCI6605.UseExistsInsteadOfAny.Fixer.cs new file mode 100644 index 00000000..99dfdfce --- /dev/null +++ b/src/Creedengo.Core/Analyzers/GCI6605.UseExistsInsteadOfAny.Fixer.cs @@ -0,0 +1,42 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Creedengo.Core.Analyzers; + +/// GCI6605 fixer: Replace Any with Exists on List<T>. +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UseExistsInsteadOfAnyFixer)), Shared] +public sealed class UseExistsInsteadOfAnyFixer : CodeFixProvider +{ + /// + public override ImmutableArray FixableDiagnosticIds => _fixableDiagnosticIds; + private static readonly ImmutableArray _fixableDiagnosticIds = ImmutableArray.Create(UseExistsInsteadOfAny.Descriptor.Id); + + /// + [ExcludeFromCodeCoverage] + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + 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 IdentifierNameSyntax { Identifier.Text: "Any" } anyIdentifier) + { + return; + } + + context.RegisterCodeFix(CodeAction.Create( + "Use Exists instead of Any", + ct => ReplaceAnyWithExistsAsync(context.Document, anyIdentifier), + equivalenceKey: "Use Exists instead of Any"), + diagnostic); + } + + private static async Task ReplaceAnyWithExistsAsync(Document document, IdentifierNameSyntax anyIdentifier) + { + var existsIdentifier = SyntaxFactory.IdentifierName("Exists") + .WithLeadingTrivia(anyIdentifier.GetLeadingTrivia()) + .WithTrailingTrivia(anyIdentifier.GetTrailingTrivia()); + + return await document.WithUpdatedRoot(anyIdentifier, existsIdentifier).ConfigureAwait(false); + } +} diff --git a/src/Creedengo.Core/Analyzers/GCI6605.UseExistsInsteadOfAny.cs b/src/Creedengo.Core/Analyzers/GCI6605.UseExistsInsteadOfAny.cs new file mode 100644 index 00000000..b49a8f34 --- /dev/null +++ b/src/Creedengo.Core/Analyzers/GCI6605.UseExistsInsteadOfAny.cs @@ -0,0 +1,107 @@ +namespace Creedengo.Core.Analyzers; + +/// GCI6605: Use List.Exists instead of LINQ Any with a predicate. +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class UseExistsInsteadOfAny : DiagnosticAnalyzer +{ + private static readonly ImmutableArray SyntaxKinds = ImmutableArray.Create(SyntaxKind.InvocationExpression); + + /// The diagnostic descriptor. + 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", + category: Rule.Categories.Performance, + severity: DiagnosticSeverity.Warning, + description: "List.Exists(predicate) is more performant than the LINQ extension method Enumerable.Any(predicate) because it avoids delegate allocation and virtual dispatch overhead."); + + /// + public override ImmutableArray SupportedDiagnostics => _supportedDiagnostics; + private static readonly ImmutableArray _supportedDiagnostics = ImmutableArray.Create(Descriptor); + + /// + 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 or derive from it + var receiverType = context.SemanticModel.GetTypeInfo(memberAccess.Expression).Type; + if (!IsList(receiverType, listType)) + return; + + // Exclude Expression 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; + } +} diff --git a/src/Creedengo.Core/Models/Rule.cs b/src/Creedengo.Core/Models/Rule.cs index 6cc5d153..38021cfd 100644 --- a/src/Creedengo.Core/Models/Rule.cs +++ b/src/Creedengo.Core/Models/Rule.cs @@ -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"; } /// Creates a diagnostic descriptor. diff --git a/src/Creedengo.Sandbox/S6605Sandbox.cs b/src/Creedengo.Sandbox/S6605Sandbox.cs new file mode 100644 index 00000000..4b891ce6 --- /dev/null +++ b/src/Creedengo.Sandbox/S6605Sandbox.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Creedengo.Sandbox; + +internal class S6605Sandbox +{ + + public bool M1(List ages) => ages.Any(a => IsUnder30(a)); + public bool M2(List ages) => ages.Exists(a => IsUnder30(a)); + + private bool IsUnder30(int age) => age < 30; + +} diff --git a/src/Creedengo.Tests/Tests/GCI6605.UseExistsInsteadOfAny.Tests.cs b/src/Creedengo.Tests/Tests/GCI6605.UseExistsInsteadOfAny.Tests.cs new file mode 100644 index 00000000..0b160206 --- /dev/null +++ b/src/Creedengo.Tests/Tests/GCI6605.UseExistsInsteadOfAny.Tests.cs @@ -0,0 +1,221 @@ +namespace Creedengo.Tests.Tests; + +[TestClass] +public sealed class UseExistsInsteadOfAnyTests +{ + private static readonly CodeFixerDlg VerifyAsync = TestRunner.VerifyAsync; + + [TestMethod] + public Task EmptyCodeAsync() => VerifyAsync(""); + + #region Positive cases (should trigger diagnostic + fix) + + [TestMethod] + public Task AnyWithLambdaOnListAsync() => VerifyAsync(""" + using System; + using System.Collections.Generic; + using System.Linq; + public static class Test + { + public static bool Run() + { + var list = new List { 1, 2, 3 }; + return list.[|Any|](x => x > 1); + } + } + """, """ + using System; + using System.Collections.Generic; + using System.Linq; + public static class Test + { + public static bool Run() + { + var list = new List { 1, 2, 3 }; + return list.Exists(x => x > 1); + } + } + """); + + [TestMethod] + public Task AnyWithMethodGroupOnListAsync() => VerifyAsync(""" + using System; + using System.Collections.Generic; + using System.Linq; + public static class Test + { + public static bool Run() + { + var list = new List { "a", "b" }; + return list.[|Any|](string.IsNullOrEmpty); + } + + private static bool IsValid(string s) => !string.IsNullOrEmpty(s); + } + """, """ + using System; + using System.Collections.Generic; + using System.Linq; + public static class Test + { + public static bool Run() + { + var list = new List { "a", "b" }; + return list.Exists(string.IsNullOrEmpty); + } + + private static bool IsValid(string s) => !string.IsNullOrEmpty(s); + } + """); + + [TestMethod] + public Task AnyOnDerivedListAsync() => VerifyAsync(""" + using System; + using System.Collections.Generic; + using System.Linq; + public static class Test + { + private sealed class MyList : List; + + public static bool Run() + { + var list = new MyList { 1, 2, 3 }; + return list.[|Any|](x => x > 1); + } + } + """, """ + using System; + using System.Collections.Generic; + using System.Linq; + public static class Test + { + private sealed class MyList : List; + + public static bool Run() + { + var list = new MyList { 1, 2, 3 }; + return list.Exists(x => x > 1); + } + } + """); + + [TestMethod] + public Task AnyInConditionAsync() => VerifyAsync(""" + using System; + using System.Collections.Generic; + using System.Linq; + public static class Test + { + public static void Run() + { + var list = new List { 1, 2, 3 }; + if (list.[|Any|](x => x == 2)) + Console.WriteLine("found"); + } + } + """, """ + using System; + using System.Collections.Generic; + using System.Linq; + public static class Test + { + public static void Run() + { + var list = new List { 1, 2, 3 }; + if (list.Exists(x => x == 2)) + Console.WriteLine("found"); + } + } + """); + + #endregion + + #region Negative cases (should NOT trigger diagnostic) + + [TestMethod] + public Task DontTriggerOnParameterlessAnyAsync() => VerifyAsync(""" + using System; + using System.Collections.Generic; + using System.Linq; + public static class Test + { + public static bool Run() + { + var list = new List { 1, 2, 3 }; + return list.Any(); + } + } + """); + + [TestMethod] + public Task DontTriggerOnNonListTypesAsync() => VerifyAsync(""" + using System; + using System.Collections.Generic; + using System.Linq; + public static class Test + { + public static void Run() + { + var enumerable = Enumerable.Range(0, 10); + _ = enumerable.Any(x => x > 5); + + var set = new HashSet { 1, 2, 3 }; + _ = set.Any(x => x > 1); + + var dic = new Dictionary { { 1, "a" } }; + _ = dic.Any(kv => kv.Key > 0); + } + } + """); + + [TestMethod] + public Task DontTriggerInsideExpressionTreeAsync() => VerifyAsync(""" + using System; + using System.Collections.Generic; + using System.Linq; + using System.Linq.Expressions; + public static class Test + { + public static void Run() + { + var list = new List { 1, 2, 3 }; + Expression> expr = () => list.Any(x => x > 1); + } + } + """); + + [TestMethod] + public Task DontTriggerOnCustomAnyMethodAsync() => VerifyAsync(""" + using System; + using System.Collections.Generic; + public static class Test + { + private sealed class MyCollection : List + { + public bool Any(Func predicate) => true; + } + + public static bool Run() + { + var coll = new MyCollection { 1, 2, 3 }; + return coll.Any(x => x > 1); + } + } + """); + + [TestMethod] + public Task DontTriggerOnArrayAsync() => VerifyAsync(""" + using System; + using System.Linq; + public static class Test + { + public static bool Run() + { + var arr = new int[] { 1, 2, 3 }; + return arr.Any(x => x > 1); + } + } + """); + + #endregion +} From bf962d0788feb45025df1bd644b7dc704e0bb8c3 Mon Sep 17 00:00:00 2001 From: Thomas GICQUEL Date: Wed, 20 May 2026 14:35:31 +0200 Subject: [PATCH 2/2] fix: address Copilot review comments on GCI6605 - Fixer: use SimpleNameSyntax instead of IdentifierNameSyntax to handle both Any and Any (GenericNameSyntax) cases - Description: fix wording to mention enumerator allocation and interface dispatch instead of incorrect delegate allocation claim - Tests: add AnyWithExplicitTypeArgumentAsync for GenericNameSyntax coverage --- .../GCI6605.UseExistsInsteadOfAny.Fixer.cs | 12 ++++----- .../GCI6605.UseExistsInsteadOfAny.cs | 2 +- .../GCI6605.UseExistsInsteadOfAny.Tests.cs | 27 +++++++++++++++++++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/Creedengo.Core/Analyzers/GCI6605.UseExistsInsteadOfAny.Fixer.cs b/src/Creedengo.Core/Analyzers/GCI6605.UseExistsInsteadOfAny.Fixer.cs index 99dfdfce..001821e7 100644 --- a/src/Creedengo.Core/Analyzers/GCI6605.UseExistsInsteadOfAny.Fixer.cs +++ b/src/Creedengo.Core/Analyzers/GCI6605.UseExistsInsteadOfAny.Fixer.cs @@ -19,24 +19,24 @@ 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 IdentifierNameSyntax { Identifier.Text: "Any" } anyIdentifier) + 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, anyIdentifier), + ct => ReplaceAnyWithExistsAsync(context.Document, anyName), equivalenceKey: "Use Exists instead of Any"), diagnostic); } - private static async Task ReplaceAnyWithExistsAsync(Document document, IdentifierNameSyntax anyIdentifier) + private static async Task ReplaceAnyWithExistsAsync(Document document, SimpleNameSyntax anyName) { var existsIdentifier = SyntaxFactory.IdentifierName("Exists") - .WithLeadingTrivia(anyIdentifier.GetLeadingTrivia()) - .WithTrailingTrivia(anyIdentifier.GetTrailingTrivia()); + .WithLeadingTrivia(anyName.GetLeadingTrivia()) + .WithTrailingTrivia(anyName.GetTrailingTrivia()); - return await document.WithUpdatedRoot(anyIdentifier, existsIdentifier).ConfigureAwait(false); + return await document.WithUpdatedRoot(anyName, existsIdentifier).ConfigureAwait(false); } } diff --git a/src/Creedengo.Core/Analyzers/GCI6605.UseExistsInsteadOfAny.cs b/src/Creedengo.Core/Analyzers/GCI6605.UseExistsInsteadOfAny.cs index b49a8f34..dd0230e4 100644 --- a/src/Creedengo.Core/Analyzers/GCI6605.UseExistsInsteadOfAny.cs +++ b/src/Creedengo.Core/Analyzers/GCI6605.UseExistsInsteadOfAny.cs @@ -13,7 +13,7 @@ public sealed class UseExistsInsteadOfAny : DiagnosticAnalyzer message: "Use 'Exists' instead of 'Any' for improved performance on List", category: Rule.Categories.Performance, severity: DiagnosticSeverity.Warning, - description: "List.Exists(predicate) is more performant than the LINQ extension method Enumerable.Any(predicate) because it avoids delegate allocation and virtual dispatch overhead."); + description: "List.Exists(predicate) is more performant than the LINQ extension method Enumerable.Any(predicate) for List because it avoids enumerator allocation and interface dispatch overhead."); /// public override ImmutableArray SupportedDiagnostics => _supportedDiagnostics; diff --git a/src/Creedengo.Tests/Tests/GCI6605.UseExistsInsteadOfAny.Tests.cs b/src/Creedengo.Tests/Tests/GCI6605.UseExistsInsteadOfAny.Tests.cs index 0b160206..b50ed2d8 100644 --- a/src/Creedengo.Tests/Tests/GCI6605.UseExistsInsteadOfAny.Tests.cs +++ b/src/Creedengo.Tests/Tests/GCI6605.UseExistsInsteadOfAny.Tests.cs @@ -128,6 +128,33 @@ public static void Run() } """); + [TestMethod] + public Task AnyWithExplicitTypeArgumentAsync() => VerifyAsync(""" + using System; + using System.Collections.Generic; + using System.Linq; + public static class Test + { + public static bool Run() + { + var list = new List { 1, 2, 3 }; + return list.[|Any|](x => x > 1); + } + } + """, """ + using System; + using System.Collections.Generic; + using System.Linq; + public static class Test + { + public static bool Run() + { + var list = new List { 1, 2, 3 }; + return list.Exists(x => x > 1); + } + } + """); + #endregion #region Negative cases (should NOT trigger diagnostic)