Skip to content

Commit

Permalink
Analyzer and fixer that recommend case insensitive string comparison (#…
Browse files Browse the repository at this point in the history
…6662)

* Resources and documentation

* Analyzer

* Fixer

* Base test class

* C# tests

* VisualBasic tests

* Address suggestion: Inline localizable strings that are used only once.

* Address suggestion: Prefer GetSpecialType for string.

* Use explicit.

* Do not pass argument to diagnostic that does not need it.

* Address suggestion: Use Length instead of Count()

* Address suggestion: Improve name of caseChangingMethodName

* Address suggestion: Remove token cancellation handling code

* Address suggestion: Remove unused cancellation token argument.

* Address suggestion: Add missing fixer attribute, seal class

* Analyzer: Remove unused using.

* Address suggestion: Use better test pattern. Simplify tests.

* More tests

* Address suggestion: Await tests.

* Fix spacing warning

* Handle all 3 IndexOf overloads that can be converted to the StringComparison overload.

* Address suggestion: Walk up parenthesized operations

* Add "No diagnostic" tests

* Do not fix CompareTo, only suggest.

* Remove unused CompareTo fixer code and fix some warnings.

* Add missing entry for second variant of diagnostic rule message in "RulesMissingDocumentation.md".

* Revert md and sarif changes to fix CI

* Address suggestion: Use WalkUpParentheses

* Add short string for the codefix CodeAction title

* Consume codefix CodeAction short string in title

* Fix CI

* Remove some unnecessary fixer code

* Use better equivalenceKey

* Diagnose inverted pattern

* Tests for named arguments

* Change resources to be able to address named parameters

* src: Address named parameters

* tests: Adjust tests to verify named parameters

* Move ExportCodeFixProvider and Shared attributes to derived types.

* public sealed fixers

* Improved resource description mentioning allocation. Use single quotes, minor spacing.

* Remove commented code

* Update xlfs

* Remove commented code

* Rename to GetNewArguments

* Fix build
  • Loading branch information
carlossanlop authored Jun 20, 2023
1 parent ebd3126 commit 126e31e
Show file tree
Hide file tree
Showing 27 changed files with 11,612 additions and 9,526 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information.

using System.Collections.Generic;
using System.Composition;
using System.Diagnostics;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Editing;
using Microsoft.CodeAnalysis.Operations;
using Microsoft.NetCore.Analyzers.Performance;

namespace Microsoft.NetCore.CSharp.Analyzers.Performance
{
using RCISCAnalyzer = RecommendCaseInsensitiveStringComparisonAnalyzer;

[ExportCodeFixProvider(LanguageNames.CSharp), Shared]
public sealed class CSharpRecommendCaseInsensitiveStringComparisonFixer : RecommendCaseInsensitiveStringComparisonFixer
{
protected override List<SyntaxNode> GetNewArguments(SyntaxGenerator generator, IInvocationOperation mainInvocationOperation,
INamedTypeSymbol stringComparisonType, out SyntaxNode? mainInvocationInstance)
{
List<SyntaxNode> arguments = new();
bool isAnyArgumentNamed = false;

InvocationExpressionSyntax invocationExpression = (InvocationExpressionSyntax)mainInvocationOperation.Syntax;

string? caseChangingApproachName = null;
bool isChangingCaseInArgument = false;

mainInvocationInstance = null;

if (invocationExpression.Expression is MemberAccessExpressionSyntax memberAccessExpression)
{
var internalExpression = memberAccessExpression.Expression is ParenthesizedExpressionSyntax parenthesizedExpression ?
parenthesizedExpression.Expression :
memberAccessExpression.Expression;

if (internalExpression is InvocationExpressionSyntax internalInvocationExpression &&
internalInvocationExpression.Expression is MemberAccessExpressionSyntax internalMemberAccessExpression)
{
mainInvocationInstance = internalMemberAccessExpression.Expression;
caseChangingApproachName = GetCaseChangingApproach(internalMemberAccessExpression.Name.Identifier.ValueText);
}
else
{
mainInvocationInstance = memberAccessExpression.Expression;
isChangingCaseInArgument = true;
}
}

foreach (ArgumentSyntax node in invocationExpression.ArgumentList.Arguments)
{
string? argumentName = node.NameColon?.Name.Identifier.ValueText;
isAnyArgumentNamed |= argumentName != null;

ExpressionSyntax argumentExpression = node.Expression is ParenthesizedExpressionSyntax argumentParenthesizedExpression ?
argumentParenthesizedExpression.Expression :
node.Expression;

MemberAccessExpressionSyntax? argumentMemberAccessExpression = null;
if (argumentExpression is InvocationExpressionSyntax argumentInvocationExpression &&
(argumentMemberAccessExpression = argumentInvocationExpression.Expression as MemberAccessExpressionSyntax) != null)
{
caseChangingApproachName = GetCaseChangingApproach(argumentMemberAccessExpression.Name.Identifier.ValueText);
}

SyntaxNode newArgumentNode;
if (isChangingCaseInArgument)
{
if (argumentMemberAccessExpression != null)
{
newArgumentNode = argumentName == RCISCAnalyzer.StringParameterName ?
generator.Argument(RCISCAnalyzer.StringParameterName, RefKind.None, argumentMemberAccessExpression.Expression) :
generator.Argument(argumentMemberAccessExpression.Expression);
}
else
{
newArgumentNode = node;
}
}
else
{
newArgumentNode = node;
}

arguments.Add(newArgumentNode);
}

Debug.Assert(caseChangingApproachName != null);
Debug.Assert(mainInvocationInstance != null);

SyntaxNode stringComparisonArgument = GetNewStringComparisonArgument(generator,
stringComparisonType, caseChangingApproachName!, isAnyArgumentNamed);

arguments.Add(stringComparisonArgument);

return arguments;
}
}
}
1 change: 1 addition & 0 deletions src/NetAnalyzers/Core/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ CA1858 | Performance | Info | UseStartsWithInsteadOfIndexOfComparisonWithZero, [
CA1859 | Performance | Info | UseConcreteTypeAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1859)
CA1860 | Performance | Info | PreferLengthCountIsEmptyOverAnyAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1860)
CA1861 | Performance | Info | AvoidConstArrays, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1861)
CA1862 | Performance | Info | RecommendCaseInsensitiveStringComparison, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1862)
CA2021 | Reliability | Warning | DoNotCallEnumerableCastOrOfTypeWithIncompatibleTypesAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2021)

### Removed Rules
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2054,4 +2054,25 @@ Widening and user defined conversions are not supported with generic types.</val
<data name="PreferIsEmptyOverAnyMessage" xml:space="preserve">
<value>Prefer an 'IsEmpty' check rather than using 'Any()', both for clarity and for performance</value>
</data>
<data name="RecommendCaseInsensitiveStringComparerStringComparisonCodeFixTitle" xml:space="preserve">
<value>Use the 'string.{0}(string, StringComparison)' overload</value>
</data>
<data name="RecommendCaseInsensitiveStringComparerTitle" xml:space="preserve">
<value>Prefer using 'StringComparer' to perform case-insensitive string comparisons</value>
</data>
<data name="RecommendCaseInsensitiveStringComparerDescription" xml:space="preserve">
<value>Avoid calling 'ToLower', 'ToUpper', 'ToLowerInvariant' and 'ToUpperInvariant' to perform case-insensitive string comparisons when using 'CompareTo', because they lead to an allocation. Instead, use 'StringComparer' to perform case-insensitive comparisons.</value>
</data>
<data name="RecommendCaseInsensitiveStringComparerMessage" xml:space="preserve">
<value>Prefer using 'StringComparer' to perform a case-insensitive comparison</value>
</data>
<data name="RecommendCaseInsensitiveStringComparisonTitle" xml:space="preserve">
<value>Prefer the 'StringComparison' method overloads to perform case-insensitive string comparisons</value>
</data>
<data name="RecommendCaseInsensitiveStringComparisonDescription" xml:space="preserve">
<value>Avoid calling 'ToLower', 'ToUpper', 'ToLowerInvariant' and 'ToUpperInvariant' to perform case-insensitive string comparisons because they lead to an allocation. Instead, prefer calling the method overloads of 'Contains', 'IndexOf' and 'StartsWith' that take a 'StringComparison' enum value to perform case-insensitive comparisons.</value>
</data>
<data name="RecommendCaseInsensitiveStringComparisonMessage" xml:space="preserve">
<value>Prefer the string comparison method overload of '{0}' that takes a 'StringComparison' enum value to perform a case-insensitive comparison</value>
</data>
</root>
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
// Copyright (c) Microsoft. All Rights Reserved. Licensed under the MIT license. See License.txt in the project root for license information.

using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Analyzer.Utilities;
using Analyzer.Utilities.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;

namespace Microsoft.NetCore.Analyzers.Performance
{
using static MicrosoftNetCoreAnalyzersResources;

/// <summary>
/// CA1862: Prefer the StringComparison method overloads to perform case-insensitive string comparisons.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)]
public sealed class RecommendCaseInsensitiveStringComparisonAnalyzer : DiagnosticAnalyzer
{
internal const string RuleId = "CA1862";

internal const string StringComparisonInvariantCultureIgnoreCaseName = "InvariantCultureIgnoreCase";
internal const string StringComparisonCurrentCultureIgnoreCaseName = "CurrentCultureIgnoreCase";
internal const string StringToLowerMethodName = "ToLower";
internal const string StringToUpperMethodName = "ToUpper";
internal const string StringToLowerInvariantMethodName = "ToLowerInvariant";
internal const string StringToUpperInvariantMethodName = "ToUpperInvariant";
internal const string StringContainsMethodName = "Contains";
internal const string StringIndexOfMethodName = "IndexOf";
internal const string StringStartsWithMethodName = "StartsWith";
internal const string StringCompareToMethodName = "CompareTo";
internal const string StringComparerCompareMethodName = "Compare";
internal const string StringParameterName = "value";
internal const string StringComparisonParameterName = "comparisonType";

internal static readonly DiagnosticDescriptor RecommendCaseInsensitiveStringComparisonRule = DiagnosticDescriptorHelper.Create(
RuleId,
CreateLocalizableResourceString(nameof(RecommendCaseInsensitiveStringComparisonTitle)),
CreateLocalizableResourceString(nameof(RecommendCaseInsensitiveStringComparisonMessage)),
DiagnosticCategory.Performance,
RuleLevel.IdeSuggestion,
CreateLocalizableResourceString(nameof(RecommendCaseInsensitiveStringComparisonDescription)),
isPortedFxCopRule: false,
isDataflowRule: false);

internal static readonly DiagnosticDescriptor RecommendCaseInsensitiveStringComparerRule = DiagnosticDescriptorHelper.Create(
RuleId,
CreateLocalizableResourceString(nameof(RecommendCaseInsensitiveStringComparerTitle)),
CreateLocalizableResourceString(nameof(RecommendCaseInsensitiveStringComparerMessage)),
DiagnosticCategory.Performance,
RuleLevel.IdeSuggestion,
CreateLocalizableResourceString(nameof(RecommendCaseInsensitiveStringComparerDescription)),
isPortedFxCopRule: false,
isDataflowRule: false);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(
RecommendCaseInsensitiveStringComparisonRule, RecommendCaseInsensitiveStringComparerRule);

public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.RegisterCompilationStartAction(AnalyzeCompilationStart);
}

private void AnalyzeCompilationStart(CompilationStartAnalysisContext context)
{
// Retrieve the essential types: string, StringComparison, StringComparer

INamedTypeSymbol stringType = context.Compilation.GetSpecialType(SpecialType.System_String);
INamedTypeSymbol int32Type = context.Compilation.GetSpecialType(SpecialType.System_Int32);

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)
};

ParameterInfo[] stringInt32Parameters = new[]
{
ParameterInfo.GetParameterInfo(stringType),
ParameterInfo.GetParameterInfo(int32Type)
};

ParameterInfo[] stringInt32Int32Parameters = new[]
{
ParameterInfo.GetParameterInfo(stringType),
ParameterInfo.GetParameterInfo(int32Type),
ParameterInfo.GetParameterInfo(int32Type)
};

// 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;
}

// IndexOf(string, int startIndex)
IMethodSymbol? indexOfStringInt32Method = indexOfMethods.GetFirstOrDefaultMemberWithParameterInfos(stringInt32Parameters);
if (indexOfStringInt32Method == null)
{
return;
}

// 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;
}

context.RegisterOperationAction(context =>
{
IInvocationOperation caseChangingInvocation = (IInvocationOperation)context.Operation;
IMethodSymbol caseChangingMethod = caseChangingInvocation.TargetMethod;

if (!caseChangingMethod.Equals(toLowerParameterlessMethod) &&
!caseChangingMethod.Equals(toLowerInvariantParameterlessMethod) &&
!caseChangingMethod.Equals(toUpperParameterlessMethod) &&
!caseChangingMethod.Equals(toUpperInvariantParameterlessMethod))
{
return;
}

// Ignore parenthesized operations
IOperation? ancestor = caseChangingInvocation.WalkUpParentheses().WalkUpConversion().Parent;

IInvocationOperation diagnosableInvocation;
if (ancestor is IInvocationOperation invocationAncestor)
{
diagnosableInvocation = invocationAncestor;
}
else if (ancestor is IArgumentOperation argumentAncestor && argumentAncestor.Parent is IInvocationOperation argumentInvocationAncestor)
{
diagnosableInvocation = argumentInvocationAncestor;
}
else
{
return;
}

IMethodSymbol diagnosableMethod = diagnosableInvocation.TargetMethod;

if (diagnosableMethod.Equals(containsStringMethod) ||
diagnosableMethod.Equals(startsWithStringMethod) ||
diagnosableMethod.Equals(indexOfStringMethod) ||
diagnosableMethod.Equals(indexOfStringInt32Method) ||
diagnosableMethod.Equals(indexOfStringInt32Int32Method))
{
context.ReportDiagnostic(diagnosableInvocation.CreateDiagnostic(RecommendCaseInsensitiveStringComparisonRule, diagnosableMethod.Name));
}
else if (diagnosableMethod.Equals(compareToStringMethod))
{
context.ReportDiagnostic(diagnosableInvocation.CreateDiagnostic(RecommendCaseInsensitiveStringComparerRule));
}
}, OperationKind.Invocation);
}
}
}
Loading

0 comments on commit 126e31e

Please sign in to comment.