diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 8cb33072..3b10e9c9 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -77,8 +77,8 @@ - - + + diff --git a/src/xunit.analyzers.fixes/X2000/AssignableFromAssertionIsConfusinglyNamedFixer.cs b/src/xunit.analyzers.fixes/X2000/AssignableFromAssertionIsConfusinglyNamedFixer.cs new file mode 100644 index 00000000..c1d59466 --- /dev/null +++ b/src/xunit.analyzers.fixes/X2000/AssignableFromAssertionIsConfusinglyNamedFixer.cs @@ -0,0 +1,79 @@ +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace Xunit.Analyzers.Fixes; + +[ExportCodeFixProvider(LanguageNames.CSharp), Shared] +public class AssignableFromAssertionIsConfusinglyNamedFixer : BatchedCodeFixProvider +{ + public const string Key_UseIsType = "xUnit2032_UseIsType"; + + public AssignableFromAssertionIsConfusinglyNamedFixer() : + base(Descriptors.X2032_AssignableFromAssertionIsConfusinglyNamed.Id) + { } + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + var invocation = root.FindNode(context.Span).FirstAncestorOrSelf(); + if (invocation is null) + return; + + var simpleNameSyntax = invocation.GetSimpleName(); + if (simpleNameSyntax is null) + return; + + var methodName = simpleNameSyntax.Identifier.Text; + if (!AssignableFromAssertionIsConfusinglyNamed.ReplacementMethods.TryGetValue(methodName, out var replacementName)) + return; + + context.RegisterCodeFix( + XunitCodeAction.Create( + ct => UseIsType(context.Document, invocation, simpleNameSyntax, replacementName, ct), + Key_UseIsType, + "Use Assert.{0}", replacementName + ), + context.Diagnostics + ); + } + + static async Task UseIsType( + Document document, + InvocationExpressionSyntax invocation, + SimpleNameSyntax simpleName, + string replacementName, + CancellationToken cancellationToken) + { + var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); + + editor.ReplaceNode( + invocation, + invocation + .ReplaceNode( + simpleName, + simpleName.WithIdentifier(Identifier(replacementName)) + ) + .WithArgumentList( + invocation + .ArgumentList + .AddArguments( + ParseArgumentList("false") + .Arguments[0] + .WithNameColon(NameColon("exactMatch")) + ) + ) + ); + + return editor.GetChangedDocument(); + } +} diff --git a/src/xunit.analyzers.tests/Analyzers/X2000/AssignableFromAssertionIsConfusinglyNamedTests.cs b/src/xunit.analyzers.tests/Analyzers/X2000/AssignableFromAssertionIsConfusinglyNamedTests.cs new file mode 100644 index 00000000..0918eb87 --- /dev/null +++ b/src/xunit.analyzers.tests/Analyzers/X2000/AssignableFromAssertionIsConfusinglyNamedTests.cs @@ -0,0 +1,76 @@ +using System; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Xunit; +using Xunit.Analyzers; +using Verify = CSharpVerifier; +using Verify_v2_Pre2_9_3 = CSharpVerifier; +using Verify_v3_Pre0_6_0 = CSharpVerifier; + +public class AssignableFromAssertionIsConfusinglyNamedTests +{ + public static TheoryData Methods = new() + { + { "IsAssignableFrom", "IsType" }, + { "IsNotAssignableFrom", "IsNotType"}, + }; + + [Theory] + [MemberData(nameof(Methods))] + public async Task WhenReplacementAvailable_Triggers( + string method, + string replacement) + { + var source = string.Format(/* lang=c#-test */ """ + using System; + using Xunit; + + class TestClass {{ + void TestMethod() {{ + {{|#0:Assert.{0}(new object())|}}; + {{|#1:Assert.{0}(typeof(object), new object())|}}; + }} + }} + """, method); + var expected = new[] { + Verify.Diagnostic().WithLocation(0).WithArguments(method, replacement), + Verify.Diagnostic().WithLocation(1).WithArguments(method, replacement), + }; + + await Verify.VerifyAnalyzer(source, expected); + } + + [Theory] + [MemberData(nameof(Methods))] + public async Task WhenReplacementNotAvailable_DoesNotTriggers( + string method, + string _) + { + var source = string.Format(/* lang=c#-test */ """ + using System; + using Xunit; + + class TestClass {{ + void TestMethod() {{ + Assert.{0}(new object()); + Assert.{0}(typeof(object), new object()); + }} + }} + """, method); + + await Verify_v2_Pre2_9_3.VerifyAnalyzer(source); + await Verify_v3_Pre0_6_0.VerifyAnalyzer(source); + } + + internal class Analyzer_v2_Pre2_9_3 : AssignableFromAssertionIsConfusinglyNamed + { + protected override XunitContext CreateXunitContext(Compilation compilation) => + XunitContext.ForV2(compilation, new Version(2, 9, 2)); + } + + internal class Analyzer_v3_Pre0_6_0 : AssignableFromAssertionIsConfusinglyNamed + { + protected override XunitContext CreateXunitContext(Compilation compilation) => + XunitContext.ForV3(compilation, new Version(0, 5, 999)); + } +} diff --git a/src/xunit.analyzers.tests/Fixes/X2000/AssignableFromAssertionIsConfusinglyNamedFixerTests.cs b/src/xunit.analyzers.tests/Fixes/X2000/AssignableFromAssertionIsConfusinglyNamedFixerTests.cs new file mode 100644 index 00000000..019ab985 --- /dev/null +++ b/src/xunit.analyzers.tests/Fixes/X2000/AssignableFromAssertionIsConfusinglyNamedFixerTests.cs @@ -0,0 +1,42 @@ +using System.Threading.Tasks; +using Xunit; +using Xunit.Analyzers.Fixes; +using Verify = CSharpVerifier; + +public class AssignableFromAssertionIsConfusinglyNamedFixerTests +{ + [Fact] + public async Task Conversions() + { + var before = /* lang=c#-test */ """ + using System; + using Xunit; + + public class TestClass { + [Fact] + public void TestMethod() { + var data = "Hello world"; + + [|Assert.IsAssignableFrom(typeof(object), data)|]; + [|Assert.IsAssignableFrom(data)|]; + } + } + """; + var after = /* lang=c#-test */ """ + using System; + using Xunit; + + public class TestClass { + [Fact] + public void TestMethod() { + var data = "Hello world"; + + Assert.IsType(typeof(object), data, exactMatch: false); + Assert.IsType(data, exactMatch: false); + } + } + """; + + await Verify.VerifyCodeFix(before, after, AssignableFromAssertionIsConfusinglyNamedFixer.Key_UseIsType); + } +} diff --git a/src/xunit.analyzers.tests/Utility/CodeAnalyzerHelper.cs b/src/xunit.analyzers.tests/Utility/CodeAnalyzerHelper.cs index 0df9ff8b..32aac83b 100644 --- a/src/xunit.analyzers.tests/Utility/CodeAnalyzerHelper.cs +++ b/src/xunit.analyzers.tests/Utility/CodeAnalyzerHelper.cs @@ -61,10 +61,10 @@ static CodeAnalyzerHelper() new PackageIdentity("Microsoft.Extensions.Primitives", "8.0.0"), new PackageIdentity("System.Threading.Tasks.Extensions", "4.5.4"), new PackageIdentity("System.Text.Json", "8.0.0"), - new PackageIdentity("xunit.v3.assert", "0.5.0-pre.35"), - new PackageIdentity("xunit.v3.common", "0.5.0-pre.35"), - new PackageIdentity("xunit.v3.extensibility.core", "0.5.0-pre.35"), - new PackageIdentity("xunit.v3.runner.common", "0.5.0-pre.35") + new PackageIdentity("xunit.v3.assert", "0.6.0-pre.1"), + new PackageIdentity("xunit.v3.common", "0.6.0-pre.1"), + new PackageIdentity("xunit.v3.extensibility.core", "0.6.0-pre.1"), + new PackageIdentity("xunit.v3.runner.common", "0.6.0-pre.1") ) ); @@ -74,8 +74,8 @@ static CodeAnalyzerHelper() new PackageIdentity("Microsoft.Extensions.Primitives", "8.0.0"), new PackageIdentity("System.Threading.Tasks.Extensions", "4.5.4"), new PackageIdentity("System.Text.Json", "8.0.0"), - new PackageIdentity("xunit.v3.common", "0.5.0-pre.35"), - new PackageIdentity("xunit.v3.runner.utility", "0.5.0-pre.35") + new PackageIdentity("xunit.v3.common", "0.6.0-pre.1"), + new PackageIdentity("xunit.v3.runner.utility", "0.6.0-pre.1") ) ); } diff --git a/src/xunit.analyzers.tests/xunit.analyzers.tests.csproj b/src/xunit.analyzers.tests/xunit.analyzers.tests.csproj index 656c0b50..c6a1629e 100644 --- a/src/xunit.analyzers.tests/xunit.analyzers.tests.csproj +++ b/src/xunit.analyzers.tests/xunit.analyzers.tests.csproj @@ -23,11 +23,11 @@ - - - - - + + + + + diff --git a/src/xunit.analyzers/Utility/Descriptors.xUnit2xxx.cs b/src/xunit.analyzers/Utility/Descriptors.xUnit2xxx.cs index c5425846..2cab2e14 100644 --- a/src/xunit.analyzers/Utility/Descriptors.xUnit2xxx.cs +++ b/src/xunit.analyzers/Utility/Descriptors.xUnit2xxx.cs @@ -287,7 +287,14 @@ public static partial class Descriptors "Do not use a Where clause to filter before calling Assert.Single. Use the overload of Assert.Single that accepts a filtering function." ); - // Placeholder for rule X2032 + public static DiagnosticDescriptor X2032_AssignableFromAssertionIsConfusinglyNamed { get; } = + Diagnostic( + "xUnit2032", + "Type assertions based on 'assignable from' are confusingly named", + Assertions, + Info, + "The naming of Assert.{0} can be confusing. An overload of Assert.{0} is available with an exact match flag which can be set to false to perform the same operation." + ); // Placeholder for rule X2033 diff --git a/src/xunit.analyzers/Utility/EmptyAssertContext.cs b/src/xunit.analyzers/Utility/EmptyAssertContext.cs index 7473c7e1..82f253d5 100644 --- a/src/xunit.analyzers/Utility/EmptyAssertContext.cs +++ b/src/xunit.analyzers/Utility/EmptyAssertContext.cs @@ -14,5 +14,7 @@ public class EmptyAssertContext : IAssertContext public bool SupportsAssertFail => false; + public bool SupportsInexactTypeAssertions => false; + public Version Version { get; } = new(); } diff --git a/src/xunit.analyzers/Utility/IAssertContext.cs b/src/xunit.analyzers/Utility/IAssertContext.cs index c823d3ba..16998f3c 100644 --- a/src/xunit.analyzers/Utility/IAssertContext.cs +++ b/src/xunit.analyzers/Utility/IAssertContext.cs @@ -15,6 +15,13 @@ public interface IAssertContext /// bool SupportsAssertFail { get; } + /// + /// Gets a flag indicating whether Assert.IsType and Assert.IsNotType + /// support inexact matches (soft-deprecating Assert.IsAssignableFrom + /// and Assert.IsNotAssignableFrom). + /// + bool SupportsInexactTypeAssertions { get; } + /// /// Gets the version number of the assertion assembly. /// diff --git a/src/xunit.analyzers/Utility/V2AssertContext.cs b/src/xunit.analyzers/Utility/V2AssertContext.cs index 0e9704ee..a926d434 100644 --- a/src/xunit.analyzers/Utility/V2AssertContext.cs +++ b/src/xunit.analyzers/Utility/V2AssertContext.cs @@ -7,6 +7,7 @@ namespace Xunit.Analyzers; public class V2AssertContext : IAssertContext { internal static readonly Version Version_2_5_0 = new("2.5.0"); + internal static readonly Version Version_2_9_3 = new("2.9.3"); readonly Lazy lazyAssertType; @@ -27,6 +28,10 @@ public class V2AssertContext : IAssertContext public bool SupportsAssertFail => Version >= Version_2_5_0; + /// + public bool SupportsInexactTypeAssertions => + Version >= Version_2_9_3; + /// public Version Version { get; } diff --git a/src/xunit.analyzers/Utility/V3AssertContext.cs b/src/xunit.analyzers/Utility/V3AssertContext.cs index 03f402c7..4cc2d287 100644 --- a/src/xunit.analyzers/Utility/V3AssertContext.cs +++ b/src/xunit.analyzers/Utility/V3AssertContext.cs @@ -6,6 +6,8 @@ namespace Xunit.Analyzers; public class V3AssertContext : IAssertContext { + internal static readonly Version Version_0_6_0 = new("0.6.0"); + readonly Lazy lazyAssertType; V3AssertContext( @@ -24,6 +26,10 @@ public class V3AssertContext : IAssertContext /// public bool SupportsAssertFail => true; + /// + public bool SupportsInexactTypeAssertions => + Version >= Version_0_6_0; + /// public Version Version { get; } diff --git a/src/xunit.analyzers/X2000/AssignableFromAssertionIsConfusinglyNamed.cs b/src/xunit.analyzers/X2000/AssignableFromAssertionIsConfusinglyNamed.cs new file mode 100644 index 00000000..9c1fd8fc --- /dev/null +++ b/src/xunit.analyzers/X2000/AssignableFromAssertionIsConfusinglyNamed.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Xunit.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class AssignableFromAssertionIsConfusinglyNamed : AssertUsageAnalyzerBase +{ + public static readonly Dictionary ReplacementMethods = new() + { + { Constants.Asserts.IsAssignableFrom, Constants.Asserts.IsType }, + { Constants.Asserts.IsNotAssignableFrom, Constants.Asserts.IsNotType }, + }; + + public AssignableFromAssertionIsConfusinglyNamed() : + base(Descriptors.X2032_AssignableFromAssertionIsConfusinglyNamed, ReplacementMethods.Keys) + { } + + protected override void AnalyzeInvocation( + OperationAnalysisContext context, + XunitContext xunitContext, + IInvocationOperation invocationOperation, + IMethodSymbol method) + { + Guard.ArgumentNotNull(xunitContext); + Guard.ArgumentNotNull(invocationOperation); + Guard.ArgumentNotNull(method); + + if (!xunitContext.Assert.SupportsInexactTypeAssertions) + return; + + if (!ReplacementMethods.TryGetValue(invocationOperation.TargetMethod.Name, out var replacement)) + return; + + context.ReportDiagnostic( + Diagnostic.Create( + Descriptors.X2032_AssignableFromAssertionIsConfusinglyNamed, + invocationOperation.Syntax.GetLocation(), + invocationOperation.TargetMethod.Name, + replacement + ) + ); + } +} diff --git a/tools/builder/build.csproj b/tools/builder/build.csproj index 7776d77a..d3724456 100644 --- a/tools/builder/build.csproj +++ b/tools/builder/build.csproj @@ -16,7 +16,7 @@ - + diff --git a/tools/builder/models/BuildContext.cs b/tools/builder/models/BuildContext.cs index bd7bdb8a..84671d16 100644 --- a/tools/builder/models/BuildContext.cs +++ b/tools/builder/models/BuildContext.cs @@ -16,7 +16,7 @@ public partial IReadOnlyList GetSkippedAnalysisFolders() => partial void Initialize() { - consoleRunner = Path.Combine(NuGetPackageCachePath, "xunit.v3.runner.console", "0.5.0-pre.33", "tools", "net472", "xunit.v3.runner.console.exe"); + consoleRunner = Path.Combine(NuGetPackageCachePath, "xunit.v3.runner.console", "0.6.0-pre.1", "tools", "net472", "xunit.v3.runner.console.exe"); if (!File.Exists(consoleRunner)) throw new InvalidOperationException($"Cannot find console runner at '{consoleRunner}'"); }