-
Notifications
You must be signed in to change notification settings - Fork 45
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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() { | ||
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)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think implementation is missing other cases, when for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
No, I think there should be separate rules for
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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Maybe it is not 100% bullet proof, but it is very likely that it will cover 99.9% of cases. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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"; | ||
|
@@ -41,5 +40,10 @@ public static class RuleIds | |
|
||
// Errors in DebuggerDisplayAttribute | ||
public const string DebuggerDisplayAttributeInvalidFormat = "ERP103"; | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"; | ||
} | ||
} |
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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."; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Message should take |
||
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. constructor? |
||
if (!Equals(method.ContainingType, context.GetClrType<ArgumentException>())) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hm... And why not to use 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"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The same, imo BTW, you can simplify this code even more: |
||
context.ReportDiagnostic(Diagnostic.Create(ParamNameRequiredRule, context.Node.GetLocation())); | ||
return; | ||
} | ||
|
||
var argumentIndex = method.Parameters.IndexOf(paramNameArgument); | ||
var argument = objectCreation.ArgumentList.Arguments[argumentIndex]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); var parent = GetEnclosingMethodBase(objectCreation); if (parameters.HasParameterOfName(literal)) { // this means that method doesn't have parameter that was used in the exception |
||
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)); | ||
} | ||
} | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
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")
.There was a problem hiding this comment.
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 casethrow 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 nameThere may be more, but even those add 4 additional rules. Looks more like additional task to me.
There was a problem hiding this comment.
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.