Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,368 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Collections.Immutable;
using System.Composition;

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 MSTest.Analyzers.Helpers;

namespace MSTest.Analyzers;

/// <summary>
/// Code fix for MSTEST0023: Do not negate boolean assertions.
/// </summary>
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(DoNotNegateBooleanAssertionFixer))]
[Shared]
public sealed class DoNotNegateBooleanAssertionFixer : CodeFixProvider
{
/// <inheritdoc />
public override ImmutableArray<string> FixableDiagnosticIds { get; }
= ImmutableArray.Create(DiagnosticIds.DoNotNegateBooleanAssertionRuleId);

/// <inheritdoc />
public override FixAllProvider GetFixAllProvider()
=> WellKnownFixAllProviders.BatchFixer;
Comment on lines +30 to +31
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GetFixAllProvider method returns WellKnownFixAllProviders.BatchFixer, but there's a complete FixAll class implementation (lines 235-367) that is never used. Either the method should return new FixAll() to use the custom implementation, or the unused FixAll class should be removed if BatchFixer is sufficient for this scenario.

Copilot uses AI. Check for mistakes.

/// <inheritdoc />
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
Diagnostic diagnostic = context.Diagnostics[0];
SyntaxNode? root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);

if (root is null)
{
return;
}

SyntaxNode node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true);
if (node is not InvocationExpressionSyntax invocation)
{
return;
}

// Get the proper assert method name from diagnostic properties
if (!diagnostic.Properties.TryGetValue(DoNotNegateBooleanAssertionAnalyzer.ProperAssertMethodNameKey, out string? properAssertMethodName) ||
properAssertMethodName is null)
{
return;
}

// Retrieve the condition argument from the additional locations provided by the analyzer
if (diagnostic.AdditionalLocations.Count == 0)
{
return;
}

// Find the argument node that corresponds to the additional location
SyntaxNode? argumentNode = root.FindNode(diagnostic.AdditionalLocations[0].SourceSpan);

// Walk up to find the ArgumentSyntax if we got a child node
while (argumentNode is not null and not ArgumentSyntax)
{
argumentNode = argumentNode.Parent;
}

if (argumentNode is not ArgumentSyntax conditionArgument)
{
return;
}

// Find this argument in the invocation's argument list
int conditionArgumentIndex = -1;
for (int i = 0; i < invocation.ArgumentList.Arguments.Count; i++)
{
if (invocation.ArgumentList.Arguments[i].Span == conditionArgument.Span)
{
conditionArgumentIndex = i;
break;
}
}

if (conditionArgumentIndex == -1)
{
return;
}

// Get the actual argument from the invocation
conditionArgument = invocation.ArgumentList.Arguments[conditionArgumentIndex];

string title = string.Format(System.Globalization.CultureInfo.InvariantCulture, Resources.DoNotNegateBooleanAssertionFix, properAssertMethodName);
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a using directive for System.Globalization instead of fully qualifying CultureInfo.InvariantCulture. This aligns with the coding guidelines that prefer using directives and is consistent with other code fixers in the codebase.

Copilot uses AI. Check for mistakes.

context.RegisterCodeFix(
CodeAction.Create(
title,
cancellationToken => FixNegatedAssertionAsync(context.Document, invocation, conditionArgument, cancellationToken),
equivalenceKey: nameof(DoNotNegateBooleanAssertionFixer)),
diagnostic);
}

private static async Task<Document> FixNegatedAssertionAsync(
Document document,
InvocationExpressionSyntax invocation,
ArgumentSyntax conditionArgument,
CancellationToken cancellationToken)
{
DocumentEditor editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false);

// Get the member access expression (Assert.IsTrue or Assert.IsFalse)
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
{
return document;
}

ExpressionSyntax argumentExpression = conditionArgument.Expression;

// Start with the CURRENT method name
string currentMethodName = memberAccess.Name.Identifier.ValueText;
ExpressionSyntax currentExpression = argumentExpression;

// Iteratively remove negations one at a time
while (true)
{
ExpressionSyntax unnegatedExpression = RemoveNegation(currentExpression);

// If no negation was removed, we're done
if (unnegatedExpression == currentExpression)
{
break;
}

// A negation was removed, so flip the method name
currentMethodName = currentMethodName == "IsTrue" ? "IsFalse" : "IsTrue";
currentExpression = unnegatedExpression;
}

// If nothing changed, return the original document
if (currentExpression == argumentExpression && currentMethodName == memberAccess.Name.Identifier.ValueText)
{
return document;
}

// Create the new method name identifier
IdentifierNameSyntax newMethodName = SyntaxFactory.IdentifierName(currentMethodName);

// Create the new member access expression
MemberAccessExpressionSyntax newMemberAccess = memberAccess.WithName(newMethodName);

// Create the new argument with the unnegated expression
ArgumentSyntax newArgument = conditionArgument.WithExpression(currentExpression);

// Find the index of the condition argument in the invocation's argument list
int conditionArgumentIndex = -1;
for (int i = 0; i < invocation.ArgumentList.Arguments.Count; i++)
{
if (invocation.ArgumentList.Arguments[i].Span == conditionArgument.Span)
{
conditionArgumentIndex = i;
break;
}
}

if (conditionArgumentIndex == -1)
{
return document;
}

// Replace the condition argument in the arguments list
SeparatedSyntaxList<ArgumentSyntax> newArguments = invocation.ArgumentList.Arguments.Replace(
invocation.ArgumentList.Arguments[conditionArgumentIndex],
newArgument);
ArgumentListSyntax newArgumentList = invocation.ArgumentList.WithArguments(newArguments);

// Create the new invocation expression
InvocationExpressionSyntax newInvocation = invocation
.WithExpression(newMemberAccess)
.WithArgumentList(newArgumentList);

// Replace the old invocation with the new one
editor.ReplaceNode(invocation, newInvocation);

return editor.GetChangedDocument();
}

private static ExpressionSyntax RemoveNegation(ExpressionSyntax expression)
{
// Handle parenthesized expressions - unwrap to find the negation
if (expression is ParenthesizedExpressionSyntax parenthesized)
{
// Check if what's inside is a negation
if (parenthesized.Expression is PrefixUnaryExpressionSyntax { OperatorToken.RawKind: (int)SyntaxKind.ExclamationToken } innerPrefixUnary)
{
// Remove the parentheses and the negation, return the operand (unwrapped)
return UnwrapParentheses(innerPrefixUnary.Operand);
}

// Recursively check inside parentheses
ExpressionSyntax inner = RemoveNegation(parenthesized.Expression);
if (inner != parenthesized.Expression)
{
// We removed a negation from inside - return without the outer parentheses (unwrapped)
return UnwrapParentheses(inner);
}

// No negation inside, return as-is
return expression;
}

// Handle logical not operator (!) - remove ONLY this one negation
if (expression is PrefixUnaryExpressionSyntax { OperatorToken.RawKind: (int)SyntaxKind.ExclamationToken } prefixUnary)
{
// Return the operand directly (unwrapped)
return UnwrapParentheses(prefixUnary.Operand);
}

return expression;
}

private static ExpressionSyntax UnwrapParentheses(ExpressionSyntax expression)
{
// Recursively unwrap all unnecessary parentheses
while (expression is ParenthesizedExpressionSyntax parenthesized)
{
expression = parenthesized.Expression;
}

return expression;
}

private sealed class FixAll : DocumentBasedFixAllProvider
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is unused and can be removed.

{
public static readonly FixAll Instance = new();
Comment on lines +235 to +237
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entire FixAll class (lines 235-367) is defined but never instantiated or used since GetFixAllProvider() returns WellKnownFixAllProviders.BatchFixer. This adds 133 lines of dead code. Remove this class unless you intend to use it by changing GetFixAllProvider() to return new FixAll().

Copilot uses AI. Check for mistakes.

protected override async Task<Document?> FixAllAsync(
FixAllContext fixAllContext,
Document document,
ImmutableArray<Diagnostic> diagnostics)
{
SyntaxNode? root = await document.GetSyntaxRootAsync(fixAllContext.CancellationToken).ConfigureAwait(false);
if (root is null)
{
return document;
}

// Collect all invocations that need to be replaced
var nodesToReplace = new Dictionary<InvocationExpressionSyntax, InvocationExpressionSyntax>();

foreach (Diagnostic diagnostic in diagnostics)
{
SyntaxNode node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true);
if (node is not InvocationExpressionSyntax invocation)
{
continue;
}

if (!diagnostic.Properties.TryGetValue(DoNotNegateBooleanAssertionAnalyzer.ProperAssertMethodNameKey, out string? properAssertMethodName) ||
properAssertMethodName is null)
{
continue;
}

// Retrieve the condition argument from the additional locations
if (diagnostic.AdditionalLocations.Count == 0)
{
continue;
}

// Find the argument node that corresponds to the additional location
SyntaxNode? argumentNode = root.FindNode(diagnostic.AdditionalLocations[0].SourceSpan);

// Walk up to find the ArgumentSyntax if we got a child node
while (argumentNode is not null and not ArgumentSyntax)
{
argumentNode = argumentNode.Parent;
}

if (argumentNode is not ArgumentSyntax conditionArgument)
{
continue;
}

// Find this argument in the invocation's argument list by span
int conditionArgumentIndex = -1;
for (int i = 0; i < invocation.ArgumentList.Arguments.Count; i++)
{
if (invocation.ArgumentList.Arguments[i].Span == conditionArgument.Span)
{
conditionArgumentIndex = i;
conditionArgument = invocation.ArgumentList.Arguments[i];
break;
}
}

if (conditionArgumentIndex == -1)
{
continue;
}

// Get the member access expression
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
{
continue;
}

ExpressionSyntax argumentExpression = conditionArgument.Expression;

// Start with the CURRENT method name
string currentMethodName = memberAccess.Name.Identifier.ValueText;
ExpressionSyntax currentExpression = argumentExpression;

// Iteratively remove negations one at a time
while (true)
{
ExpressionSyntax unnegatedExpression = RemoveNegation(currentExpression);

// If no negation was removed, we're done
if (unnegatedExpression == currentExpression)
{
break;
}

// A negation was removed, so flip the method name
currentMethodName = currentMethodName == "IsTrue" ? "IsFalse" : "IsTrue";
currentExpression = unnegatedExpression;
}

// If nothing changed, skip this invocation
if (currentExpression == argumentExpression && currentMethodName == memberAccess.Name.Identifier.ValueText)
{
continue;
}

// Create the new method name identifier
IdentifierNameSyntax newMethodName = SyntaxFactory.IdentifierName(currentMethodName);
MemberAccessExpressionSyntax newMemberAccess = memberAccess.WithName(newMethodName);
ArgumentSyntax newArgument = conditionArgument.WithExpression(currentExpression);

SeparatedSyntaxList<ArgumentSyntax> newArguments = invocation.ArgumentList.Arguments.Replace(
invocation.ArgumentList.Arguments[conditionArgumentIndex],
newArgument);
ArgumentListSyntax newArgumentList = invocation.ArgumentList.WithArguments(newArguments);

InvocationExpressionSyntax newInvocation = invocation
.WithExpression(newMemberAccess)
.WithArgumentList(newArgumentList);

nodesToReplace[invocation] = newInvocation;
}

// Apply all replacements at once
if (nodesToReplace.Count > 0)
{
root = root.ReplaceNodes(
nodesToReplace.Keys,
(originalNode, _) => nodesToReplace[originalNode]);

return document.WithSyntaxRoot(root);
}

return document;
}
}
}
Loading
Loading