diff --git a/TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs
index 42b5de9480..c749977f9f 100644
--- a/TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs
+++ b/TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs
@@ -384,6 +384,7 @@ private static CompilationUnitSyntax AddTwoPhaseFailureTodoComments(
///
/// Removes excessive blank lines at the start of class members (after opening brace).
/// This can occur after removing members like ITestOutputHelper fields/properties.
+ /// Preserves preprocessor directives (#region, #if, #endif, etc.) and associated comments.
///
protected static CompilationUnitSyntax CleanupClassMemberLeadingTrivia(CompilationUnitSyntax root)
{
@@ -398,12 +399,63 @@ protected static CompilationUnitSyntax CleanupClassMemberLeadingTrivia(Compilati
var firstMember = classToFix.Members.First();
var leadingTrivia = firstMember.GetLeadingTrivia();
- // 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();
+ // Build the new trivia list, preserving preprocessor directives and their context
+ var triviaToKeep = new List();
+ var consecutiveNewlines = 0;
+ var lastWasPreprocessorOrComment = false;
+
+ foreach (var trivia in leadingTrivia)
+ {
+ // Always preserve preprocessor directives
+ if (IsPreprocessorDirective(trivia))
+ {
+ triviaToKeep.Add(trivia);
+ consecutiveNewlines = 0;
+ lastWasPreprocessorOrComment = true;
+ continue;
+ }
+
+ // Always preserve comments
+ if (trivia.IsKind(SyntaxKind.SingleLineCommentTrivia) ||
+ trivia.IsKind(SyntaxKind.MultiLineCommentTrivia) ||
+ trivia.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia) ||
+ trivia.IsKind(SyntaxKind.MultiLineDocumentationCommentTrivia))
+ {
+ triviaToKeep.Add(trivia);
+ consecutiveNewlines = 0;
+ lastWasPreprocessorOrComment = true;
+ continue;
+ }
+
+ // Preserve whitespace (indentation)
+ if (trivia.IsKind(SyntaxKind.WhitespaceTrivia))
+ {
+ triviaToKeep.Add(trivia);
+ continue;
+ }
+
+ // Handle newlines: allow one newline after preprocessor/comment, remove excessive newlines
+ if (trivia.IsKind(SyntaxKind.EndOfLineTrivia))
+ {
+ consecutiveNewlines++;
+
+ // Keep the first newline after a preprocessor directive or comment
+ // This ensures proper formatting like:
+ // #region Test
+ // [Test]
+ if (lastWasPreprocessorOrComment && consecutiveNewlines == 1)
+ {
+ triviaToKeep.Add(trivia);
+ }
+ // Otherwise skip excessive newlines at the start
+ lastWasPreprocessorOrComment = false;
+ continue;
+ }
+
+ // Preserve any other trivia (structured trivia, etc.)
+ triviaToKeep.Add(trivia);
+ lastWasPreprocessorOrComment = false;
+ }
var newFirstMember = firstMember.WithLeadingTrivia(triviaToKeep);
var updatedClass = classToFix.ReplaceNode(firstMember, newFirstMember);
@@ -413,9 +465,29 @@ protected static CompilationUnitSyntax CleanupClassMemberLeadingTrivia(Compilati
return currentRoot;
}
+ ///
+ /// Checks if a trivia is a preprocessor directive.
+ ///
+ private static bool IsPreprocessorDirective(SyntaxTrivia trivia)
+ {
+ return trivia.IsKind(SyntaxKind.RegionDirectiveTrivia) ||
+ trivia.IsKind(SyntaxKind.EndRegionDirectiveTrivia) ||
+ trivia.IsKind(SyntaxKind.IfDirectiveTrivia) ||
+ trivia.IsKind(SyntaxKind.ElseDirectiveTrivia) ||
+ trivia.IsKind(SyntaxKind.ElifDirectiveTrivia) ||
+ trivia.IsKind(SyntaxKind.EndIfDirectiveTrivia) ||
+ trivia.IsKind(SyntaxKind.DefineDirectiveTrivia) ||
+ trivia.IsKind(SyntaxKind.UndefDirectiveTrivia) ||
+ trivia.IsKind(SyntaxKind.PragmaWarningDirectiveTrivia) ||
+ trivia.IsKind(SyntaxKind.PragmaChecksumDirectiveTrivia) ||
+ trivia.IsKind(SyntaxKind.NullableDirectiveTrivia);
+ }
+
///
/// Finds a class with excessive leading trivia on its first member.
/// Returns null if no such class exists.
+ /// Only considers trivia "excessive" if there are multiple consecutive newlines
+ /// without preprocessor directives or comments between them.
///
private static ClassDeclarationSyntax? FindClassWithExcessiveLeadingTrivia(CompilationUnitSyntax root)
{
@@ -425,7 +497,27 @@ protected static CompilationUnitSyntax CleanupClassMemberLeadingTrivia(Compilati
.FirstOrDefault(c =>
{
var leadingTrivia = c.Members.First().GetLeadingTrivia();
- return leadingTrivia.Any(t => t.IsKind(SyntaxKind.EndOfLineTrivia));
+
+ // Check for excessive newlines (more than one consecutive newline without meaningful trivia)
+ var consecutiveNewlines = 0;
+ foreach (var trivia in leadingTrivia)
+ {
+ if (trivia.IsKind(SyntaxKind.EndOfLineTrivia))
+ {
+ consecutiveNewlines++;
+ if (consecutiveNewlines > 1)
+ {
+ return true; // Excessive newlines found
+ }
+ }
+ else if (!trivia.IsKind(SyntaxKind.WhitespaceTrivia))
+ {
+ // Non-whitespace, non-newline trivia resets the counter
+ consecutiveNewlines = 0;
+ }
+ }
+
+ return false;
});
}
diff --git a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs
index ecef844479..9dd2aa3d5b 100644
--- a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs
+++ b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs
@@ -345,6 +345,17 @@ public class MethodSignatureChange : ConversionTarget
/// Whether to make the method public (for lifecycle methods)
///
public bool MakePublic { get; init; }
+
+ ///
+ /// Whether to wrap the return type in Task<T> (for non-void, non-Task return types)
+ ///
+ public bool WrapReturnTypeInTask { get; init; }
+
+ ///
+ /// The original return type to wrap (e.g., "object", "int")
+ /// Only set when WrapReturnTypeInTask is true.
+ ///
+ public string? OriginalReturnType { get; init; }
}
///
diff --git a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationAnalyzer.cs b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationAnalyzer.cs
index 08e323bbdf..7ddbc48bad 100644
--- a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationAnalyzer.cs
+++ b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationAnalyzer.cs
@@ -521,10 +521,17 @@ protected virtual CompilationUnitSyntax AnalyzeMethodSignatures(CompilationUnitS
try
{
+ var returnTypeText = method.ReturnType.ToString();
+
+ // Determine what return type change is needed
+ var (changeReturnTypeToTask, wrapReturnTypeInTask, originalReturnType) = AnalyzeReturnTypeForAsync(returnTypeText);
+
var change = new MethodSignatureChange
{
AddAsync = true,
- ChangeReturnTypeToTask = method.ReturnType.ToString() == "void",
+ ChangeReturnTypeToTask = changeReturnTypeToTask,
+ WrapReturnTypeInTask = wrapReturnTypeInTask,
+ OriginalReturnType = originalReturnType,
OriginalText = $"{method.ReturnType} {method.Identifier}"
};
@@ -548,6 +555,44 @@ protected virtual CompilationUnitSyntax AnalyzeMethodSignatures(CompilationUnitS
return currentRoot;
}
+ ///
+ /// Analyzes the return type to determine what changes are needed for async conversion.
+ ///
+ ///
+ /// A tuple of (changeReturnTypeToTask, wrapReturnTypeInTask, originalReturnType):
+ /// - changeReturnTypeToTask: true if return type is void and should become Task
+ /// - wrapReturnTypeInTask: true if return type is non-void, non-Task and should become Task<T>
+ /// - originalReturnType: the original return type to wrap (only set when wrapReturnTypeInTask is true)
+ ///
+ private static (bool changeReturnTypeToTask, bool wrapReturnTypeInTask, string? originalReturnType) AnalyzeReturnTypeForAsync(string returnTypeText)
+ {
+ // void → Task
+ if (returnTypeText == "void")
+ {
+ return (true, false, null);
+ }
+
+ // Already Task or Task → no change needed
+ if (returnTypeText == "Task" ||
+ returnTypeText.StartsWith("Task<") ||
+ returnTypeText.StartsWith("System.Threading.Tasks.Task"))
+ {
+ return (false, false, null);
+ }
+
+ // Already ValueTask or ValueTask → no change needed (async already works with ValueTask)
+ if (returnTypeText == "ValueTask" ||
+ returnTypeText.StartsWith("ValueTask<") ||
+ returnTypeText.StartsWith("System.Threading.Tasks.ValueTask"))
+ {
+ return (false, false, null);
+ }
+
+ // Non-void, non-Task return type → wrap in Task
+ // e.g., object → Task