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
65 changes: 40 additions & 25 deletions TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,33 +141,46 @@ protected async Task<Document> ConvertCodeAsync(Document document, SyntaxNode? r
/// </summary>
protected static CompilationUnitSyntax CleanupClassMemberLeadingTrivia(CompilationUnitSyntax root)
{
var classesToFix = root.DescendantNodes().OfType<ClassDeclarationSyntax>()
.Where(c => c.Members.Any())
.ToList();

var currentRoot = root;
foreach (var classDecl in classesToFix)

// Use a while loop to re-query after each modification.
// This is necessary because ReplaceNode returns a new tree, and node references
// from the original tree won't match nodes in the new tree.
ClassDeclarationSyntax? classToFix;
while ((classToFix = FindClassWithExcessiveLeadingTrivia(currentRoot)) != null)
{
var firstMember = classDecl.Members.First();
var firstMember = classToFix.Members.First();
var leadingTrivia = firstMember.GetLeadingTrivia();
int newlineCount = leadingTrivia.Count(t => t.IsKind(SyntaxKind.EndOfLineTrivia));

if (newlineCount > 0)
{
// Keep only indentation (whitespace), remove all newlines
var triviaToKeep = leadingTrivia
.Where(t => !t.IsKind(SyntaxKind.EndOfLineTrivia))
.Where(t => t.IsKind(SyntaxKind.WhitespaceTrivia) ||
(!t.IsKind(SyntaxKind.WhitespaceTrivia) && !t.IsKind(SyntaxKind.EndOfLineTrivia)))
.ToList();
// Keep only indentation (whitespace), remove all newlines
var triviaToKeep = leadingTrivia
.Where(t => !t.IsKind(SyntaxKind.EndOfLineTrivia))
.Where(t => t.IsKind(SyntaxKind.WhitespaceTrivia) ||
(!t.IsKind(SyntaxKind.WhitespaceTrivia) && !t.IsKind(SyntaxKind.EndOfLineTrivia)))
.ToList();

var newFirstMember = firstMember.WithLeadingTrivia(triviaToKeep);
var updatedClass = classDecl.ReplaceNode(firstMember, newFirstMember);
currentRoot = currentRoot.ReplaceNode(classDecl, updatedClass);
}
var newFirstMember = firstMember.WithLeadingTrivia(triviaToKeep);
var updatedClass = classToFix.ReplaceNode(firstMember, newFirstMember);
currentRoot = currentRoot.ReplaceNode(classToFix, updatedClass);
}

return (CompilationUnitSyntax)currentRoot;
return currentRoot;
}

/// <summary>
/// Finds a class with excessive leading trivia on its first member.
/// Returns null if no such class exists.
/// </summary>
private static ClassDeclarationSyntax? FindClassWithExcessiveLeadingTrivia(CompilationUnitSyntax root)
{
return root.DescendantNodes()
.OfType<ClassDeclarationSyntax>()
.Where(c => c.Members.Any())
.FirstOrDefault(c =>
{
var leadingTrivia = c.Members.First().GetLeadingTrivia();
return leadingTrivia.Any(t => t.IsKind(SyntaxKind.EndOfLineTrivia));
});
}

/// <summary>
Expand Down Expand Up @@ -251,11 +264,11 @@ public abstract class AttributeRewriter : CSharpSyntaxRewriter
var hookAttributeList = MigrationHelpers.ConvertHookAttribute(attribute, FrameworkName);
if (hookAttributeList != null)
{
// Preserve only the leading trivia (indentation) from the original node
// and strip any trailing trivia to prevent extra blank lines
return hookAttributeList
.WithLeadingTrivia(node.GetLeadingTrivia())
.WithTrailingTrivia(node.GetTrailingTrivia());
// Add converted hook attribute(s) to the list - don't return early!
// This preserves other attributes that may be in the same attribute list.
// e.g., [SetUp, Category("Unit")] -> [Before(HookType.Test), Category("Unit")]
attributes.AddRange(hookAttributeList.Attributes);
continue;
}
}

Expand All @@ -268,6 +281,8 @@ public abstract class AttributeRewriter : CSharpSyntaxRewriter

return attributes.Count > 0
? node.WithAttributes(SyntaxFactory.SeparatedList(attributes))
.WithLeadingTrivia(node.GetLeadingTrivia())
.WithTrailingTrivia(node.GetTrailingTrivia())
: null;
}

Expand Down
31 changes: 27 additions & 4 deletions TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,14 +161,17 @@ private AttributeArgumentListSyntax ConvertOwnerArguments(AttributeArgumentListS
public override SyntaxNode? VisitAttributeList(AttributeListSyntax node)
{
// Handle ClassInitialize and ClassCleanup specially - they need static context parameter removed
// Process all attributes, don't return early to preserve sibling attributes
var attributes = new List<AttributeSyntax>();

bool hasClassLifecycleAttribute = false;

foreach (var attribute in node.Attributes)
{
var attributeName = MigrationHelpers.GetAttributeName(attribute);

if (attributeName is "ClassInitialize" or "ClassCleanup")
{
hasClassLifecycleAttribute = true;
var hookType = attributeName == "ClassInitialize" ? "Before" : "After";
var newAttribute = SyntaxFactory.Attribute(
SyntaxFactory.IdentifierName(hookType),
Expand All @@ -184,10 +187,30 @@ private AttributeArgumentListSyntax ConvertOwnerArguments(AttributeArgumentListS
)
)
);
return SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(newAttribute));
attributes.Add(newAttribute);
// Don't return early - continue processing other attributes!
}
else
{
// For non-ClassInitialize/ClassCleanup, use base conversion logic
var converted = ConvertAttribute(attribute);
if (converted != null)
{
attributes.Add(converted);
}
}
}


// If we processed class lifecycle attributes, return our combined list
if (hasClassLifecycleAttribute)
{
return attributes.Count > 0
? node.WithAttributes(SyntaxFactory.SeparatedList(attributes))
.WithLeadingTrivia(node.GetLeadingTrivia())
.WithTrailingTrivia(node.GetTrailingTrivia())
: null;
}

return base.VisitAttributeList(node);
}
}
Expand Down
83 changes: 78 additions & 5 deletions TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
"Test" or "Theory" or "TestCase" or "TestCaseSource" or
"SetUp" or "TearDown" or "OneTimeSetUp" or "OneTimeTearDown" or
"TestFixture" or "Category" or "Ignore" or "Explicit" or "Apartment" or
"Platform" or "Description" or
"Platform" or "Description" or "Author" or
// Parallelization attributes
"Parallelizable" or "NonParallelizable" or
// Repeat attribute (same in TUnit)
Expand Down Expand Up @@ -145,9 +145,45 @@
return ConvertPlatformAttribute(attribute);
}

// [Description("...")] -> [Property("Description", "...")]
if (attributeName == "Description")
{
return ConvertToPropertyAttribute("Description", attribute);
}

// [Author("...")] -> [Property("Author", "...")]
if (attributeName == "Author")
{
return ConvertToPropertyAttribute("Author", attribute);
}

return base.ConvertAttribute(attribute);
}

private AttributeSyntax? ConvertToPropertyAttribute(string propertyName, AttributeSyntax attribute)
{
// Get the value from the attribute argument
if (attribute.ArgumentList == null || attribute.ArgumentList.Arguments.Count == 0)
{
return null; // No value, remove the attribute
}

var valueExpression = attribute.ArgumentList.Arguments[0].Expression;

// Create [Property("propertyName", value)]
return SyntaxFactory.Attribute(
SyntaxFactory.IdentifierName("Property"),
SyntaxFactory.AttributeArgumentList(
SyntaxFactory.SeparatedList(new[]
{
SyntaxFactory.AttributeArgument(
SyntaxFactory.LiteralExpression(
SyntaxKind.StringLiteralExpression,
SyntaxFactory.Literal(propertyName))),
SyntaxFactory.AttributeArgument(valueExpression)
})));
}

private AttributeSyntax? ConvertApartmentAttribute(AttributeSyntax attribute)
{
// Check if the argument is ApartmentState.STA
Expand Down Expand Up @@ -268,7 +304,7 @@
}

var osNames = new List<string>();
var platforms = platformString.Split(',');

Check warning on line 307 in TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Dereference of a possibly null reference.

foreach (var platform in platforms)
{
Expand Down Expand Up @@ -915,15 +951,17 @@
};
}

// Handle Does.StartWith, Does.EndWith, Does.Match, Contains.Substring
// Handle Does.StartWith, Does.EndWith, Does.Contain, Does.Match, Contains.Substring, Contains.Item
if (memberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "Does" or "Contains" })
{
return methodName switch
{
"StartWith" => CreateTUnitAssertionWithMessage("StartsWith", actualValue, message, constraint.ArgumentList.Arguments.ToArray()),
"EndWith" => CreateTUnitAssertionWithMessage("EndsWith", actualValue, message, constraint.ArgumentList.Arguments.ToArray()),
"Contain" => CreateTUnitAssertionWithMessage("Contains", actualValue, message, constraint.ArgumentList.Arguments.ToArray()),
"Match" => CreateTUnitAssertionWithMessage("Matches", actualValue, message, constraint.ArgumentList.Arguments.ToArray()),
"Substring" => CreateTUnitAssertionWithMessage("Contains", actualValue, message, constraint.ArgumentList.Arguments.ToArray()),
"Item" => CreateTUnitAssertionWithMessage("Contains", actualValue, message, constraint.ArgumentList.Arguments.ToArray()),
_ => CreateTUnitAssertionWithMessage("IsEqualTo", actualValue, message, SyntaxFactory.Argument(constraint))
};
}
Expand Down Expand Up @@ -1115,15 +1153,17 @@
};
}

// Handle Does.StartWith, Does.EndWith, Does.Match, Contains.Substring
// Handle Does.StartWith, Does.EndWith, Does.Contain, Does.Match, Contains.Substring, Contains.Item
if (memberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "Does" or "Contains" })
{
return methodName switch
{
"StartWith" => CreateTUnitAssertion("StartsWith", actualValue, constraint.ArgumentList.Arguments.ToArray()),
"EndWith" => CreateTUnitAssertion("EndsWith", actualValue, constraint.ArgumentList.Arguments.ToArray()),
"Contain" => CreateTUnitAssertion("Contains", actualValue, constraint.ArgumentList.Arguments.ToArray()),
"Match" => CreateTUnitAssertion("Matches", actualValue, constraint.ArgumentList.Arguments.ToArray()),
"Substring" => CreateTUnitAssertion("Contains", actualValue, constraint.ArgumentList.Arguments.ToArray()),
"Item" => CreateTUnitAssertion("Contains", actualValue, constraint.ArgumentList.Arguments.ToArray()),
_ => CreateTUnitAssertion("IsEqualTo", actualValue, SyntaxFactory.Argument(constraint))
};
}
Expand Down Expand Up @@ -2037,13 +2077,46 @@
{
// Lifecycle methods are handled by attribute conversion
// Just ensure they're public and have correct signature
// NOTE: Check for ORIGINAL NUnit attribute names since this runs BEFORE attribute conversion
var hasLifecycleAttribute = node.AttributeLists
.SelectMany(al => al.Attributes)
.Any(a => a.Name.ToString() is "Before" or "After");
.Select(a => MigrationHelpers.GetAttributeName(a))
.Any(name => name is "SetUp" or "TearDown" or "OneTimeSetUp" or "OneTimeTearDown");

if (hasLifecycleAttribute && !node.Modifiers.Any(SyntaxKind.PublicKeyword))
{
return node.AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword));
// Remove existing access modifiers (private, protected, internal) before adding public
var accessModifierKinds = new[]
{
SyntaxKind.PrivateKeyword,
SyntaxKind.ProtectedKeyword,
SyntaxKind.InternalKeyword
};

var newModifiers = node.Modifiers
.Where(m => !accessModifierKinds.Contains(m.Kind()))
.ToList();

// Preserve leading trivia from the first modifier, or get indentation from return type
var leadingTrivia = node.Modifiers.Any()
? node.Modifiers.First().LeadingTrivia
: node.ReturnType.GetLeadingTrivia();

var publicToken = SyntaxFactory.Token(SyntaxKind.PublicKeyword)
.WithLeadingTrivia(leadingTrivia)
.WithTrailingTrivia(SyntaxFactory.Space);

newModifiers.Insert(0, publicToken);

// If there were no modifiers, we need to strip the leading trivia from the return type
// since it's now on the public keyword
var newNode = node.WithModifiers(SyntaxFactory.TokenList(newModifiers));
if (!node.Modifiers.Any())
{
newNode = newNode.WithReturnType(newNode.ReturnType.WithLeadingTrivia());
}

return newNode;
}

return base.VisitMethodDeclaration(node);
Expand Down
Loading
Loading