From f764ee17e05b521cccd6a7633efc568fff24dd01 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:19:08 +0000 Subject: [PATCH 1/3] fix: handle multi-type TheoryData in xUnit migration (#5000) TheoryData was incorrectly converted to IEnumerable (invalid) with a T1[] array (losing other types). Now correctly converts to IEnumerable<(T1, T2)> with tuple array and tuple initializer expressions. --- .../Base/TwoPhase/ConversionPlan.cs | 9 ++- .../Base/TwoPhase/MigrationTransformer.cs | 60 ++++++++++++++++-- .../TwoPhase/XUnitTwoPhaseAnalyzer.cs | 14 +++-- .../XUnitMigrationAnalyzerTests.cs | 62 +++++++++++++++++++ 4 files changed, 135 insertions(+), 10 deletions(-) diff --git a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs index 9dd2aa3d5b..1430108dfc 100644 --- a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs +++ b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs @@ -483,10 +483,17 @@ public class InvocationReplacement : ConversionTarget public class TheoryDataConversion : ConversionTarget { /// - /// The element type(s) from TheoryData<T> (e.g., "TimeSpan" from TheoryData<TimeSpan>) + /// The element type(s) from TheoryData<T> (e.g., "TimeSpan" from TheoryData<TimeSpan> + /// or "(string, int)" from TheoryData<string, int>) /// public required string ElementType { get; init; } + /// + /// Whether the TheoryData has multiple type arguments (e.g., TheoryData<string, int>). + /// When true, initializer expressions { val1, val2 } must be converted to tuple expressions (val1, val2). + /// + public bool IsMultiType { get; init; } + /// /// Annotation for the GenericName (TheoryData<T>) type syntax to convert to IEnumerable<T> /// diff --git a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs index d9bb94f30c..28aaebdc97 100644 --- a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs +++ b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs @@ -229,7 +229,7 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root) if (objectCreation?.Initializer != null) { - // Build array type: T[] + // Build array type: T[] or (T1, T2)[] var arrayType = SyntaxFactory.ArrayType( SyntaxFactory.ParseTypeName(conversion.ElementType), SyntaxFactory.SingletonList( @@ -251,11 +251,21 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root) SyntaxFactory.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 @@ -273,7 +283,7 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root) } } - // Then, transform the type declaration from TheoryData to IEnumerable + // Then, transform the type declaration from TheoryData to IEnumerable or IEnumerable<(T1, T2)> if (conversion.TypeAnnotation != null) { var genericName = currentRoot.DescendantNodes() @@ -282,10 +292,27 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root) if (genericName != null) { + TypeArgumentListSyntax typeArgList; + if (conversion.IsMultiType) + { + // TheoryData → IEnumerable<(T1, T2, ...)> + var tupleType = SyntaxFactory.TupleType( + SyntaxFactory.SeparatedList( + genericName.TypeArgumentList.Arguments.Select( + arg => SyntaxFactory.TupleElement(arg)))); + typeArgList = SyntaxFactory.TypeArgumentList( + SyntaxFactory.SingletonSeparatedList(tupleType)); + } + else + { + // TheoryData → IEnumerable + typeArgList = SyntaxFactory.TypeArgumentList( + SyntaxFactory.SeparatedList(genericName.TypeArgumentList.Arguments)); + } + var enumerableType = SyntaxFactory.GenericName( SyntaxFactory.Identifier("IEnumerable"), - SyntaxFactory.TypeArgumentList( - SyntaxFactory.SeparatedList(genericName.TypeArgumentList.Arguments))) + typeArgList) .WithLeadingTrivia(genericName.GetLeadingTrivia()) .WithTrailingTrivia(genericName.GetTrailingTrivia()); @@ -308,6 +335,29 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root) return currentRoot; } + /// + /// Converts a complex initializer expression { val1, val2 } to a tuple expression (val1, val2). + /// For simple expressions (single-value), returns the expression unchanged. + /// + 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; diff --git a/TUnit.Analyzers.CodeFixers/TwoPhase/XUnitTwoPhaseAnalyzer.cs b/TUnit.Analyzers.CodeFixers/TwoPhase/XUnitTwoPhaseAnalyzer.cs index 9a13310ca0..2ca47c9e97 100644 --- a/TUnit.Analyzers.CodeFixers/TwoPhase/XUnitTwoPhaseAnalyzer.cs +++ b/TUnit.Analyzers.CodeFixers/TwoPhase/XUnitTwoPhaseAnalyzer.cs @@ -1833,11 +1833,16 @@ 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(); + // For single type: TheoryData → IEnumerable, element type is T + // For multi type: TheoryData → IEnumerable<(T1, T2)>, element type is (T1, T2) + var isMultiType = typeArgs.Count > 1; + var elementType = isMultiType + ? $"({string.Join(", ", typeArgs.Select(t => t.ToString()))})" + : typeArgs[0].ToString(); // Create annotations for both the type and the object creation var typeAnnotation = new SyntaxAnnotation("TUnitMigration", Guid.NewGuid().ToString()); @@ -1846,6 +1851,7 @@ protected override CompilationUnitSyntax AnalyzeTheoryData(CompilationUnitSyntax var conversion = new TheoryDataConversion { ElementType = elementType, + IsMultiType = isMultiType, TypeAnnotation = typeAnnotation, CreationAnnotation = creationAnnotation, OriginalText = originalGeneric.ToString() diff --git a/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs b/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs index dcd7d31399..a016e91b55 100644 --- a/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs +++ b/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs @@ -563,6 +563,68 @@ public class MyClass ); } + [Test] + public async Task TheoryData_MultiType_Is_Flagged() + { + await Verifier + .VerifyAnalyzerAsync( + """ + {|#0:using System; + + public class MyClass + { + public static TheoryData MyData + { + get + { + var data = new TheoryData + { + { "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 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 ITestOutputHelper_Is_Flagged() { From e221848a3775527bca6d25447d17a958c2aa0735 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:00:50 +0000 Subject: [PATCH 2/3] test: add 3-type TheoryData conversion test Addresses code review feedback to verify separator handling and tuple construction work correctly at higher arities (TheoryData). --- .../XUnitMigrationAnalyzerTests.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs b/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs index a016e91b55..56d59d59da 100644 --- a/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs +++ b/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs @@ -625,6 +625,39 @@ public class MyClass ); } + [Test] + public async Task TheoryData_ThreeTypes_Can_Be_Converted() + { + await CodeFixer + .VerifyCodeFixAsync( + """ + {|#0:using Xunit; + + public class MyClass + { + public static readonly TheoryData 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() { From ccff4ebec480da37c6324ec7f39665c4eef83a4b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:39:22 +0000 Subject: [PATCH 3/3] refactor: improve TheoryData conversion data model and fix brace indentation Address code review feedback: - Replace ElementType string + IsMultiType flag with ElementTypes list; IsMultiType is now a derived property, eliminating redundant invariant - Add shared BuildElementType() helper using Roslyn TupleType consistently for both IEnumerable type and array type construction (no more string roundtrip via ParseTypeName for tuple types) - Fix pre-existing brace indentation bug: preserve original whitespace when the newline is trailing trivia of the preceding token - Add property-getter code fix test confirming multi-type conversion works for the exact pattern from the issue report --- .../Base/TwoPhase/ConversionPlan.cs | 7 +-- .../Base/TwoPhase/MigrationTransformer.cs | 58 ++++++++++++------- .../TwoPhase/XUnitTwoPhaseAnalyzer.cs | 10 +--- .../XUnitMigrationAnalyzerTests.cs | 47 +++++++++++++++ 4 files changed, 89 insertions(+), 33 deletions(-) diff --git a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs index 1430108dfc..3983091e31 100644 --- a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs +++ b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/ConversionPlan.cs @@ -483,16 +483,15 @@ public class InvocationReplacement : ConversionTarget public class TheoryDataConversion : ConversionTarget { /// - /// The element type(s) from TheoryData<T> (e.g., "TimeSpan" from TheoryData<TimeSpan> - /// or "(string, int)" from TheoryData<string, int>) + /// The individual type arguments from TheoryData (e.g., ["TimeSpan"] or ["string", "int"]). /// - public required string ElementType { get; init; } + public required IReadOnlyList ElementTypes { get; init; } /// /// Whether the TheoryData has multiple type arguments (e.g., TheoryData<string, int>). /// When true, initializer expressions { val1, val2 } must be converted to tuple expressions (val1, val2). /// - public bool IsMultiType { get; init; } + public bool IsMultiType => ElementTypes.Count > 1; /// /// Annotation for the GenericName (TheoryData<T>) type syntax to convert to IEnumerable<T> diff --git a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs index 28aaebdc97..2d0d1488ad 100644 --- a/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs +++ b/TUnit.Analyzers.CodeFixers/Base/TwoPhase/MigrationTransformer.cs @@ -229,9 +229,12 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root) if (objectCreation?.Initializer != null) { + // 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( @@ -241,14 +244,23 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root) ) ).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 @@ -292,23 +304,9 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root) if (genericName != null) { - TypeArgumentListSyntax typeArgList; - if (conversion.IsMultiType) - { - // TheoryData → IEnumerable<(T1, T2, ...)> - var tupleType = SyntaxFactory.TupleType( - SyntaxFactory.SeparatedList( - genericName.TypeArgumentList.Arguments.Select( - arg => SyntaxFactory.TupleElement(arg)))); - typeArgList = SyntaxFactory.TypeArgumentList( - SyntaxFactory.SingletonSeparatedList(tupleType)); - } - else - { - // TheoryData → IEnumerable - typeArgList = SyntaxFactory.TypeArgumentList( - SyntaxFactory.SeparatedList(genericName.TypeArgumentList.Arguments)); - } + var elementTypeSyntax = BuildElementType(conversion.ElementTypes); + var typeArgList = SyntaxFactory.TypeArgumentList( + SyntaxFactory.SingletonSeparatedList(elementTypeSyntax)); var enumerableType = SyntaxFactory.GenericName( SyntaxFactory.Identifier("IEnumerable"), @@ -335,6 +333,24 @@ private CompilationUnitSyntax TransformTheoryData(CompilationUnitSyntax root) return currentRoot; } + /// + /// 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)). + /// + private static TypeSyntax BuildElementType(IReadOnlyList elementTypes) + { + if (elementTypes.Count == 1) + { + return SyntaxFactory.ParseTypeName(elementTypes[0]); + } + + return SyntaxFactory.TupleType( + SyntaxFactory.SeparatedList( + elementTypes.Select(t => + SyntaxFactory.TupleElement(SyntaxFactory.ParseTypeName(t))))); + } + /// /// Converts a complex initializer expression { val1, val2 } to a tuple expression (val1, val2). /// For simple expressions (single-value), returns the expression unchanged. diff --git a/TUnit.Analyzers.CodeFixers/TwoPhase/XUnitTwoPhaseAnalyzer.cs b/TUnit.Analyzers.CodeFixers/TwoPhase/XUnitTwoPhaseAnalyzer.cs index 2ca47c9e97..68c50ac78b 100644 --- a/TUnit.Analyzers.CodeFixers/TwoPhase/XUnitTwoPhaseAnalyzer.cs +++ b/TUnit.Analyzers.CodeFixers/TwoPhase/XUnitTwoPhaseAnalyzer.cs @@ -1837,12 +1837,7 @@ protected override CompilationUnitSyntax AnalyzeTheoryData(CompilationUnitSyntax var typeArgs = originalGeneric.TypeArgumentList.Arguments; if (typeArgs.Count == 0) continue; - // For single type: TheoryData → IEnumerable, element type is T - // For multi type: TheoryData → IEnumerable<(T1, T2)>, element type is (T1, T2) - var isMultiType = typeArgs.Count > 1; - var elementType = isMultiType - ? $"({string.Join(", ", typeArgs.Select(t => t.ToString()))})" - : typeArgs[0].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()); @@ -1850,8 +1845,7 @@ protected override CompilationUnitSyntax AnalyzeTheoryData(CompilationUnitSyntax var conversion = new TheoryDataConversion { - ElementType = elementType, - IsMultiType = isMultiType, + ElementTypes = elementTypes, TypeAnnotation = typeAnnotation, CreationAnnotation = creationAnnotation, OriginalText = originalGeneric.ToString() diff --git a/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs b/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs index 56d59d59da..7b6a603d63 100644 --- a/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs +++ b/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs @@ -625,6 +625,53 @@ public class MyClass ); } + [Test] + public async Task TheoryData_MultiType_PropertyGetter_Can_Be_Converted() + { + await CodeFixer + .VerifyCodeFixAsync( + """ + {|#0:using Xunit; + + public class MyClass + { + public static TheoryData MyData + { + get + { + var data = new TheoryData + { + { "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() {