Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix #17: Warn when provided argument name in ArgumentException is incorrect #19

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="ExceptionHandlingRules\ArgumentExceptionUsesStringAnalyzerTests.cs" />
<Compile Include="DebuggerDisplayAnalysis\DebuggerDisplayAttributeAnalyzerTests.cs" />
<Compile Include="ExceptionHandlingRules\ExtractPreconditionInIteratorBlockTests.cs" />
<Compile Include="ExceptionHandlingRules\ExtractPreconditionInAsyncMethodTests.cs" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
using ErrorProne.NET.Common;
using ErrorProne.NET.Rules.ExceptionHandling;
using NUnit.Framework;
using RoslynNunitTestRunner;

namespace ErrorProne.NET.Test.ExceptionHandling
{
[TestFixture]
public class ArgumentExceptionUsesStringAnalyzerTests : CSharpAnalyzerTestFixture<ArgumentExceptionUsesStringAnalyzer> {
[Test]
public void ShouldWarnOnArgumentExceptionHasNoParametersInCtor() {
var code = @"
using System;
class Test
{
public Test(string arg)
{
throw [|new ArgumentException()|];
}
}";
HasDiagnostic(code, RuleIds.ArgumentExceptionParamNameRequired);
}

[Test]
public void ShouldWarnOnArgumentExceptionHasNoParametersInMethod() {
var code = @"
using System;
class Test
{
public Foo(string arg)
{
throw [|new ArgumentException()|];
}
}";
HasDiagnostic(code, RuleIds.ArgumentExceptionParamNameRequired);
}

[Test]
public void ShouldWarnOnArgumentExceptionHasOnlyMessageParameterInCtor() {
var code = @"
using System;
class Test
{
public Test(string arg)
{
throw [|new ArgumentException(""It's an argument exception"")|];
}
}";
HasDiagnostic(code, RuleIds.ArgumentExceptionParamNameRequired);
}

[Test]
public void ShouldWarnOnArgumentExceptionHasOnlyMessageParameterInMethod() {
var code = @"
using System;
class Test
{
public Foo(string arg)
{
throw [|new ArgumentException(""It's an argument exception"")|];
}
}";
HasDiagnostic(code, RuleIds.ArgumentExceptionParamNameRequired);
}

[Test]
public void ShouldWarnOnArgumentExceptionHasWrongParameterNameInCtor() {
var code = @"
using System;
class Test
{
public Test(string arg)
{
throw new ArgumentException(""It's an argument exception"", [|""arg1""|]);
}
}";
HasDiagnostic(code, RuleIds.ArgumentExceptionMethodHasNoSuchParamName);
}

[Test]
public void ShouldWarnOnArgumentExceptionHasWrongParameterNameInMethod() {
var code = @"
using System;
class Test
{
public Foo(string arg)
{
throw new ArgumentException(""It's an argument exception"", [|""arg1""|]);
}
}";
HasDiagnostic(code, RuleIds.ArgumentExceptionMethodHasNoSuchParamName);
}

[Test]
public void ShouldWarnOnArgumentExceptionHasStringParameterNameInCtor() {
var code = @"
using System;
class Test
{
public Test(string arg)
{
throw new ArgumentException(""It's an argument exception"", [|""arg""|]);
}
}";
HasDiagnostic(code, RuleIds.ArgumentExceptionParamNameShouldNotBeString);
}

[Test]
public void ShouldWarnOnArgumentExceptionHasStringParameterNameInMethod() {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think explicit test case and even special message would be highly useful (but maybe not part of this PR) when user uses different order of arguments - throw ArgumentExcpetion("name", "message").

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see at least 4 different cases here:

  • throw new ArgumentException("name", "message") - your case
  • throw new ArgumentException("name", "name") - the name of the argument is used as description (probably valid). Also, it can be `throw new ArgumentException("name", nameof(name))
  • throw new ArgumentException("name1", "name2") - the name of one argument is used as description, the other one is used as parameter (both are valid names)
  • throw new ArgumentException("name", variable) - variable can provide required name

There may be more, but even those add 4 additional rules. Looks more like additional task to me.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely. Out of scope for this task.

var code = @"
using System;
class Test
{
public Foo(string arg)
{
throw new ArgumentException(""It's an argument exception"", [|""arg""|]);
}
}";
HasDiagnostic(code, RuleIds.ArgumentExceptionParamNameShouldNotBeString);
}

[Test]
public void ShouldNotWarnOnArgumentExceptionHasNameofInCtor() {
var code = @"
using System;
class Test
{
public Test(string arg)
{
throw new ArgumentException(""It's an argument exception"", nameof(arg));
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check should be applicable for any exception type that derives from ArgumentException and has argument called paramName.

It means that here we need to cover ArgumentNullException that has paramName in different position and custom exception type.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think implementation is missing other cases, when for paramName was constructed by something else except string literal. I.e. this code is fine due to current implementation: throw new ArgumentEception("foo", GetArgName()). I'm not saying that this is not correct, but just curious what the behaviour should be in this case.

Copy link
Author

@AlexanderSher AlexanderSher May 24, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It means that here we need to cover ArgumentNullException that has paramName in different position and custom exception type.

No, I think there should be separate rules for ArgumentNullException and ArgumentOutOfRangeException - they have different set of constructors. Also, user can define his own class derived from ArgumentException that we can't analyze - unless we will add rules that will enforce him to implement this derived class in a specific way.

I think implementation is missing other cases, when for paramName was constructed by something else except string literal.

Yes, but I don't know what we can do in other cases. For example:

public static void WrongState(string methodName, string argumentName, string state) {
    throw new ArgumentException($"It is not allowed to pass {argumentName} to method {methodName} when object is in {state} state", argumentName);
}

is completely valid helper, yet it doesn't follow our rule

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to generalize the rule and cover derived types. Yes, we can't get exact match for all other cases but we can cover this one: if exception was constructed by the constructor that has an argument with name argumentName and for that paramter string literal was used - emit a warning.

Maybe it is not 100% bullet proof, but it is very likely that it will cover 99.9% of cases.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For later case: I thought just to cover the case when method invocation was used for paramName (or property name or field) because it is very, VERY unlikely that somebody will use methods, fields or properties in order to get argument names. We can leave 'parameter' to exclude the case you've provided.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, DuplicateWaitObjectException has parameterName, InvalidEnumArgumentException has argumentName. Do you want to generalize the rule for all derived types, or just for ArgumentException, ArgumentNullException and ArgumentOutOfRangeException?

Copy link
Author

@AlexanderSher AlexanderSher May 24, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought just to cover the case when method invocation was used for paramName (or property name or field)

One can use a builder or fluent chain of methods to raise exception. That's a bit crazy, but still allowed.

}
}";
NoDiagnostic(code, RuleIds.ArgumentExceptionParamNameRequired);
NoDiagnostic(code, RuleIds.ArgumentExceptionMethodHasNoSuchParamName);
NoDiagnostic(code, RuleIds.ArgumentExceptionParamNameShouldNotBeString);
}

[Test]
public void ShouldNotWarnOnArgumentExceptionHasNameofInMethod() {
var code = @"
using System;
class Test
{
public Foo(string arg)
{
throw new ArgumentException(""It's an argument exception"", nameof(arg));
}
}";
NoDiagnostic(code, RuleIds.ArgumentExceptionParamNameRequired);
NoDiagnostic(code, RuleIds.ArgumentExceptionMethodHasNoSuchParamName);
NoDiagnostic(code, RuleIds.ArgumentExceptionParamNameShouldNotBeString);
}
}
}
6 changes: 5 additions & 1 deletion src/ErrorProne.NET/ErrorProne.NET/Common/RuleIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ namespace ErrorProne.NET.Common
{
public static class RuleIds
{
//
public const string UnobservedPureMethodInvocationId = "ERP001";
public const string SideEffectFreeExceptionContructionId = "ERP002";
public const string AssignmentFreeImmutableObjectContructionId = "ERP003";
Expand Down Expand Up @@ -41,5 +40,10 @@ public static class RuleIds

// Errors in DebuggerDisplayAttribute
public const string DebuggerDisplayAttributeInvalidFormat = "ERP103";

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because those contants has lower value than previous, maybe it would be reasonable to move them up to have them in order?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure

// Argument exception
public const string ArgumentExceptionParamNameRequired = "ERP061";
public const string ArgumentExceptionMethodHasNoSuchParamName = "ERP062";
public const string ArgumentExceptionParamNameShouldNotBeString = "ERP063";
}
}
2 changes: 2 additions & 0 deletions src/ErrorProne.NET/ErrorProne.NET/ErrorProne.NET.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
<CodeAnalysisRuleSet>ErrorProne.NET.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<Compile Include="Rules\ExceptionHandling\ArgumentExceptionUsesStringAnalyzer.cs" />
<Compile Include="Annotations\ImmutableAttribute.cs" />
<Compile Include="Annotations\ReadOnlyAttribute.cs" />
<Compile Include="Annotations\StringFormatMethodAttribute.cs" />
Expand Down Expand Up @@ -110,6 +111,7 @@
<Compile Include="Extensions\PropertyDeclarationExtensions.cs" />
<Compile Include="Extensions\SymbolExtensions.cs" />
<Compile Include="Extensions\SyntaxNodeExtensions.cs" />
<Compile Include="Extensions\SyntaxNodeAnalysisContextExtensions.cs" />
<Compile Include="Extensions\TypeSymbolExtensions.cs" />
<Compile Include="Rules\ExceptionHandling\AsyncMethodPreconditionCodeFixProvider.cs" />
<Compile Include="Rules\Formatting\RegexPatternAnalyzer.cs" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Diagnostics.Contracts;
using System.Text.RegularExpressions;
using ErrorProne.NET.Common;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace ErrorProne.NET.Extensions {
public static class SyntaxNodeAnalysisContextExtensions {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that we're using different 'curly-braces' strategy. To be consistent I would think this code needs to be reformatted.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I'll do it on a different instance of VS

public static INamedTypeSymbol GetClrType<T>(this SyntaxNodeAnalysisContext context) => context.SemanticModel.GetClrType(typeof(T));
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is existing extensions that creates a named type. I would suggest to add this method there.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean use it here or move this method declaration to the different class?


public static ITypeSymbol GetTypeSymbol(this SyntaxNodeAnalysisContext context, TypeSyntax typeSyntax) {
Contract.Requires(typeSyntax != null);
return context.SemanticModel.GetSymbolInfo(typeSyntax).Symbol as ITypeSymbol;
}

public static IMethodSymbol GetCtorSymbol(this SyntaxNodeAnalysisContext context, ObjectCreationExpressionSyntax objectCreationExpressionSyntax) {
Contract.Requires(objectCreationExpressionSyntax != null);
return context.SemanticModel.GetSymbolInfo(objectCreationExpressionSyntax).Symbol as IMethodSymbol;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this shoudl have ensures result is not null and actually cast to IMethodSymbol, because if the symbol is not a method it is a bug. Right?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but I don't know how safe it is to fail with exception inside DiagnosticAnalyzer. If it is safe for VS, I'll switch it to hard cast

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it is a bug, then it is safe:)

Roslyn will catch it and swallow, but in debug builds in the tests you'll see this failure.

}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System;
using System.Collections.Immutable;
using System.Linq;
using ErrorProne.NET.Common;
using ErrorProne.NET.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace ErrorProne.NET.Rules.ExceptionHandling {
/// <summary>
/// Detects `ArgumentException` ctor's that use strings instead of nameof operator for argument names
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class ArgumentExceptionUsesStringAnalyzer : DiagnosticAnalyzer {
private const string ParamNameRequiredTitle = "ArgumentException should provide argument name.";
private const string ParamNameRequiredMessage = "ArgumentException instance misses invalid argument name.";
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Message should take {0} because type of exception could be different.

private const string ParamNameRequiredDescription = "ArgumentException indicates that a method was called with an invalid argument, so it is better to provide argument name for further investigation of exception cause.";

private const string MethodHasNoSuchParamNameRuleTitle = "Declaring method has no argument with specified name.";
private const string MethodHasNoSuchParamNameRuleMessageFormat = "{0} '{1}{2}' has no argument with name '{3}'.";
private const string MethodHasNoSuchParamNameRuleDescription = "ArgumentException indicates that a method on the top of the call stack was called with an invalid argument, so it is confusing if ArgumentException.ParamName provides missing paramater name. Consider using nameof operator to avoid typos and post-refactoring errors.";

private const string ParamNameShouldNotBeStringRuleTitle = "Use nameof instead of string.";
private const string ParamNameShouldNotBeStringRuleMessageFormat = "ArgumentException constructor obtains parameter name from string \"{0}\" instead of nameof({0}).";
private const string ParamNameShouldNotBeStringRuleDescription = "It is better to obtain string name of the paramater by using nameof operator to avoid typos and post-refactoring errors.";

private const string Category = "CodeSmell";

private static readonly DiagnosticDescriptor ParamNameRequiredRule =
new DiagnosticDescriptor(RuleIds.ArgumentExceptionParamNameRequired, ParamNameRequiredTitle, ParamNameRequiredMessage, Category, DiagnosticSeverity.Warning, description: ParamNameRequiredDescription, isEnabledByDefault: true);
private static readonly DiagnosticDescriptor MethodHasNoSuchParamNameRule =
new DiagnosticDescriptor(RuleIds.ArgumentExceptionMethodHasNoSuchParamName, MethodHasNoSuchParamNameRuleTitle, MethodHasNoSuchParamNameRuleMessageFormat, Category, DiagnosticSeverity.Warning, description: MethodHasNoSuchParamNameRuleDescription, isEnabledByDefault: true);
private static readonly DiagnosticDescriptor ParamNameShouldNotBeStringRule =
new DiagnosticDescriptor(RuleIds.ArgumentExceptionParamNameShouldNotBeString, ParamNameShouldNotBeStringRuleTitle, ParamNameShouldNotBeStringRuleMessageFormat, Category, DiagnosticSeverity.Warning, description: ParamNameShouldNotBeStringRuleDescription, isEnabledByDefault: true);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(ParamNameRequiredRule, MethodHasNoSuchParamNameRule, ParamNameShouldNotBeStringRule);

public override void Initialize(AnalysisContext context) {
context.RegisterSyntaxNodeAction(AnalyzeObjectCreation, SyntaxKind.ObjectCreationExpression);
}

private void AnalyzeObjectCreation(SyntaxNodeAnalysisContext context) {
var objectCreation = (ObjectCreationExpressionSyntax)context.Node;
var method = context.GetCtorSymbol(objectCreation);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

constructor?

if (!Equals(method.ContainingType, context.GetClrType<ArgumentException>())) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm... And why not to use method.ContainingType != context.GetClrType?

And as I mentioned before this should be something like:

if (!method.ContainingType.DerivesFromArgumentException())

return;
}

var stringType = context.GetClrType<string>();
var paramNameArgument = method.Parameters.FirstOrDefault(p => p.Name == "paramName");
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this check correct? Because method is a symbol, i.e. it is a definition of the method but not the call-site. I.e. paramNameArgument would be null if exception doesn't have a costructor with argument name 'paramName'. Or I'm missing something something?

if (paramNameArgument == null || !Equals(paramNameArgument.Type, stringType)) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same, imo paramNameArgument.Type == stringType is easier to deal with.

BTW, you can simplify this code even more: paramNameArgument?.Type == stringType

context.ReportDiagnostic(Diagnostic.Create(ParamNameRequiredRule, context.Node.GetLocation()));
return;
}

var argumentIndex = method.Parameters.IndexOf(paramNameArgument);
var argument = objectCreation.ArgumentList.Arguments[argumentIndex];
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I have an extension method for getting literals. So I would prefer to have slightly higher level code here:

var argumentLiteral = argument.Exception.AsLiteral();
if (argumentLiteral == null) {
return; // not a string literal, analyzer doesn't care about other cases
}

var parent = GetEnclosingMethodBase(objectCreation);
Contract.Assert(parent != null, "Object creation node should definitely have a method parent");
// You may remove this check if this assumption is not valid

if (parameters.HasParameterOfName(literal)) {
// first diagnostic
return; // I guess we have some kind of a rule, that method returns when diagnostic was found
}

// this means that method doesn't have parameter that was used in the exception
string methodOrConstructor = method is ConstructorDeclarationSyntax ? "Constructor" : "Method";
context.ReportDiagnostic ...

if (argument.Expression is LiteralExpressionSyntax) {
var literal = argument.Expression.ToString();
literal = literal.Substring(1, literal.Length - 2);

var methodBaseDeclaration = context.Node.EnumerateParents()
.OfType<BaseMethodDeclarationSyntax>()
.FirstOrDefault(d => d is MethodDeclarationSyntax || d is ConstructorDeclarationSyntax);

if (methodBaseDeclaration == null) {
return;
}

var ctorDeclaration = methodBaseDeclaration as ConstructorDeclarationSyntax;

var parameters = methodBaseDeclaration.ParameterList.Parameters;
if (parameters.Any(p => p.Identifier.ValueText.Equals(literal))) {
context.ReportDiagnostic(Diagnostic.Create(ParamNameShouldNotBeStringRule, argument.GetLocation(), literal));
} else if (ctorDeclaration != null) {
context.ReportDiagnostic(Diagnostic.Create(MethodHasNoSuchParamNameRule, argument.GetLocation(),
"Constructor", ctorDeclaration.Identifier.Text, ctorDeclaration.ParameterList.ToString(), literal));
} else {
var methodDeclaration = (MethodDeclarationSyntax)methodBaseDeclaration;
context.ReportDiagnostic(Diagnostic.Create(MethodHasNoSuchParamNameRule, argument.GetLocation(),
"Method", methodDeclaration.Identifier.Text, methodDeclaration.ParameterList.ToString(), literal));
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@ public override void Initialize(AnalysisContext context)
private void AnalyzeRegexCreation(SyntaxNodeAnalysisContext context)
{
var objectCreation = (ObjectCreationExpressionSyntax) context.Node;

var type = context.SemanticModel.GetSymbolInfo(objectCreation.Type).Symbol as ITypeSymbol;
var type = context.GetTypeSymbol(objectCreation.Type);
if (type == null || !type.Equals(context.SemanticModel.GetClrType(typeof(Regex))))
{
return;
Expand Down