diff --git a/docs/Rules/MA0015.md b/docs/Rules/MA0015.md index f820bf7a5..b595da623 100644 --- a/docs/Rules/MA0015.md +++ b/docs/Rules/MA0015.md @@ -34,3 +34,20 @@ class Sample } } ```` + +## Configuration + +```editorconfig +# Allow member access expressions (e.g. request.Definition) where the root is a parameter +MA0015.consider_member_access_as_parameter = true | false (default: false) +``` + +When `consider_member_access_as_parameter` is set to `true`, member access expressions such as `request.Definition` are considered valid when the root of the expression (`request`) is a parameter. This also applies to string parameter names like `"request.Definition"`. + +````c# +// With consider_member_access_as_parameter = true: +void Test(Request request) +{ + ArgumentNullException.ThrowIfNull(request.Definition); // ok: 'request' is a parameter +} +```` diff --git a/src/Meziantou.Analyzer/Rules/ArgumentExceptionShouldSpecifyArgumentNameAnalyzer.cs b/src/Meziantou.Analyzer/Rules/ArgumentExceptionShouldSpecifyArgumentNameAnalyzer.cs index 58a240529..15e91c949 100644 --- a/src/Meziantou.Analyzer/Rules/ArgumentExceptionShouldSpecifyArgumentNameAnalyzer.cs +++ b/src/Meziantou.Analyzer/Rules/ArgumentExceptionShouldSpecifyArgumentNameAnalyzer.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using Meziantou.Analyzer.Configurations; using Meziantou.Analyzer.Internals; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -101,6 +102,14 @@ private static void AnalyzeObjectCreation(OperationAnalysisContext context) return; } + var considerMemberAccessAsParameter = ConsiderMemberAccessAsParameter(context, argument.Value); + if (considerMemberAccessAsParameter) + { + var dotIndex = value.IndexOf('.', StringComparison.Ordinal); + if (dotIndex > 0 && parameterNames.Contains(value[..dotIndex], StringComparer.Ordinal)) + return; + } + if (argument.Syntax is ArgumentSyntax argumentSyntax) { context.ReportDiagnostic(Rule, argumentSyntax.Expression, $"'{value}' is not a valid parameter name"); @@ -196,6 +205,9 @@ private static void AnalyzeInvocation(OperationAnalysisContext context, INamedTy } } + private static bool ConsiderMemberAccessAsParameter(OperationAnalysisContext context, IOperation operation) + => context.Options.GetConfigurationValue(operation, RuleIdentifiers.ArgumentExceptionShouldSpecifyArgumentName + ".consider_member_access_as_parameter", defaultValue: false); + private static void ValidateParamNameArgument(OperationAnalysisContext context, IArgumentOperation paramNameArgument) { // Check if the argument is a constant string value @@ -214,6 +226,14 @@ private static void ValidateParamNameArgument(OperationAnalysisContext context, return; } + var considerMemberAccessAsParameter = ConsiderMemberAccessAsParameter(context, paramNameArgument.Value); + if (considerMemberAccessAsParameter) + { + var dotIndex = paramNameValue.IndexOf('.', StringComparison.Ordinal); + if (dotIndex > 0 && availableParameterNames.Contains(paramNameValue[..dotIndex], StringComparer.Ordinal)) + return; + } + context.ReportDiagnostic(Rule, paramNameArgument, $"'{paramNameValue}' is not a valid parameter name"); } @@ -229,9 +249,29 @@ private static void ValidateExpression(OperationAnalysisContext context, IArgume return; } + var considerMemberAccessAsParameter = ConsiderMemberAccessAsParameter(context, argument.Value); + if (considerMemberAccessAsParameter && IsRootParameterReference(unwrappedValue)) + return; + context.ReportDiagnostic(Rule, argument, "The expression does not match a parameter"); } + private static bool IsRootParameterReference(IOperation operation) + { + var current = operation; + while (current is IMemberReferenceOperation memberRef) + { + // A null instance means this is a static member access (no receiver), + // which cannot be rooted in a parameter reference. + if (memberRef.Instance is null) + return false; + + current = memberRef.Instance.UnwrapImplicitConversionOperations(); + } + + return current is IParameterReferenceOperation; + } + 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 44db5cf88..ae1d9c573 100755 --- a/tests/Meziantou.Analyzer.Test/Rules/ArgumentExceptionShouldSpecifyArgumentNameAnalyzerTests.cs +++ b/tests/Meziantou.Analyzer.Test/Rules/ArgumentExceptionShouldSpecifyArgumentNameAnalyzerTests.cs @@ -957,4 +957,173 @@ await CreateProjectBuilder() .ShouldReportDiagnosticWithMessage("'invalid' is not a valid parameter name") .ValidateAsync(); } + + [Fact] + public async Task ThrowIfNull_MemberAccess_OptionDisabled_ShouldReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(Request request) + { + ArgumentNullException.ThrowIfNull([|request.Definition|]); + } + } + class Request { public string? Definition { get; set; } } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNull_MemberAccess_OptionEnabled_ShouldNotReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(Request request) + { + ArgumentNullException.ThrowIfNull(request.Definition); + } + } + class Request { public string? Definition { get; set; } } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .AddAnalyzerConfiguration("MA0015.consider_member_access_as_parameter", "true") + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNull_DeepMemberAccess_OptionEnabled_ShouldNotReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(Request request) + { + ArgumentNullException.ThrowIfNull(request.Inner.Definition); + } + } + class Inner { public string? Definition { get; set; } } + class Request { public Inner? Inner { get; set; } } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .AddAnalyzerConfiguration("MA0015.consider_member_access_as_parameter", "true") + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNull_MemberAccess_NonParameterRoot_OptionEnabled_ShouldReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(string test) + { + ArgumentNullException.ThrowIfNull([|Name.Length|]); + } + + public static string Name { get; } = ""; + } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .AddAnalyzerConfiguration("MA0015.consider_member_access_as_parameter", "true") + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNull_ExplicitDottedParamName_OptionEnabled_ShouldNotReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(Request request) + { + ArgumentNullException.ThrowIfNull(request.Definition, "request.Definition"); + } + } + class Request { public string? Definition { get; set; } } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .AddAnalyzerConfiguration("MA0015.consider_member_access_as_parameter", "true") + .ValidateAsync(); + } + + [Fact] + public async Task ThrowIfNull_ExplicitDottedParamName_OptionDisabled_ShouldReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(Request request) + { + ArgumentNullException.ThrowIfNull(request.Definition, [|"request.Definition"|]); + } + } + class Request { public string? Definition { get; set; } } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } + + [Fact] + public async Task ArgumentNullException_Constructor_DottedParamName_OptionEnabled_ShouldNotReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(Request request) + { + if (request.Definition is null) + throw new ArgumentNullException("request.Definition"); + } + } + class Request { public string? Definition { get; set; } } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .AddAnalyzerConfiguration("MA0015.consider_member_access_as_parameter", "true") + .ValidateAsync(); + } + + [Fact] + public async Task ArgumentNullException_Constructor_DottedParamName_OptionDisabled_ShouldReportError() + { + var sourceCode = """ + using System; + class Sample + { + void Test(Request request) + { + if (request.Definition is null) + throw new ArgumentNullException([|"request.Definition"|]); + } + } + class Request { public string? Definition { get; set; } } + """; + + await CreateProjectBuilder() + .WithSourceCode(sourceCode) + .ValidateAsync(); + } }