Skip to content

Add analyzer and code fix to migrate from StringAssert to Assert APIs #5792

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,7 @@
<data name="ReplaceDataTestMethodWithTestMethodTitle" xml:space="preserve">
<value>Replace 'DataTestMethod' with 'TestMethod'</value>
</data>
<data name="StringAssertToAssertTitle" xml:space="preserve">
<value>Use 'Assert.{0}' instead of 'StringAssert'</value>
</data>
</root>
106 changes: 106 additions & 0 deletions src/Analyzers/MSTest.Analyzers.CodeFixes/StringAssertToAssertFixer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Collections.Immutable;
using System.Composition;
using System.Linq;

using Analyzer.Utilities;

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Editing;

using MSTest.Analyzers.Helpers;

namespace MSTest.Analyzers.CodeFixes;

[ExportCodeFixProvider(LanguageNames.CSharp, LanguageNames.VisualBasic, Name = nameof(StringAssertToAssertFixer))]
[Shared]
public sealed class StringAssertToAssertFixer : CodeFixProvider
{
public override ImmutableArray<string> FixableDiagnosticIds { get; }
= ImmutableArray.Create(DiagnosticIds.StringAssertToAssertRuleId);

public sealed override FixAllProvider GetFixAllProvider()
// See https://github.com/dotnet/roslyn/blob/main/docs/analyzers/FixAllProvider.md for more information on Fix All Providers
=> WellKnownFixAllProviders.BatchFixer;

public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
SyntaxNode root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);

Diagnostic diagnostic = context.Diagnostics.First();
if (!diagnostic.Properties.TryGetValue(StringAssertToAssertAnalyzer.ProperAssertMethodNameKey, out string? properAssertMethodName)
|| properAssertMethodName == null)
{
return;
}

var simpleNameSyntax = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true)
.DescendantNodesAndSelf()
.OfType<SimpleNameSyntax>()
.First();

// Register a code fix that will invoke the fix operation.
string title = string.Format(CodeFixResources.StringAssertToAssertTitle, properAssertMethodName);
CodeAction action = CodeAction.Create(
title: title,
createChangedDocument: ct => FixStringAssertAsync(context.Document, root, simpleNameSyntax, properAssertMethodName, ct),
equivalenceKey: title);

context.RegisterCodeFix(action, diagnostic);
}

private static async Task<Document> FixStringAssertAsync(
Document document,
SyntaxNode root,
SimpleNameSyntax simpleNameSyntax,
string properAssertMethodName,
CancellationToken cancellationToken)
{
// Find the invocation expression that contains the SimpleNameSyntax
if (simpleNameSyntax.Ancestors().OfType<InvocationExpressionSyntax>().FirstOrDefault() is not InvocationExpressionSyntax invocationExpr)
{
return document;
}

DocumentEditor editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false);

// Replace StringAssert with Assert in the member access expression
if (invocationExpr.Expression is MemberAccessExpressionSyntax memberAccessExpr)
{
// Change StringAssert.MethodName to Assert.MethodName
var newMemberAccess = memberAccessExpr.WithExpression(SyntaxFactory.IdentifierName("Assert"));
editor.ReplaceNode(memberAccessExpr, newMemberAccess);
}

// Swap the first two arguments
SeparatedSyntaxList<ArgumentSyntax> arguments = invocationExpr.ArgumentList.Arguments;
if (arguments.Count >= 2)
{
ArgumentSyntax firstArg = arguments[0];
ArgumentSyntax secondArg = arguments[1];

// Create new argument list with swapped first two arguments
var newArguments = new List<ArgumentSyntax>(arguments.Count);
newArguments.Add(secondArg); // Second argument becomes first
newArguments.Add(firstArg); // First argument becomes second

// Add remaining arguments if any
for (int i = 2; i < arguments.Count; i++)
{
newArguments.Add(arguments[i]);
}

ArgumentListSyntax newArgumentList = SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(newArguments));
InvocationExpressionSyntax newInvocationExpr = invocationExpr.WithArgumentList(newArgumentList);
editor.ReplaceNode(invocationExpr, newInvocationExpr);
}

return editor.GetChangedDocument();
}
}
1 change: 1 addition & 0 deletions src/Analyzers/MSTest.Analyzers/Helpers/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,5 @@ internal static class DiagnosticIds
public const string DuplicateDataRowRuleId = "MSTEST0042";
public const string UseRetryWithTestMethodRuleId = "MSTEST0043";
public const string PreferTestMethodOverDataTestMethodRuleId = "MSTEST0044";
public const string StringAssertToAssertRuleId = "MSTEST0045";
}
18 changes: 18 additions & 0 deletions src/Analyzers/MSTest.Analyzers/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/Analyzers/MSTest.Analyzers/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,12 @@ The type declaring these methods should also respect the following rules:
<data name="UseProperAssertMethodsMessageFormat" xml:space="preserve">
<value>Use 'Assert.{0}' instead of 'Assert.{1}'</value>
</data>
<data name="StringAssertToAssertTitle" xml:space="preserve">
<value>Use 'Assert' instead of 'StringAssert'</value>
</data>
<data name="StringAssertToAssertMessageFormat" xml:space="preserve">
<value>Use 'Assert.{0}' instead of 'StringAssert.{1}'</value>
</data>
<data name="DataRowShouldBeValidMessageFormat_GenericTypeArgumentNotResolved" xml:space="preserve">
<value>The type of the generic parameter '{0}' could not be inferred.</value>
</data>
Expand Down
130 changes: 130 additions & 0 deletions src/Analyzers/MSTest.Analyzers/StringAssertToAssertAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Collections.Immutable;

using Analyzer.Utilities;
using Analyzer.Utilities.Extensions;

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;

using MSTest.Analyzers.Helpers;

namespace MSTest.Analyzers;

/// <summary>
/// MSTEST0045: Use 'Assert' instead of 'StringAssert'.
/// </summary>
/// <remarks>
/// The analyzer captures StringAssert method calls and suggests using equivalent Assert methods:
/// <list type="bullet">
/// <item>
/// <description>
/// <code>StringAssert.Contains(value, substring)</code> → <code>Assert.Contains(substring, value)</code>
/// </description>
/// </item>
/// <item>
/// <description>
/// <code>StringAssert.StartsWith(value, substring)</code> → <code>Assert.StartsWith(substring, value)</code>
/// </description>
/// </item>
/// <item>
/// <description>
/// <code>StringAssert.EndsWith(value, substring)</code> → <code>Assert.EndsWith(substring, value)</code>
/// </description>
/// </item>
/// <item>
/// <description>
/// <code>StringAssert.Matches(value, pattern)</code> → <code>Assert.Matches(pattern, value)</code>
/// </description>
/// </item>
/// <item>
/// <description>
/// <code>StringAssert.DoesNotMatch(value, pattern)</code> → <code>Assert.DoesNotMatch(pattern, value)</code>
/// </description>
/// </item>
/// </list>
/// </remarks>
[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)]
internal sealed class StringAssertToAssertAnalyzer : DiagnosticAnalyzer
{
/// <summary>
/// Key to retrieve the proper assert method name from the properties bag.
/// </summary>
internal const string ProperAssertMethodNameKey = nameof(ProperAssertMethodNameKey);

private static readonly LocalizableResourceString Title = new(nameof(Resources.StringAssertToAssertTitle), Resources.ResourceManager, typeof(Resources));
private static readonly LocalizableResourceString MessageFormat = new(nameof(Resources.StringAssertToAssertMessageFormat), Resources.ResourceManager, typeof(Resources));

internal static readonly DiagnosticDescriptor Rule = DiagnosticDescriptorHelper.Create(
DiagnosticIds.StringAssertToAssertRuleId,
Title,
MessageFormat,
null,
Category.Usage,
DiagnosticSeverity.Info,
isEnabledByDefault: true);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; }
= ImmutableArray.Create(Rule);

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();

context.RegisterCompilationStartAction(context =>
{
if (!context.Compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.MicrosoftVisualStudioTestToolsUnitTestingStringAssert, out INamedTypeSymbol? stringAssertTypeSymbol))
{
return;
}

context.RegisterOperationAction(context => AnalyzeInvocationOperation(context, stringAssertTypeSymbol), OperationKind.Invocation);
});
}

private static void AnalyzeInvocationOperation(OperationAnalysisContext context, INamedTypeSymbol stringAssertTypeSymbol)
{
var operation = (IInvocationOperation)context.Operation;
IMethodSymbol targetMethod = operation.TargetMethod;

if (!SymbolEqualityComparer.Default.Equals(targetMethod.ContainingType, stringAssertTypeSymbol))
{
return;
}

// Map StringAssert methods to their equivalent Assert methods
string? assertMethodName = targetMethod.Name switch
{
"Contains" => "Contains",
"StartsWith" => "StartsWith",
"EndsWith" => "EndsWith",
"Matches" => "Matches",
"DoesNotMatch" => "DoesNotMatch",
_ => null
};

if (assertMethodName == null)
{
return;
}

// StringAssert methods all have at least 2 arguments that need to be swapped
if (operation.Arguments.Length < 2)
{
return;
}

var properties = ImmutableDictionary.CreateBuilder<string, string?>();
properties.Add(ProperAssertMethodNameKey, assertMethodName);

context.ReportDiagnostic(context.Operation.CreateDiagnostic(
Rule,
properties: properties.ToImmutable(),
assertMethodName,
$"StringAssert.{targetMethod.Name}"));
}
}
Loading
Loading