Skip to content
Merged
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
225 changes: 221 additions & 4 deletions TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,11 +221,114 @@ private AttributeArgumentListSyntax ConvertCategoryArguments(AttributeArgumentLi
public class NUnitAssertionRewriter : AssertionRewriter
{
protected override string FrameworkName => "NUnit";

public NUnitAssertionRewriter(SemanticModel semanticModel) : base(semanticModel)
{
}


/// <summary>
/// Handles Assert.Multiple(() => { ... }) conversion to using (Assert.Multiple()) { ... }
/// </summary>
public override SyntaxNode? VisitExpressionStatement(ExpressionStatementSyntax node)
{
// Check if this is Assert.Multiple(() => { ... })
if (node.Expression is InvocationExpressionSyntax invocation &&
invocation.Expression is MemberAccessExpressionSyntax memberAccess &&
memberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "Assert" } &&
memberAccess.Name.Identifier.Text == "Multiple" &&
invocation.ArgumentList.Arguments.Count == 1)
{
var argument = invocation.ArgumentList.Arguments[0].Expression;

// Handle lambda: Assert.Multiple(() => { ... })
if (argument is ParenthesizedLambdaExpressionSyntax lambda)
{
return ConvertAssertMultipleLambda(node, lambda);
}

// Handle simple lambda: Assert.Multiple(() => expr)
if (argument is SimpleLambdaExpressionSyntax simpleLambda)
{
return ConvertAssertMultipleSimpleLambda(node, simpleLambda);
}
}

return base.VisitExpressionStatement(node);
}

private SyntaxNode ConvertAssertMultipleLambda(ExpressionStatementSyntax originalStatement, ParenthesizedLambdaExpressionSyntax lambda)
{
// Extract statements from lambda body
SyntaxList<StatementSyntax> statements;
if (lambda.Body is BlockSyntax block)
{
// Visit each statement to convert inner assertions
var convertedStatements = block.Statements.Select(s => (StatementSyntax)Visit(s)!).ToArray();
statements = SyntaxFactory.List(convertedStatements);
}
else if (lambda.Body is ExpressionSyntax expr)
{
// Single expression lambda - convert it
var visitedExpr = (ExpressionSyntax)Visit(expr)!;
Comment on lines +266 to +272
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

The Visit method is being called with null-forgiving operators (!) on lines 266, 272, 289, and 294, but CSharpSyntaxRewriter.Visit can return null for certain node types. While in this specific case the Visit calls are likely safe (they're visiting statements/expressions that will be converted), it would be more defensive to add null checks before casting to avoid potential NullReferenceExceptions if the Visit method returns null in unexpected cases.

Suggested change
var convertedStatements = block.Statements.Select(s => (StatementSyntax)Visit(s)!).ToArray();
statements = SyntaxFactory.List(convertedStatements);
}
else if (lambda.Body is ExpressionSyntax expr)
{
// Single expression lambda - convert it
var visitedExpr = (ExpressionSyntax)Visit(expr)!;
var convertedStatements = block.Statements
.Select(s => (StatementSyntax)(Visit(s) ?? s))
.ToArray();
statements = SyntaxFactory.List(convertedStatements);
}
else if (lambda.Body is ExpressionSyntax expr)
{
// Single expression lambda - convert it
var visitedExpr = (ExpressionSyntax)(Visit(expr) ?? expr);

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

Similar to line 266, this Visit call uses a null-forgiving operator but could potentially return null. Consider adding null checks before casting.

Copilot uses AI. Check for mistakes.
statements = SyntaxFactory.SingletonList<StatementSyntax>(
SyntaxFactory.ExpressionStatement(visitedExpr));
}
else
{
return originalStatement;
}

return CreateUsingMultipleStatement(originalStatement, statements);
}

private SyntaxNode ConvertAssertMultipleSimpleLambda(ExpressionStatementSyntax originalStatement, SimpleLambdaExpressionSyntax lambda)
{
SyntaxList<StatementSyntax> statements;
if (lambda.Body is BlockSyntax block)
{
var convertedStatements = block.Statements.Select(s => (StatementSyntax)Visit(s)!).ToArray();
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

This Visit call uses a null-forgiving operator but could potentially return null. Consider adding null checks before casting to avoid potential NullReferenceExceptions.

Copilot uses AI. Check for mistakes.
statements = SyntaxFactory.List(convertedStatements);
}
else if (lambda.Body is ExpressionSyntax expr)
{
var visitedExpr = (ExpressionSyntax)Visit(expr)!;
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

This Visit call uses a null-forgiving operator but could potentially return null. Consider adding null checks before casting to avoid potential NullReferenceExceptions.

Copilot uses AI. Check for mistakes.
statements = SyntaxFactory.SingletonList<StatementSyntax>(
SyntaxFactory.ExpressionStatement(visitedExpr));
}
else
{
return originalStatement;
}

return CreateUsingMultipleStatement(originalStatement, statements);
}
Comment on lines +284 to +304
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

The ConvertAssertMultipleSimpleLambda and ConvertAssertMultipleLambda methods contain identical implementation logic. Consider extracting the common code into a single helper method that both can call, passing the lambda body as a parameter. This would reduce code duplication and improve maintainability.

Copilot uses AI. Check for mistakes.

private UsingStatementSyntax CreateUsingMultipleStatement(ExpressionStatementSyntax originalStatement, SyntaxList<StatementSyntax> statements)
{
// Create: Assert.Multiple()
var assertMultipleInvocation = SyntaxFactory.InvocationExpression(
SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
SyntaxFactory.IdentifierName("Assert"),
SyntaxFactory.IdentifierName("Multiple")),
SyntaxFactory.ArgumentList());

// Create the using statement: using (Assert.Multiple()) { ... }
var usingStatement = SyntaxFactory.UsingStatement(
declaration: null,
expression: assertMultipleInvocation,
statement: SyntaxFactory.Block(statements)
.WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken).WithLeadingTrivia(SyntaxFactory.LineFeed))
.WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken).WithLeadingTrivia(originalStatement.GetLeadingTrivia())));

return usingStatement
.WithUsingKeyword(SyntaxFactory.Token(SyntaxKind.UsingKeyword).WithTrailingTrivia(SyntaxFactory.Space))
.WithOpenParenToken(SyntaxFactory.Token(SyntaxKind.OpenParenToken))
.WithCloseParenToken(SyntaxFactory.Token(SyntaxKind.CloseParenToken))
.WithLeadingTrivia(originalStatement.GetLeadingTrivia())
.WithTrailingTrivia(originalStatement.GetTrailingTrivia());
}

protected override bool IsFrameworkAssertionNamespace(string namespaceName)
{
// Exclude NUnit.Framework.Legacy - ClassicAssert should not be converted
Expand Down Expand Up @@ -374,7 +477,25 @@ private ExpressionSyntax ConvertConstraintToTUnitWithMessage(ExpressionSyntax ac
};
}

// Handle Has.Count.EqualTo(n) -> Count().IsEqualTo(n)
// Pattern: Has.Count is a MemberAccess, then .EqualTo(n) is invoked on it
if (memberAccess.Expression is MemberAccessExpressionSyntax hasCountAccess &&
hasCountAccess.Expression is IdentifierNameSyntax { Identifier.Text: "Has" } &&
hasCountAccess.Name.Identifier.Text == "Count")
{
return methodName switch
{
"EqualTo" => CreateCountAssertion(actualValue, "IsEqualTo", message, constraint.ArgumentList.Arguments.ToArray()),
"GreaterThan" => CreateCountAssertion(actualValue, "IsGreaterThan", message, constraint.ArgumentList.Arguments.ToArray()),
"LessThan" => CreateCountAssertion(actualValue, "IsLessThan", message, constraint.ArgumentList.Arguments.ToArray()),
"GreaterThanOrEqualTo" => CreateCountAssertion(actualValue, "IsGreaterThanOrEqualTo", message, constraint.ArgumentList.Arguments.ToArray()),
"LessThanOrEqualTo" => CreateCountAssertion(actualValue, "IsLessThanOrEqualTo", message, constraint.ArgumentList.Arguments.ToArray()),
_ => CreateCountAssertion(actualValue, "IsEqualTo", message, constraint.ArgumentList.Arguments.ToArray())
};
}
Comment on lines +480 to +495
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

The Has.Count.EqualTo(n) pattern handling is implemented in ConvertConstraintToTUnitWithMessage (lines 480-495) but is missing in the ConvertConstraintToTUnit method (line 589+). While ConvertConstraintToTUnit is primarily used for handling chained constraints like .Within(), it should still handle Has.Count patterns consistently for completeness and to avoid potential bugs if the code is refactored in the future.

Copilot uses AI. Check for mistakes.

// Handle Has.Member(item) -> Contains(item)
// Handle Has.Exactly(n) -> will be picked up in member pattern for Has.Exactly(n).Items
if (memberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "Has" })
{
return methodName switch
Expand Down Expand Up @@ -420,6 +541,20 @@ private ExpressionSyntax ConvertConstraintMemberToTUnitWithMessage(ExpressionSyn
{
var memberName = constraint.Name.Identifier.Text;

// Handle Has.Exactly(n).Items -> Count().IsEqualTo(n)
// Pattern: constraint.Name is "Items", constraint.Expression is Has.Exactly(n) invocation
if (memberName == "Items" &&
constraint.Expression is InvocationExpressionSyntax exactlyInvocation &&
exactlyInvocation.Expression is MemberAccessExpressionSyntax exactlyMemberAccess &&
exactlyMemberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "Has" } &&
exactlyMemberAccess.Name.Identifier.Text == "Exactly" &&
exactlyInvocation.ArgumentList.Arguments.Count > 0)
{
// Extract the count argument from Has.Exactly(n)
var countArg = exactlyInvocation.ArgumentList.Arguments[0];
return CreateCountAssertion(actualValue, "IsEqualTo", message, countArg);
}
Comment on lines +544 to +556
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

This code block for handling Has.Exactly(n).Items is duplicated in the ConvertConstraintMemberToTUnit method (lines 729-741). Consider extracting this pattern matching logic into a shared helper method to reduce code duplication and improve maintainability.

Copilot uses AI. Check for mistakes.

// Handle Is.Not.X patterns (member access, not invocation)
if (constraint.Expression is MemberAccessExpressionSyntax innerMemberAccess &&
innerMemberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "Is" } &&
Expand All @@ -433,7 +568,7 @@ private ExpressionSyntax ConvertConstraintMemberToTUnitWithMessage(ExpressionSyn
"False" => CreateTUnitAssertionWithMessage("IsTrue", actualValue, message),
"Positive" => CreateTUnitAssertionWithMessage("IsLessThanOrEqualTo", actualValue, message, SyntaxFactory.Argument(SyntaxFactory.LiteralExpression(SyntaxKind.NumericLiteralExpression, SyntaxFactory.Literal(0)))),
"Negative" => CreateTUnitAssertionWithMessage("IsGreaterThanOrEqualTo", actualValue, message, SyntaxFactory.Argument(SyntaxFactory.LiteralExpression(SyntaxKind.NumericLiteralExpression, SyntaxFactory.Literal(0)))),
"Zero" => CreateTUnitAssertionWithMessage("IsNotEqualTo", actualValue, message, SyntaxFactory.Argument(SyntaxFactory.LiteralExpression(SyntaxKind.NumericLiteralExpression, SyntaxFactory.Literal(0)))),
"Zero" => CreateTUnitAssertionWithMessage("IsNotZero", actualValue, message),
_ => CreateTUnitAssertionWithMessage("IsNotEqualTo", actualValue, message, SyntaxFactory.Argument(constraint))
};
}
Expand Down Expand Up @@ -591,6 +726,20 @@ private ExpressionSyntax ConvertConstraintMemberToTUnit(ExpressionSyntax actualV
{
var memberName = constraint.Name.Identifier.Text;

// Handle Has.Exactly(n).Items -> Count().IsEqualTo(n)
// Pattern: constraint.Name is "Items", constraint.Expression is Has.Exactly(n) invocation
if (memberName == "Items" &&
constraint.Expression is InvocationExpressionSyntax exactlyInvocation &&
exactlyInvocation.Expression is MemberAccessExpressionSyntax exactlyMemberAccess &&
exactlyMemberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "Has" } &&
exactlyMemberAccess.Name.Identifier.Text == "Exactly" &&
exactlyInvocation.ArgumentList.Arguments.Count > 0)
{
// Extract the count argument from Has.Exactly(n)
var countArg = exactlyInvocation.ArgumentList.Arguments[0];
return CreateCountAssertion(actualValue, "IsEqualTo", null, countArg);
}

// Handle Is.Not.X patterns (member access, not invocation)
if (constraint.Expression is MemberAccessExpressionSyntax innerMemberAccess &&
innerMemberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "Is" } &&
Expand All @@ -604,7 +753,7 @@ private ExpressionSyntax ConvertConstraintMemberToTUnit(ExpressionSyntax actualV
"False" => CreateTUnitAssertion("IsTrue", actualValue),
"Positive" => CreateTUnitAssertion("IsLessThanOrEqualTo", actualValue, SyntaxFactory.Argument(SyntaxFactory.LiteralExpression(SyntaxKind.NumericLiteralExpression, SyntaxFactory.Literal(0)))),
"Negative" => CreateTUnitAssertion("IsGreaterThanOrEqualTo", actualValue, SyntaxFactory.Argument(SyntaxFactory.LiteralExpression(SyntaxKind.NumericLiteralExpression, SyntaxFactory.Literal(0)))),
"Zero" => CreateTUnitAssertion("IsNotEqualTo", actualValue, SyntaxFactory.Argument(SyntaxFactory.LiteralExpression(SyntaxKind.NumericLiteralExpression, SyntaxFactory.Literal(0)))),
"Zero" => CreateTUnitAssertion("IsNotZero", actualValue),
_ => CreateTUnitAssertion("IsNotEqualTo", actualValue, SyntaxFactory.Argument(constraint))
};
}
Expand Down Expand Up @@ -634,6 +783,74 @@ private ExpressionSyntax CreateInRangeAssertion(ExpressionSyntax actualValue, Se
return CreateTUnitAssertion("IsInRange", actualValue);
}

/// <summary>
/// Creates a count-based assertion: await Assert.That(collection).Count().IsEqualTo(n)
/// Used for Has.Count.EqualTo(n) and Has.Exactly(n).Items patterns
/// </summary>
private ExpressionSyntax CreateCountAssertion(ExpressionSyntax actualValue, string comparisonMethod, ExpressionSyntax? message, params ArgumentSyntax[] arguments)
{
// Create Assert.That(collection)
var assertThatInvocation = SyntaxFactory.InvocationExpression(
SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
SyntaxFactory.IdentifierName("Assert"),
SyntaxFactory.IdentifierName("That")
),
SyntaxFactory.ArgumentList(
SyntaxFactory.SingletonSeparatedList(
SyntaxFactory.Argument(actualValue)
)
)
);

// Create Assert.That(collection).Count()
var countInvocation = SyntaxFactory.InvocationExpression(
SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
assertThatInvocation,
SyntaxFactory.IdentifierName("Count")
),
SyntaxFactory.ArgumentList()
);

// Create Assert.That(collection).Count().IsEqualTo(n) (or other comparison method)
var comparisonAccess = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
countInvocation,
SyntaxFactory.IdentifierName(comparisonMethod)
);

var comparisonArgs = arguments.Length > 0
? SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(arguments))
: SyntaxFactory.ArgumentList();

ExpressionSyntax fullInvocation = SyntaxFactory.InvocationExpression(comparisonAccess, comparisonArgs);

// Add .Because(message) if message is provided
if (message != null)
{
var becauseAccess = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
fullInvocation,
SyntaxFactory.IdentifierName("Because")
);

fullInvocation = SyntaxFactory.InvocationExpression(
becauseAccess,
SyntaxFactory.ArgumentList(
SyntaxFactory.SingletonSeparatedList(
SyntaxFactory.Argument(message)
)
)
);
}

// Wrap in await
var awaitKeyword = SyntaxFactory.Token(SyntaxKind.AwaitKeyword)
.WithTrailingTrivia(SyntaxFactory.Space);
return SyntaxFactory.AwaitExpression(awaitKeyword, fullInvocation);
}

/// <summary>
/// Chains a method call onto an existing await expression.
/// For example: await Assert.That(x).IsEqualTo(5) becomes await Assert.That(x).IsEqualTo(5).Within(2)
Expand Down
Loading
Loading