diff --git a/TUnit.Analyzers.CodeFixers/Base/AssertionRewriter.cs b/TUnit.Analyzers.CodeFixers/Base/AssertionRewriter.cs index 77da7b16a6..a5ef3fa98d 100644 --- a/TUnit.Analyzers.CodeFixers/Base/AssertionRewriter.cs +++ b/TUnit.Analyzers.CodeFixers/Base/AssertionRewriter.cs @@ -307,49 +307,100 @@ protected static ExpressionSyntax CreateMessageExpression( /// /// Checks if the argument at the given index appears to be a comparer (IComparer, IEqualityComparer). - /// Returns null if the type cannot be determined. + /// Uses semantic analysis when available, with syntax-based fallback for resilience across TFMs. /// - protected bool? IsLikelyComparerArgument(ArgumentSyntax argument) + protected bool IsLikelyComparerArgument(ArgumentSyntax argument) { - var typeInfo = SemanticModel.GetTypeInfo(argument.Expression); - if (typeInfo.Type == null || typeInfo.Type.TypeKind == TypeKind.Error) + // First, try syntax-based detection for string literals (most common message case) + // This is deterministic and consistent across all TFMs + if (argument.Expression.IsKind(SyntaxKind.StringLiteralExpression) || + argument.Expression.IsKind(SyntaxKind.InterpolatedStringExpression)) { - // Type couldn't be resolved - return null to indicate unknown - return null; + return false; // String literals are messages, not comparers } - var typeName = typeInfo.Type.ToDisplayString(); - - // If it's a string type, it's definitely a message, not a comparer - if (typeInfo.Type.SpecialType == SpecialType.System_String || - typeName == "string" || typeName == "System.String") + // Try semantic analysis + var typeInfo = SemanticModel.GetTypeInfo(argument.Expression); + if (typeInfo.Type != null && typeInfo.Type.TypeKind != TypeKind.Error) { + var typeName = typeInfo.Type.ToDisplayString(); + + // If it's a string type, it's definitely a message, not a comparer + if (typeInfo.Type.SpecialType == SpecialType.System_String || + typeName == "string" || typeName == "System.String") + { + return false; + } + + // Check for IComparer, IComparer, IEqualityComparer, IEqualityComparer + if (typeName.Contains("IComparer") || typeName.Contains("IEqualityComparer")) + { + return true; + } + + // Check interfaces - also check for generic interface names like IComparer`1 + if (typeInfo.Type is INamedTypeSymbol namedType) + { + if (namedType.AllInterfaces.Any(i => + i.Name.StartsWith("IComparer") || + i.Name.StartsWith("IEqualityComparer"))) + { + return true; + } + } + + // Also check if the type name itself contains Comparer (for StringComparer, etc.) + if (typeName.Contains("Comparer")) + { + return true; + } + + // Semantic analysis resolved to a non-comparer type return false; } - // Check for IComparer, IComparer, IEqualityComparer, IEqualityComparer - if (typeName.Contains("IComparer") || typeName.Contains("IEqualityComparer")) + // Fallback: Syntax-based detection when semantic analysis fails + // This ensures consistent behavior across TFMs + return IsLikelyComparerArgumentBySyntax(argument.Expression); + } + + /// + /// Syntax-based fallback for comparer detection. Used when semantic analysis fails. + /// Must be deterministic to ensure consistent behavior across TFMs. + /// + private static bool IsLikelyComparerArgumentBySyntax(ExpressionSyntax expression) + { + var expressionText = expression.ToString(); + var lowerExpressionText = expressionText.ToLowerInvariant(); + + // Check for variable names or expressions containing "comparer" (case-insensitive) + // This catches variable names like 'comparer', 'myComparer', 'stringComparer', etc. + if (lowerExpressionText.EndsWith("comparer") || + lowerExpressionText.Contains("comparer.") || + lowerExpressionText.Contains("comparer<") || + lowerExpressionText.Contains("equalitycomparer")) { return true; } - // Check interfaces - also check for generic interface names like IComparer`1 - if (typeInfo.Type is INamedTypeSymbol namedType) + // Check for new SomeComparer() or new SomeComparer() patterns + if (expression is ObjectCreationExpressionSyntax objectCreation) { - if (namedType.AllInterfaces.Any(i => - i.Name.StartsWith("IComparer") || - i.Name.StartsWith("IEqualityComparer"))) + var typeText = objectCreation.Type.ToString().ToLowerInvariant(); + if (typeText.Contains("comparer")) { return true; } } - // Also check if the type name itself contains Comparer (for StringComparer, etc.) - if (typeName.Contains("Comparer")) + // Check for ImplicitObjectCreationExpressionSyntax (new() { ... }) + // These are ambiguous without semantic info, assume not a comparer + if (expression is ImplicitObjectCreationExpressionSyntax) { - return true; + return false; } + // Default: assume it's a message (conservative - avoids incorrect comparer handling) return false; } @@ -361,19 +412,50 @@ protected static SyntaxTrivia CreateTodoComment(string message) return SyntaxFactory.Comment($"// TODO: TUnit migration - {message}"); } + /// + /// Determines if an invocation is a framework assertion method. + /// Uses semantic analysis when available, with syntax-based fallback for resilience across TFMs. + /// protected bool IsFrameworkAssertion(InvocationExpressionSyntax invocation) { + // Try semantic analysis first var symbolInfo = SemanticModel.GetSymbolInfo(invocation); - var symbol = symbolInfo.Symbol; + if (symbolInfo.Symbol is IMethodSymbol methodSymbol) + { + var namespaceName = methodSymbol.ContainingNamespace?.ToDisplayString() ?? ""; + if (IsFrameworkAssertionNamespace(namespaceName)) + { + return true; + } + } - if (symbol is not IMethodSymbol methodSymbol) + // Fallback: Syntax-based detection when semantic analysis fails + // This ensures consistent behavior across TFMs + return IsFrameworkAssertionBySyntax(invocation); + } + + /// + /// Syntax-based fallback for framework assertion detection. Used when semantic analysis fails. + /// Must be deterministic to ensure consistent behavior across TFMs. + /// + private bool IsFrameworkAssertionBySyntax(InvocationExpressionSyntax invocation) + { + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) { return false; } - var namespaceName = methodSymbol.ContainingNamespace?.ToDisplayString() ?? ""; - return IsFrameworkAssertionNamespace(namespaceName); + var targetType = memberAccess.Expression.ToString(); + var methodName = memberAccess.Name.Identifier.Text; + + return IsKnownAssertionTypeBySyntax(targetType, methodName); } - + + /// + /// Checks if the target type and method name match known framework assertion patterns. + /// Override in derived classes to provide framework-specific patterns. + /// + protected abstract bool IsKnownAssertionTypeBySyntax(string targetType, string methodName); + protected abstract bool IsFrameworkAssertionNamespace(string namespaceName); } \ No newline at end of file diff --git a/TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs index 763773508e..02b4b7c411 100644 --- a/TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs +++ b/TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs @@ -202,10 +202,16 @@ public MSTestAssertionRewriter(SemanticModel semanticModel) : base(semanticModel protected override bool IsFrameworkAssertionNamespace(string namespaceName) { - return namespaceName == "Microsoft.VisualStudio.TestTools.UnitTesting" || + return namespaceName == "Microsoft.VisualStudio.TestTools.UnitTesting" || namespaceName.StartsWith("Microsoft.VisualStudio.TestTools.UnitTesting."); } - + + protected override bool IsKnownAssertionTypeBySyntax(string targetType, string methodName) + { + // MSTest assertion types that can be detected by syntax + return targetType is "Assert" or "CollectionAssert" or "StringAssert" or "FileAssert" or "DirectoryAssert"; + } + protected override ExpressionSyntax? ConvertAssertionIfNeeded(InvocationExpressionSyntax invocation) { // First try semantic analysis diff --git a/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs index 2e7bdf5df6..210021b544 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)] @@ -520,7 +664,15 @@ protected override bool IsFrameworkAssertionNamespace(string namespaceName) return (namespaceName == "NUnit.Framework" || namespaceName.StartsWith("NUnit.Framework.")) && namespaceName != "NUnit.Framework.Legacy"; } - + + protected override bool IsKnownAssertionTypeBySyntax(string targetType, string methodName) + { + // NUnit assertion types that can be detected by syntax + // NOTE: ClassicAssert is NOT included because it's in NUnit.Framework.Legacy namespace + // and should not be auto-converted. The semantic check excludes it properly. + return targetType is "Assert" or "CollectionAssert" or "StringAssert" or "FileAssert" or "DirectoryAssert"; + } + protected override ExpressionSyntax? ConvertAssertionIfNeeded(InvocationExpressionSyntax invocation) { // Handle FileAssert - check BEFORE IsFrameworkAssertion since FileAssert is a separate class @@ -1217,7 +1369,7 @@ private ExpressionSyntax ConvertAreEqualWithComparer(SeparatedSyntaxList= 3 && IsLikelyComparerArgument(arguments[2]) == true) + if (arguments.Count >= 3 && IsLikelyComparerArgument(arguments[2])) { // Add TODO comment and skip the comparer var result = CreateTUnitAssertion("IsEqualTo", actual, expected); @@ -1238,7 +1390,7 @@ private ExpressionSyntax ConvertAreNotEqualWithMessage(SeparatedSyntaxList= 3 && IsLikelyComparerArgument(arguments[2]) == true) + if (arguments.Count >= 3 && IsLikelyComparerArgument(arguments[2])) { var result = CreateTUnitAssertion("IsNotEqualTo", actual, expected); return result.WithLeadingTrivia( diff --git a/TUnit.Analyzers.CodeFixers/XUnitMigrationCodeFixProvider.cs b/TUnit.Analyzers.CodeFixers/XUnitMigrationCodeFixProvider.cs index d93b47ccc2..411ce36b9a 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; @@ -144,7 +385,7 @@ private static SyntaxNode ConvertTheoryData(Compilation compilation, SyntaxNode var type = objectCreationExpressionSyntax switch { ObjectCreationExpressionSyntax explicitObjectCreationExpressionSyntax => explicitObjectCreationExpressionSyntax.Type, - ImplicitObjectCreationExpressionSyntax implicitObjectCreationExpressionSyntax => SyntaxFactory.ParseTypeName(compilation.GetSemanticModel(implicitObjectCreationExpressionSyntax.SyntaxTree).GetTypeInfo(implicitObjectCreationExpressionSyntax).Type!.ToDisplayString()), + ImplicitObjectCreationExpressionSyntax implicitObjectCreationExpressionSyntax => GetTypeFromImplicitCreation(compilation, implicitObjectCreationExpressionSyntax), _ => null }; @@ -215,6 +456,31 @@ private static SyntaxNode ConvertTheoryData(Compilation compilation, SyntaxNode return currentRoot; } + /// + /// Safely gets the type from an implicit object creation expression using semantic analysis. + /// Returns null if semantic analysis fails (defensive for multi-TFM scenarios). + /// + private static TypeSyntax? GetTypeFromImplicitCreation(Compilation compilation, ImplicitObjectCreationExpressionSyntax implicitCreation) + { + try + { + var semanticModel = compilation.GetSemanticModel(implicitCreation.SyntaxTree); + var typeInfo = semanticModel.GetTypeInfo(implicitCreation); + + if (typeInfo.Type is null || typeInfo.Type.TypeKind == TypeKind.Error) + { + return null; + } + + return SyntaxFactory.ParseTypeName(typeInfo.Type.ToDisplayString()); + } + catch (InvalidOperationException) + { + // Semantic analysis failed due to invalid compilation state + return null; + } + } + private static SyntaxNode UpdateInitializeDispose(Compilation compilation, SyntaxNode root) { // Always operate on the latest root @@ -519,6 +785,12 @@ protected override bool IsFrameworkAssertionNamespace(string namespaceName) namespaceName.StartsWith("Xunit.", StringComparison.OrdinalIgnoreCase); } + protected override bool IsKnownAssertionTypeBySyntax(string targetType, string methodName) + { + // XUnit assertion type that can be detected by syntax + return targetType == "Assert"; + } + protected override ExpressionSyntax? ConvertAssertionIfNeeded(InvocationExpressionSyntax invocation) { if (!IsFrameworkAssertion(invocation)) @@ -542,11 +814,11 @@ protected override bool IsFrameworkAssertionNamespace(string namespaceName) return methodName switch { // Equality assertions - check for comparer overloads - "Equal" when arguments.Count >= 3 && IsLikelyComparerArgument(arguments[2]) == true => + "Equal" when arguments.Count >= 3 && IsLikelyComparerArgument(arguments[2]) => CreateEqualWithComparerComment(arguments), "Equal" when arguments.Count >= 2 => CreateTUnitAssertion("IsEqualTo", arguments[1].Expression, arguments[0]), - "NotEqual" when arguments.Count >= 3 && IsLikelyComparerArgument(arguments[2]) == true => + "NotEqual" when arguments.Count >= 3 && IsLikelyComparerArgument(arguments[2]) => CreateNotEqualWithComparerComment(arguments), "NotEqual" when arguments.Count >= 2 => CreateTUnitAssertion("IsNotEqualTo", arguments[1].Expression, arguments[0]), diff --git a/TUnit.Analyzers.Tests/MSTestMigrationAnalyzerTests.cs b/TUnit.Analyzers.Tests/MSTestMigrationAnalyzerTests.cs index 6858434068..edbea903d5 100644 --- a/TUnit.Analyzers.Tests/MSTestMigrationAnalyzerTests.cs +++ b/TUnit.Analyzers.Tests/MSTestMigrationAnalyzerTests.cs @@ -782,8 +782,8 @@ public async Task TestWithFormatStrings() [Test] public async Task MSTest_Assertions_With_Comparer_AddsTodoComment() { - // When the comparer type cannot be determined via semantic analysis (e.g., in test context), - // a TODO comment is added for manual review instead of passing invalid arguments to .Because(). + // When a comparer is detected (via semantic or syntax-based detection), + // a TODO comment is added explaining that TUnit uses different comparison semantics. await CodeFixer.VerifyCodeFixAsync( """ using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -814,7 +814,7 @@ public class MyClass public async Task TestWithComparer() { var comparer = StringComparer.OrdinalIgnoreCase; - // TODO: TUnit migration - third argument could not be identified as comparer or message. Manual verification required. + // TODO: TUnit migration - IEqualityComparer was used. TUnit uses .IsEqualTo() which may have different comparison semantics. await Assert.That("HELLO").IsEqualTo("hello"); } } 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 };