Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
129 changes: 129 additions & 0 deletions TUnit.Mocks.Analyzers.Tests/ArgIsNullNonNullableAnalyzerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using TUnit.Mocks.Analyzers.Tests.Verifiers;

using Verifier = TUnit.Mocks.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier<TUnit.Mocks.Analyzers.ArgIsNullNonNullableAnalyzer>;

namespace TUnit.Mocks.Analyzers.Tests;

public class ArgIsNullNonNullableAnalyzerTests
{
private const string ArgStub = """
namespace TUnit.Mocks.Arguments
{
public static class Arg
{
public static Arg<T> IsNull<T>() => default!;
public static Arg<T> IsNotNull<T>() => default!;
}

public struct Arg<T> { }
}
""";

[Test]
public async Task IsNull_With_Non_Nullable_Struct_Reports_TM005()
{
await Verifier.VerifyAnalyzerAsync(
ArgStub + """

public class TestClass
{
public void Test()
{
{|#0:TUnit.Mocks.Arguments.Arg.IsNull<int>()|};
}
}
""",
Verifier.Diagnostic(Rules.TM005_ArgIsNullNonNullableValueType)
.WithLocation(0)
.WithArguments("IsNull", "int")
);
}

[Test]
public async Task IsNotNull_With_Non_Nullable_Struct_Reports_TM005()
{
await Verifier.VerifyAnalyzerAsync(
ArgStub + """

public class TestClass
{
public void Test()
{
{|#0:TUnit.Mocks.Arguments.Arg.IsNotNull<bool>()|};
}
}
""",
Verifier.Diagnostic(Rules.TM005_ArgIsNullNonNullableValueType)
.WithLocation(0)
.WithArguments("IsNotNull", "bool")
);
}

[Test]
public async Task IsNull_With_Nullable_Value_Type_Does_Not_Report()
{
await Verifier.VerifyAnalyzerAsync(
ArgStub + """

public class TestClass
{
public void Test()
{
TUnit.Mocks.Arguments.Arg.IsNull<int?>();
}
}
"""
);
}

[Test]
public async Task IsNotNull_With_Nullable_Value_Type_Does_Not_Report()
{
await Verifier.VerifyAnalyzerAsync(
ArgStub + """

public class TestClass
{
public void Test()
{
TUnit.Mocks.Arguments.Arg.IsNotNull<int?>();
}
}
"""
);
}

[Test]
public async Task IsNull_With_Reference_Type_Does_Not_Report()
{
await Verifier.VerifyAnalyzerAsync(
ArgStub + """

public class TestClass
{
public void Test()
{
TUnit.Mocks.Arguments.Arg.IsNull<string>();
}
}
"""
);
}

[Test]
public async Task IsNotNull_With_Reference_Type_Does_Not_Report()
{
await Verifier.VerifyAnalyzerAsync(
ArgStub + """

public class TestClass
{
public void Test()
{
TUnit.Mocks.Arguments.Arg.IsNotNull<string>();
}
}
"""
);
}
}
72 changes: 72 additions & 0 deletions TUnit.Mocks.Analyzers/ArgIsNullNonNullableAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace TUnit.Mocks.Analyzers;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ArgIsNullNonNullableAnalyzer : DiagnosticAnalyzer
{
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } =
ImmutableArray.Create(Rules.TM005_ArgIsNullNonNullableValueType);

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

context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
}

private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
{
if (context.Node is not InvocationExpressionSyntax invocation)
{
return;
}

var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken);

if (symbolInfo.Symbol is not IMethodSymbol methodSymbol)
{
return;
}

if (!IsArgNullMethod(methodSymbol))
{
return;
}

var typeArgument = methodSymbol.TypeArguments[0];

if (!typeArgument.IsValueType)
{
return;
}

// Nullable<T> is a value type but IS nullable — only flag non-nullable structs
if (typeArgument.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T)
{
return;
}

context.ReportDiagnostic(
Diagnostic.Create(
Rules.TM005_ArgIsNullNonNullableValueType,
invocation.GetLocation(),
methodSymbol.Name,
typeArgument.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)
)
);
}

private static bool IsArgNullMethod(IMethodSymbol method)
{
return method.Name is "IsNull" or "IsNotNull"
&& method.IsGenericMethod
&& method.Parameters.Length == 0
&& method.ContainingType is { Name: "Arg", ContainingNamespace: { Name: "Arguments", ContainingNamespace: { Name: "Mocks", ContainingNamespace: { Name: "TUnit", ContainingNamespace.IsGlobalNamespace: true } } } };
}
}
9 changes: 9 additions & 0 deletions TUnit.Mocks.Analyzers/Rules.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ public static class Rules
isEnabledByDefault: true
);

public static readonly DiagnosticDescriptor TM005_ArgIsNullNonNullableValueType = new(
id: "TM005",
title: "Arg.IsNull/IsNotNull used with non-nullable value type",
messageFormat: "Arg.{0}<{1}>() will never match because '{1}' is a non-nullable value type. Use '{1}?' instead.",
category: "TUnit.Mocks",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true
);

public static readonly DiagnosticDescriptor TM004_RequiresCSharp14 = new(
id: "TM004",
title: "TUnit.Mocks requires C# 14 or later",
Expand Down
35 changes: 35 additions & 0 deletions TUnit.Mocks.Tests/ArgumentMatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,41 @@ public async Task Arg_Capture_Does_Not_Capture_On_Partial_Match()
await Assert.That(result).IsEqualTo(999);
}

[Test]
public async Task Arg_IsNull_Matches_Null_Nullable_Value_Type()
{
// Arrange
var mock = Mock.Of<INullableValueConsumer>();
mock.Process(IsNull<int?>()).Returns("got null");

// Act
INullableValueConsumer consumer = mock.Object;

// Assert — null matches
await Assert.That(consumer.Process(null)).IsEqualTo("got null");

// Assert — non-null does not match, returns default
await Assert.That(consumer.Process(42)).IsNotEqualTo("got null");
}

[Test]
public async Task Arg_IsNotNull_Matches_NonNull_Nullable_Value_Type()
{
// Arrange
var mock = Mock.Of<INullableValueConsumer>();
mock.Process(IsNotNull<int?>()).Returns("got value");

// Act
INullableValueConsumer consumer = mock.Object;

// Assert — non-null matches
await Assert.That(consumer.Process(42)).IsEqualTo("got value");
await Assert.That(consumer.Process(0)).IsEqualTo("got value");

// Assert — null does not match
await Assert.That(consumer.Process(null)).IsNotEqualTo("got value");
}

[Test]
public async Task Predicate_Matcher_With_String()
{
Expand Down
5 changes: 5 additions & 0 deletions TUnit.Mocks.Tests/BasicMockTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ public interface IGreeter
string Greet(string name);
}

public interface INullableValueConsumer
{
string Process(int? value);
}

/// <summary>
/// US1 Integration Tests: Create a mock and configure return values.
/// </summary>
Expand Down
8 changes: 4 additions & 4 deletions TUnit.Mocks/Arguments/Arg.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ public static class Arg
/// <summary>Matches when the predicate returns true for the actual argument.</summary>
public static Arg<T> Is<T>(Func<T?, bool> predicate) => new(new PredicateMatcher<T>(predicate));

/// <summary>Matches only when the argument is null.</summary>
public static Arg<T> IsNull<T>() where T : class => new(new NullMatcher<T>());
/// <summary>Matches only when the argument is null. Supports reference types and nullable value types.</summary>
public static Arg<T> IsNull<T>() => new(new NullMatcher<T>());

/// <summary>Matches only when the argument is not null.</summary>
public static Arg<T> IsNotNull<T>() where T : class => new(new NotNullMatcher<T>());
/// <summary>Matches only when the argument is not null. Supports reference types and nullable value types.</summary>
public static Arg<T> IsNotNull<T>() => new(new NotNullMatcher<T>());

/// <summary>Matches a string against a regular expression pattern.</summary>
public static Arg<string> Matches(string pattern) => new(new RegexMatcher(pattern));
Expand Down
Loading