File: Microsoft.NetCore.Analyzers\Runtime\AvoidRedundantRegexIsMatchBeforeMatch.cs
Web Access
Project: src\sdk\src\Microsoft.CodeAnalysis.NetAnalyzers\src\Microsoft.CodeAnalysis.NetAnalyzers\Microsoft.CodeAnalysis.NetAnalyzers.csproj (Microsoft.CodeAnalysis.NetAnalyzers)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Generic;
using System.Collections.Immutable;
using Analyzer.Utilities;
using Analyzer.Utilities.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;

namespace Microsoft.NetCore.Analyzers.Runtime
{
    using static MicrosoftNetCoreAnalyzersResources;

    /// <summary>
    /// CA2028: Avoid redundant Regex.IsMatch before Regex.Match
    /// <para>
    /// Detects the pattern where <c>Regex.IsMatch</c> is used as a condition and then
    /// <c>Regex.Match</c> is called inside the body with the same arguments, causing
    /// the regex engine to execute twice. The fix is to call <c>Regex.Match</c> once
    /// and check <c>Success</c>.
    /// </para>
    /// </summary>
    [DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)]
    public sealed class AvoidRedundantRegexIsMatchBeforeMatch : DiagnosticAnalyzer
    {
        internal const string RuleId = "CA2028";

        internal static readonly DiagnosticDescriptor Rule = DiagnosticDescriptorHelper.Create(
            RuleId,
            CreateLocalizableResourceString(nameof(AvoidRedundantRegexIsMatchBeforeMatchTitle)),
            CreateLocalizableResourceString(nameof(AvoidRedundantRegexIsMatchBeforeMatchMessage)),
            DiagnosticCategory.Reliability,
            RuleLevel.IdeSuggestion,
            CreateLocalizableResourceString(nameof(AvoidRedundantRegexIsMatchBeforeMatchDescription)),
            isPortedFxCopRule: false,
            isDataflowRule: false);

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

        public override void Initialize(AnalysisContext context)
        {
            context.EnableConcurrentExecution();
            context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);

            context.RegisterCompilationStartAction(context =>
            {
                if (!context.Compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemTextRegularExpressionsRegex, out var regexType))
                {
                    return;
                }

                context.RegisterOperationAction(context =>
                {
                    var conditional = (IConditionalOperation)context.Operation;

                    // OperationKind.Conditional fires for both if-statements and ternary
                    // expressions (`?:` in C#, `If(...)` in VB). Statement-form conditionals
                    // have a null Type; expression-form conditionals have the result Type.
                    // Skip expressions — the rest of the analyzer (and the C# fixer) is
                    // structured around statement-shaped WhenTrue bodies.
                    if (conditional.Type is not null)
                    {
                        return;
                    }

                    // The condition must be a direct call to Regex.IsMatch (not negated).
                    if (UnwrapInvocationFromCondition(conditional.Condition) is not IInvocationOperation isMatchInvocation ||
                        isMatchInvocation.TargetMethod.Name != "IsMatch" ||
                        !SymbolEqualityComparer.Default.Equals(isMatchInvocation.TargetMethod.ContainingType, regexType))
                    {
                        return;
                    }

                    if (conditional.WhenTrue is null)
                    {
                        return;
                    }

                    // Search direct children of the WhenTrue block for Regex.Match calls.
                    IInvocationOperation? matchInvocation = FindMatchingMatchCall(
                        conditional.WhenTrue, isMatchInvocation, regexType);

                    if (matchInvocation is null)
                    {
                        return;
                    }

                    // Report diagnostic on the IsMatch call, with additional location on the Match call.
                    context.ReportDiagnostic(
                        isMatchInvocation.CreateDiagnostic(
                            Rule,
                            ImmutableArray.Create(matchInvocation.Syntax.GetLocation()),
                            properties: null));
                }, OperationKind.Conditional);
            });
        }

        /// <summary>
        /// Unwraps parenthesized and conversion operations from a condition to get the underlying invocation.
        /// </summary>
        private static IInvocationOperation? UnwrapInvocationFromCondition(IOperation operation)
        {
            // Walk through parentheses and conversions (e.g., implicit bool conversion)
            operation = operation.WalkDownParentheses().WalkDownConversion();

            return operation as IInvocationOperation;
        }

        /// <summary>
        /// Searches the WhenTrue block for a Regex.Match call whose arguments are semantically
        /// equivalent to the given IsMatch call.
        /// </summary>
        private static IInvocationOperation? FindMatchingMatchCall(
            IOperation whenTrue,
            IInvocationOperation isMatchInvocation,
            INamedTypeSymbol regexType)
        {
            // If WhenTrue is not a block, check the single operation directly.
            // Still perform the write check for consistency with the block path —
            // if the single statement mutates a tracked operand, don't flag it.
            if (whenTrue is not IBlockOperation block)
            {
                HashSet<ISymbol>? singleTracked = null;
                CollectReferencedMutableSymbols(isMatchInvocation, ref singleTracked);
                if (singleTracked is not null && ContainsWriteToSymbols(whenTrue, singleTracked))
                {
                    return null;
                }

                return TryFindMatchInOperation(whenTrue, isMatchInvocation, regexType);
            }

            // Collect local/parameter symbols referenced by the IsMatch arguments.
            // If any of these are written to before a candidate Match call, the
            // values may differ and the calls are not truly redundant.
            HashSet<ISymbol>? trackedSymbols = null;
            CollectReferencedMutableSymbols(isMatchInvocation, ref trackedSymbols);

            foreach (var op in block.Operations)
            {
                // If this statement writes to any tracked symbol, stop searching.
                // Any Match call in this or later statements could see a different value.
                if (trackedSymbols is not null && ContainsWriteToSymbols(op, trackedSymbols))
                {
                    return null;
                }

                // Look for Match calls in direct children only — don't recurse into
                // lambdas, local functions, or nested blocks (except expression-level nesting).
                if (TryFindMatchInOperation(op, isMatchInvocation, regexType) is { } found)
                {
                    return found;
                }
            }

            return null;
        }

        /// <summary>
        /// Tries to find a matching Regex.Match invocation within a single statement operation.
        /// Only looks at expression-level nesting (e.g., within variable declarations, assignments,
        /// member access chains) — does NOT recurse into nested control flow, lambdas, or local functions.
        /// </summary>
        private static IInvocationOperation? TryFindMatchInOperation(
            IOperation operation,
            IInvocationOperation isMatchInvocation,
            INamedTypeSymbol regexType)
        {
            // Handle variable declaration: Match m = Regex.Match(...)
            if (operation is IVariableDeclarationGroupOperation declGroup)
            {
                foreach (var declaration in declGroup.Declarations)
                {
                    // In VB, the initializer is on the declaration (not the declarator).
                    if (declaration.Initializer?.Value is { } declInitValue)
                    {
                        var found = FindMatchInExpression(declInitValue, isMatchInvocation, regexType);
                        if (found is not null)
                        {
                            return found;
                        }
                    }

                    // In C#, the initializer is on each declarator.
                    foreach (var declarator in declaration.Declarators)
                    {
                        if (declarator.Initializer?.Value is { } initValue)
                        {
                            var found = FindMatchInExpression(initValue, isMatchInvocation, regexType);
                            if (found is not null)
                            {
                                return found;
                            }
                        }
                    }
                }

                return null;
            }

            // Handle expression statements: Regex.Match(...) or m = Regex.Match(...)
            if (operation is IExpressionStatementOperation exprStatement)
            {
                return FindMatchInExpression(exprStatement.Operation, isMatchInvocation, regexType);
            }

            // Handle return statements: return Regex.Match(...)
            if (operation is IReturnOperation returnOp && returnOp.ReturnedValue is not null)
            {
                return FindMatchInExpression(returnOp.ReturnedValue, isMatchInvocation, regexType);
            }

            return null;
        }

        /// <summary>
        /// Walks an expression tree looking for a Regex.Match invocation with equivalent arguments.
        /// Walks into member access, indexer access, invocation arguments, assignment right-hand sides,
        /// and conversions — but NOT into lambdas, anonymous functions, or nested blocks.
        /// </summary>
        private static IInvocationOperation? FindMatchInExpression(
            IOperation expression,
            IInvocationOperation isMatchInvocation,
            INamedTypeSymbol regexType)
        {
            expression = expression.WalkDownParentheses().WalkDownConversion();

            // Direct invocation: check if it's a matching Regex.Match call.
            if (expression is IInvocationOperation invocation)
            {
                if (IsMatchingMatchCall(invocation, isMatchInvocation, regexType))
                {
                    return invocation;
                }

                // Also check arguments of this invocation (e.g., SomeMethod(Regex.Match(...)))
                // and the instance receiver.
                if (invocation.Instance is not null)
                {
                    var found = FindMatchInExpression(invocation.Instance, isMatchInvocation, regexType);
                    if (found is not null)
                    {
                        return found;
                    }
                }

                foreach (var arg in invocation.Arguments)
                {
                    var found = FindMatchInExpression(arg.Value, isMatchInvocation, regexType);
                    if (found is not null)
                    {
                        return found;
                    }
                }

                return null;
            }

            // Member access: e.g., Regex.Match(...).Groups[1].Value
            if (expression is IPropertyReferenceOperation propRef)
            {
                if (propRef.Instance is not null)
                {
                    var found = FindMatchInExpression(propRef.Instance, isMatchInvocation, regexType);
                    if (found is not null)
                    {
                        return found;
                    }
                }

                if (propRef.Property.IsIndexer)
                {
                    foreach (var arg in propRef.Arguments)
                    {
                        var found = FindMatchInExpression(arg.Value, isMatchInvocation, regexType);
                        if (found is not null)
                        {
                            return found;
                        }
                    }
                }

                return null;
            }

            // Array/indexer element access
            if (expression is IArrayElementReferenceOperation arrayRef)
            {
                var found = FindMatchInExpression(arrayRef.ArrayReference, isMatchInvocation, regexType);
                if (found is not null)
                {
                    return found;
                }

                foreach (var index in arrayRef.Indices)
                {
                    found = FindMatchInExpression(index, isMatchInvocation, regexType);
                    if (found is not null)
                    {
                        return found;
                    }
                }

                return null;
            }

            // Simple assignment: x = Regex.Match(...)
            if (expression is ISimpleAssignmentOperation assignment)
            {
                return FindMatchInExpression(assignment.Value, isMatchInvocation, regexType);
            }

            // Conditional access: Regex.Match(...)?.Groups or obj?.Method(Regex.Match(...))
            if (expression is IConditionalAccessOperation condAccess)
            {
                return FindMatchInExpression(condAccess.Operation, isMatchInvocation, regexType)
                    ?? FindMatchInExpression(condAccess.WhenNotNull, isMatchInvocation, regexType);
            }

            // Object/collection creation: new Foo(Regex.Match(...)) / new[] { Regex.Match(...) }
            if (expression is IObjectCreationOperation objCreation)
            {
                foreach (var arg in objCreation.Arguments)
                {
                    var found = FindMatchInExpression(arg.Value, isMatchInvocation, regexType);
                    if (found is not null)
                    {
                        return found;
                    }
                }

                if (objCreation.Initializer is not null)
                {
                    var found = FindMatchInExpression(objCreation.Initializer, isMatchInvocation, regexType);
                    if (found is not null)
                    {
                        return found;
                    }
                }

                return null;
            }

            // Object/collection initializer: walk the member assignments
            // (e.g. `new Foo { Bar = Regex.Match(...) }`) and any nested initializers.
            if (expression is IObjectOrCollectionInitializerOperation initializer)
            {
                foreach (var inner in initializer.Initializers)
                {
                    var found = FindMatchInExpression(inner, isMatchInvocation, regexType);
                    if (found is not null)
                    {
                        return found;
                    }
                }

                return null;
            }

            if (expression is IArrayCreationOperation arrayCreation && arrayCreation.Initializer is not null)
            {
                foreach (var element in arrayCreation.Initializer.ElementValues)
                {
                    var found = FindMatchInExpression(element, isMatchInvocation, regexType);
                    if (found is not null)
                    {
                        return found;
                    }
                }

                return null;
            }

            // Coalesce: Regex.Match(...) ?? defaultMatch
            if (expression is ICoalesceOperation coalesce)
            {
                return FindMatchInExpression(coalesce.Value, isMatchInvocation, regexType)
                    ?? FindMatchInExpression(coalesce.WhenNull, isMatchInvocation, regexType);
            }

            // Tuple: (Regex.Match(...), other)
            if (expression is ITupleOperation tuple)
            {
                foreach (var element in tuple.Elements)
                {
                    var found = FindMatchInExpression(element, isMatchInvocation, regexType);
                    if (found is not null)
                    {
                        return found;
                    }
                }

                return null;
            }

            // Interpolated string: $"{Regex.Match(...).Value}"
            if (expression is IInterpolatedStringOperation interpString)
            {
                foreach (var part in interpString.Parts)
                {
                    if (part is IInterpolationOperation interpolation)
                    {
                        var found = FindMatchInExpression(interpolation.Expression, isMatchInvocation, regexType);
                        if (found is not null)
                        {
                            return found;
                        }
                    }
                }

                return null;
            }

            // Binary: Regex.Match(...) != null, x + Regex.Match(...).Length, etc.
            if (expression is IBinaryOperation binary)
            {
                return FindMatchInExpression(binary.LeftOperand, isMatchInvocation, regexType)
                    ?? FindMatchInExpression(binary.RightOperand, isMatchInvocation, regexType);
            }

            // Unary: !Regex.Match(...).Success, -Regex.Match(...).Length, etc.
            if (expression is IUnaryOperation unary)
            {
                return FindMatchInExpression(unary.Operand, isMatchInvocation, regexType);
            }

            // Await: await Task.FromResult(Regex.Match(...))
            if (expression is IAwaitOperation awaitOp)
            {
                return FindMatchInExpression(awaitOp.Operation, isMatchInvocation, regexType);
            }

            return null;
        }

        /// <summary>
        /// Checks whether a given invocation is a Regex.Match call with arguments semantically
        /// equivalent to the provided IsMatch call.
        /// </summary>
        private static bool IsMatchingMatchCall(
            IInvocationOperation matchInvocation,
            IInvocationOperation isMatchInvocation,
            INamedTypeSymbol regexType)
        {
            // Must be a Regex.Match method
            if (matchInvocation.TargetMethod.Name != "Match" ||
                !SymbolEqualityComparer.Default.Equals(matchInvocation.TargetMethod.ContainingType, regexType))
            {
                return false;
            }

            // Both must be static or both instance
            if (isMatchInvocation.TargetMethod.IsStatic != matchInvocation.TargetMethod.IsStatic)
            {
                return false;
            }

            // For instance methods, verify same receiver
            if (!isMatchInvocation.TargetMethod.IsStatic)
            {
                if (!AreOperandsEquivalent(isMatchInvocation.Instance, matchInvocation.Instance))
                {
                    return false;
                }
            }

            // Both must have the same parameter types (same overload shape)
            if (!ParameterTypesMatch(
                    isMatchInvocation.TargetMethod.Parameters,
                    matchInvocation.TargetMethod.Parameters))
            {
                return false;
            }

            // All arguments must be semantically equivalent
            if (isMatchInvocation.Arguments.Length != matchInvocation.Arguments.Length)
            {
                return false;
            }

            // Compare arguments by parameter ordinal rather than array index,
            // because named arguments may reorder the Arguments array.
            foreach (IArgumentOperation isMatchArg in isMatchInvocation.Arguments)
            {
                if (isMatchArg.Parameter is null)
                {
                    return false;
                }

                IArgumentOperation? matchArg = null;
                foreach (IArgumentOperation candidate in matchInvocation.Arguments)
                {
                    if (candidate.Parameter?.Ordinal == isMatchArg.Parameter.Ordinal)
                    {
                        matchArg = candidate;
                        break;
                    }
                }

                if (matchArg is null ||
                    !AreOperandsEquivalent(isMatchArg.Value, matchArg.Value))
                {
                    return false;
                }
            }

            return true;
        }

        /// <summary>
        /// Checks whether two lists of parameters have the same types in the same order.
        /// </summary>
        private static bool ParameterTypesMatch(
            ImmutableArray<IParameterSymbol> left,
            ImmutableArray<IParameterSymbol> right)
        {
            if (left.Length != right.Length)
            {
                return false;
            }

            for (int i = 0; i < left.Length; i++)
            {
                if (!SymbolEqualityComparer.Default.Equals(left[i].Type, right[i].Type))
                {
                    return false;
                }
            }

            return true;
        }

        /// <summary>
        /// Checks whether two operations refer to the same value. Only considers stable
        /// operand sources: locals, parameters, constants, and readonly fields.
        /// </summary>
        private static bool AreOperandsEquivalent(IOperation? left, IOperation? right)
        {
            if (left is null || right is null)
            {
                return left is null && right is null;
            }

            left = left.WalkDownParentheses().WalkDownConversion();
            right = right.WalkDownParentheses().WalkDownConversion();

            // Same local variable reference
            if (left is ILocalReferenceOperation leftLocal &&
                right is ILocalReferenceOperation rightLocal)
            {
                return SymbolEqualityComparer.Default.Equals(leftLocal.Local, rightLocal.Local);
            }

            // Same parameter reference
            if (left is IParameterReferenceOperation leftParam &&
                right is IParameterReferenceOperation rightParam)
            {
                return SymbolEqualityComparer.Default.Equals(leftParam.Parameter, rightParam.Parameter);
            }

            // Same constant value
            if (left.ConstantValue.HasValue && right.ConstantValue.HasValue)
            {
                return Equals(left.ConstantValue.Value, right.ConstantValue.Value);
            }

            // Same instance reference (this/Me).
            // Restrict to reference types only: `this` is reassignable inside struct
            // instance methods (e.g. `this = new S(...)`), so two `this` references
            // separated by such a write are not necessarily the same value.
            if (left is IInstanceReferenceOperation leftInstance &&
                right is IInstanceReferenceOperation rightInstance)
            {
                return leftInstance.Type is { IsValueType: false } &&
                       rightInstance.Type is { IsValueType: false };
            }

            // Same field reference (readonly fields only for safety)
            if (left is IFieldReferenceOperation leftField &&
                right is IFieldReferenceOperation rightField)
            {
                if (!SymbolEqualityComparer.Default.Equals(leftField.Field, rightField.Field))
                {
                    return false;
                }

                // Only trust readonly/const fields
                if (!leftField.Field.IsReadOnly && !leftField.Field.IsConst)
                {
                    return false;
                }

                // For instance fields, receivers must also be equivalent
                if (!leftField.Field.IsStatic)
                {
                    return AreOperandsEquivalent(leftField.Instance, rightField.Instance);
                }

                return true;
            }

            return false;
        }

        /// <summary>
        /// Collects all local and parameter symbols directly referenced by the invocation's
        /// arguments and instance receiver.
        /// </summary>
        private static void CollectReferencedMutableSymbols(
            IInvocationOperation invocation,
            ref HashSet<ISymbol>? symbols)
        {
            foreach (var arg in invocation.Arguments)
            {
                AddMutableSymbol(arg.Value, ref symbols);
            }

            if (invocation.Instance is not null)
            {
                AddMutableSymbol(invocation.Instance, ref symbols);
            }

            static void AddMutableSymbol(IOperation operation, ref HashSet<ISymbol>? symbols)
            {
                operation = operation.WalkDownParentheses().WalkDownConversion();

                if (operation is ILocalReferenceOperation localRef)
                {
                    symbols ??= new HashSet<ISymbol>(SymbolEqualityComparer.Default);
                    symbols.Add(localRef.Local);
                }
                else if (operation is IParameterReferenceOperation paramRef)
                {
                    symbols ??= new HashSet<ISymbol>(SymbolEqualityComparer.Default);
                    symbols.Add(paramRef.Parameter);
                }
                else if (operation is IFieldReferenceOperation fieldRef && fieldRef.Instance is not null)
                {
                    // For h.ReadonlyField, track h so that reassignment of h is detected.
                    AddMutableSymbol(fieldRef.Instance, ref symbols);
                }
            }
        }

        /// <summary>
        /// Checks whether an operation or any of its descendants writes to any of the
        /// given symbols (via assignment, compound assignment, increment/decrement, or
        /// ref/out argument passing). Does not recurse into lambdas or local functions.
        /// </summary>
        private static bool ContainsWriteToSymbols(IOperation operation, HashSet<ISymbol> symbols)
        {
            // Local function and lambda bodies are not executed at the declaration
            // point, so writes inside them are not intervening writes.
            if (operation is IAnonymousFunctionOperation or ILocalFunctionOperation)
            {
                return false;
            }

            ISymbol? writtenSymbol = GetWrittenSymbol(operation);
            if (writtenSymbol is not null && symbols.Contains(writtenSymbol))
            {
                return true;
            }

            // Check for ref/out argument passing which can mutate tracked symbols.
            if (operation is IArgumentOperation argOp &&
                argOp.Parameter is not null &&
                argOp.Parameter.RefKind is RefKind.Ref or RefKind.Out)
            {
                var argValue = argOp.Value.WalkDownParentheses().WalkDownConversion();
                ISymbol? argSymbol = argValue switch
                {
                    ILocalReferenceOperation localRef => localRef.Local,
                    IParameterReferenceOperation paramRef => paramRef.Parameter,
                    _ => null
                };

                if (argSymbol is not null && symbols.Contains(argSymbol))
                {
                    return true;
                }
            }

            // Check for deconstruction assignments: (x, y) = expr
            // The target tuple elements are written to, but aren't modeled as
            // individual assignment operations.
            if (operation is IDeconstructionAssignmentOperation decon &&
                ContainsTrackedSymbolReference(decon.Target, symbols))
            {
                return true;
            }

            foreach (var child in operation.Children)
            {
                if (child is IAnonymousFunctionOperation or ILocalFunctionOperation)
                {
                    continue;
                }

                if (ContainsWriteToSymbols(child, symbols))
                {
                    return true;
                }
            }

            return false;
        }

        /// <summary>
        /// If the operation is an assignment or increment/decrement targeting a local or parameter,
        /// returns that symbol. Otherwise returns null.
        /// </summary>
        private static ISymbol? GetWrittenSymbol(IOperation operation)
        {
            IOperation? target = operation switch
            {
                ISimpleAssignmentOperation assignment => assignment.Target,
                ICompoundAssignmentOperation compound => compound.Target,
                ICoalesceAssignmentOperation coalesce => coalesce.Target,
                IIncrementOrDecrementOperation incDec => incDec.Target,
                _ => null
            };

            if (target is null)
            {
                return null;
            }

            target = target.WalkDownParentheses().WalkDownConversion();

            return target switch
            {
                ILocalReferenceOperation localRef => localRef.Local,
                IParameterReferenceOperation paramRef => paramRef.Parameter,
                _ => null
            };
        }
        /// <summary>
        /// Checks whether an operation tree contains a local or parameter reference
        /// to any of the tracked symbols. Used for deconstruction assignment targets.
        /// </summary>
        private static bool ContainsTrackedSymbolReference(IOperation operation, HashSet<ISymbol> symbols)
        {
            var unwrapped = operation.WalkDownParentheses().WalkDownConversion();

            ISymbol? symbol = unwrapped switch
            {
                ILocalReferenceOperation localRef => localRef.Local,
                IParameterReferenceOperation paramRef => paramRef.Parameter,
                _ => null
            };

            if (symbol is not null && symbols.Contains(symbol))
            {
                return true;
            }

            foreach (var child in unwrapped.Children)
            {
                if (ContainsTrackedSymbolReference(child, symbols))
                {
                    return true;
                }
            }

            return false;
        }
    }
}