-
Notifications
You must be signed in to change notification settings - Fork 470
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
Changes from all commits
78d78e9
c3408c7
6e503b6
13ff0a4
1963ab1
db69ec2
7883135
0c0d788
5f5b92d
579f683
390f6fa
4f0881f
2371844
6562d14
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,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; | ||
|
||
// 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; | ||
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 also true for cases where a boolean variable is used? I haven't checked bool CompareNames(Person p, bool ignoreCase) => string.Compare(Name, p.Name, ignoreCase) 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 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 ? | ||
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 should likely be 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. @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. 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 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 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 examples in the issue specified that we use 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 don't see any explicit discussion in the issue about whether the replacement for 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
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
and
This fix is listed as 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 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. 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 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); | ||
} | ||
} | ||
} | ||
} |
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.
return is not needed here
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.
@ryzngard It's a convention used in the repository (and dotnet/roslyn as well) to put an explicit return just before local functions
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.
o.o who decided that? Because I definitely haven't been
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.
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
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've added https://github.com/dotnet/roslyn-analyzers/issues/5122 for further discussion
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.
@mavasani has asked me to have a
return
before the local function definitions for clarity.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.
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.