Skip to content
7 changes: 5 additions & 2 deletions TUnit.Analyzers.CodeFixers/Base/AssertionRewriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ protected AssertionRewriter(SemanticModel semanticModel)
var convertedAssertion = ConvertAssertionIfNeeded(node);
if (convertedAssertion != null)
{
return convertedAssertion;
// Preserve the original trivia (whitespace, comments, etc.)
return convertedAssertion
.WithLeadingTrivia(node.GetLeadingTrivia())
.WithTrailingTrivia(node.GetTrailingTrivia());
}

return base.VisitInvocationExpression(node);
}

Expand Down
145 changes: 124 additions & 21 deletions TUnit.Analyzers.CodeFixers/Base/BaseMigrationCodeFixProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Formatting;
using TUnit.Analyzers.Migrators.Base;

namespace TUnit.Analyzers.CodeFixers.Base;
Expand Down Expand Up @@ -47,54 +46,158 @@ protected async Task<Document> ConvertCodeAsync(Document document, SyntaxNode? r
return document;
}

var compilation = semanticModel.Compilation;

try
{
// Convert assertions FIRST (while semantic model still matches the syntax tree)
var assertionRewriter = CreateAssertionRewriter(semanticModel);
var assertionRewriter = CreateAssertionRewriter(semanticModel, compilation);
compilationUnit = (CompilationUnitSyntax)assertionRewriter.Visit(compilationUnit);

// Framework-specific conversions (also use semantic model while it still matches)
compilationUnit = ApplyFrameworkSpecificConversions(compilationUnit, semanticModel);
compilationUnit = ApplyFrameworkSpecificConversions(compilationUnit, semanticModel, compilation);

// Remove unnecessary base classes and interfaces
var baseTypeRewriter = CreateBaseTypeRewriter(semanticModel);
var baseTypeRewriter = CreateBaseTypeRewriter(semanticModel, compilation);
compilationUnit = (CompilationUnitSyntax)baseTypeRewriter.Visit(compilationUnit);

// Update lifecycle methods
var lifecycleRewriter = CreateLifecycleRewriter();
var lifecycleRewriter = CreateLifecycleRewriter(compilation);
compilationUnit = (CompilationUnitSyntax)lifecycleRewriter.Visit(compilationUnit);

// Convert attributes
var attributeRewriter = CreateAttributeRewriter();
var attributeRewriter = CreateAttributeRewriter(compilation);
compilationUnit = (CompilationUnitSyntax)attributeRewriter.Visit(compilationUnit);

// Remove framework usings and add TUnit usings (do this LAST)
compilationUnit = MigrationHelpers.RemoveFrameworkUsings(compilationUnit, FrameworkName);
compilationUnit = MigrationHelpers.AddTUnitUsings(compilationUnit);

// Format the document first
var documentWithNewRoot = document.WithSyntaxRoot(compilationUnit);
var formattedDocument = await Formatter.FormatAsync(documentWithNewRoot, options: null, cancellationToken).ConfigureAwait(false);
if (ShouldAddTUnitUsings())
{
compilationUnit = MigrationHelpers.AddTUnitUsings(compilationUnit);
}

// Normalize all line endings to CRLF for cross-platform consistency
var text = await formattedDocument.GetTextAsync(cancellationToken).ConfigureAwait(false);
var normalizedContent = text.ToString().Replace("\r\n", "\n").Replace("\n", "\r\n");
var normalizedText = Microsoft.CodeAnalysis.Text.SourceText.From(normalizedContent, text.Encoding);
// Clean up trivia issues that can occur after transformations
compilationUnit = CleanupClassMemberLeadingTrivia(compilationUnit);
compilationUnit = CleanupEndOfFileTrivia(compilationUnit);

return formattedDocument.WithText(normalizedText);
// Normalize line endings to match original document (fixes cross-platform issues)
compilationUnit = NormalizeLineEndings(compilationUnit, root);

// Return the document with updated syntax root, preserving original formatting
return document.WithSyntaxRoot(compilationUnit);
}
catch
{
// If any transformation fails, return the original document unchanged
return document;
}
}

protected abstract AttributeRewriter CreateAttributeRewriter();
protected abstract CSharpSyntaxRewriter CreateAssertionRewriter(SemanticModel semanticModel);
protected abstract CSharpSyntaxRewriter CreateBaseTypeRewriter(SemanticModel semanticModel);
protected abstract CSharpSyntaxRewriter CreateLifecycleRewriter();
protected abstract CompilationUnitSyntax ApplyFrameworkSpecificConversions(CompilationUnitSyntax compilationUnit, SemanticModel semanticModel);

protected abstract AttributeRewriter CreateAttributeRewriter(Compilation compilation);
protected abstract CSharpSyntaxRewriter CreateAssertionRewriter(SemanticModel semanticModel, Compilation compilation);
protected abstract CSharpSyntaxRewriter CreateBaseTypeRewriter(SemanticModel semanticModel, Compilation compilation);
protected abstract CSharpSyntaxRewriter CreateLifecycleRewriter(Compilation compilation);
protected abstract CompilationUnitSyntax ApplyFrameworkSpecificConversions(CompilationUnitSyntax compilationUnit, SemanticModel semanticModel, Compilation compilation);

/// <summary>
/// Determines whether to add TUnit usings (including assertion usings).
/// Override to return false for frameworks that don't need assertion usings (e.g., XUnit).
/// </summary>
protected virtual bool ShouldAddTUnitUsings() => true;

/// <summary>
/// Removes excessive blank lines at the start of class members (after opening brace).
/// This can occur after removing members like ITestOutputHelper fields/properties.
/// </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)
{
var firstMember = classDecl.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();

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

return (CompilationUnitSyntax)currentRoot;
}

/// <summary>
/// Removes trailing blank lines at end of file.
/// Files should end immediately after the closing brace with no trailing newlines.
/// </summary>
protected static CompilationUnitSyntax CleanupEndOfFileTrivia(CompilationUnitSyntax compilationUnit)
{
var lastMember = compilationUnit.Members.LastOrDefault();
if (lastMember != null)
{
var trailingTrivia = lastMember.GetTrailingTrivia();
int newlineCount = trailingTrivia.Count(t => t.IsKind(SyntaxKind.EndOfLineTrivia));

if (newlineCount > 0)
{
var newTrivia = trailingTrivia
.Where(t => !t.IsKind(SyntaxKind.EndOfLineTrivia))
.ToList();

var newLastMember = lastMember.WithTrailingTrivia(newTrivia);
return compilationUnit.ReplaceNode(lastMember, newLastMember);
}
}

return compilationUnit;
}

/// <summary>
/// Normalizes all line endings in the compilation unit to LF (Unix format) for cross-platform consistency.
/// This ensures code fixes produce consistent output regardless of the platform or original document's line endings,
/// preventing test failures between Windows (CRLF) and Unix (LF) systems.
/// </summary>
private static CompilationUnitSyntax NormalizeLineEndings(CompilationUnitSyntax compilationUnit, SyntaxNode originalRoot)
{
// Always normalize to LF for cross-platform consistency
// This matches our test infrastructure which also normalizes to LF
const string targetEol = "\n";

// Create a rewriter that replaces all EndOfLine trivia with the target line ending
var rewriter = new LineEndingNormalizer(targetEol);
return (CompilationUnitSyntax)rewriter.Visit(compilationUnit);
}

/// <summary>
/// Rewrites all EndOfLine trivia to use a consistent line ending style.
/// </summary>
private class LineEndingNormalizer(string lineEnding) : CSharpSyntaxRewriter(visitIntoStructuredTrivia: true)
{
public override SyntaxTrivia VisitTrivia(SyntaxTrivia trivia)
{
if (trivia.IsKind(SyntaxKind.EndOfLineTrivia))
{
return SyntaxFactory.EndOfLine(lineEnding);
}

return base.VisitTrivia(trivia);
}
}
}

public abstract class AttributeRewriter : CSharpSyntaxRewriter
Expand Down
18 changes: 9 additions & 9 deletions TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,27 @@ public class MSTestMigrationCodeFixProvider : BaseMigrationCodeFixProvider
protected override string DiagnosticId => Rules.MSTestMigration.Id;
protected override string CodeFixTitle => "Convert MSTest code to TUnit";

protected override AttributeRewriter CreateAttributeRewriter()
protected override AttributeRewriter CreateAttributeRewriter(Compilation compilation)
{
return new MSTestAttributeRewriter();
}
protected override CSharpSyntaxRewriter CreateAssertionRewriter(SemanticModel semanticModel)

protected override CSharpSyntaxRewriter CreateAssertionRewriter(SemanticModel semanticModel, Compilation compilation)
{
return new MSTestAssertionRewriter(semanticModel);
}
protected override CSharpSyntaxRewriter CreateBaseTypeRewriter(SemanticModel semanticModel)

protected override CSharpSyntaxRewriter CreateBaseTypeRewriter(SemanticModel semanticModel, Compilation compilation)
{
return new MSTestBaseTypeRewriter();
}
protected override CSharpSyntaxRewriter CreateLifecycleRewriter()

protected override CSharpSyntaxRewriter CreateLifecycleRewriter(Compilation compilation)
{
return new MSTestLifecycleRewriter();
}
protected override CompilationUnitSyntax ApplyFrameworkSpecificConversions(CompilationUnitSyntax compilationUnit, SemanticModel semanticModel)

protected override CompilationUnitSyntax ApplyFrameworkSpecificConversions(CompilationUnitSyntax compilationUnit, SemanticModel semanticModel, Compilation compilation)
{
// MSTest-specific conversions if needed
return compilationUnit;
Expand Down
18 changes: 9 additions & 9 deletions TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,27 +15,27 @@ public class NUnitMigrationCodeFixProvider : BaseMigrationCodeFixProvider
protected override string DiagnosticId => Rules.NUnitMigration.Id;
protected override string CodeFixTitle => "Convert NUnit code to TUnit";

protected override AttributeRewriter CreateAttributeRewriter()
protected override AttributeRewriter CreateAttributeRewriter(Compilation compilation)
{
return new NUnitAttributeRewriter();
}
protected override CSharpSyntaxRewriter CreateAssertionRewriter(SemanticModel semanticModel)

protected override CSharpSyntaxRewriter CreateAssertionRewriter(SemanticModel semanticModel, Compilation compilation)
{
return new NUnitAssertionRewriter(semanticModel);
}
protected override CSharpSyntaxRewriter CreateBaseTypeRewriter(SemanticModel semanticModel)

protected override CSharpSyntaxRewriter CreateBaseTypeRewriter(SemanticModel semanticModel, Compilation compilation)
{
return new NUnitBaseTypeRewriter();
}
protected override CSharpSyntaxRewriter CreateLifecycleRewriter()

protected override CSharpSyntaxRewriter CreateLifecycleRewriter(Compilation compilation)
{
return new NUnitLifecycleRewriter();
}
protected override CompilationUnitSyntax ApplyFrameworkSpecificConversions(CompilationUnitSyntax compilationUnit, SemanticModel semanticModel)

protected override CompilationUnitSyntax ApplyFrameworkSpecificConversions(CompilationUnitSyntax compilationUnit, SemanticModel semanticModel, Compilation compilation)
{
// NUnit-specific conversions if needed
return compilationUnit;
Expand Down
Loading
Loading