diff --git a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs index 2e7bdf5df6..f1d6fdd726 100644 --- a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs @@ -138,6 +138,13 @@ protected override bool IsFrameworkAttribute(string attributeName) return null; } + // [Platform(Include = "Win")] -> [RunOn(OS.Windows)] + // [Platform(Exclude = "Linux")] -> [ExcludeOn(OS.Linux)] + if (attributeName == "Platform") + { + return ConvertPlatformAttribute(attribute); + } + return base.ConvertAttribute(attribute); } @@ -191,6 +198,143 @@ protected override bool IsFrameworkAttribute(string attributeName) return null; } + private AttributeSyntax? ConvertPlatformAttribute(AttributeSyntax attribute) + { + // [Platform(Include = "Win")] -> [RunOn(OS.Windows)] + // [Platform(Exclude = "Linux")] -> [ExcludeOn(OS.Linux)] + // [Platform("Win")] -> [RunOn(OS.Windows)] + + string? includeValue = null; + string? excludeValue = null; + + if (attribute.ArgumentList == null || attribute.ArgumentList.Arguments.Count == 0) + { + return null; // No arguments, remove the attribute + } + + foreach (var arg in attribute.ArgumentList.Arguments) + { + var argName = arg.NameEquals?.Name.Identifier.Text; + var value = GetStringLiteralValue(arg.Expression); + + if (argName == "Include" || argName == null) + { + // Named argument Include= or positional argument (which is Include) + includeValue = value; + } + else if (argName == "Exclude") + { + excludeValue = value; + } + } + + // Prefer Include (RunOn) over Exclude (ExcludeOn) if both are present + if (!string.IsNullOrEmpty(includeValue)) + { + var osBits = ParsePlatformString(includeValue); + if (osBits != null) + { + return CreateOsAttribute("RunOn", osBits); + } + } + else if (!string.IsNullOrEmpty(excludeValue)) + { + var osBits = ParsePlatformString(excludeValue); + if (osBits != null) + { + return CreateOsAttribute("ExcludeOn", osBits); + } + } + + // Cannot convert - return null to remove the attribute + return null; + } + + private static string? GetStringLiteralValue(ExpressionSyntax expression) + { + return expression switch + { + LiteralExpressionSyntax literal when literal.IsKind(SyntaxKind.StringLiteralExpression) + => literal.Token.ValueText, + _ => null + }; + } + + private static List? ParsePlatformString(string? platformString) + { + if (string.IsNullOrEmpty(platformString)) + { + return null; + } + + var osNames = new List(); + var platforms = platformString.Split(','); + + foreach (var platform in platforms) + { + var trimmed = platform.Trim(); + var osName = MapNUnitPlatformToTUnitOS(trimmed); + if (osName != null && !osNames.Contains(osName)) + { + osNames.Add(osName); + } + } + + return osNames.Count > 0 ? osNames : null; + } + + private static string? MapNUnitPlatformToTUnitOS(string nunitPlatform) + { + // NUnit platform names: https://docs.nunit.org/articles/nunit/writing-tests/attributes/platform.html + return nunitPlatform.ToLowerInvariant() switch + { + "win" or "win32" or "win32s" or "win32nt" or "win32windows" or "wince" or "windows" => "Windows", + "linux" or "unix" => "Linux", + "macosx" or "macos" or "osx" or "mac" => "MacOs", + _ => null // Unknown platform - cannot convert + }; + } + + private static AttributeSyntax CreateOsAttribute(string attributeName, List osNames) + { + // Build OS.Windows | OS.Linux | OS.MacOs expression + ExpressionSyntax osExpression; + + if (osNames.Count == 1) + { + // Single OS: OS.Windows + osExpression = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("OS"), + SyntaxFactory.IdentifierName(osNames[0])); + } + else + { + // Multiple OSes: OS.Windows | OS.Linux + osExpression = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("OS"), + SyntaxFactory.IdentifierName(osNames[0])); + + for (int i = 1; i < osNames.Count; i++) + { + osExpression = SyntaxFactory.BinaryExpression( + SyntaxKind.BitwiseOrExpression, + osExpression, + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("OS"), + SyntaxFactory.IdentifierName(osNames[i]))); + } + } + + return SyntaxFactory.Attribute( + SyntaxFactory.IdentifierName(attributeName), + SyntaxFactory.AttributeArgumentList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.AttributeArgument(osExpression)))); + } + private AttributeSyntax ConvertValuesAttribute(AttributeSyntax attribute) { // [Values(1, 2, 3)] -> [Matrix(1, 2, 3)] diff --git a/TUnit.Analyzers.CodeFixers/XUnitMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/XUnitMigrationCodeFixProvider.cs index d93b47ccc2..d1f49e044e 100644 --- a/TUnit.Analyzers.CodeFixers/XUnitMigrationCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/XUnitMigrationCodeFixProvider.cs @@ -64,6 +64,9 @@ protected override CompilationUnitSyntax ApplyFrameworkSpecificConversions(Compi updatedRoot = ConvertTestOutputHelpers(ref compilation, ref syntaxTree, updatedRoot); UpdateSyntaxTrees(ref compilation, ref syntaxTree, ref updatedRoot); + updatedRoot = ConvertRecordException(ref compilation, ref syntaxTree, updatedRoot); + UpdateSyntaxTrees(ref compilation, ref syntaxTree, ref updatedRoot); + return (CompilationUnitSyntax)updatedRoot; } @@ -136,6 +139,244 @@ private static bool IsTestOutputHelperInvocation(Compilation compilation, Invoca is "global::Xunit.Abstractions.ITestOutputHelper" or "global::Xunit.ITestOutputHelper"; } + private static SyntaxNode ConvertRecordException(ref Compilation compilation, ref SyntaxTree syntaxTree, SyntaxNode root) + { + var currentRoot = root; + var compilationValue = compilation; + + // Find local declarations with Record.Exception() or Record.ExceptionAsync() + while (currentRoot.DescendantNodes() + .OfType() + .FirstOrDefault(x => IsRecordExceptionDeclaration(compilationValue, x)) + is { } localDeclaration) + { + var variableDeclarator = localDeclaration.Declaration.Variables.First(); + var variableName = variableDeclarator.Identifier.Text; + var invocation = variableDeclarator.Initializer?.Value as InvocationExpressionSyntax; + + if (invocation == null) + { + break; + } + + // Get the action/func argument + var actionArg = invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression; + if (actionArg == null) + { + break; + } + + // Check if this is async (Record.ExceptionAsync) + var isAsync = invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "ExceptionAsync"; + + // Extract the body from the lambda/action + var actionBody = ExtractActionBody(actionArg); + + // Create the try-catch replacement + // Exception? ex = null; + // try { actionBody; } catch (Exception e) { ex = e; } + var statements = CreateTryCatchStatements(variableName, actionBody, isAsync, localDeclaration.GetLeadingTrivia()); + + // Replace the local declaration with the try-catch statements + var parent = localDeclaration.Parent; + if (parent is BlockSyntax block) + { + var index = block.Statements.IndexOf(localDeclaration); + var newStatements = block.Statements + .RemoveAt(index) + .InsertRange(index, statements); + var newBlock = block.WithStatements(newStatements); + currentRoot = currentRoot.ReplaceNode(block, newBlock); + } + else + { + // If not in a block, just replace with the first statement (may not work perfectly) + currentRoot = currentRoot.ReplaceNode(localDeclaration, statements.First()); + } + + UpdateSyntaxTrees(ref compilation, ref syntaxTree, ref currentRoot); + compilationValue = compilation; + } + + return currentRoot; + } + + private static bool IsRecordExceptionDeclaration(Compilation compilation, LocalDeclarationStatementSyntax localDeclaration) + { + var variableDeclarator = localDeclaration.Declaration.Variables.FirstOrDefault(); + if (variableDeclarator?.Initializer?.Value is not InvocationExpressionSyntax invocation) + { + return false; + } + + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + { + return false; + } + + // Check if it's Record.Exception or Record.ExceptionAsync by name first (fast check) + if (memberAccess.Expression is not IdentifierNameSyntax { Identifier.Text: "Record" }) + { + return false; + } + + if (memberAccess.Name.Identifier.Text is not ("Exception" or "ExceptionAsync")) + { + return false; + } + + // Verify with semantic model + var semanticModel = compilation.GetSemanticModel(invocation.SyntaxTree); + var symbolInfo = semanticModel.GetSymbolInfo(invocation); + + if (symbolInfo.Symbol is not IMethodSymbol methodSymbol) + { + return false; + } + + return methodSymbol.ContainingType?.ToDisplayString(DisplayFormats.FullyQualifiedGenericWithGlobalPrefix) + is "global::Xunit.Record" or "global::Xunit.Assert"; + } + + private static StatementSyntax ExtractActionBody(ExpressionSyntax actionExpression) + { + // Handle lambda expressions: () => SomeMethod() or () => { statements } + if (actionExpression is SimpleLambdaExpressionSyntax simpleLambda) + { + return ConvertLambdaBodyToStatement(simpleLambda.Body); + } + + if (actionExpression is ParenthesizedLambdaExpressionSyntax parenLambda) + { + return ConvertLambdaBodyToStatement(parenLambda.Body); + } + + // Handle method group or direct invocation + // For Action delegates, wrap in invocation + return SyntaxFactory.ExpressionStatement( + SyntaxFactory.InvocationExpression(actionExpression)); + } + + private static StatementSyntax ConvertLambdaBodyToStatement(CSharpSyntaxNode body) + { + if (body is BlockSyntax block) + { + // If it's a single statement block, extract the statement + if (block.Statements.Count == 1) + { + return block.Statements[0]; + } + // Otherwise return the block as-is (will need to be a compound statement) + return block; + } + + // Handle throw expressions - convert to throw statement + if (body is ThrowExpressionSyntax throwExpression) + { + return SyntaxFactory.ThrowStatement(throwExpression.Expression); + } + + if (body is ExpressionSyntax expression) + { + return SyntaxFactory.ExpressionStatement(expression); + } + + // Fallback - wrap in expression statement + return SyntaxFactory.ExpressionStatement( + SyntaxFactory.IdentifierName("/* TODO: Convert lambda body */")); + } + + private static IEnumerable CreateTryCatchStatements( + string variableName, + StatementSyntax actionBody, + bool isAsync, + SyntaxTriviaList leadingTrivia) + { + // Extract the base indentation from leading trivia + var indentation = leadingTrivia.Where(t => t.IsKind(SyntaxKind.WhitespaceTrivia)).LastOrDefault(); + var indentString = indentation.ToFullString(); + + // Exception? variableName = null; + var nullableExceptionType = SyntaxFactory.NullableType( + SyntaxFactory.IdentifierName("Exception")); + + var declarationStatement = SyntaxFactory.LocalDeclarationStatement( + SyntaxFactory.VariableDeclaration(nullableExceptionType) + .WithVariables(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.VariableDeclarator(variableName) + .WithInitializer(SyntaxFactory.EqualsValueClause( + SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression)))))) + .WithLeadingTrivia(leadingTrivia) + .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed); + + // Prepare the action body with proper indentation (inside try block) + var tryBodyIndent = indentString + " "; + StatementSyntax tryBodyStatement; + + if (actionBody is BlockSyntax blockBody) + { + // If it's a block, use its statements directly + tryBodyStatement = blockBody + .WithOpenBraceToken(blockBody.OpenBraceToken.WithLeadingTrivia(SyntaxFactory.Whitespace(tryBodyIndent))) + .WithCloseBraceToken(blockBody.CloseBraceToken.WithLeadingTrivia(SyntaxFactory.Whitespace(tryBodyIndent))); + } + else + { + tryBodyStatement = actionBody + .WithLeadingTrivia(SyntaxFactory.Whitespace(tryBodyIndent)) + .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed); + } + + // If async, we may need to await the action + if (isAsync && tryBodyStatement is ExpressionStatementSyntax exprStmt) + { + var awaitExpr = SyntaxFactory.AwaitExpression( + SyntaxFactory.Token(SyntaxKind.AwaitKeyword).WithTrailingTrivia(SyntaxFactory.Space), + exprStmt.Expression); + tryBodyStatement = SyntaxFactory.ExpressionStatement(awaitExpr) + .WithLeadingTrivia(SyntaxFactory.Whitespace(tryBodyIndent)) + .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed); + } + + // catch (Exception e) { variableName = e; } + var catchClause = SyntaxFactory.CatchClause() + .WithCatchKeyword(SyntaxFactory.Token(SyntaxKind.CatchKeyword) + .WithLeadingTrivia(SyntaxFactory.CarriageReturnLineFeed, SyntaxFactory.Whitespace(indentString))) + .WithDeclaration(SyntaxFactory.CatchDeclaration( + SyntaxFactory.IdentifierName("Exception"), + SyntaxFactory.Identifier("e"))) + .WithBlock(SyntaxFactory.Block( + SyntaxFactory.ExpressionStatement( + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName(variableName), + SyntaxFactory.IdentifierName("e"))) + .WithLeadingTrivia(SyntaxFactory.Whitespace(tryBodyIndent)) + .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed)) + .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken) + .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed)) + .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken) + .WithLeadingTrivia(SyntaxFactory.Whitespace(indentString)))); + + // try { actionBody; } + var tryBlock = SyntaxFactory.Block(tryBodyStatement) + .WithOpenBraceToken(SyntaxFactory.Token(SyntaxKind.OpenBraceToken) + .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed)) + .WithCloseBraceToken(SyntaxFactory.Token(SyntaxKind.CloseBraceToken) + .WithLeadingTrivia(SyntaxFactory.Whitespace(indentString))); + + var tryStatement = SyntaxFactory.TryStatement() + .WithTryKeyword(SyntaxFactory.Token(SyntaxKind.TryKeyword) + .WithLeadingTrivia(SyntaxFactory.Whitespace(indentString))) + .WithBlock(tryBlock) + .WithCatches(SyntaxFactory.SingletonList(catchClause)) + .WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed); + + yield return declarationStatement; + yield return tryStatement; + } + private static SyntaxNode ConvertTheoryData(Compilation compilation, SyntaxNode root) { var currentRoot = root; diff --git a/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs b/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs index c9c648a6c2..ac2d6ac4be 100644 --- a/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs +++ b/TUnit.Analyzers.Tests/NUnitMigrationAnalyzerTests.cs @@ -2856,7 +2856,7 @@ public async Task TestMethod() } [Test] - public async Task NUnit_Platform_Attribute_Removed() + public async Task NUnit_Platform_Include_Converted_To_RunOn() { await CodeFixer.VerifyCodeFixAsync( """ @@ -2881,6 +2881,79 @@ public void TestMethod() public class MyClass { [Test] + [RunOn(OS.Windows)] + public void TestMethod() + { + } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_Platform_Exclude_Converted_To_ExcludeOn() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + public class MyClass + { + {|#0:[Test]|} + [Platform(Exclude = "Linux")] + public void TestMethod() + { + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + [ExcludeOn(OS.Linux)] + public void TestMethod() + { + } + } + """, + ConfigureNUnitTest + ); + } + + [Test] + public async Task NUnit_Platform_Multiple_Platforms_Converted() + { + await CodeFixer.VerifyCodeFixAsync( + """ + using NUnit.Framework; + + public class MyClass + { + {|#0:[Test]|} + [Platform(Include = "Win,Linux")] + public void TestMethod() + { + } + } + """, + Verifier.Diagnostic(Rules.NUnitMigration).WithLocation(0), + """ + using TUnit.Core; + using TUnit.Assertions; + using static TUnit.Assertions.Assert; + using TUnit.Assertions.Extensions; + + public class MyClass + { + [Test] + [RunOn(OS.Windows | OS.Linux)] public void TestMethod() { } diff --git a/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs b/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs index fe7362552d..db9e695043 100644 --- a/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs +++ b/TUnit.Analyzers.Tests/XUnitMigrationAnalyzerTests.cs @@ -984,6 +984,192 @@ public async Task MyTest() ); } + [Test] + public async Task Record_Exception_Can_Be_Converted() + { + await CodeFixer + .VerifyCodeFixAsync( + """ + {|#0:using System; + using TUnit.Core; + + public class MyClass + { + [Fact] + public void MyTest() + { + var ex = Record.Exception(() => throw new InvalidOperationException("Test")); + Assert.NotNull(ex); + Assert.IsType(ex); + } + }|} + """, + Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0), + """ + using System; + using TUnit.Core; + using System.Threading.Tasks; + + public class MyClass + { + [Test] + public async Task MyTest() + { + Exception? ex = null; + try + { + throw new InvalidOperationException("Test"); + } + catch (Exception e) + { + ex = e; + } + await Assert.That(ex).IsNotNull(); + await Assert.That(ex).IsTypeOf(); + } + } + """, + ConfigureXUnitTest + ); + } + + [Test] + public async Task Assert_Throws_With_Property_Access_Can_Be_Converted() + { + await CodeFixer + .VerifyCodeFixAsync( + """ + {|#0:using System; + using TUnit.Core; + + public class MyClass + { + [Fact] + public void MyTest() + { + var ex = Assert.Throws(() => ThrowException()); + Assert.Equal("param", ex.ParamName); + } + + private void ThrowException() => throw new ArgumentException("error", "param"); + }|} + """, + Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0), + """ + using System; + using TUnit.Core; + using System.Threading.Tasks; + + public class MyClass + { + [Test] + public async Task MyTest() + { + var ex = await Assert.ThrowsAsync(() => ThrowException()); + await Assert.That(ex.ParamName).IsEqualTo("param"); + } + + private void ThrowException() => throw new ArgumentException("error", "param"); + } + """, + ConfigureXUnitTest + ); + } + + [Test] + public async Task Assert_Throws_With_Message_Contains_Can_Be_Converted() + { + await CodeFixer + .VerifyCodeFixAsync( + """ + {|#0:using System; + using TUnit.Core; + + public class MyClass + { + [Fact] + public void MyTest() + { + var ex = Assert.Throws(() => ThrowException()); + Assert.Contains("error occurred", ex.Message); + } + + private void ThrowException() => throw new InvalidOperationException("An error occurred"); + }|} + """, + Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0), + """ + using System; + using TUnit.Core; + using System.Threading.Tasks; + + public class MyClass + { + [Test] + public async Task MyTest() + { + var ex = await Assert.ThrowsAsync(() => ThrowException()); + await Assert.That(ex.Message).Contains("error occurred"); + } + + private void ThrowException() => throw new InvalidOperationException("An error occurred"); + } + """, + ConfigureXUnitTest + ); + } + + [Test] + public async Task Record_Exception_With_Method_Call_Can_Be_Converted() + { + await CodeFixer + .VerifyCodeFixAsync( + """ + {|#0:using System; + using TUnit.Core; + + public class MyClass + { + [Fact] + public void MyTest() + { + var ex = Record.Exception(() => DoSomething()); + Assert.Null(ex); + } + + private void DoSomething() { } + }|} + """, + Verifier.Diagnostic(Rules.XunitMigration).WithLocation(0), + """ + using System; + using TUnit.Core; + using System.Threading.Tasks; + + public class MyClass + { + [Test] + public async Task MyTest() + { + Exception? ex = null; + try + { + DoSomething(); + } + catch (Exception e) + { + ex = e; + } + await Assert.That(ex).IsNull(); + } + + private void DoSomething() { } + } + """, + ConfigureXUnitTest + ); + } + private static void ConfigureXUnitTest(Verifier.Test test) { var globalUsings = ("GlobalUsings.cs", SourceText.From("global using Xunit;")); diff --git a/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs b/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs index 6c6bf05070..bc569d97a6 100644 --- a/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs +++ b/TUnit.Analyzers/Migrators/Base/MigrationHelpers.cs @@ -135,7 +135,8 @@ public static bool ShouldRemoveAttribute(string attributeName, string framework) { // Note: Parallelizable is NOT listed here because it needs special handling in the code fixer // for ParallelScope.None -> [NotInParallel]. ConvertAttribute handles all cases. - "NUnit" => attributeName is "TestFixture" or "Platform" or "Description", + // Note: Platform is NOT listed here because it gets converted to RunOn/ExcludeOn. + "NUnit" => attributeName is "TestFixture" or "Description", "MSTest" => attributeName is "TestClass", _ => false };