// 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;
}
}
}