|
// 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.Immutable;
using System.Composition;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Analyzer.Utilities;
using Analyzer.Utilities.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Editing;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.NetCore.Analyzers;
using Microsoft.NetCore.Analyzers.Runtime;
namespace Microsoft.NetCore.CSharp.Analyzers.Runtime
{
/// <summary>
/// C#-specific fixer for <see cref="AvoidRedundantRegexIsMatchBeforeMatch"/>.
/// Transforms:
/// <code>
/// if (Regex.IsMatch(input, pattern))
/// {
/// Match m = Regex.Match(input, pattern);
/// // use m
/// }
/// </code>
/// Into:
/// <code>
/// if (Regex.Match(input, pattern) is { Success: true } m)
/// {
/// // use m
/// }
/// </code>
/// </summary>
[ExportCodeFixProvider(LanguageNames.CSharp), Shared]
public sealed class CSharpAvoidRedundantRegexIsMatchBeforeMatchFixer : CodeFixProvider
{
public sealed override ImmutableArray<string> FixableDiagnosticIds { get; } =
ImmutableArray.Create(AvoidRedundantRegexIsMatchBeforeMatch.RuleId);
public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var diagnostic = context.Diagnostics[0];
Document doc = context.Document;
SyntaxNode root = await doc.GetRequiredSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
// Require C# 8.0+ for property patterns (is { Success: true } m)
if (root.SyntaxTree.Options is CSharpParseOptions parseOptions &&
parseOptions.LanguageVersion < LanguageVersion.CSharp8)
{
return;
}
// Find the IsMatch invocation from the primary diagnostic location.
if (root.FindNode(context.Span, getInnermostNodeForTie: true) is not SyntaxNode isMatchNode)
{
return;
}
// Find the Match invocation from the additional location.
if (diagnostic.AdditionalLocations.Count < 1)
{
return;
}
var matchLocation = diagnostic.AdditionalLocations[0];
if (root.FindNode(matchLocation.SourceSpan, getInnermostNodeForTie: true) is not SyntaxNode matchNode)
{
return;
}
// The IsMatch call must be the condition of an if statement.
var ifStatement = isMatchNode.FirstAncestorOrSelf<IfStatementSyntax>();
if (ifStatement is null)
{
return;
}
SemanticModel model = await doc.GetRequiredSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
// Path 1: Match m = Regex.Match(...); — local declaration in if body
var matchDeclarationStatement = matchNode.FirstAncestorOrSelf<LocalDeclarationStatementSyntax>();
if (matchDeclarationStatement is not null)
{
TryRegisterDeclarationFix(context, diagnostic, doc, model, ifStatement, matchDeclarationStatement, matchNode);
return;
}
// Path 2: m = Regex.Match(...); — assignment to pre-declared variable
var assignmentExpression = matchNode.FirstAncestorOrSelf<AssignmentExpressionSyntax>();
if (assignmentExpression is not null)
{
TryRegisterAssignmentFix(context, diagnostic, doc, model, ifStatement, assignmentExpression, matchNode);
}
}
/// <summary>
/// Path 1: The Match result is assigned via a local declaration in the if body:
/// <c>Match m = Regex.Match(...);</c>
/// </summary>
private static void TryRegisterDeclarationFix(
CodeFixContext context,
Diagnostic diagnostic,
Document doc,
SemanticModel model,
IfStatementSyntax ifStatement,
LocalDeclarationStatementSyntax matchDeclarationStatement,
SyntaxNode matchNode)
{
var declaration = matchDeclarationStatement.Declaration;
if (declaration.Variables.Count != 1)
{
return;
}
var declarator = declaration.Variables[0];
if (declarator.Initializer?.Value is null)
{
return;
}
// Verify the initializer is exactly the Match invocation reported by the analyzer
// (unwrapping any parentheses/casts). If the Match call is embedded inside a larger
// expression (e.g., SomeMethod(Regex.Match(...))), the fix would change semantics.
if (!IsMatchNode(declarator.Initializer.Value, matchNode))
{
return;
}
// Only apply fixer when the declared type is 'var' or exactly
// System.Text.RegularExpressions.Match. If the user wrote a wider type
// (e.g., Group, Capture, object), the pattern variable would change
// the static type and could alter overload resolution.
if (!IsVarOrMatchType(declaration.Type, model, context.CancellationToken))
{
return;
}
string variableName = declarator.Identifier.ValueText;
// Only apply fixer when the Match declaration is the first executable statement
// in the if body. If there are preceding statements, moving Match into the
// condition would change their execution order relative to the Match call.
if (ifStatement.Statement is BlockSyntax block)
{
var firstStatement = block.Statements.FirstOrDefault();
if (firstStatement != matchDeclarationStatement)
{
return;
}
}
if (!PassesNameConflictChecks(ifStatement, variableName))
{
return;
}
string title = MicrosoftNetCoreAnalyzersResources.AvoidRedundantRegexIsMatchBeforeMatchFix;
context.RegisterCodeFix(
CodeAction.Create(
title,
createChangedDocument: ct => ApplyDeclarationFixAsync(doc, ifStatement, matchDeclarationStatement, variableName, ct),
equivalenceKey: title),
diagnostic);
}
/// <summary>
/// Path 2: The Match result is assigned to a pre-declared variable in the if body:
/// <c>Match m; if (IsMatch(...)) { m = Regex.Match(...); }</c>
/// Transforms to: <c>if (Regex.Match(...) is { Success: true } m) { }</c>
/// Only applies when the pre-existing declaration is immediately before the if
/// and the variable is not referenced after the if statement.
/// </summary>
private static void TryRegisterAssignmentFix(
CodeFixContext context,
Diagnostic diagnostic,
Document doc,
SemanticModel model,
IfStatementSyntax ifStatement,
AssignmentExpressionSyntax assignmentExpression,
SyntaxNode matchNode)
{
// The left side must be a simple identifier (the variable being assigned).
if (assignmentExpression.Left is not IdentifierNameSyntax identName)
{
return;
}
// Verify the right side is exactly the Match invocation.
if (!IsMatchNode(assignmentExpression.Right, matchNode))
{
return;
}
// The assignment must be in an expression statement.
var assignmentStatement = assignmentExpression.FirstAncestorOrSelf<ExpressionStatementSyntax>();
if (assignmentStatement is null)
{
return;
}
// The assignment statement must be the first statement in a block body.
if (ifStatement.Statement is not BlockSyntax block ||
block.Statements.FirstOrDefault() != assignmentStatement)
{
return;
}
// The if must be inside a block so we can find the preceding declaration.
if (ifStatement.Parent is not BlockSyntax parentBlock)
{
return;
}
string variableName = identName.Identifier.ValueText;
// Find the pre-existing declaration of the variable immediately before the if.
int ifIndex = parentBlock.Statements.IndexOf(ifStatement);
if (ifIndex <= 0)
{
return;
}
if (parentBlock.Statements[ifIndex - 1] is not LocalDeclarationStatementSyntax preDecl)
{
return;
}
if (preDecl.Declaration.Variables.Count != 1)
{
return;
}
var preVar = preDecl.Declaration.Variables[0];
if (preVar.Identifier.ValueText != variableName)
{
return;
}
// The pre-existing declaration must have no initializer, or be initialized
// to a constant default expression (`null`, `default`, or `default(T)`) so
// removing it doesn't lose meaningful computation.
if (preVar.Initializer is not null)
{
var initValue = preVar.Initializer.Value;
bool acceptable = initValue switch
{
LiteralExpressionSyntax literal =>
literal.IsKind(SyntaxKind.NullLiteralExpression) ||
literal.IsKind(SyntaxKind.DefaultLiteralExpression),
DefaultExpressionSyntax => true,
_ => false,
};
if (!acceptable)
{
return;
}
}
// Verify the declared type is 'var' or exactly Match.
if (!IsVarOrMatchType(preDecl.Declaration.Type, model, context.CancellationToken))
{
return;
}
// The variable must not be referenced in any statement after the if
// or in the else branch, because the pattern variable won't be
// definitely assigned there.
if (IsVariableReferencedAfterIf(parentBlock, ifIndex, variableName) ||
IsVariableReferencedInElse(ifStatement, variableName))
{
return;
}
if (!PassesNameConflictChecks(ifStatement, variableName))
{
return;
}
string title = MicrosoftNetCoreAnalyzersResources.AvoidRedundantRegexIsMatchBeforeMatchFix;
context.RegisterCodeFix(
CodeAction.Create(
title,
createChangedDocument: ct => ApplyAssignmentFixAsync(doc, ifStatement, preDecl, assignmentStatement, variableName, ct),
equivalenceKey: title),
diagnostic);
}
/// <summary>
/// Checks whether <paramref name="expression"/> is exactly the Match invocation
/// <paramref name="matchNode"/> after unwrapping parentheses and casts.
/// </summary>
private static bool IsMatchNode(ExpressionSyntax expression, SyntaxNode matchNode)
{
SyntaxNode core = expression;
while (core is ParenthesizedExpressionSyntax parenExpr)
{
core = parenExpr.Expression;
}
while (core is CastExpressionSyntax castExpr)
{
core = castExpr.Expression;
}
return core.Span.Equals(matchNode.Span);
}
/// <summary>
/// Returns true when the type syntax is <c>var</c> or exactly
/// <c>System.Text.RegularExpressions.Match</c>.
/// </summary>
private static bool IsVarOrMatchType(
TypeSyntax typeSyntax, SemanticModel model, CancellationToken cancellationToken)
{
if (typeSyntax.IsVar)
{
return true;
}
var typeInfo = model.GetTypeInfo(typeSyntax, cancellationToken);
var matchType = model.Compilation.GetTypeByMetadataName("System.Text.RegularExpressions.Match");
return typeInfo.Type is not null &&
matchType is not null &&
SymbolEqualityComparer.Default.Equals(typeInfo.Type, matchType);
}
/// <summary>
/// Returns true when the variable name doesn't conflict with bindings in else
/// branches or subsequent sibling statements.
/// </summary>
private static bool PassesNameConflictChecks(
IfStatementSyntax ifStatement, string variableName)
{
if (ifStatement.Else is not null &&
HasConflictingName(ifStatement.Else, variableName))
{
return false;
}
if (HasConflictingNameInSubsequentSiblings(ifStatement, variableName))
{
return false;
}
return true;
}
/// <summary>
/// Checks whether the variable is referenced in any statement after
/// <paramref name="ifIndex"/> in the parent block.
/// </summary>
private static bool IsVariableReferencedAfterIf(
BlockSyntax parentBlock, int ifIndex, string variableName)
{
for (int i = ifIndex + 1; i < parentBlock.Statements.Count; i++)
{
if (ContainsIdentifierReference(parentBlock.Statements[i], variableName))
{
return true;
}
}
return false;
}
private static bool IsVariableReferencedInElse(
IfStatementSyntax ifStatement, string variableName)
{
if (ifStatement.Else is null)
{
return false;
}
return ContainsIdentifierReference(ifStatement.Else, variableName);
}
/// <summary>
/// Returns true when <paramref name="node"/> contains an identifier that
/// could plausibly bind to a local named <paramref name="variableName"/>.
/// Excludes identifiers that are syntactically the name of a member access
/// (e.g. <c>obj.m</c>) or a qualified name suffix, since those bind to a
/// member rather than the local. Conservative — does not consult the
/// semantic model, so it can still over-report (e.g. type names, nameof).
/// </summary>
private static bool ContainsIdentifierReference(SyntaxNode node, string variableName)
{
foreach (var id in node.DescendantNodesAndSelf().OfType<IdentifierNameSyntax>())
{
if (id.Identifier.ValueText != variableName)
{
continue;
}
// Skip `something.m` (right-hand side of a member access) — `m` here is
// a member name, not a reference to the local.
if (id.Parent is MemberAccessExpressionSyntax memberAccess && memberAccess.Name == id)
{
continue;
}
// Skip `Foo.m` where `m` is the right-hand side of a qualified name.
if (id.Parent is QualifiedNameSyntax qualified && qualified.Right == id)
{
continue;
}
// Skip `M(m: value)` — `m` here is a parameter name label, not a reference.
if (id.Parent is NameColonSyntax nameColon && nameColon.Name == id)
{
continue;
}
return true;
}
return false;
}
/// <summary>
/// Applies the fix for Path 1 (local declaration in if body).
/// </summary>
private static async Task<Document> ApplyDeclarationFixAsync(
Document document,
IfStatementSyntax ifStatement,
LocalDeclarationStatementSyntax matchDeclarationStatement,
string variableName,
CancellationToken cancellationToken)
{
var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false);
var matchCallExpression = matchDeclarationStatement.Declaration.Variables[0].Initializer!.Value;
editor.ReplaceNode(ifStatement.Condition, BuildIsPatternCondition(ifStatement, matchCallExpression, variableName));
editor.RemoveNode(matchDeclarationStatement);
return editor.GetChangedDocument();
}
/// <summary>
/// Applies the fix for Path 2 (assignment to pre-declared variable).
/// </summary>
private static async Task<Document> ApplyAssignmentFixAsync(
Document document,
IfStatementSyntax ifStatement,
LocalDeclarationStatementSyntax preDeclaration,
ExpressionStatementSyntax assignmentStatement,
string variableName,
CancellationToken cancellationToken)
{
var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false);
var assignmentExpr = (AssignmentExpressionSyntax)assignmentStatement.Expression;
var matchCallExpression = assignmentExpr.Right;
editor.ReplaceNode(ifStatement.Condition, BuildIsPatternCondition(ifStatement, matchCallExpression, variableName));
editor.RemoveNode(assignmentStatement);
editor.RemoveNode(preDeclaration);
return editor.GetChangedDocument();
}
/// <summary>
/// Builds: <c>Regex.Match(input, pattern) is { Success: true } m</c>
/// </summary>
private static IsPatternExpressionSyntax BuildIsPatternCondition(
IfStatementSyntax ifStatement,
ExpressionSyntax matchCallExpression,
string variableName)
{
var successPattern = SyntaxFactory.RecursivePattern()
.WithPropertyPatternClause(
SyntaxFactory.PropertyPatternClause(
SyntaxFactory.SeparatedList(new[]
{
SyntaxFactory.Subpattern(
SyntaxFactory.NameColon(SyntaxFactory.IdentifierName("Success")),
SyntaxFactory.ConstantPattern(
SyntaxFactory.LiteralExpression(SyntaxKind.TrueLiteralExpression)))
})))
.WithDesignation(
SyntaxFactory.SingleVariableDesignation(
SyntaxFactory.Identifier(variableName)))
.NormalizeWhitespace();
return SyntaxFactory.IsPatternExpression(
matchCallExpression.WithoutTrivia(),
successPattern)
.WithLeadingTrivia(ifStatement.Condition.GetLeadingTrivia())
.WithTrailingTrivia(SyntaxFactory.TriviaList())
.WithAdditionalAnnotations(Formatter.Annotation);
}
/// <summary>
/// Checks whether the given syntax node (typically an else clause) contains any
/// variable binding with the specified name — including variable declarators,
/// pattern designations (is/switch patterns), out var, foreach, and catch.
/// </summary>
private static bool HasConflictingName(SyntaxNode node, string variableName)
{
foreach (var descendant in node.DescendantNodes())
{
if (descendant is VariableDeclaratorSyntax declarator &&
declarator.Identifier.ValueText == variableName)
{
return true;
}
if (descendant is SingleVariableDesignationSyntax designation &&
designation.Identifier.ValueText == variableName)
{
return true;
}
if (descendant is ForEachStatementSyntax forEach &&
forEach.Identifier.ValueText == variableName)
{
return true;
}
// Deconstruction foreach: foreach (var (x, y) in ...)
if (descendant is ForEachVariableStatementSyntax forEachVariable &&
forEachVariable.Variable
.DescendantNodesAndSelf()
.OfType<SingleVariableDesignationSyntax>()
.Any(d => d.Identifier.ValueText == variableName))
{
return true;
}
if (descendant is CatchDeclarationSyntax catchDecl &&
catchDecl.Identifier.ValueText == variableName)
{
return true;
}
// Lambda, anonymous method, and local function parameters
if (descendant is ParameterSyntax parameter &&
parameter.Identifier.ValueText == variableName)
{
return true;
}
// LINQ query range variables. Pattern variables introduced by
// `is { ... } m` scope to the enclosing block, so any subsequent
// query that already binds the same name would start conflicting.
if (descendant is FromClauseSyntax fromClause &&
fromClause.Identifier.ValueText == variableName)
{
return true;
}
if (descendant is LetClauseSyntax letClause &&
letClause.Identifier.ValueText == variableName)
{
return true;
}
if (descendant is JoinClauseSyntax joinClause &&
joinClause.Identifier.ValueText == variableName)
{
return true;
}
if (descendant is JoinIntoClauseSyntax joinIntoClause &&
joinIntoClause.Identifier.ValueText == variableName)
{
return true;
}
if (descendant is QueryContinuationSyntax queryCont &&
queryCont.Identifier.ValueText == variableName)
{
return true;
}
}
return false;
}
/// <summary>
/// Checks whether any statement after the given if statement in its parent block
/// declares a variable with the specified name. Pattern variables from the if
/// condition scope to the entire enclosing block, so later declarations conflict.
/// For parent containers other than <see cref="BlockSyntax"/>, conservatively
/// assume a conflict because this helper only scans block statements.
/// </summary>
private static bool HasConflictingNameInSubsequentSiblings(
IfStatementSyntax ifStatement, string variableName)
{
// Walk up through else-if chains to find the outermost if statement.
// Pattern variables scope to the enclosing block, so for an "else if" we
// must check siblings after the outermost if in that chain.
SyntaxNode current = ifStatement;
while (current.Parent is ElseClauseSyntax elseClause &&
elseClause.Parent is IfStatementSyntax parentIf)
{
current = parentIf;
}
if (current.Parent is not BlockSyntax parentBlock)
{
return true;
}
bool foundIf = false;
foreach (var statement in parentBlock.Statements)
{
if (statement == current)
{
foundIf = true;
continue;
}
if (foundIf && HasConflictingName(statement, variableName))
{
return true;
}
}
return false;
}
}
}
|