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

#45552 use string equals over string compare #5116

Merged
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
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 @@ CA1844 | Performance | Info | ProvideStreamMemoryBasedAsyncOverrides, [Documenta
CA1845 | Performance | Info | UseSpanBasedStringConcat, [Documentation](https://docs.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1845)
CA1846 | Performance | Info | PreferAsSpanOverSubstring, [Documentation](https://docs.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1846)
CA2250 | Usage | Info | UseCancellationTokenThrowIfCancellationRequested, [Documentation](https://docs.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2250)
CA2251 | Usage | Hidden | UseStringEqualsOverStringCompare, [Documentation](https://docs.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2251)

### Removed Rules

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1635,4 +1635,16 @@
<data name="DoNotUseWhenAllWithSingleTaskFix" xml:space="preserve">
<value>Replace 'WhenAll' call with argument</value>
</data>
<data name="UseStringEqualsOverStringCompareCodeFixTitle" xml:space="preserve">
<value>Use 'string.Equals'</value>
</data>
<data name="UseStringEqualsOverStringCompareDescription" xml:space="preserve">
<value>It is both clearer and likely faster to use 'string.Equals' instead of comparing the result of 'string.Compare' to zero.</value>
</data>
<data name="UseStringEqualsOverStringCompareMessage" xml:space="preserve">
<value>Use 'string.Equals' instead of comparing the result of 'string.Compare' to 0</value>
</data>
<data name="UseStringEqualsOverStringCompareTitle" xml:space="preserve">
<value>Use 'string.Equals'</value>
</data>
</root>
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Collections.Immutable;
using System.Composition;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Analyzer.Utilities;
using Analyzer.Utilities.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.Editing;
using Microsoft.CodeAnalysis.Operations;

using Resx = Microsoft.NetCore.Analyzers.MicrosoftNetCoreAnalyzersResources;
using RequiredSymbols = Microsoft.NetCore.Analyzers.Runtime.UseStringEqualsOverStringCompare.RequiredSymbols;

namespace Microsoft.NetCore.Analyzers.Runtime
{
[ExportCodeFixProvider(LanguageNames.CSharp, LanguageNames.VisualBasic), Shared]
public sealed class UseStringEqualsOverStringCompareFixer : CodeFixProvider
{
public override ImmutableArray<string> FixableDiagnosticIds => ImmutableArray.Create(UseStringEqualsOverStringCompare.RuleId);

public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var document = context.Document;
var token = context.CancellationToken;
var semanticModel = await document.GetSemanticModelAsync(token).ConfigureAwait(false);

_ = RequiredSymbols.TryGetSymbols(semanticModel.Compilation, out var symbols);
RoslynDebug.Assert(symbols is not null);

var root = await document.GetSyntaxRootAsync(token).ConfigureAwait(false);
var node = root.FindNode(context.Span, getInnermostNodeForTie: true);
if (semanticModel.GetOperation(node, token) is not IBinaryOperation violation)
return;

// Get the replacer that applies to the reported violation.
var replacer = GetOperationReplacers(symbols).First(x => x.IsMatch(violation));

var codeAction = CodeAction.Create(
Resx.UseStringEqualsOverStringCompareCodeFixTitle,
CreateChangedDocument,
nameof(Resx.UseStringEqualsOverStringCompareCodeFixTitle));
context.RegisterCodeFix(codeAction, context.Diagnostics);
return;
Copy link
Contributor

Choose a reason for hiding this comment

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

return is not needed here

Copy link
Member

Choose a reason for hiding this comment

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

@ryzngard It's a convention used in the repository (and dotnet/roslyn as well) to put an explicit return just before local functions

Copy link
Contributor

Choose a reason for hiding this comment

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

o.o who decided that? Because I definitely haven't been

Copy link
Contributor

Choose a reason for hiding this comment

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

Apparently this is @CyrusNajmabadi preferrence... I won't block a PR on style preference that isn't documented. You can keep if you like, and I'll just grumble in my corner alone :P

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor Author

@NewellClark NewellClark May 21, 2021

Choose a reason for hiding this comment

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

@mavasani has asked me to have a return before the local function definitions for clarity.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yea, I followed up offline with him and a few others. I was unaware that was a practice we started. Hopefully we can get an analyzer to help the underlying issue, but for now this is correct.


// Local functions

async Task<Document> CreateChangedDocument(CancellationToken token)
{
var editor = await DocumentEditor.CreateAsync(document, token).ConfigureAwait(false);
var replacementNode = replacer.CreateReplacementExpression(violation, editor.Generator);
editor.ReplaceNode(violation.Syntax, replacementNode);

return editor.GetChangedDocument();
}
}

public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;

private static ImmutableArray<OperationReplacer> GetOperationReplacers(RequiredSymbols symbols)
{
return ImmutableArray.Create<OperationReplacer>(
new StringStringCaseReplacer(symbols),
new StringStringBoolReplacer(symbols),
new StringStringStringComparisonReplacer(symbols));
}

/// <summary>
/// Base class for an object that generate the replacement code for a reported violation.
/// </summary>
private abstract class OperationReplacer
{
protected OperationReplacer(RequiredSymbols symbols)
{
Symbols = symbols;
}

protected RequiredSymbols Symbols { get; }

/// <summary>
/// Indicates whether the current <see cref="OperationReplacer"/> applies to the specified violation.
/// </summary>
/// <param name="violation">The <see cref="IBinaryOperation"/> at the location reported by the analyzer.</param>
/// <returns>True if the current <see cref="OperationReplacer"/> applies to the specified violation.</returns>
public abstract bool IsMatch(IBinaryOperation violation);

/// <summary>
/// Creates a replacement node for a violation that the current <see cref="OperationReplacer"/> applies to.
/// Asserts if the current <see cref="OperationReplacer"/> does not apply to the specified violation.
/// </summary>
/// <param name="violation">The <see cref="IBinaryOperation"/> obtained at the location reported by the analyzer.
/// <see cref="IsMatch(IBinaryOperation)"/> must return <see langword="true"/> for this operation.</param>
/// <param name="generator"></param>
/// <returns></returns>
public abstract SyntaxNode CreateReplacementExpression(IBinaryOperation violation, SyntaxGenerator generator);

protected SyntaxNode CreateEqualsMemberAccess(SyntaxGenerator generator)
{
var stringTypeExpression = generator.TypeExpressionForStaticMemberAccess(Symbols.StringType);
return generator.MemberAccessExpression(stringTypeExpression, nameof(string.Equals));
}

protected static IInvocationOperation GetInvocation(IBinaryOperation violation)
{
var result = UseStringEqualsOverStringCompare.GetInvocationFromEqualityCheckWithLiteralZero(violation);

RoslynDebug.Assert(result is not null);

return result;
}

protected static SyntaxNode InvertIfNotEquals(SyntaxNode stringEqualsInvocationExpression, IBinaryOperation equalsOrNotEqualsOperation, SyntaxGenerator generator)
{
return equalsOrNotEqualsOperation.OperatorKind is BinaryOperatorKind.NotEquals ?
generator.LogicalNotExpression(stringEqualsInvocationExpression) :
stringEqualsInvocationExpression;
}
}

/// <summary>
/// Replaces <see cref="string.Compare(string, string)"/> violations.
/// </summary>
private sealed class StringStringCaseReplacer : OperationReplacer
{
public StringStringCaseReplacer(RequiredSymbols symbols)
: base(symbols)
{ }

public override bool IsMatch(IBinaryOperation violation) => UseStringEqualsOverStringCompare.IsStringStringCase(violation, Symbols);

public override SyntaxNode CreateReplacementExpression(IBinaryOperation violation, SyntaxGenerator generator)
{
RoslynDebug.Assert(IsMatch(violation));

var compareInvocation = GetInvocation(violation);
var equalsInvocationSyntax = generator.InvocationExpression(
CreateEqualsMemberAccess(generator),
compareInvocation.Arguments.GetArgumentsInParameterOrder().Select(x => x.Value.Syntax));

return InvertIfNotEquals(equalsInvocationSyntax, violation, generator);
}
}

/// <summary>
/// Replaces <see cref="string.Compare(string, string, bool)"/> violations.
/// </summary>
private sealed class StringStringBoolReplacer : OperationReplacer
{
public StringStringBoolReplacer(RequiredSymbols symbols)
: base(symbols)
{ }

public override bool IsMatch(IBinaryOperation violation) => UseStringEqualsOverStringCompare.IsStringStringBoolCase(violation, Symbols);

public override SyntaxNode CreateReplacementExpression(IBinaryOperation violation, SyntaxGenerator generator)
{
RoslynDebug.Assert(IsMatch(violation));

var compareInvocation = GetInvocation(violation);

// We know that the 'ignoreCase' argument in 'string.Compare(string, string, bool)' is a boolean literal
// because we've asserted that 'IsMatch' returns true.
var ignoreCaseLiteral = (ILiteralOperation)compareInvocation.Arguments.GetArgumentForParameterAtIndex(2).Value;
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this also true for cases where a boolean variable is used? I haven't checked UseStringEqualsOverStringCompare.IsStringStringBoolCase to know if it handles that as well

bool CompareNames(Person p, bool ignoreCase) => string.Compare(Name, p.Name, ignoreCase)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The analyzer only flags cases where the boolean argument is a literal.


// If the violation contains a call to 'string.Compare(x, y, true)' then we
// replace it with a call to 'string.Equals(x, y, StringComparison.CurrentCultureIgnoreCase)'.
// If the violation contains a call to 'string.Compare(x, y, false)' then we
// replace it with a call to 'string.Equals(x, y, StringComparison.CurrentCulture)'.
var stringComparisonEnumMemberName = (bool)ignoreCaseLiteral.ConstantValue.Value ?
Copy link
Contributor

Choose a reason for hiding this comment

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

This should likely be Ordinal and OrdinalIgnoreCase based on guidelines

Copy link
Member

Choose a reason for hiding this comment

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

@ryzngard string.Compare is using CurrentCulture by default.

https://source.dot.net/#System.Private.CoreLib/String.Comparison.cs,200

So probably the codefix would want to keep the same semantics.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't fully agree. In general we don't want to change explicit semantics for code, but there's no way to know if the user intended to use CurrentCulture or not. When it's ambiguous, I think suggesting the public documented guidelines would be a good thing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The examples in the issue specified that we use CurrentCulture and CurrentCultureIgnoreCase so we don't change semantics.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't see any explicit discussion in the issue about whether the replacement for string.Compare(s1, s2) should use CurrentCulture or Ordinal. That said, I'm not strong enough of the opinion that we should change the functional meaning for potential correction. Especially if others think it should be exact parity.

I will expand on my thought process for discussion, but consider this non-blocking:

https://docs.microsoft.com/en-us/dotnet/standard/base-types/best-practices-strings specifies

"Use overloads that explicitly specify the string comparison rules for string operations. Typically, this involves calling a method overload that has a parameter of type StringComparison."

If I were a user trying to update code to follow that guideline, how would I consider doing it? I think most cases I would add a Ordinal or OrdinalIgnoreCase as the intent for string comparison unless context dictates I should be using culture aware comparison. That also follows the guidance of

"Use StringComparison.Ordinal or StringComparison.OrdinalIgnoreCase for comparisons as your safe default for culture-agnostic string matching."

and

"Use comparisons with StringComparison.Ordinal or StringComparison.OrdinalIgnoreCase for better performance."

This fix is listed as Usage, but the key findings in the issue are centered around performance. Probably because nobody disagreed that string.Equals(...) is more readable than string.Compare(...) == 0.

If we are encouraging users to have mostly correct and performant code, or even for what I would consider to give as guidance as "probably correct" in most cases, we would use Ordinal or OrdinalIgnoreCase.

Copy link
Contributor Author

@NewellClark NewellClark May 21, 2021

Choose a reason for hiding this comment

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

In my opinion, a code fix should only change the issue that its analyzer is reporting on. This analyzer is not flagging incorrect uses of CurrentCulture, it is flagging uses of a total-order comparison when a simple equality check was desired. I would not be opposed to an analyzer that flags usages of any Compare or Equals overload that doesn't specify the kind of comparison, but I don't think this analyzer should be changing semantics.

If analyzers that appear to be purely about style or performance start suggesting code fixes that quietly change semantics, as a developer I'm going to be much more wary of using code fixes.

nameof(StringComparison.CurrentCultureIgnoreCase) :
nameof(StringComparison.CurrentCulture);
var stringComparisonMemberAccessSyntax = generator.MemberAccessExpression(
generator.TypeExpressionForStaticMemberAccess(Symbols.StringComparisonType),
stringComparisonEnumMemberName);

var equalsInvocationSyntax = generator.InvocationExpression(
CreateEqualsMemberAccess(generator),
compareInvocation.Arguments.GetArgumentForParameterAtIndex(0).Value.Syntax,
compareInvocation.Arguments.GetArgumentForParameterAtIndex(1).Value.Syntax,
stringComparisonMemberAccessSyntax);

return InvertIfNotEquals(equalsInvocationSyntax, violation, generator);
}
}

/// <summary>
/// Replaces <see cref="string.Compare(string, string, StringComparison)"/> violations.
/// </summary>
private sealed class StringStringStringComparisonReplacer : OperationReplacer
{
public StringStringStringComparisonReplacer(RequiredSymbols symbols)
: base(symbols)
{ }

public override bool IsMatch(IBinaryOperation violation) => UseStringEqualsOverStringCompare.IsStringStringStringComparisonCase(violation, Symbols);

public override SyntaxNode CreateReplacementExpression(IBinaryOperation violation, SyntaxGenerator generator)
{
RoslynDebug.Assert(IsMatch(violation));

var invocation = GetInvocation(violation);
var equalsInvocationSyntax = generator.InvocationExpression(
CreateEqualsMemberAccess(generator),
invocation.Arguments.GetArgumentsInParameterOrder().Select(x => x.Value.Syntax));

return InvertIfNotEquals(equalsInvocationSyntax, violation, generator);
}
}
}
}
Loading