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..001821e7 --- /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 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 ReplaceAnyWithExistsAsync(Document document, SimpleNameSyntax anyName) + { + var existsIdentifier = SyntaxFactory.IdentifierName("Exists") + .WithLeadingTrivia(anyName.GetLeadingTrivia()) + .WithTrailingTrivia(anyName.GetTrailingTrivia()); + + 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 new file mode 100644 index 00000000..dd0230e4 --- /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) for List because it avoids enumerator allocation and interface 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..b50ed2d8 --- /dev/null +++ b/src/Creedengo.Tests/Tests/GCI6605.UseExistsInsteadOfAny.Tests.cs @@ -0,0 +1,248 @@ +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"); + } + } + """); + + [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) + + [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 +}