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
10 changes: 8 additions & 2 deletions TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -483,9 +483,15 @@ public class InvocationReplacement : ConversionTarget
public class TheoryDataConversion : ConversionTarget
{
/// <summary>
/// The element type(s) from TheoryData&lt;T&gt; (e.g., "TimeSpan" from TheoryData&lt;TimeSpan&gt;)
/// The individual type arguments from TheoryData (e.g., ["TimeSpan"] or ["string", "int"]).
/// </summary>
public required string ElementType { get; init; }
public required IReadOnlyList<string> ElementTypes { get; init; }

/// <summary>
/// Whether the TheoryData has multiple type arguments (e.g., TheoryData&lt;string, int&gt;).
/// When true, initializer expressions { val1, val2 } must be converted to tuple expressions (val1, val2).
/// </summary>
public bool IsMultiType => ElementTypes.Count > 1;

/// <summary>
/// Annotation for the GenericName (TheoryData&lt;T&gt;) type syntax to convert to IEnumerable&lt;T&gt;
Expand Down
84 changes: 75 additions & 9 deletions TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,12 @@

if (objectCreation?.Initializer != null)
{
// Build array type: T[]
// Build the element type: T for single, (T1, T2) for multi
var elementTypeSyntax = BuildElementType(conversion.ElementTypes);

// Build array type: T[] or (T1, T2)[]
var arrayType = SyntaxFactory.ArrayType(
SyntaxFactory.ParseTypeName(conversion.ElementType),
elementTypeSyntax,
SyntaxFactory.SingletonList(
SyntaxFactory.ArrayRankSpecifier(
SyntaxFactory.SingletonSeparatedList<ExpressionSyntax>(
Expand All @@ -241,21 +244,40 @@
)
).WithoutTrailingTrivia();

// Get the open brace token and ensure it has proper newline trivia
// Get the open brace token and ensure it has proper newline trivia.
// When the brace is on a separate line (e.g., new Type\n{), the newline
// may be trailing trivia of the preceding token, not leading trivia of {.
// Preserve existing whitespace indentation when adding the newline.
var openBrace = objectCreation.Initializer.OpenBraceToken;
if (!openBrace.LeadingTrivia.Any(t => t.IsKind(SyntaxKind.EndOfLineTrivia)))
{
// Add newline and proper indentation before the brace
var existingWhitespace = openBrace.LeadingTrivia
.LastOrDefault(t => t.IsKind(SyntaxKind.WhitespaceTrivia));

var whitespace = existingWhitespace.IsKind(SyntaxKind.WhitespaceTrivia)
? existingWhitespace
: SyntaxFactory.Whitespace(" ");

openBrace = openBrace.WithLeadingTrivia(
SyntaxFactory.EndOfLine("\n"),
SyntaxFactory.Whitespace(" "));
whitespace);
}

// For multi-type TheoryData, convert complex initializer expressions
// { val1, val2 } to tuple expressions (val1, val2)
var expressions = objectCreation.Initializer.Expressions;
if (conversion.IsMultiType)
{
expressions = SyntaxFactory.SeparatedList(
expressions.Select(expr => ConvertToTupleExpression(expr)),
expressions.GetSeparators());
}

// Create array initializer from the collection initializer
var newInitializer = SyntaxFactory.InitializerExpression(
SyntaxKind.ArrayInitializerExpression,
openBrace,
objectCreation.Initializer.Expressions,
expressions,
objectCreation.Initializer.CloseBraceToken);

// Build the array creation expression
Expand All @@ -273,7 +295,7 @@
}
}

// Then, transform the type declaration from TheoryData<T> to IEnumerable<T>
// Then, transform the type declaration from TheoryData<T> to IEnumerable<T> or IEnumerable<(T1, T2)>
if (conversion.TypeAnnotation != null)
{
var genericName = currentRoot.DescendantNodes()
Expand All @@ -282,10 +304,13 @@

if (genericName != null)
{
var elementTypeSyntax = BuildElementType(conversion.ElementTypes);
var typeArgList = SyntaxFactory.TypeArgumentList(
SyntaxFactory.SingletonSeparatedList(elementTypeSyntax));

var enumerableType = SyntaxFactory.GenericName(
SyntaxFactory.Identifier("IEnumerable"),
SyntaxFactory.TypeArgumentList(
SyntaxFactory.SeparatedList(genericName.TypeArgumentList.Arguments)))
typeArgList)
.WithLeadingTrivia(genericName.GetLeadingTrivia())
.WithTrailingTrivia(genericName.GetTrailingTrivia());

Expand All @@ -308,6 +333,47 @@
return currentRoot;
}

/// <summary>
/// Builds the element TypeSyntax from the stored type argument strings.
/// Single type: returns the type directly (e.g., "TimeSpan" → TimeSpan).
/// Multi type: returns a tuple type (e.g., ["string", "int"] → (string, int)).
/// </summary>
private static TypeSyntax BuildElementType(IReadOnlyList<string> elementTypes)
{
if (elementTypes.Count == 1)
{
return SyntaxFactory.ParseTypeName(elementTypes[0]);
}

return SyntaxFactory.TupleType(
SyntaxFactory.SeparatedList(
elementTypes.Select(t =>
SyntaxFactory.TupleElement(SyntaxFactory.ParseTypeName(t)))));
}

/// <summary>
/// Converts a complex initializer expression { val1, val2 } to a tuple expression (val1, val2).
/// For simple expressions (single-value), returns the expression unchanged.
/// </summary>
private static ExpressionSyntax ConvertToTupleExpression(ExpressionSyntax expression)
{
if (expression is not InitializerExpressionSyntax initializer
|| !initializer.IsKind(SyntaxKind.ComplexElementInitializerExpression))
{
return expression;
}

var arguments = initializer.Expressions.Select(
expr => SyntaxFactory.Argument(expr.WithoutLeadingTrivia().WithoutTrailingTrivia()));

var tupleExpression = SyntaxFactory.TupleExpression(
SyntaxFactory.SeparatedList(arguments))
.WithLeadingTrivia(initializer.GetLeadingTrivia())
.WithTrailingTrivia(initializer.GetTrailingTrivia());

return tupleExpression;
}

private CompilationUnitSyntax TransformAssertions(CompilationUnitSyntax root)
{
var currentRoot = root;
Expand Down Expand Up @@ -346,7 +412,7 @@
{
todoTrivia.Add(indentationTrivia);
}
todoTrivia.Add(SyntaxFactory.Comment(assertion.TodoComment));

Check warning on line 415 in TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Possible null reference argument for parameter 'text' in 'SyntaxTrivia SyntaxFactory.Comment(string text)'.

Check warning on line 415 in TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Possible null reference argument for parameter 'text' in 'SyntaxTrivia SyntaxFactory.Comment(string text)'.

Check warning on line 415 in TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Possible null reference argument for parameter 'text' in 'SyntaxTrivia SyntaxFactory.Comment(string text)'.
todoTrivia.Add(SyntaxFactory.EndOfLine("\n"));

// Combine TODO comment with existing leading trivia
Expand Down Expand Up @@ -426,7 +492,7 @@
SyntaxFactory.Identifier("Task"),
SyntaxFactory.TypeArgumentList(
SyntaxFactory.SingletonSeparatedList(
SyntaxFactory.ParseTypeName(change.OriginalReturnType))))

Check warning on line 495 in TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Possible null reference argument for parameter 'text' in 'TypeSyntax SyntaxFactory.ParseTypeName(string text, int offset = 0, ParseOptions? options = null, bool consumeFullText = true)'.

Check warning on line 495 in TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Possible null reference argument for parameter 'text' in 'TypeSyntax SyntaxFactory.ParseTypeName(string text, int offset = 0, ParseOptions? options = null, bool consumeFullText = true)'.

Check warning on line 495 in TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Possible null reference argument for parameter 'text' in 'TypeSyntax SyntaxFactory.ParseTypeName(string text, int offset = 0, ParseOptions? options = null, bool consumeFullText = true)'.
.WithTrailingTrivia(SyntaxFactory.Space);
newMethod = newMethod.WithReturnType(taskGenericType);
}
Expand Down Expand Up @@ -572,7 +638,7 @@
if (!string.IsNullOrEmpty(additional.Arguments))
{
additionalAttr = additionalAttr.WithArgumentList(
SyntaxFactory.ParseAttributeArgumentList(additional.Arguments));

Check warning on line 641 in TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Possible null reference argument for parameter 'text' in 'AttributeArgumentListSyntax? SyntaxFactory.ParseAttributeArgumentList(string text, int offset = 0, ParseOptions? options = null, bool consumeFullText = true)'.

Check warning on line 641 in TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Possible null reference argument for parameter 'text' in 'AttributeArgumentListSyntax? SyntaxFactory.ParseAttributeArgumentList(string text, int offset = 0, ParseOptions? options = null, bool consumeFullText = true)'.

Check warning on line 641 in TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Possible null reference argument for parameter 'text' in 'AttributeArgumentListSyntax? SyntaxFactory.ParseAttributeArgumentList(string text, int offset = 0, ParseOptions? options = null, bool consumeFullText = true)'.
}

// Use only indentation for additional attributes (no blank lines)
Expand Down Expand Up @@ -1030,7 +1096,7 @@
if (!string.IsNullOrEmpty(addition.NewReturnType))
{
newMethod = newMethod.WithReturnType(
SyntaxFactory.ParseTypeName(addition.NewReturnType)

Check warning on line 1099 in TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Possible null reference argument for parameter 'text' in 'TypeSyntax SyntaxFactory.ParseTypeName(string text, int offset = 0, ParseOptions? options = null, bool consumeFullText = true)'.

Check warning on line 1099 in TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Possible null reference argument for parameter 'text' in 'TypeSyntax SyntaxFactory.ParseTypeName(string text, int offset = 0, ParseOptions? options = null, bool consumeFullText = true)'.
.WithTrailingTrivia(SyntaxFactory.Space));
}

Expand Down
10 changes: 5 additions & 5 deletions TUnit.Analyzers.CodeFixers/TwoPhase/XUnitTwoPhaseAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1833,19 +1833,19 @@ protected override CompilationUnitSyntax AnalyzeTheoryData(CompilationUnitSyntax
{
try
{
// Get the type argument
var typeArg = originalGeneric.TypeArgumentList.Arguments.FirstOrDefault();
if (typeArg == null) continue;
// Get the type arguments
var typeArgs = originalGeneric.TypeArgumentList.Arguments;
if (typeArgs.Count == 0) continue;

var elementType = typeArg.ToString();
var elementTypes = typeArgs.Select(t => t.ToString()).ToList();

// Create annotations for both the type and the object creation
var typeAnnotation = new SyntaxAnnotation("TUnitMigration", Guid.NewGuid().ToString());
var creationAnnotation = new SyntaxAnnotation("TUnitMigration", Guid.NewGuid().ToString());

var conversion = new TheoryDataConversion
{
ElementType = elementType,
ElementTypes = elementTypes,
TypeAnnotation = typeAnnotation,
CreationAnnotation = creationAnnotation,
OriginalText = originalGeneric.ToString()
Expand Down
142 changes: 142 additions & 0 deletions TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,148 @@ public class MyClass
);
}

[Test]
public async Task TheoryData_MultiType_Is_Flagged()
{
await Verifier
.VerifyAnalyzerAsync(
"""
{|#0:using System;

public class MyClass
{
public static TheoryData<string, int> MyData
{
get
{
var data = new TheoryData<string, int>
{
{ "a", 1 },
{ "b", 2 }
};
return data;
}
}
}|}
""",
ConfigureXUnitTest,
Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0)
);
}

[Test]
public async Task TheoryData_MultiType_Can_Be_Converted()
{
await CodeFixer
.VerifyCodeFixAsync(
"""
{|#0:using Xunit;

public class MyClass
{
public static readonly TheoryData<string, int> Items = new()
{
{ "a", 1 },
{ "b", 2 }
};
}|}
""",
Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0),
"""

public class MyClass
{
public static readonly IEnumerable<(string, int)> Items = new (string, int)[]
{
("a", 1),
("b", 2)
};
}
""",
ConfigureXUnitTest
);
}

[Test]
public async Task TheoryData_MultiType_PropertyGetter_Can_Be_Converted()
{
await CodeFixer
.VerifyCodeFixAsync(
"""
{|#0:using Xunit;

public class MyClass
{
public static TheoryData<string, int> MyData
{
get
{
var data = new TheoryData<string, int>
{
{ "a", 1 },
{ "b", 2 }
};
return data;
}
}
}|}
""",
Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0),
"""

public class MyClass
{
public static IEnumerable<(string, int)> MyData
{
get
{
var data = new (string, int)[]
{
("a", 1),
("b", 2)
};
return data;
}
}
}
""",
ConfigureXUnitTest
);
}

[Test]
public async Task TheoryData_ThreeTypes_Can_Be_Converted()
{
await CodeFixer
.VerifyCodeFixAsync(
"""
{|#0:using Xunit;

public class MyClass
{
public static readonly TheoryData<string, int, bool> Items = new()
{
{ "a", 1, true },
{ "b", 2, false }
};
}|}
""",
Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0),
"""

public class MyClass
{
public static readonly IEnumerable<(string, int, bool)> Items = new (string, int, bool)[]
{
("a", 1, true),
("b", 2, false)
};
}
""",
ConfigureXUnitTest
);
}

[Test]
public async Task ITestOutputHelper_Is_Flagged()
{
Expand Down
Loading