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
22 changes: 22 additions & 0 deletions TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ public class ConversionPlan
/// </summary>
public List<InvocationReplacement> InvocationReplacements { get; } = new();

/// <summary>
/// Expressions to replace (e.g., TestContext.CurrentContext.TestDirectory → TestContext.TestDirectory)
/// </summary>
public List<ExpressionReplacement> ExpressionReplacements { get; } = new();

/// <summary>
/// TheoryData conversions (TheoryData&lt;T&gt; → IEnumerable&lt;T&gt;)
/// </summary>
Expand Down Expand Up @@ -111,6 +116,7 @@ public class ConversionPlan
ConstructorParameterRemovals.Count > 0 ||
RecordExceptionConversions.Count > 0 ||
InvocationReplacements.Count > 0 ||
ExpressionReplacements.Count > 0 ||
TheoryDataConversions.Count > 0 ||
ParameterAttributes.Count > 0 ||
UsingsToAdd.Count > 0 ||
Expand Down Expand Up @@ -476,6 +482,22 @@ public class InvocationReplacement : ConversionTarget
public required string ReplacementCode { get; init; }
}

/// <summary>
/// Represents an expression to replace (e.g., TestContext.CurrentContext.TestDirectory → TestContext.TestDirectory)
/// </summary>
public class ExpressionReplacement : ConversionTarget
{
/// <summary>
/// The new expression code (e.g., "TestContext.TestDirectory")
/// </summary>
public required string ReplacementCode { get; init; }

/// <summary>
/// Optional TODO comment to add before the containing statement.
/// </summary>
public string? TodoComment { get; init; }
}

/// <summary>
/// Represents a TheoryData field/property that needs to be converted to IEnumerable.
/// This handles both the type declaration and the object creation expression.
Expand Down
78 changes: 78 additions & 0 deletions TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,84 @@ protected virtual CompilationUnitSyntax AnalyzeSpecialInvocations(CompilationUni
return root;
}

protected CompilationUnitSyntax AddInvocationReplacement(
CompilationUnitSyntax root,
InvocationExpressionSyntax originalCall,
string replacementCode,
string phase)
{
try
{
var replacement = new InvocationReplacement
{
ReplacementCode = replacementCode,
OriginalText = originalCall.ToString()
};

Plan.InvocationReplacements.Add(replacement);

var nodeToAnnotate = root.DescendantNodes()
.OfType<InvocationExpressionSyntax>()
.FirstOrDefault(n => n.Span == originalCall.Span);

return nodeToAnnotate == null
? root
: root.ReplaceNode(nodeToAnnotate, nodeToAnnotate.WithAdditionalAnnotations(replacement.Annotation));
}
catch (Exception ex)
{
Plan.Failures.Add(new ConversionFailure
{
Phase = phase,
Description = ex.Message,
OriginalCode = originalCall.ToString(),
Exception = ex
});

return root;
}
}

protected CompilationUnitSyntax AddExpressionReplacement(
CompilationUnitSyntax root,
ExpressionSyntax originalExpression,
string replacementCode,
string phase,
string? todoComment = null)
{
try
{
var replacement = new ExpressionReplacement
{
ReplacementCode = replacementCode,
OriginalText = originalExpression.ToString(),
TodoComment = todoComment
};

Plan.ExpressionReplacements.Add(replacement);

var nodeToAnnotate = root.DescendantNodes()
.OfType<ExpressionSyntax>()
.FirstOrDefault(n => n.Span == originalExpression.Span && n.Kind() == originalExpression.Kind());

return nodeToAnnotate == null
? root
: root.ReplaceNode(nodeToAnnotate, nodeToAnnotate.WithAdditionalAnnotations(replacement.Annotation));
}
catch (Exception ex)
{
Plan.Failures.Add(new ConversionFailure
{
Phase = phase,
Description = ex.Message,
OriginalCode = originalExpression.ToString(),
Exception = ex
});

return root;
}
}

/// <summary>
/// Analyzes TheoryData fields/properties for conversion to IEnumerable.
/// </summary>
Expand Down
142 changes: 104 additions & 38 deletions TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,49 +32,52 @@ public CompilationUnitSyntax Transform(CompilationUnitSyntax root)
// 1. Record.Exception conversions (before assertions - may affect structure)
currentRoot = TransformRecordExceptionCalls(currentRoot);

// 2. Invocation replacements (ITestOutputHelper → Console)
// 2. Expression replacements (TestContext directory properties, etc.)
currentRoot = TransformExpressionReplacements(currentRoot);

// 3. Invocation replacements (ITestOutputHelper → Console)
currentRoot = TransformInvocationReplacements(currentRoot);

// 3. TheoryData conversions (TheoryData<T> → IEnumerable<T>)
// 4. TheoryData conversions (TheoryData<T> → IEnumerable<T>)
currentRoot = TransformTheoryData(currentRoot);

// 4. Assertions (may introduce await)
// 5. Assertions (may introduce await)
currentRoot = TransformAssertions(currentRoot);

// 4. Method signatures (add async/Task based on new awaits)
// 6. Method signatures (add async/Task based on new awaits)
currentRoot = TransformMethodSignatures(currentRoot);

// 5. Add method attributes (e.g., [Before(Test)])
// 7. Add method attributes (e.g., [Before(Test)])
currentRoot = AddMethodAttributes(currentRoot);

// 6. Attributes
// 8. Attributes
currentRoot = TransformAttributes(currentRoot);

// 6b. Parameter attributes (e.g., [Range] → [MatrixRange])
// 9. Parameter attributes (e.g., [Range] → [MatrixRange])
currentRoot = TransformParameterAttributes(currentRoot);

// 7. Remove attributes
// 10. Remove attributes
currentRoot = RemoveAttributes(currentRoot);

// 8. Remove base types
// 11. Remove base types
currentRoot = RemoveBaseTypes(currentRoot);

// 9. Add base types (e.g., IAsyncInitializer)
// 12. Add base types (e.g., IAsyncInitializer)
currentRoot = AddBaseTypes(currentRoot);

// 10. Add class attributes (e.g., ClassDataSource)
// 13. Add class attributes (e.g., ClassDataSource)
currentRoot = AddClassAttributes(currentRoot);

// 11. Remove members
// 14. Remove members
currentRoot = RemoveMembers(currentRoot);

// 12. Remove constructor parameters
// 15. Remove constructor parameters
currentRoot = RemoveConstructorParameters(currentRoot);

// 13. Update usings (last, pure syntax)
// 16. Update usings (last, pure syntax)
currentRoot = TransformUsings(currentRoot);

// 14. Add TODO comments for failures
// 17. Add TODO comments for failures
if (_plan.HasFailures)
{
currentRoot = AddFailureComments(currentRoot);
Expand All @@ -83,6 +86,55 @@ public CompilationUnitSyntax Transform(CompilationUnitSyntax root)
return currentRoot;
}

private CompilationUnitSyntax TransformExpressionReplacements(CompilationUnitSyntax root)
{
var currentRoot = root;

foreach (var replacement in _plan.ExpressionReplacements)
{
try
{
var expression = currentRoot.DescendantNodes()
.OfType<ExpressionSyntax>()
.FirstOrDefault(i => i.HasAnnotation(replacement.Annotation));

if (expression == null)
{
continue;
}

var newExpression = SyntaxFactory.ParseExpression(replacement.ReplacementCode)
.WithLeadingTrivia(expression.GetLeadingTrivia())
.WithTrailingTrivia(expression.GetTrailingTrivia());

if (replacement.TodoComment is { Length: > 0 } todoComment
&& expression.FirstAncestorOrSelf<StatementSyntax>() is { } statement)
{
var newStatement = statement.ReplaceNode(expression, newExpression)
.WithLeadingTrivia(PrependTodoComment(statement.GetLeadingTrivia(), todoComment));

currentRoot = currentRoot.ReplaceNode(statement, newStatement);
}
else
{
currentRoot = currentRoot.ReplaceNode(expression, newExpression);
}
}
catch (Exception ex)
{
_plan.Failures.Add(new ConversionFailure
{
Phase = "ExpressionReplacementTransformation",
Description = ex.Message,
OriginalCode = replacement.OriginalText,
Exception = ex
});
}
}

return currentRoot;
}

private CompilationUnitSyntax TransformRecordExceptionCalls(CompilationUnitSyntax root)
{
var currentRoot = root;
Expand Down Expand Up @@ -190,8 +242,18 @@ private CompilationUnitSyntax TransformInvocationReplacements(CompilationUnitSyn

if (invocation == null) continue;

// Parse the replacement code
var newInvocation = SyntaxFactory.ParseExpression(replacement.ReplacementCode);
if (newInvocation is InvocationExpressionSyntax replacementInvocation &&
replacementInvocation.ArgumentList.Arguments.Count == invocation.ArgumentList.Arguments.Count)
{
var currentArguments = invocation.ArgumentList.Arguments;
var refreshedArguments = replacementInvocation.ArgumentList.Arguments
.Select((argument, index) => argument.WithExpression(currentArguments[index].Expression));

newInvocation = replacementInvocation.WithArgumentList(
replacementInvocation.ArgumentList.WithArguments(
SyntaxFactory.SeparatedList(refreshedArguments)));
}

currentRoot = currentRoot.ReplaceNode(invocation, newInvocation
.WithLeadingTrivia(invocation.GetLeadingTrivia())
Expand Down Expand Up @@ -400,23 +462,9 @@ private CompilationUnitSyntax TransformAssertions(CompilationUnitSyntax root)
{
// Build the leading trivia, including TODO comment if present
var leadingTrivia = containingStatement.GetLeadingTrivia();
if (!string.IsNullOrEmpty(assertion.TodoComment))
if (assertion.TodoComment is { Length: > 0 } todoComment)
{
// Extract the indentation from existing trivia
var indentationTrivia = leadingTrivia
.Where(t => t.IsKind(SyntaxKind.WhitespaceTrivia))
.LastOrDefault();

var todoTrivia = new List<SyntaxTrivia>();
if (indentationTrivia != default)
{
todoTrivia.Add(indentationTrivia);
}
todoTrivia.Add(SyntaxFactory.Comment(assertion.TodoComment));
todoTrivia.Add(SyntaxFactory.EndOfLine("\n"));

// Combine TODO comment with existing leading trivia
leadingTrivia = SyntaxFactory.TriviaList(todoTrivia.Concat(leadingTrivia));
leadingTrivia = PrependTodoComment(leadingTrivia, todoComment);
}

// Replace the entire statement with the new expression statement
Expand Down Expand Up @@ -449,6 +497,23 @@ private CompilationUnitSyntax TransformAssertions(CompilationUnitSyntax root)
return currentRoot;
}

private static SyntaxTriviaList PrependTodoComment(SyntaxTriviaList leadingTrivia, string todoComment)
{
var indentationTrivia = leadingTrivia
.Where(t => t.IsKind(SyntaxKind.WhitespaceTrivia))
.LastOrDefault();

var todoTrivia = new List<SyntaxTrivia>();
if (indentationTrivia != default)
{
todoTrivia.Add(indentationTrivia);
}

todoTrivia.Add(SyntaxFactory.Comment(todoComment));
todoTrivia.Add(SyntaxFactory.EndOfLine("\n"));
return SyntaxFactory.TriviaList(todoTrivia.Concat(leadingTrivia));
}

private CompilationUnitSyntax TransformMethodSignatures(CompilationUnitSyntax root)
{
var currentRoot = root;
Expand Down Expand Up @@ -485,14 +550,15 @@ private CompilationUnitSyntax TransformMethodSignatures(CompilationUnitSyntax ro
}

// Wrap return type in Task<T> if needed (non-void, non-Task return type)
if (change.WrapReturnTypeInTask && !string.IsNullOrEmpty(change.OriginalReturnType))
if (change.WrapReturnTypeInTask &&
change.OriginalReturnType is { Length: > 0 } originalReturnType)
{
// Build Task<OriginalReturnType>
var taskGenericType = SyntaxFactory.GenericName(
SyntaxFactory.Identifier("Task"),
SyntaxFactory.TypeArgumentList(
SyntaxFactory.SingletonSeparatedList(
SyntaxFactory.ParseTypeName(change.OriginalReturnType))))
SyntaxFactory.ParseTypeName(originalReturnType))))
.WithTrailingTrivia(SyntaxFactory.Space);
newMethod = newMethod.WithReturnType(taskGenericType);
}
Expand Down Expand Up @@ -635,10 +701,10 @@ private CompilationUnitSyntax TransformAttributes(CompilationUnitSyntax root)
var additionalAttr = SyntaxFactory.Attribute(
SyntaxFactory.IdentifierName(additional.Name));

if (!string.IsNullOrEmpty(additional.Arguments))
if (additional.Arguments is { Length: > 0 } additionalArguments)
{
additionalAttr = additionalAttr.WithArgumentList(
SyntaxFactory.ParseAttributeArgumentList(additional.Arguments));
SyntaxFactory.ParseAttributeArgumentList(additionalArguments));
}

// Use only indentation for additional attributes (no blank lines)
Expand Down Expand Up @@ -1091,10 +1157,10 @@ private CompilationUnitSyntax AddMethodAttributes(CompilationUnitSyntax root)
.WithAttributeLists(SyntaxFactory.List(newAttributeLists));

// Change return type if specified
if (!string.IsNullOrEmpty(addition.NewReturnType))
if (addition.NewReturnType is { Length: > 0 } newReturnType)
{
newMethod = newMethod.WithReturnType(
SyntaxFactory.ParseTypeName(addition.NewReturnType)
SyntaxFactory.ParseTypeName(newReturnType)
.WithTrailingTrivia(SyntaxFactory.Space));
}

Expand Down
Loading
Loading