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
134 changes: 108 additions & 26 deletions TUnit.Analyzers.CodeFixers/Base/AssertionRewriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -307,49 +307,100 @@ protected static ExpressionSyntax CreateMessageExpression(

/// <summary>
/// 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.
/// </summary>
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<T>, IEqualityComparer, IEqualityComparer<T>
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<T>, IEqualityComparer, IEqualityComparer<T>
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);
}

/// <summary>
/// Syntax-based fallback for comparer detection. Used when semantic analysis fails.
/// Must be deterministic to ensure consistent behavior across TFMs.
/// </summary>
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<T>() 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;
}

Expand All @@ -361,19 +412,50 @@ protected static SyntaxTrivia CreateTodoComment(string message)
return SyntaxFactory.Comment($"// TODO: TUnit migration - {message}");
}

/// <summary>
/// Determines if an invocation is a framework assertion method.
/// Uses semantic analysis when available, with syntax-based fallback for resilience across TFMs.
/// </summary>
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);
}

/// <summary>
/// Syntax-based fallback for framework assertion detection. Used when semantic analysis fails.
/// Must be deterministic to ensure consistent behavior across TFMs.
/// </summary>
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);
}


/// <summary>
/// Checks if the target type and method name match known framework assertion patterns.
/// Override in derived classes to provide framework-specific patterns.
/// </summary>
protected abstract bool IsKnownAssertionTypeBySyntax(string targetType, string methodName);

protected abstract bool IsFrameworkAssertionNamespace(string namespaceName);
}
10 changes: 8 additions & 2 deletions TUnit.Analyzers.CodeFixers/MSTestMigrationCodeFixProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
158 changes: 155 additions & 3 deletions TUnit.Analyzers.CodeFixers/NUnitMigrationCodeFixProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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<string>? ParsePlatformString(string? platformString)
{
if (string.IsNullOrEmpty(platformString))
{
return null;
}

var osNames = new List<string>();
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<string> 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)]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1217,7 +1369,7 @@ private ExpressionSyntax ConvertAreEqualWithComparer(SeparatedSyntaxList<Argumen
var actual = arguments[1].Expression;

// Check if 3rd argument is a comparer (not a string message)
if (arguments.Count >= 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);
Expand All @@ -1238,7 +1390,7 @@ private ExpressionSyntax ConvertAreNotEqualWithMessage(SeparatedSyntaxList<Argum
var actual = arguments[1].Expression;

// Check if 3rd argument is a comparer
if (arguments.Count >= 3 && IsLikelyComparerArgument(arguments[2]) == true)
if (arguments.Count >= 3 && IsLikelyComparerArgument(arguments[2]))
{
var result = CreateTUnitAssertion("IsNotEqualTo", actual, expected);
return result.WithLeadingTrivia(
Expand Down
Loading
Loading