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
54 changes: 45 additions & 9 deletions src/TUnit.Assertions.Analyzers/IsNotNullAssertionSuppressor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ namespace TUnit.Assertions.Analyzers;

/// <summary>
/// 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.
Expand Down Expand Up @@ -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"))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
return null;
}
Expand All @@ -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"))
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
thomhurst marked this conversation as resolved.
{
return null;
}
Expand All @@ -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,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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()."
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> 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<IsNotNullAssertionSuppressor>(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<IsNotNullAssertionSuppressor>(code)
.IgnoringDiagnostics("CS1591")
.WithSpecificDiagnostics(CS8602)
.WithExpectedDiagnosticsResults(CS8602.WithLocation(0).WithIsSuppressed(true))
.WithCompilerDiagnostics(CompilerDiagnostics.Warnings)
.RunAsync();
}
Comment thread
thomhurst marked this conversation as resolved.

[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<IsNotNullAssertionSuppressor>(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<IsNotNullAssertionSuppressor>(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<string> 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<IsNotNullAssertionSuppressor>(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<IsNotNullAssertionSuppressor>(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()
{
Expand Down
Loading