diff --git a/src/TUnit.Assertions.Analyzers/IsNotNullAssertionSuppressor.cs b/src/TUnit.Assertions.Analyzers/IsNotNullAssertionSuppressor.cs index 5024822c1b..0f26c72df5 100644 --- a/src/TUnit.Assertions.Analyzers/IsNotNullAssertionSuppressor.cs +++ b/src/TUnit.Assertions.Analyzers/IsNotNullAssertionSuppressor.cs @@ -9,7 +9,8 @@ namespace TUnit.Assertions.Analyzers; /// /// Suppresses nullability warnings (CS8600, CS8602, CS8604, CS8618, CS8629) for variables -/// after they have been asserted as non-null using Assert.That(x).IsNotNull(). +/// after they have been asserted as non-null using Assert.That(x).IsNotNull() +/// or x.Should().NotBeNull(). /// /// Note: This suppressor only hides the warnings; it does not change the compiler's /// null-state flow analysis. Variables will still appear as nullable in IntelliSense. @@ -187,7 +188,18 @@ private bool IsNotNullAssertion( var assertThatCall = FindAssertThatInChain(invocation); if (assertThatCall is null || assertThatCall.ArgumentList.Arguments.Count != 1 - || !IsTUnitMethod(assertThatCall, semanticModel, cancellationToken, "global::TUnit.Assertions.Assert.That")) + || !IsTUnitMethod( + invocation, + semanticModel, + cancellationToken, + "global::TUnit.Assertions.Extensions.AssertionExtensions", + "IsNotNull") + || !IsTUnitMethod( + assertThatCall, + semanticModel, + cancellationToken, + "global::TUnit.Assertions.Assert", + "That")) { return null; } @@ -200,9 +212,24 @@ private bool IsNotNullAssertion( SemanticModel semanticModel, CancellationToken cancellationToken) { + if (!IsTUnitMethod( + invocation, + semanticModel, + cancellationToken, + "global::TUnit.Assertions.Should.Extensions.ShouldAssertionExtensions", + "NotBeNull")) + { + return null; + } + var shouldCall = FindShouldInChain(invocation); if (shouldCall is null - || !IsTUnitMethod(shouldCall, semanticModel, cancellationToken, "global::TUnit.Assertions.Should.ShouldExtensions.Should")) + || !IsTUnitMethod( + shouldCall, + semanticModel, + cancellationToken, + "global::TUnit.Assertions.Should.ShouldExtensions", + "Should")) { return null; } @@ -217,9 +244,18 @@ private static bool IsTUnitMethod( InvocationExpressionSyntax invocation, SemanticModel semanticModel, CancellationToken cancellationToken, - string fullyQualifiedNonGenericName) - => semanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol is IMethodSymbol symbol - && symbol.GloballyQualifiedNonGeneric() == fullyQualifiedNonGenericName; + string fullyQualifiedContainingTypeName, + string methodName) + { + if (semanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol is not IMethodSymbol symbol) + { + return false; + } + + var method = symbol.ReducedFrom ?? symbol; + return method.Name == methodName + && method.ContainingType.GloballyQualifiedNonGeneric() == fullyQualifiedContainingTypeName; + } private bool ExpressionsMatch( ExpressionSyntax assertArgument, @@ -261,8 +297,8 @@ private bool SymbolsMatch( => FindInvocationInChain(invocation, identifierName: "That", parentName: "Assert"); // Should() is an extension method, so its receiver is the asserted value (any expression). - // parentName MUST stay null — constraining it would break the suppressor for user-defined - // assertion entry points and for Should() reached via using-aliases / namespace imports. + // parentName MUST stay null because the receiver is the asserted value. Semantic validation + // in GetShouldReceiver still ensures that only TUnit's Should extension qualifies. private static InvocationExpressionSyntax? FindShouldInChain(InvocationExpressionSyntax invocation) => FindInvocationInChain(invocation, identifierName: "Should", parentName: null); @@ -338,6 +374,6 @@ private static SuppressionDescriptor CreateDescriptor(string id) => new( id: $"{id}Suppression", suppressedDiagnosticId: id, - justification: $"Suppress {id} for variables asserted as non-null via Assert.That(x).IsNotNull()." + justification: $"Suppress {id} for variables asserted as non-null via Assert.That(x).IsNotNull() or x.Should().NotBeNull()." ); } diff --git a/tests/TUnit.Assertions.Analyzers.Tests/IsNotNullAssertionSuppressorTests.cs b/tests/TUnit.Assertions.Analyzers.Tests/IsNotNullAssertionSuppressorTests.cs index 4d1b974408..2dfc6ac986 100644 --- a/tests/TUnit.Assertions.Analyzers.Tests/IsNotNullAssertionSuppressorTests.cs +++ b/tests/TUnit.Assertions.Analyzers.Tests/IsNotNullAssertionSuppressorTests.cs @@ -53,6 +53,240 @@ await AnalyzerTestHelpers .RunAsync(); } + [Test] + public async Task Does_Not_Suppress_CS8602_After_Custom_IsNotNull_Assertion() + { + const string code = """ + #nullable enable + using System.Threading.Tasks; + using CustomAssertions; + using TUnit.Assertions; + using TUnit.Assertions.Sources; + + namespace CustomAssertions + { + public static class ValueAssertionExtensions + { + public static Task IsNotNull(this ValueAssertion source) => Task.CompletedTask; + } + } + + public class MyTests + { + public async Task TestMethod() + { + string? nullableString = GetNullableString(); + + await Assert.That(nullableString).IsNotNull(); + + var length = {|#0:nullableString|}.Length; + } + + private string? GetNullableString() => "test"; + } + """; + + await AnalyzerTestHelpers + .CreateSuppressorTest(code) + .IgnoringDiagnostics("CS1591") + .WithSpecificDiagnostics(CS8602) + .WithExpectedDiagnosticsResults(CS8602.WithLocation(0).WithIsSuppressed(false)) + .WithCompilerDiagnostics(CompilerDiagnostics.Warnings) + .RunAsync(); + } + + [Test] + public async Task Suppresses_CS8602_After_Should_NotBeNull_Assertion() + { + const string code = """ + #nullable enable + using System.Threading.Tasks; + using TUnit.Assertions.Should; + using TUnit.Assertions.Should.Extensions; + + public class MyTests + { + public async Task TestMethod() + { + string? nullableString = GetNullableString(); + + await nullableString.Should().NotBeNull(); + + var length = {|#0:nullableString|}.Length; + } + + private string? GetNullableString() => "test"; + } + """; + + await AnalyzerTestHelpers + .CreateSuppressorTest(code) + .IgnoringDiagnostics("CS1591") + .WithSpecificDiagnostics(CS8602) + .WithExpectedDiagnosticsResults(CS8602.WithLocation(0).WithIsSuppressed(true)) + .WithCompilerDiagnostics(CompilerDiagnostics.Warnings) + .RunAsync(); + } + + [Test] + public async Task Does_Not_Suppress_CS8602_After_Should_Assertion_Without_NotBeNull() + { + const string code = """ + #nullable enable + using System.Threading.Tasks; + using TUnit.Assertions.Should; + using TUnit.Assertions.Should.Extensions; + + public class MyTests + { + public async Task TestMethod() + { + string? nullableString = GetNullableString(); + + await nullableString.Should().BeNull(); + + var length = {|#0:nullableString|}.Length; + } + + private string? GetNullableString() => "test"; + } + """; + + await AnalyzerTestHelpers + .CreateSuppressorTest(code) + .IgnoringDiagnostics("CS1591") + .WithSpecificDiagnostics(CS8602) + .WithExpectedDiagnosticsResults(CS8602.WithLocation(0).WithIsSuppressed(false)) + .WithCompilerDiagnostics(CompilerDiagnostics.Warnings) + .RunAsync(); + } + + [Test] + public async Task Does_Not_Suppress_CS8602_After_Unrelated_Should_NotBeNull_Assertion() + { + const string code = """ + #nullable enable + using System.Threading.Tasks; + using OtherLibrary; + + namespace OtherLibrary + { + public sealed class Wrapper + { + public Task NotBeNull() => Task.CompletedTask; + } + + public static class ShouldExtensions + { + public static Wrapper Should(this string? value) => new(); + } + } + + public class MyTests + { + public async Task TestMethod() + { + string? nullableString = GetNullableString(); + + await nullableString.Should().NotBeNull(); + + var length = {|#0:nullableString|}.Length; + } + + private string? GetNullableString() => "test"; + } + """; + + await AnalyzerTestHelpers + .CreateSuppressorTest(code) + .IgnoringDiagnostics("CS1591") + .WithSpecificDiagnostics(CS8602) + .WithExpectedDiagnosticsResults(CS8602.WithLocation(0).WithIsSuppressed(false)) + .WithCompilerDiagnostics(CompilerDiagnostics.Warnings) + .RunAsync(); + } + + [Test] + public async Task Does_Not_Suppress_CS8602_After_Custom_NotBeNull_Assertion() + { + const string code = """ + #nullable enable + using System.Threading.Tasks; + using CustomAssertions; + using TUnit.Assertions.Should; + using TUnit.Assertions.Should.Core; + + namespace CustomAssertions + { + public static class ShouldSourceExtensions + { + public static Task NotBeNull(this ShouldSource source) => Task.CompletedTask; + } + } + + public class MyTests + { + public async Task TestMethod() + { + string? nullableString = GetNullableString(); + + await nullableString.Should().NotBeNull(); + + var length = {|#0:nullableString|}.Length; + } + + private string? GetNullableString() => "test"; + } + """; + + await AnalyzerTestHelpers + .CreateSuppressorTest(code) + .IgnoringDiagnostics("CS1591") + .WithSpecificDiagnostics(CS8602) + .WithExpectedDiagnosticsResults(CS8602.WithLocation(0).WithIsSuppressed(false)) + .WithCompilerDiagnostics(CompilerDiagnostics.Warnings) + .RunAsync(); + } + + [Test] + public async Task Suppresses_CS8602_After_Should_NotBeNull_In_Assertion_Chains() + { + const string code = """ + #nullable enable + using System.Threading.Tasks; + using TUnit.Assertions.Should; + using TUnit.Assertions.Should.Extensions; + + public class MyTests + { + public async Task TestMethod() + { + string? notBeNullFirst = GetNullableString(); + string? notBeNullLast = GetNullableString(); + + await notBeNullFirst.Should().NotBeNull().And.Contain("test"); + await notBeNullLast.Should().Contain("test").And.NotBeNull(); + + var firstLength = {|#0:notBeNullFirst|}.Length; + var lastLength = {|#1:notBeNullLast|}.Length; + } + + private string? GetNullableString() => "test"; + } + """; + + await AnalyzerTestHelpers + .CreateSuppressorTest(code) + .IgnoringDiagnostics("CS1591") + .WithSpecificDiagnostics(CS8602) + .WithExpectedDiagnosticsResults( + CS8602.WithLocation(0).WithIsSuppressed(true), + CS8602.WithLocation(1).WithIsSuppressed(true) + ) + .WithCompilerDiagnostics(CompilerDiagnostics.Warnings) + .RunAsync(); + } + [Test] public async Task Suppresses_CS8604_After_IsNotNull_Assertion() {