diff --git a/docs/Rules/MA0015.md b/docs/Rules/MA0015.md index a94b5233..1cbc5822 100644 --- a/docs/Rules/MA0015.md +++ b/docs/Rules/MA0015.md @@ -12,4 +12,22 @@ void Sample(string str) if (str == "") throw new ArgumentException("Error message", paramName: nameof(str)); // ok } + +class Sample +{ + void Test(string test) + { + ArgumentNullException.ThrowIfNull(Name); // non-compliant: 'Name' is not a parameter + } + + public static string Name { get; } +} + +class Sample +{ + void Test(string test) + { + ArgumentNullException.ThrowIfNull(test); // ok + } +} ```` diff --git a/src/Meziantou.Analyzer/Rules/ArgumentExceptionShouldSpecifyArgumentNameAnalyzer.cs b/src/Meziantou.Analyzer/Rules/ArgumentExceptionShouldSpecifyArgumentNameAnalyzer.cs index ce48df78..31b51367 100644 --- a/src/Meziantou.Analyzer/Rules/ArgumentExceptionShouldSpecifyArgumentNameAnalyzer.cs +++ b/src/Meziantou.Analyzer/Rules/ArgumentExceptionShouldSpecifyArgumentNameAnalyzer.cs @@ -37,10 +37,27 @@ public override void Initialize(AnalysisContext context) context.EnableConcurrentExecution(); context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.RegisterOperationAction(Analyze, OperationKind.ObjectCreation); + context.RegisterCompilationStartAction(context => + { + var argumentExceptionType = context.Compilation.GetBestTypeByMetadataName("System.ArgumentException"); + var argumentNullExceptionType = context.Compilation.GetBestTypeByMetadataName("System.ArgumentNullException"); + var argumentOutOfRangeExceptionType = context.Compilation.GetBestTypeByMetadataName("System.ArgumentOutOfRangeException"); + var callerArgumentExpressionAttribute = context.Compilation.GetBestTypeByMetadataName("System.Runtime.CompilerServices.CallerArgumentExpressionAttribute"); + + if (argumentExceptionType is null || argumentNullExceptionType is null) + return; + + context.RegisterOperationAction(AnalyzeObjectCreation, OperationKind.ObjectCreation); + + if (callerArgumentExpressionAttribute is not null) + { + context.RegisterOperationAction(ctx => AnalyzeInvocation(ctx, argumentExceptionType, argumentNullExceptionType, argumentOutOfRangeExceptionType, callerArgumentExpressionAttribute), OperationKind.Invocation); + } + }); } - private static void Analyze(OperationAnalysisContext context) + // Validate throw new ArgumentException("message", "paramName"); + private static void AnalyzeObjectCreation(OperationAnalysisContext context) { var op = (IObjectCreationOperation)context.Operation; if (op is null) @@ -112,6 +129,109 @@ private static void Analyze(OperationAnalysisContext context) } } + private static void AnalyzeInvocation(OperationAnalysisContext context, INamedTypeSymbol argumentExceptionType, INamedTypeSymbol argumentNullExceptionType, INamedTypeSymbol? argumentOutOfRangeExceptionType, INamedTypeSymbol callerArgumentExpressionAttribute) + { + var op = (IInvocationOperation)context.Operation; + if (op is null) + return; + + var method = op.TargetMethod; + if (method is null || !method.IsStatic) + return; + + // Check if the method name starts with "ThrowIf" + if (!method.Name.StartsWith("ThrowIf", StringComparison.Ordinal)) + return; + + // There must be at least one argument + if (op.Arguments.Length == 0) + return; + + // Check if this is a ThrowIfXxx method on ArgumentException, ArgumentNullException, or ArgumentOutOfRangeException + var containingType = method.ContainingType; + if (containingType is null) + return; + + if (!containingType.IsEqualToAny(argumentExceptionType, argumentNullExceptionType, argumentOutOfRangeExceptionType)) + return; + + // Find the parameter with CallerArgumentExpressionAttribute + foreach (var parameter in method.Parameters) + { + if (!parameter.Type.IsString()) + continue; + + var attribute = parameter.GetAttribute(callerArgumentExpressionAttribute); + if (attribute is null) + continue; + + if (attribute.ConstructorArguments.Length == 0) + continue; + + // Get the parameter name referenced by the CallerArgumentExpressionAttribute + var referencedParameterName = attribute.ConstructorArguments[0].Value as string; + if (string.IsNullOrEmpty(referencedParameterName)) + continue; + + // Find the parameter being referenced + var referencedParameter = method.Parameters.FirstOrDefault(p => p.Name == referencedParameterName); + if (referencedParameter is null) + continue; + + // Find the argument for the paramName parameter + var paramNameArgument = op.Arguments.FirstOrDefault(arg => arg.Parameter is not null && arg.Parameter.IsEqualTo(parameter)); + if (paramNameArgument is not null && !paramNameArgument.IsImplicit && paramNameArgument.Value is not null) + { + ValidateParamNameArgument(context, paramNameArgument); + return; + } + + // Find the argument for the referenced parameter (the one being validated) + var referencedArgument = op.Arguments.FirstOrDefault(arg => arg.Parameter is not null && arg.Parameter.IsEqualTo(referencedParameter)); + if (referencedArgument is not null) + { + ValidateExpression(context, referencedArgument); + return; + } + } + } + + private static void ValidateParamNameArgument(OperationAnalysisContext context, IArgumentOperation paramNameArgument) + { + // Check if the argument is a constant string value + if (!paramNameArgument.Value.ConstantValue.HasValue || paramNameArgument.Value.ConstantValue.Value is not string paramNameValue) + return; + + var availableParameterNames = GetParameterNames(paramNameArgument, context.CancellationToken); + if (availableParameterNames.Contains(paramNameValue, StringComparer.Ordinal)) + { + if (paramNameArgument.Value is not INameOfOperation) + { + var properties = ImmutableDictionary.Empty.Add(ArgumentExceptionShouldSpecifyArgumentNameAnalyzerCommon.ArgumentNameKey, paramNameValue); + context.ReportDiagnostic(NameofRule, properties, paramNameArgument.Value); + } + + return; + } + + context.ReportDiagnostic(Rule, paramNameArgument, $"'{paramNameValue}' is not a valid parameter name"); + } + + private static void ValidateExpression(OperationAnalysisContext context, IArgumentOperation argument) + { + if (argument.Value is null) + return; + + var unwrappedValue = argument.Value.UnwrapImplicitConversionOperations(); + if (unwrappedValue is IParameterReferenceOperation) + { + // Parameter references are always valid - no need to validate the name + return; + } + + context.ReportDiagnostic(Rule, argument, "The expression does not match a parameter"); + } + private static IEnumerable GetParameterNames(IOperation operation, CancellationToken cancellationToken) { var symbols = operation.LookupAvailableSymbols(cancellationToken); diff --git a/tests/Meziantou.Analyzer.Test/Rules/ArgumentExceptionShouldSpecifyArgumentNameAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/ArgumentExceptionShouldSpecifyArgumentNameAnalyzerTests.cs index a8be3f61..28ae1fc9 100755 --- a/tests/Meziantou.Analyzer.Test/Rules/ArgumentExceptionShouldSpecifyArgumentNameAnalyzerTests.cs +++ b/tests/Meziantou.Analyzer.Test/Rules/ArgumentExceptionShouldSpecifyArgumentNameAnalyzerTests.cs @@ -1,4 +1,5 @@ using Meziantou.Analyzer.Rules; +using Meziantou.Analyzer.Test.Helpers; using TestHelper; namespace Meziantou.Analyzer.Test.Rules; @@ -8,7 +9,8 @@ public sealed class ArgumentExceptionShouldSpecifyArgumentNameAnalyzerTests private static ProjectBuilder CreateProjectBuilder() { return new ProjectBuilder() - .WithAnalyzer(id: "MA0015"); + .WithAnalyzer(id: "MA0015") + .WithTargetFramework(TargetFramework.NetLatest); } [Fact] @@ -450,4 +452,497 @@ await CreateProjectBuilder() .ValidateAsync(); } #endif + + [Fact] + public async Task ThrowIfNull_ValidParameter_ShouldNotReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentNullException.ThrowIfNull(test); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNull_InvalidParameter_ShouldReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentNullException.ThrowIfNull([|Name|]); + } + + public static string Name { get; } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNullOrEmpty_ValidParameter_ShouldNotReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentNullException.ThrowIfNullOrEmpty(test); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNullOrEmpty_InvalidParameter_ShouldReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentNullException.ThrowIfNullOrEmpty([|Name|]); + } + + public static string Name { get; } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNullOrWhiteSpace_ValidParameter_ShouldNotReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentNullException.ThrowIfNullOrWhiteSpace(test); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNullOrWhiteSpace_InvalidParameter_ShouldReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentNullException.ThrowIfNullOrWhiteSpace([|Name|]); + } + + public static string Name { get; } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ArgumentException_ThrowIfNullOrEmpty_ValidParameter_ShouldNotReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentException.ThrowIfNullOrEmpty(test); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ArgumentException_ThrowIfNullOrEmpty_InvalidParameter_ShouldReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentException.ThrowIfNullOrEmpty([|Name|]); + } + + public static string Name { get; } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ArgumentException_ThrowIfNullOrWhiteSpace_ValidParameter_ShouldNotReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentException.ThrowIfNullOrWhiteSpace(test); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ArgumentException_ThrowIfNullOrWhiteSpace_InvalidParameter_ShouldReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentException.ThrowIfNullOrWhiteSpace([|Name|]); + } + + public static string Name { get; } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNull_WithValidParamNameArgument_ShouldNotReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentNullException.ThrowIfNull(test, nameof(test)); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNull_WithInvalidParamNameArgument_ShouldReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentNullException.ThrowIfNull(test, [|"invalid"|]); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ShouldReportDiagnosticWithMessage("'invalid' is not a valid parameter name") + .ValidateAsync(); + } + + [Fact] + public async Task ArgumentException_ThrowIfNullOrEmpty_WithValidParamNameArgument_ShouldNotReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentException.ThrowIfNullOrEmpty(test, nameof(test)); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ArgumentException_ThrowIfNullOrEmpty_WithInvalidParamNameArgument_ShouldReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentException.ThrowIfNullOrEmpty(test, [|"invalid"|]); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ShouldReportDiagnosticWithMessage("'invalid' is not a valid parameter name") + .ValidateAsync(); + } + + [Fact] + public async Task ArgumentOutOfRangeException_ThrowIfNegative_ValidParameter_ShouldNotReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(int value) + { + ArgumentOutOfRangeException.ThrowIfNegative(value); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ArgumentOutOfRangeException_ThrowIfNegative_InvalidParameter_ShouldReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(int value) + { + ArgumentOutOfRangeException.ThrowIfNegative([|Count|]); + } + + public static int Count { get; } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ArgumentOutOfRangeException_ThrowIfNegativeOrZero_ValidParameter_ShouldNotReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(int value) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ArgumentOutOfRangeException_ThrowIfGreaterThan_ValidParameter_ShouldNotReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(int value) + { + ArgumentOutOfRangeException.ThrowIfGreaterThan(value, 100); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ArgumentOutOfRangeException_ThrowIfGreaterThanOrEqual_InvalidParameter_ShouldReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(int value) + { + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual([|MaxValue|], 100); + } + + public static int MaxValue { get; } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNull_WithNullExpression_ShouldReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentNullException.ThrowIfNull([|""|]); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNull_WithNullExpressionAndValidParamName_ShouldNotReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentNullException.ThrowIfNull("", nameof(test)); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNull_WithNullExpressionAndInvalidParamName_ShouldReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentNullException.ThrowIfNull("", [|"invalid"|]); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNull_WithBooleanExpression_ShouldReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentNullException.ThrowIfNull([|0 == 1|]); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ShouldReportDiagnosticWithMessage("The expression does not match a parameter") + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNull_WithBooleanExpressionAndValidParamName_ShouldNotReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentNullException.ThrowIfNull(0 == 1, nameof(test)); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNull_WithBooleanExpressionAndInvalidParamName_ShouldReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentNullException.ThrowIfNull(0 == 1, [|"invalid"|]); + } + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ShouldReportDiagnosticWithMessage("'invalid' is not a valid parameter name") + .ValidateAsync(); + } }