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
14 changes: 3 additions & 11 deletions TUnit.Analyzers.CodeFixers/NUnitExpectedResultRewriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@
if (isDirectReturn)
{
// Direct return - use the expression directly
returnExpression = returnStatements[0].Expression;

Check warning on line 168 in TUnit.Analyzers.CodeFixers/NUnitExpectedResultRewriter.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Converting null literal or possible null value to non-nullable type.

// Build new body with all statements except the return, plus assertion
var statementsWithoutReturn = new List<StatementSyntax>();
Expand All @@ -177,7 +177,7 @@
}
}

var assertStatement = CreateAssertStatement(returnExpression);

Check warning on line 180 in TUnit.Analyzers.CodeFixers/NUnitExpectedResultRewriter.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Possible null reference argument for parameter 'actualExpression' in 'ExpressionStatementSyntax NUnitExpectedResultRewriter.CreateAssertStatement(ExpressionSyntax actualExpression)'.
statementsWithoutReturn.Add(assertStatement);

return SyntaxFactory.Block(statementsWithoutReturn);
Expand Down Expand Up @@ -441,7 +441,6 @@

var newArgs = new List<AttributeArgumentSyntax>();
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

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

According to TUnit coding guidelines, use collection expressions (C# 12+). This should be:

var newArgs = new List<AttributeArgumentSyntax>();

should be:

List<AttributeArgumentSyntax> newArgs = [];

Copilot generated this review using guidance from repository custom instructions.
ExpressionSyntax? expectedValue = null;
var unsupportedProperties = new List<string>();

foreach (var arg in attribute.ArgumentList.Arguments)
{
Expand All @@ -467,8 +466,9 @@
}
else if (namedProperty is "TestName" or "Category" or "Description" or "Author" or "Explicit" or "ExplicitReason")
{
// These properties don't have direct TUnit equivalents
unsupportedProperties.Add($"{namedProperty} = {arg.Expression}");
// These properties are converted to separate TUnit attributes by NUnitTestCasePropertyRewriter:
// TestName → [DisplayName], Category → [Category], Description/Author → [Property], Explicit → [Explicit]
// Skip them here - they don't belong in the [Arguments] attribute
}
// Other named arguments are preserved as-is (they might be TUnit-compatible)
else
Expand All @@ -486,14 +486,6 @@
var newAttribute = attribute.WithArgumentList(
SyntaxFactory.AttributeArgumentList(SyntaxFactory.SeparatedList(newArgs)));

// Add TODO comment for unsupported properties
if (unsupportedProperties.Count > 0)
{
var todoComment = SyntaxFactory.Comment($"/* TODO: TUnit migration - unsupported TestCase properties: {string.Join(", ", unsupportedProperties)} */");
newAttribute = newAttribute.WithLeadingTrivia(
newAttribute.GetLeadingTrivia().Add(todoComment).Add(SyntaxFactory.Space));
}

// The attribute will be renamed to "Arguments" by the existing attribute rewriter
return newAttribute;
}
Expand Down
39 changes: 18 additions & 21 deletions TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ protected override CSharpSyntaxRewriter CreateLifecycleRewriter(Compilation comp

protected override CompilationUnitSyntax ApplyFrameworkSpecificConversions(CompilationUnitSyntax compilationUnit, SemanticModel semanticModel, Compilation compilation)
{
// Transform ExpectedResult patterns before attribute conversion
// Extract TestCase properties FIRST (before ExpectedResult conversion changes the attributes)
// Maps: TestName → DisplayName, Category → Category, Description/Author → Property, Explicit → Explicit
var testCasePropertyRewriter = new NUnitTestCasePropertyRewriter();
compilationUnit = (CompilationUnitSyntax)testCasePropertyRewriter.Visit(compilationUnit);

// Transform ExpectedResult patterns (TestCase with ExpectedResult → Arguments with assertion)
var expectedResultRewriter = new NUnitExpectedResultRewriter(semanticModel);
compilationUnit = (CompilationUnitSyntax)expectedResultRewriter.Visit(compilationUnit);

Expand Down Expand Up @@ -98,13 +103,16 @@ private AttributeArgumentListSyntax ConvertTestCaseArguments(AttributeArgumentLi
arg.Expression);
newArgs.Add(skipArg);
}
else if (namedProperty is "TestName" or "Category" or "Description" or "Author" or "Explicit" or "ExplicitReason" or "ExpectedResult")
else if (namedProperty is "TestName" or "Category" or "Description" or "Author" or "Explicit" or "ExplicitReason")
{
// These properties are converted to separate TUnit attributes by NUnitTestCasePropertyRewriter:
// TestName → [DisplayName], Category → [Category], Description/Author → [Property], Explicit → [Explicit]
// Skip them here - they don't belong in the [Arguments] attribute
}
else if (namedProperty == "ExpectedResult")
{
// These properties don't have direct TUnit equivalents - preserve as comment
// ExpectedResult is handled by NUnitExpectedResultRewriter, so if we get here it's a case without special handling
var commentArg = SyntaxFactory.AttributeArgument(arg.Expression)
.WithLeadingTrivia(SyntaxFactory.Comment($"/* TODO: {namedProperty} not supported */ "));
newArgs.Add(commentArg);
// ExpectedResult is handled by NUnitExpectedResultRewriter
// If we get here, it's a case without the ExpectedResult transformation, skip it
}
else
{
Expand Down Expand Up @@ -141,20 +149,9 @@ private AttributeArgumentListSyntax ConvertTestCaseSourceArguments(AttributeArgu

private AttributeArgumentListSyntax ConvertCategoryArguments(AttributeArgumentListSyntax argumentList)
{
// Convert Category to Property
var arguments = new List<AttributeArgumentSyntax>();

arguments.Add(SyntaxFactory.AttributeArgument(
SyntaxFactory.LiteralExpression(SyntaxKind.StringLiteralExpression,
SyntaxFactory.Literal("Category"))
));

if (argumentList.Arguments.Count > 0)
{
arguments.Add(argumentList.Arguments[0]);
}

return SyntaxFactory.AttributeArgumentList(SyntaxFactory.SeparatedList(arguments));
// TUnit has a native Category attribute with the same signature as NUnit
// [Category("Unit")] in NUnit -> [Category("Unit")] in TUnit
return argumentList;
}
}

Expand Down
298 changes: 298 additions & 0 deletions TUnit.Analyzers.CodeFixers/NUnitTestCasePropertyRewriter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
using System.Linq;
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

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

Missing using directive for System.Collections.Generic. The code uses List and HashSet but only includes System.Linq. While this might compile due to implicit global usings in newer .NET versions, it's better to be explicit for clarity and compatibility.

Copilot uses AI. Check for mistakes.
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace TUnit.Analyzers.CodeFixers;

/// <summary>
/// Extracts NUnit TestCase properties and converts them to TUnit attributes.
/// Maps: TestName → DisplayName, Category → Category, Description/Author → Property, Explicit → Explicit
/// </summary>
public class NUnitTestCasePropertyRewriter : CSharpSyntaxRewriter
{
public override SyntaxNode? VisitMethodDeclaration(MethodDeclarationSyntax node)
{
// Get all TestCase attributes on this method
var testCaseAttributes = GetTestCaseAttributes(node);

if (testCaseAttributes.Count == 0)
{
return base.VisitMethodDeclaration(node);
}

// Extract properties from all TestCase attributes
var properties = ExtractProperties(testCaseAttributes);

// Generate new attribute lists for the extracted properties
var newAttributeLists = GeneratePropertyAttributes(properties, node.AttributeLists);

// If we generated new attributes, add them to the method
if (newAttributeLists.Count > node.AttributeLists.Count)
{
return node.WithAttributeLists(newAttributeLists);
}

return base.VisitMethodDeclaration(node);
}

private List<AttributeSyntax> GetTestCaseAttributes(MethodDeclarationSyntax method)
{
var result = new List<AttributeSyntax>();
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

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

According to TUnit coding guidelines, use collection expressions (C# 12+) instead of the old initialization syntax. This should be:

var result = new List<AttributeSyntax>();

should be:

List<AttributeSyntax> result = [];

Copilot generated this review using guidance from repository custom instructions.

foreach (var attributeList in method.AttributeLists)
{
foreach (var attribute in attributeList.Attributes)
{
var name = attribute.Name.ToString();
if (name is "TestCase" or "NUnit.Framework.TestCase" or "TestCaseAttribute" or "NUnit.Framework.TestCaseAttribute")
{
result.Add(attribute);
}
}
}

return result;
}

private TestCaseProperties ExtractProperties(List<AttributeSyntax> testCaseAttributes)
{
// Early return if no TestCase has named arguments (performance optimization)
bool hasNamedArguments = false;
foreach (var attribute in testCaseAttributes)
{
if (attribute.ArgumentList?.Arguments.Any(a => a.NameEquals != null) == true)
{
hasNamedArguments = true;
break;
}
}

if (!hasNamedArguments)
{
return new TestCaseProperties();
}

var properties = new TestCaseProperties();

foreach (var attribute in testCaseAttributes)
{
if (attribute.ArgumentList == null)
{
continue;
}

foreach (var arg in attribute.ArgumentList.Arguments)
{
var propertyName = arg.NameEquals?.Name.Identifier.Text;

switch (propertyName)
{
case "TestName":
var testNameValue = GetStringValue(arg.Expression);
if (testNameValue != null)
{
properties.TestNames.Add(testNameValue);
}
break;

case "Category":
var categoryValue = GetStringValue(arg.Expression);
if (categoryValue != null)
{
properties.Categories.Add(categoryValue);
}
break;

case "Description":
var descValue = GetStringValue(arg.Expression);
if (descValue != null)
{
properties.Descriptions.Add(descValue);
}
break;

case "Author":
var authorValue = GetStringValue(arg.Expression);
if (authorValue != null)
{
properties.Authors.Add(authorValue);
}
break;

case "Explicit":
if (arg.Expression is LiteralExpressionSyntax literal &&
literal.IsKind(SyntaxKind.TrueLiteralExpression))
{
properties.IsExplicit = true;
}
break;

case "ExplicitReason":
var explicitReason = GetStringValue(arg.Expression);
if (explicitReason != null)
{
properties.ExplicitReasons.Add(explicitReason);
properties.IsExplicit = true;
}
break;
}
}
}

return properties;
}

private string? GetStringValue(ExpressionSyntax expression)
{
// Only handle string literals - interpolated strings, concatenation, and const references
// cannot be reliably migrated and should be handled manually by the user
if (expression is LiteralExpressionSyntax literal &&
literal.IsKind(SyntaxKind.StringLiteralExpression))
{
return literal.Token.ValueText;
}

return null;
}

private SyntaxList<AttributeListSyntax> GeneratePropertyAttributes(
TestCaseProperties properties,
SyntaxList<AttributeListSyntax> existingAttributeLists)
{
var newLists = new List<AttributeListSyntax>(existingAttributeLists);
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

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

According to TUnit coding guidelines, use collection expressions (C# 12+). This should be:

var newLists = new List<AttributeListSyntax>(existingAttributeLists);

should be:

List<AttributeListSyntax> newLists = [..existingAttributeLists];

Copilot generated this review using guidance from repository custom instructions.
var leadingTrivia = existingAttributeLists.Count > 0
? existingAttributeLists[0].GetLeadingTrivia()
: SyntaxTriviaList.Empty;

// Get indentation from existing attributes
var indentation = GetIndentation(leadingTrivia);

// DisplayName from TestName (use first if multiple, or try to create pattern)
if (properties.TestNames.Count > 0)
{
var displayNameAttr = CreateDisplayNameAttribute(properties.TestNames);
if (displayNameAttr != null)
{
newLists.Add(CreateAttributeList(displayNameAttr, indentation));
}
}

// Category - add all unique categories
foreach (var category in properties.Categories.Distinct())
{
var categoryAttr = SyntaxFactory.Attribute(
SyntaxFactory.IdentifierName("Category"),
SyntaxFactory.AttributeArgumentList(
SyntaxFactory.SingletonSeparatedList(
SyntaxFactory.AttributeArgument(
SyntaxFactory.LiteralExpression(
SyntaxKind.StringLiteralExpression,
SyntaxFactory.Literal(category))))));
newLists.Add(CreateAttributeList(categoryAttr, indentation));
}
Comment on lines +182 to +193
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

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

This foreach loop immediately maps its iteration variable to another variable - consider mapping the sequence explicitly using '.Select(...)'.

Suggested change
foreach (var category in properties.Categories.Distinct())
{
var categoryAttr = SyntaxFactory.Attribute(
SyntaxFactory.IdentifierName("Category"),
SyntaxFactory.AttributeArgumentList(
SyntaxFactory.SingletonSeparatedList(
SyntaxFactory.AttributeArgument(
SyntaxFactory.LiteralExpression(
SyntaxKind.StringLiteralExpression,
SyntaxFactory.Literal(category))))));
newLists.Add(CreateAttributeList(categoryAttr, indentation));
}
var categoryAttributeLists = properties.Categories
.Distinct()
.Select(category =>
CreateAttributeList(
SyntaxFactory.Attribute(
SyntaxFactory.IdentifierName("Category"),
SyntaxFactory.AttributeArgumentList(
SyntaxFactory.SingletonSeparatedList(
SyntaxFactory.AttributeArgument(
SyntaxFactory.LiteralExpression(
SyntaxKind.StringLiteralExpression,
SyntaxFactory.Literal(category)))))),
indentation));
newLists = newLists.AddRange(categoryAttributeLists);

Copilot uses AI. Check for mistakes.

// Description - use Property attribute
if (properties.Descriptions.Count > 0)
{
var description = properties.Descriptions.First();
var propAttr = CreatePropertyAttribute("Description", description);
newLists.Add(CreateAttributeList(propAttr, indentation));
}

// Author - use Property attribute
if (properties.Authors.Count > 0)
{
var author = properties.Authors.First();
var propAttr = CreatePropertyAttribute("Author", author);
newLists.Add(CreateAttributeList(propAttr, indentation));
}

// Explicit
if (properties.IsExplicit)
{
var explicitAttr = SyntaxFactory.Attribute(SyntaxFactory.IdentifierName("Explicit"));
newLists.Add(CreateAttributeList(explicitAttr, indentation));

// If there's an explicit reason, add it as a Property
if (properties.ExplicitReasons.Count > 0)
{
var reason = properties.ExplicitReasons.First();
var propAttr = CreatePropertyAttribute("ExplicitReason", reason);
newLists.Add(CreateAttributeList(propAttr, indentation));
}
}

return SyntaxFactory.List(newLists);
}

private AttributeSyntax? CreateDisplayNameAttribute(HashSet<string> testNames)
{
// Only generate DisplayName if there's exactly one unique TestName
// Multiple different TestNames cannot be represented by a single DisplayName attribute
if (testNames.Count != 1)
{
return null;
}

var displayName = testNames.First();

return SyntaxFactory.Attribute(
SyntaxFactory.IdentifierName("DisplayName"),
SyntaxFactory.AttributeArgumentList(
SyntaxFactory.SingletonSeparatedList(
SyntaxFactory.AttributeArgument(
SyntaxFactory.LiteralExpression(
SyntaxKind.StringLiteralExpression,
SyntaxFactory.Literal(displayName))))));
}

private AttributeSyntax CreatePropertyAttribute(string name, string value)
{
return SyntaxFactory.Attribute(
SyntaxFactory.IdentifierName("Property"),
SyntaxFactory.AttributeArgumentList(
SyntaxFactory.SeparatedList(new[]
{
SyntaxFactory.AttributeArgument(
SyntaxFactory.LiteralExpression(
SyntaxKind.StringLiteralExpression,
SyntaxFactory.Literal(name))),
SyntaxFactory.AttributeArgument(
SyntaxFactory.LiteralExpression(
SyntaxKind.StringLiteralExpression,
SyntaxFactory.Literal(value)))
})));
}

private AttributeListSyntax CreateAttributeList(AttributeSyntax attribute, SyntaxTrivia indentation)
{
return SyntaxFactory.AttributeList(
SyntaxFactory.SingletonSeparatedList(attribute))
.WithLeadingTrivia(indentation)
.WithTrailingTrivia(SyntaxFactory.EndOfLine("\n"));
}

private SyntaxTrivia GetIndentation(SyntaxTriviaList triviaList)
{
foreach (var trivia in triviaList)
{
if (trivia.IsKind(SyntaxKind.WhitespaceTrivia))
{
return trivia;
}
Comment on lines +278 to +283
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

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

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Suggested change
foreach (var trivia in triviaList)
{
if (trivia.IsKind(SyntaxKind.WhitespaceTrivia))
{
return trivia;
}
var whitespaceTrivia = triviaList.FirstOrDefault(t => t.IsKind(SyntaxKind.WhitespaceTrivia));
if (whitespaceTrivia.IsKind(SyntaxKind.WhitespaceTrivia))
{
return whitespaceTrivia;

Copilot uses AI. Check for mistakes.
}

return SyntaxFactory.Whitespace(" ");
}

private class TestCaseProperties
{
public HashSet<string> TestNames { get; } = new();
public HashSet<string> Categories { get; } = new();
public HashSet<string> Descriptions { get; } = new();
public HashSet<string> Authors { get; } = new();
public bool IsExplicit { get; set; }
public HashSet<string> ExplicitReasons { get; } = new();
Comment on lines +291 to +296
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

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

According to TUnit coding guidelines, use collection expressions (C# 12+). These HashSet initializations in the TestCaseProperties class should use collection expression syntax:

public HashSet<string> TestNames { get; } = new();

should be:

public HashSet<string> TestNames { get; } = [];

Apply the same change to all other HashSet properties (Categories, Descriptions, Authors, ExplicitReasons).

Copilot generated this review using guidance from repository custom instructions.
}
}
Loading
Loading