-
Notifications
You must be signed in to change notification settings - Fork 469
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
Recommend using Equals with StringComparison instead of string.ToLower() == otherString.ToLower() #6720
Conversation
This comment was marked as outdated.
This comment was marked as outdated.
!caseChangingMethod.Equals(toLowerInvariantParameterlessMethod) && | ||
!caseChangingMethod.Equals(toUpperParameterlessMethod) && | ||
!caseChangingMethod.Equals(toUpperInvariantParameterlessMethod)) | ||
if (context.Operation is IInvocationOperation caseChangingInvocation) |
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.
Nit: Alternatively, you can just register separate operation callbacks for each operation kind - up to you.
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.
Can you show me an example? I was initially assuming this was possible but I couldn't get it to work.
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.
context.RegisterOpationAction(context =>
{
var caseChangingInvocation = (IInvocationOperation)context.Operation;
AnalyzeInvocation(context, caseChangingInvocation, stringType,
containsStringMethod, startsWithStringMethod, compareToStringMethod,
indexOfStringMethod, indexOfStringInt32Method, indexOfStringInt32Int32Method);
}, OperationKind.Invocation);
context.RegisterOpationAction(context =>
{
var binaryOperation = (IBinaryOperation)context.Operation;
AnalyzeBinaryOperation(context, binaryOperation, stringType);
}, OperationKind.Binary);
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.
Thanks! I was doing something like that, but don't remember exactly what I was doing differently, it just wouldn't work. Let me test it again.
@@ -16,6 +16,16 @@ public abstract class RecommendCaseInsensitiveStringComparison_Base_Tests | |||
|
|||
private static readonly string[] ContainsStartsWith = new[] { "Contains", "StartsWith" }; | |||
private static readonly string[] UnnamedArgs = new[] { "", ", 1", ", 1, 1" }; | |||
|
|||
private static readonly Tuple<string, string>[] CSharpComparisonOperators = new[] { |
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.
Should we use ValueTuple instead?
|
||
private static readonly Tuple<string, string>[] CSharpComparisonOperators = new[] { | ||
Tuple.Create("==", ""), | ||
Tuple.Create("!=", "!") |
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.
Shouldn't it cover a.ToLower().Equals(b.ToLower())
, !a.ToLower().Equals(b.ToLower())
, ... scenarios too?
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.
Maybe. It was not contemplated in the approved API proposal. I can ask in the main issue to determine if we want that case handled too. If that's the case, I would fix it in a separate PR.
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.
Maybe. It was not contemplated in the approved API proposal. I can ask in the main issue to determine if we want that case handled too
string ==
operator just same as string.Equals and !=
same as !string.Equals, in fact they actually calls string.Equals inside. https://github.com/dotnet/runtime/blob/0fce03e6bb0251f6e5d8abacd97d90f0a1a200f9/src/libraries/System.Private.CoreLib/src/System/String.Comparison.cs#L744-L746
I don't think its have to specifically mentioned in the API proposal
diagnosableMethod.Equals(startsWithStringMethod) || | ||
diagnosableMethod.Equals(indexOfStringMethod) || | ||
diagnosableMethod.Equals(indexOfStringInt32Method) || | ||
diagnosableMethod.Equals(indexOfStringInt32Int32Method)) |
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.
It seems IMethodSymbol containsStringMethod, IMethodSymbol startsWithStringMethod, IMethodSymbol indexOfStringMethod, IMethodSymbol indexOfStringInt32Method, IMethodSymbol indexOfStringInt32Int32Method
all these IMethodSymbols created and used similarly, could we add them into a ImmutableDictionary<IMethodSymbol>
and check if it contains the diagnosableMethod?
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.
Can you show me how to pass an ImmutableDictionary<IMethodSymbol>
? I am not sure if any of the available CreateDiagnostic
method overloads would let me do such thing.
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.
No, sorry I meant ImmutableArray
not ImmutableDictionary
, I meant populate them once and add them into a ImmutableArray<IMethodSymbol> list
and instead of these multiple ORs check if (list.Contains(diagnosableMethod))
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 we can save some space by not allocating a new ImmutableList.
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.
Even though it would be faster for lookup, and it will be cleaner to use without passing so many parameters. Though my concern was more about that all symbols are loaded one by one using Linq, they could have populated with one or 2 loops, even without adding them into a ImmutableArray
.
Lines 83 to 204 in 4e17f73
if (!context.Compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemStringComparison, out INamedTypeSymbol? stringComparisonType)) | |
{ | |
return; | |
} | |
if (!context.Compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemStringComparer, out INamedTypeSymbol? stringComparerType)) | |
{ | |
return; | |
} | |
// Retrieve the offending parameterless methods: ToLower, ToLowerInvariant, ToUpper, ToUpperInvariant | |
IMethodSymbol? toLowerParameterlessMethod = stringType.GetMembers(StringToLowerMethodName).OfType<IMethodSymbol>().GetFirstOrDefaultMemberWithParameterInfos(); | |
if (toLowerParameterlessMethod == null) | |
{ | |
return; | |
} | |
IMethodSymbol? toLowerInvariantParameterlessMethod = stringType.GetMembers(StringToLowerInvariantMethodName).OfType<IMethodSymbol>().GetFirstOrDefaultMemberWithParameterInfos(); | |
if (toLowerInvariantParameterlessMethod == null) | |
{ | |
return; | |
} | |
IMethodSymbol? toUpperParameterlessMethod = stringType.GetMembers(StringToUpperMethodName).OfType<IMethodSymbol>().GetFirstOrDefaultMemberWithParameterInfos(); | |
if (toUpperParameterlessMethod == null) | |
{ | |
return; | |
} | |
IMethodSymbol? toUpperInvariantParameterlessMethod = stringType.GetMembers(StringToUpperInvariantMethodName).OfType<IMethodSymbol>().GetFirstOrDefaultMemberWithParameterInfos(); | |
if (toUpperInvariantParameterlessMethod == null) | |
{ | |
return; | |
} | |
// Create the different expected parameter combinations | |
ParameterInfo[] stringParameter = new[] | |
{ | |
ParameterInfo.GetParameterInfo(stringType) | |
}; | |
// Equals(string) | |
IMethodSymbol? stringEqualsStringMethod = stringType.GetMembers(StringEqualsMethodName).OfType<IMethodSymbol>().GetFirstOrDefaultMemberWithParameterInfos(stringParameter); | |
if (stringEqualsStringMethod == null) | |
{ | |
return; | |
} | |
// Retrieve the diagnosable string overload methods: Contains, IndexOf (3 overloads), StartsWith, CompareTo | |
// Contains(string) | |
IMethodSymbol? containsStringMethod = stringType.GetMembers(StringContainsMethodName).OfType<IMethodSymbol>().GetFirstOrDefaultMemberWithParameterInfos(stringParameter); | |
if (containsStringMethod == null) | |
{ | |
return; | |
} | |
// StartsWith(string) | |
IMethodSymbol? startsWithStringMethod = stringType.GetMembers(StringStartsWithMethodName).OfType<IMethodSymbol>().GetFirstOrDefaultMemberWithParameterInfos(stringParameter); | |
if (startsWithStringMethod == null) | |
{ | |
return; | |
} | |
IEnumerable<IMethodSymbol> indexOfMethods = stringType.GetMembers(StringIndexOfMethodName).OfType<IMethodSymbol>(); | |
// IndexOf(string) | |
IMethodSymbol? indexOfStringMethod = indexOfMethods.GetFirstOrDefaultMemberWithParameterInfos(stringParameter); | |
if (indexOfStringMethod == null) | |
{ | |
return; | |
} | |
ParameterInfo[] stringInt32Parameters = new[] | |
{ | |
ParameterInfo.GetParameterInfo(stringType), | |
ParameterInfo.GetParameterInfo(int32Type) | |
}; | |
// IndexOf(string, int startIndex) | |
IMethodSymbol? indexOfStringInt32Method = indexOfMethods.GetFirstOrDefaultMemberWithParameterInfos(stringInt32Parameters); | |
if (indexOfStringInt32Method == null) | |
{ | |
return; | |
} | |
ParameterInfo[] stringInt32Int32Parameters = new[] | |
{ | |
ParameterInfo.GetParameterInfo(stringType), | |
ParameterInfo.GetParameterInfo(int32Type), | |
ParameterInfo.GetParameterInfo(int32Type) | |
}; | |
// IndexOf(string, int startIndex, int count) | |
IMethodSymbol? indexOfStringInt32Int32Method = indexOfMethods.GetFirstOrDefaultMemberWithParameterInfos(stringInt32Int32Parameters); | |
if (indexOfStringInt32Int32Method == null) | |
{ | |
return; | |
} | |
// CompareTo(string) | |
IMethodSymbol? compareToStringMethod = stringType.GetMembers(StringCompareToMethodName).OfType<IMethodSymbol>().GetFirstOrDefaultMemberWithParameterInfos(stringParameter); | |
if (compareToStringMethod == null) | |
{ | |
return; | |
} | |
// Retrieve the StringComparer properties that need to be flagged: CurrentCultureIgnoreCase, InvariantCultureIgnoreCase | |
IEnumerable<IPropertySymbol> ccicPropertyGroup = stringComparerType.GetMembers(StringComparisonCurrentCultureIgnoreCaseName).OfType<IPropertySymbol>(); | |
if (!ccicPropertyGroup.Any()) | |
{ | |
return; | |
} | |
IEnumerable<IPropertySymbol> icicPropertyGroup = stringComparerType.GetMembers(StringComparisonInvariantCultureIgnoreCaseName).OfType<IPropertySymbol>(); | |
if (!icicPropertyGroup.Any()) | |
{ | |
return; | |
} |
But as that logic was not added with this PR so its not have to be handled in this PR
if (!caseChangingMethod.Equals(toLowerParameterlessMethod) && | ||
!caseChangingMethod.Equals(toLowerInvariantParameterlessMethod) && | ||
!caseChangingMethod.Equals(toUpperParameterlessMethod) && | ||
!caseChangingMethod.Equals(toUpperInvariantParameterlessMethod)) |
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.
It seems these toLowerParameterlessMethod, toLowerInvariantParameterlessMethod, toUpperParameterlessMethod, toUpperInvariantParameterlessMethod
symbols not used anymore, could we remove the rows populating them?
Lines 95 to 117 in 79db421
IMethodSymbol? toLowerParameterlessMethod = stringType.GetMembers(StringToLowerMethodName).OfType<IMethodSymbol>().GetFirstOrDefaultMemberWithParameterInfos(); | |
if (toLowerParameterlessMethod == null) | |
{ | |
return; | |
} | |
IMethodSymbol? toLowerInvariantParameterlessMethod = stringType.GetMembers(StringToLowerInvariantMethodName).OfType<IMethodSymbol>().GetFirstOrDefaultMemberWithParameterInfos(); | |
if (toLowerInvariantParameterlessMethod == null) | |
{ | |
return; | |
} | |
IMethodSymbol? toUpperParameterlessMethod = stringType.GetMembers(StringToUpperMethodName).OfType<IMethodSymbol>().GetFirstOrDefaultMemberWithParameterInfos(); | |
if (toUpperParameterlessMethod == null) | |
{ | |
return; | |
} | |
IMethodSymbol? toUpperInvariantParameterlessMethod = stringType.GetMembers(StringToUpperInvariantMethodName).OfType<IMethodSymbol>().GetFirstOrDefaultMemberWithParameterInfos(); | |
if (toUpperInvariantParameterlessMethod == null) | |
{ | |
return; | |
} |
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.
Don't we need to exit early if we don't find the symbol?
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.
That was actually part of the question, these symbols are not used anywhere, should we keep loading them and exit early? Or they required to be existed to proceed further analysis?
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.
They are used here to exit early if the symbols aren't found.
ImmutableDictionary<string, string?> dict = new Dictionary<string, string?>() | ||
{ | ||
{ CaseChangingApproachName, caseChangingApproach } | ||
}.ToImmutableDictionary(); |
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.
NIT:
ImmutableDictionary<string, string?> dict = new Dictionary<string, string?>() | |
{ | |
{ CaseChangingApproachName, caseChangingApproach } | |
}.ToImmutableDictionary(); | |
ImmutableDictionary<string, string> dict = ImmutableDictionary.CreateRange( | |
new Dictionary<string, string> { { CaseChangingApproachName, caseChangingApproach } }); |
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.
Is that actually better? I am still allocating a dictionary with the suggestion, so I'm not sure if there's an improvement.
BTW the second typeparam should be a string?
, otherwise it complains.
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'm not sure if there's an improvement.
I would expect it to be better, though do not know for sure, that is why I put NIT
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.
Thanks for your reviews, @buyaa-n, @Youssef1313 and @mavasani, and I appreciate the patience, I had to address some higher priority tasks before I could get to check your feedback.
I addressed most of the feedback and left questions for some of the rest. Can you please take another look?
Edit: CI was failing due to some recent nullability changes in the main branch. I rebased and fixed the issues, then force pushed.
bd55933
to
4e17f73
Compare
private static readonly ValueTuple<string, string>[] Cultures = new[] { | ||
ValueTuple.Create("ToLower", "CurrentCultureIgnoreCase"), | ||
ValueTuple.Create("ToUpper", "CurrentCultureIgnoreCase"), | ||
ValueTuple.Create("ToLowerInvariant", "InvariantCultureIgnoreCase"), | ||
ValueTuple.Create("ToUpperInvariant", "InvariantCultureIgnoreCase") |
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.
private static readonly ValueTuple<string, string>[] Cultures = new[] { | |
ValueTuple.Create("ToLower", "CurrentCultureIgnoreCase"), | |
ValueTuple.Create("ToUpper", "CurrentCultureIgnoreCase"), | |
ValueTuple.Create("ToLowerInvariant", "InvariantCultureIgnoreCase"), | |
ValueTuple.Create("ToUpperInvariant", "InvariantCultureIgnoreCase") | |
private static readonly (string, string)[] Cultures = new[] { | |
("ToLower", "CurrentCultureIgnoreCase"), | |
("ToUpper", "CurrentCultureIgnoreCase"), | |
("ToLowerInvariant", "InvariantCultureIgnoreCase"), | |
("ToUpperInvariant", "InvariantCultureIgnoreCase") |
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 like it. But it makes me sad that we don't have that analyzer enabled in this repo, I didn't get the automatic suggestion.
private static readonly ValueTuple<string, string>[] CSharpComparisonOperators = new[] { | ||
ValueTuple.Create("==", ""), | ||
ValueTuple.Create("!=", "!") | ||
}; | ||
private static readonly ValueTuple<string, string>[] VisualBasicComparisonOperators = new[] { | ||
ValueTuple.Create("=", ""), | ||
ValueTuple.Create("<>", "Not ") | ||
}; |
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.
private static readonly ValueTuple<string, string>[] CSharpComparisonOperators = new[] { | |
ValueTuple.Create("==", ""), | |
ValueTuple.Create("!=", "!") | |
}; | |
private static readonly ValueTuple<string, string>[] VisualBasicComparisonOperators = new[] { | |
ValueTuple.Create("=", ""), | |
ValueTuple.Create("<>", "Not ") | |
}; | |
private static readonly (string, string)[] CSharpComparisonOperators = new[] { | |
("==", ""), | |
("!=", "!") | |
}; | |
private static readonly (string, string)[] VisualBasicComparisonOperators = new[] { | |
("=", ""), | |
("<>", "Not ") | |
}; |
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.
Looks good with some comments:
-
Fixes [Analyzer] string.ToLower() == otherString.ToLower() runtime#78607
The PR doesn't add a new diagnostic nor new messaging, just want to make sure that the
existing messaging and ID could apply as is for [Analyzer] string.ToLower() == otherString.ToLower() runtime#78607 -
Please file an issue if you plan to cover
a.ToLower().Equals(b.ToLower()), !a.ToLower().Equals(b.ToLower())
in tests later
I think defaulting to one of the culture-ignore-case options is unsafe, and would suggest you at least consider I refer you to:- dotnet/runtime#78442 (comment) |
You're right, @buyaa-n. Maybe I should add a new, more appropriate message specifically for the cases handled in this PR. Let me send a new commit. |
</data> | ||
<data name="RecommendCaseInsensitiveStringEqualsTitle" xml:space="preserve"> | ||
<value>Prefer using 'string.Equals(string, StringComparison)' to perform case-insensitive string comparisons</value> | ||
</data> |
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.
Seems this is using the same ID as the existing string comparison analyzers, this could cause an issue or even if it is not cause an issue I believe only one of RecommendCaseInsensitiveStringEqualsTitle
and RecommendCaseInsensitiveStringComparisonTitle
will be used. It looks you can just use the same title RecommendCaseInsensitiveStringComparisonTitle
for all.
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.
Fixed
@@ -8,7 +8,7 @@ CA1512 | <https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality- | |||
CA1513 | <https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1513> | Use ObjectDisposedException throw helper | | |||
CA1856 | <https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1856> | Incorrect usage of ConstantExpected attribute | | |||
CA1857 | <https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1857> | A constant is expected for the parameter | | |||
CA1862 | <https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1862> | Prefer using 'StringComparer' to perform case-insensitive string comparisons | | |||
CA1862 | <https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1862> | Prefer using 'string.Equals(string, StringComparison)' to perform case-insensitive string comparisons | |
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 the old title suit for all diagnostics
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.
Actually, neither this change nor the previous text were appropriate enough for all 3 different cases. The correct message that applies to all 3 is:
Prefer using 'StringComparison' method overloads to perform case-insensitive string comparisons
.
…r() == otherString.ToLower() (dotnet#6720) * Analyzer: Use string.Format with CultureInfo for title. * Almost, only needs to handle inequality * Fix inequality case, fix trivia, still need to add C# test * Add C# tests for trivia and string method * Add tests for diagnostic-only, no fix, for Equals that don't match variant/invariant. * Use ValueTuple in tests * Register operation actions separately. * Fix recent nullability changes * Use simplified valuetuple creation in tests * Add extra rule and resource messages for the 'string.Method() == string.Method()' comparison case. * msbuild /t:pack * Address suggestion
@carlossanlop - did you take my comments
into consideration? It's all very well to say "Recommend using Equals with StringComparison instead of string.ToLower() == otherString.ToLower()", but which Unless this issue and also dotnet/runtime#78606 consider and prominently expose the The trouble with this table is that the right-hand column is a literal, or rote, translation of the left-hand column. This translation, however, ignores a very important option: In over twenty years of C# and .NET I have never had to compare "linguistic" strings wrt case-insensitivity. And I haven't code-reviewed any code that does so. On the other hand, comparing the following strings wrt case-insensitivity:
is something I (and others in the code I've reviewed) do 20 times a day, every day. And as the Microsoft documentation makes clear that for these cases, we should be using I would ask you to comment on the above before closing this issue. Also: I would like you to re-consider the implementation of dotnet/runtime#78606 in light of the above. As it stands, I will be avoiding these two analyzers and will be warning my team to do the same. I believe that the analyzers currently stand at odds with the established and correct Microsoft documentation here. This is predicated on my belief that the vast majority of case-insensitive string comparisons are carried out on non-linguistic strings (e.g. identifiers). |
@carlossanlop @MichalStrehovsky I'm not saying (above) that people would never want to compare "linguistic" / "cultural" strings in a case-insensitive manner. It might happen, and it's good that those options exist in And what I'm saying most emphatically is the fact that neither analyzer seems to consider the As I said over here, the fact that the GH issue dotnet/runtime#78606 doesn't mention the word "ordinal" at all is a serious omission. |
Fixes dotnet/runtime#78607
Follow up of dotnet/runtime#78606
==
/=
and!=
/<>
(C#/VB)