[DeadCodeAnalysis] Add analyzer/fixer to flag and remove unused private members (fields/…#29511
[DeadCodeAnalysis] Add analyzer/fixer to flag and remove unused private members (fields/…#29511mavasani merged 11 commits intodotnet:masterfrom
Conversation
…methods/properties/events)
Analyzer flags two cases: members with no read/writes and members with only writes.
1. Members with no read or writes: `Type '{0}' has an unused private member '{1}' which can be removed.`
2. Only writes: `Type '{0}' has a private member '{1}' which can be removed as the value assigned to it is never used.`
Code fix removes the unused member declaration.
Fixes dotnet#24225
Open questions:
1. Current analyzer design uses a single code style option for all members and both the above kinds of unused members. We can potentially have multiple options, but this should probably be done based on feedback.
2. Should the analyzer use different diagnostic IDs for the above two kinds of unused members? This will mean that the FixAll experience will need multiple iterations for removing unused members.
3. Should we update the code fix (or have an additional code fix) that also updates the write references for the case (2) above? Or is it better to leave the references to break the code so the user can analyze if the value being assigned can also be removed. The PR current chooses the latter approach as it is more conservative and unlikely to cause silent breaks.
| OperationBlockActionsCount > 0 || | ||
| OperationBlockStartActionsCount > 0; | ||
| OperationBlockStartActionsCount > 0 || | ||
| SymbolStartActionsCount > 0; |
There was a problem hiding this comment.
Single line change in compiler code: HasAnyExecutableCodeActions conservatively returns true for analyzers with SymbolStartActions as we cannot compute this upfront for an analyzer (need to actually execute the symbol start action for each symbol). #Closed
| using Xunit; | ||
| using Microsoft.CodeAnalysis.CSharp; | ||
|
|
||
| namespace Microsoft.CodeAnalysis.Editor.CSharp.UnitTests.AvoidUnusedMembers |
There was a problem hiding this comment.
the name of this feature does not match our names for other similar features. Should probably be RemoveUnusedMembers. Like RemoveUnusedImports RemoveUnusedVariable, etc. #Resolved
| <value>Type '{0}' has an unused private member '{1}' which can be removed.</value> | ||
| </data> | ||
| <data name="Avoid_Unused_Members_Title" xml:space="preserve"> | ||
| <value>Avoid unused private members.</value> |
There was a problem hiding this comment.
- IDE resource strings should match resource values. This should be "Avoid_unused_private_members".
- do we generally have periods in these strings? I dont' think we do...
#Resolved
| // For instance fields, ensure that the instance reference is being initialized by the constructor. | ||
| var instanceFieldWrittenInCtor = isInConstructor && | ||
| fieldReference.Instance?.Kind == OperationKind.InstanceReference && | ||
| (!(fieldReference.Parent is IAssignmentOperation) || fieldReference.Parent.Parent?.Kind != OperationKind.ObjectOrCollectionInitializer); |
There was a problem hiding this comment.
this expression has gotten too complex to understand. Far too many &&s, ||s, and !s. #Resolved
| CancellationToken cancellationToken) | ||
| { | ||
| return FixWithEditorAsync(document, editor, diagnostics, cancellationToken); | ||
| } |
There was a problem hiding this comment.
i don't understand why we needed to forward this function to the function you wrote below... All you seemed to do was reorder hte args.. Why not just remove the below funciton and inline all the code into here... #Resolved
| var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); | ||
| var diagnosticSpan = diagnostic.Location.SourceSpan; | ||
| var symbolName = diagnostic.Properties[AvoidUnusedMembersDiagnosticAnalyzer.UnunsedMemberNameProperty]; | ||
| var symbolKind = diagnostic.Properties[AvoidUnusedMembersDiagnosticAnalyzer.UnunsedMemberKindProperty]; |
There was a problem hiding this comment.
mispelling: "Ununsed" #Resolved
| var symbolName = diagnostic.Properties[AvoidUnusedMembersDiagnosticAnalyzer.UnunsedMemberNameProperty]; | ||
| var symbolKind = diagnostic.Properties[AvoidUnusedMembersDiagnosticAnalyzer.UnunsedMemberKindProperty]; | ||
|
|
||
| var node = GetTopmostSyntaxNodeForSymbolDeclaration(root.FindNode(diagnosticSpan), |
There was a problem hiding this comment.
- i believe we already have location.FindNode
- you didn't pass getInnermostNodeForTie. could that cause a problem? #Resolved
| var symbolKind = diagnostic.Properties[AvoidUnusedMembersDiagnosticAnalyzer.UnunsedMemberKindProperty]; | ||
|
|
||
| var node = GetTopmostSyntaxNodeForSymbolDeclaration(root.FindNode(diagnosticSpan), | ||
| isSymbolDeclarationNode: n => n != null && semanticModel.GetDeclaredSymbol(n, cancellationToken)?.Name == symbolName); |
There was a problem hiding this comment.
i'm confused why this helper function would need to be provided. Are there other situations where you pass a different helper? If not, why not have GetTopmostSyntaxNodeForSymbolDeclaration contain this code internally? #Resolved
There was a problem hiding this comment.
That method is a virtual function, which is overridden in VB (we need to map the StatementSyntax to BlockSyntax)
In reply to: 212728972 [](ancestors = 212728972)
There was a problem hiding this comment.
in general, we don't use that pattern. Instead, we keep things abstract, making C# and VB have to decide what behavior they want. It's weird to have a defualt behavior that is then only used by one language, and then have the other language override it. In essence, this is saying "VB is overriding C#'s behavior". And instead we want to say: it's up to each language to decide what the correct behavior is.
Also see later note: we have a service that already does this work. So i don't think this extension point is needed in this feature. Look up ISymbolDeclarationService :) #Resolved
| if (fieldDeclarators.Count > 0) | ||
| { | ||
| AdjustDeclarators(fieldDeclarators, declarators); | ||
| } |
There was a problem hiding this comment.
needs explanatory comments. #Resolved
| protected virtual SyntaxNode GetTopmostSyntaxNodeForSymbolDeclaration(SyntaxNode syntaxNode, Func<SyntaxNode, bool> isSymbolDeclarationNode) | ||
| { | ||
| return syntaxNode.FirstAncestorOrSelf(isSymbolDeclarationNode); | ||
| } |
There was a problem hiding this comment.
this helper function seems unnecessary. you could just inline directly into the callsite. #Resolved
There was a problem hiding this comment.
There was a problem hiding this comment.
See above:
in general, we don't use that pattern. Instead, we keep things abstract, making C# and VB have to decide what behavior they want. It's weird to have a defualt behavior that is then only used by one language, and then have the other language override it. In essence, this is saying "VB is overriding C#'s behavior". And instead we want to say: it's up to each language to decide what the correct behavior is.
Also see later note: we have a service that already does this work. So i don't think this extension point is needed in this feature. Look up ISymbolDeclarationService :)
#unbydesigned :) #Resolved
| declarators.Add(parentDeclaration); | ||
| declarators.RemoveAll(childDeclarators); | ||
| } | ||
| } |
There was a problem hiding this comment.
TODO(cyrusn). Have not reviewed this yet. waiting to get explanation/comments as to what this all for.
| UnnecessaryWithSuggestionDescriptor.IsEnabledByDefault, | ||
| UnnecessaryWithSuggestionDescriptor.Description, | ||
| UnnecessaryWithSuggestionDescriptor.HelpLinkUri, | ||
| UnnecessaryWithSuggestionDescriptor.CustomTags.ToArray()); |
There was a problem hiding this comment.
does DiagnosticDescriptor not have a With'er? #Resolved
There was a problem hiding this comment.
There was a problem hiding this comment.
Note: instead of manually constructing the descriptor, you can use the helpers defined in the base class. That would help remove a lot of redundancy here. #unresolved #Resolved
There was a problem hiding this comment.
I am going to combine this into a single message/option/ID for now.
In reply to: 212774354 [](ancestors = 212774354)
| _noReadsUnnecessaryWithSuggestionDescriptor = new DiagnosticDescriptor( | ||
| UnnecessaryWithSuggestionDescriptor.Id, | ||
| UnnecessaryWithSuggestionDescriptor.Title, | ||
| new LocalizableResourceString(nameof(FeaturesResources.Avoid_Members_Without_Reads_Message), FeaturesResources.ResourceManager, typeof(FeaturesResources)), |
There was a problem hiding this comment.
this feels super weird... same diagnostic ID... but different message.... #Resolved
There was a problem hiding this comment.
This is a very common pattern in FxCop analyzers where we have large number of minor variations for the same underlying issue, here "an unused member". The pattern we chose there was to avoid assigning a new ID for each such case, and instead just have different descriptors with differing messages. This also improves the FixAll experience by avoiding extra iterations per ID. I am not sure what is the pattern for such scenarios in the IDE analyzers, if we have encountered one till now. I can merge both the cases into a single message, but I felt it is helpful to provide the distinction to the end user, especially as we already know it in the analyzer.
I will wait for further feedback from other reviewers on this.
In reply to: 212729488 [](ancestors = 212729488)
There was a problem hiding this comment.
Well, is there a problem with just havin a suitable single message? The specific code-fix can have a particular message to show the user if necessary.
After all, you only have one option controlling this. And you have fix-all fix all of these up. So distinguishing them in any way at all seems superfluous to me. #Resolved
There was a problem hiding this comment.
I am not sure what is the pattern for such scenarios in the IDE analyzers
IDE analyzer pattern is that these are either actually different things that should be displayed to the users, or they're not. If htey are, then they should be controlled through separate mechanisms (like options) and should not participate in fix-all together (since we have presented them as being different). If they aren't considered different things (i.e. you can only turn them all on/off at once, and you fix them all together), then they have the same message. #Resolved
There was a problem hiding this comment.
I have taken this route - we can split into multiple messages/options/IDs if needed in future.
In reply to: 212774669 [](ancestors = 212774669)
There was a problem hiding this comment.
Sounds good. Note, if you want, you can always have different messages for the fix title. That part is very flexible and can be different if you think it would provide clarity and whatnot. For example, you could have 'Remove unused member' as well as 'Remove unwritten member'. #Resolved
|
|
||
| public override bool OpenFileOnly(Workspace workspace) => false; | ||
|
|
||
| public override DiagnosticAnalyzerCategory GetAnalyzerCategory() => DiagnosticAnalyzerCategory.SemanticDocumentAnalysis; |
There was a problem hiding this comment.
comment why this is appropriate. #Resolved
|
|
||
| protected override void InitializeWorker(AnalysisContext context) | ||
| { | ||
| context.EnableConcurrentExecution(); |
There was a problem hiding this comment.
this should be unnecessary. the base type already sets this. #Resolved
| // We want to analyze references in generated code, but not report unused members in generated code. | ||
| context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze); | ||
|
|
||
| context.RegisterCompilationStartAction(compilationStartContext => |
There was a problem hiding this comment.
please follow IDE conventions. We do not do the 'ultra nested function' approach to analyzers. We sometimes hav ea small amount of code here (for example to get compilation information, or to create some shared state). but then we call explicit functions that are passed this information, rather than having nested functions that capture. it makes data flow explicit and helps with function readability. I know you like this style, but it stands out highly against all the other features and I would prefer we not unnecesarily do that. #Resolved
|
|
||
| context.RegisterCompilationStartAction(compilationStartContext => | ||
| { | ||
| var entryPoint = compilationStartContext.Compilation.GetEntryPoint(compilationStartContext.CancellationToken); |
There was a problem hiding this comment.
this is fairly expensive i believe. Is there any concern aobut the costs here and how this could affect time for analysis results to appear? #Resolved
There was a problem hiding this comment.
See below in the review for suggestion on how to avoid this expensive cost. #Resolved
|
|
||
| return; | ||
|
|
||
| // Local functions. |
There was a problem hiding this comment.
please make into sibling functions. That's our pattern for at least 99% of our Analyze methods in our anlayzers. Note that he analyzer itself already acts as hte proper container for these guys. We don't need another level of encapsulation #Resolved
| if (IsCandidateSymbol(symbolContext.Symbol)) | ||
| { | ||
| // Initialize unused state to 'true'. | ||
| symbolValueUsageStateMap.GetOrAdd(symbolContext.Symbol, valueFactory: s => ValueUsageInfo.None); |
There was a problem hiding this comment.
we should only hit this once per symbol. Can we have some sort of assert that this never 'get's an existing value? #Resolved
There was a problem hiding this comment.
We might receive a symbol reference (operation) callback before a symbol declaration callback, so even though we cannot receive duplicate callbacks for a symbol, an entry might already be present of the declared symbol here.
In reply to: 212730552 [](ancestors = 212730552)
There was a problem hiding this comment.
That definitely needs a comment :) #Resolved
| symbolValueUsageStateMap.AddOrUpdate( | ||
| memberSymbol, | ||
| addValue: usageInfo, | ||
| updateValueFactory: (sym, currentUsageInfo) => currentUsageInfo | usageInfo); |
There was a problem hiding this comment.
it seems unfortunate that this causes an allocation on every symbol usage. Is there any overload of symbolValueUsageStateMap that would prevent this.
If not, i would consider actually not using a concurrent dictionary and just use a plain lock as allocation costs here could be significant. #Resolved
| // So, we treat this usage as a write-only usage. | ||
| if (memberReference.Language == LanguageNames.VisualBasic && | ||
| memberReference.Parent is ICompoundAssignmentOperation compoundAssignment && | ||
| compoundAssignment.Target == memberReference) |
There was a problem hiding this comment.
This is an innapropriate way to do things. We do not want our code layer to have language specific knowledge between VB and C#. If it needs to we can encode that in a few ways, including:
- subclassing. Make this an abstract class and allow VB to customize control flow.
- using an appropriate service if this is a general question that would be vlauable to expose for several features. have the VB impl of the service do whatever it needs to do that is distinct. #Resolved
| if (IsCandidateSymbol(invocation.TargetMethod)) | ||
| { | ||
| OnSymbolUsage(invocation.TargetMethod, ValueUsageInfo.Read); | ||
| } |
There was a problem hiding this comment.
probably worth explaining why we need this hwen we have AnalyzeMemberReferenceOperation #Resolved
|
@CyrusNajmabadi Addressed feedback, thanks. |
|
Thanks. Looking good. Skimmed over the last commit and liked it a lot. I'll do a thorough pass tonight/tomorrow! |
|
@CyrusNajmabadi @jcouv @sharwell any further feedback? Thanks. |
|
Looking now! |
| { | ||
| // Get symbol to be removed. | ||
| var diagnosticNode = diagnostic.Location.FindNode(getInnermostNodeForTie: true, cancellationToken); | ||
| var symbol = semanticModel.GetDeclaredSymbol(diagnosticNode, cancellationToken); |
There was a problem hiding this comment.
note: that this works is super fragile. You are using the Location of the diagnostic (which is often a token of the member). You're then calling FindNode, which fortunately is always finding the node that works for GetDeclaredSymbol.
What would be safer is to use .AdditionalLocations to store the actual Symbol Location[0].
There was a problem hiding this comment.
Diagnostic is reported with the primary location as Symbol.Locations[0].
In reply to: 213882742 [](ancestors = 213882742)
| // For example, if we had an unused field with no references, then editing any single method body | ||
| // to reference this field should clear the unused field diagnostic. | ||
| // Hence, we need to re-analyze the declarations in the whole file for any edits within the document. | ||
| public override DiagnosticAnalyzerCategory GetAnalyzerCategory() => DiagnosticAnalyzerCategory.SemanticDocumentAnalysis; |
There was a problem hiding this comment.
great comment. thanks!
| return true; | ||
| } | ||
|
|
||
| if (methodSymbol.IsAsync && |
There was a problem hiding this comment.
see previous comment about Main not needing to be 'async' to be Task-returning. #Resolved
|
|
||
| var diagnostic = DiagnosticHelper.Create( | ||
| rule, | ||
| member.Locations[0], |
There was a problem hiding this comment.
won't this squiggle the entire member? or is .Locations just hte name? if so, this is fine, but then 'additionalLocations' is not. #Resolved
There was a problem hiding this comment.
It squiggles/fades just the symbol name. Additional locations should almost always be null here, except for partial methods, so I am just going to ignore it for now and pass null.
In reply to: 213883172 [](ancestors = 213883172)
CyrusNajmabadi
left a comment
There was a problem hiding this comment.
Some small concerns. Almost there!
|
|
||
| class MyClass | ||
| { | ||
| private static Task [|Main|]() => return Task.CompletedTask; |
There was a problem hiding this comment.
return [](start = 38, length = 6)
Ah, the unit test had compile error that cause it not to be flagged, and I forgot to remove the IsAsync check. Fixed now. #Resolved
There was a problem hiding this comment.
Verified test fails after fixing the compile error, and passes after removing the IsAsync check.
In reply to: 213887741 [](ancestors = 213887741)
CyrusNajmabadi
left a comment
There was a problem hiding this comment.
Awesome feature. Very excited for this.
|
retest ubuntu_16_debug_prtest please |
|
@dotnet/roslyn-ide Can I get one more review? |
|
|
||
| private static ImmutableArray<DiagnosticDescriptor> CreateDescriptors(bool forceEnableRules) | ||
| { | ||
| // TODO: Enable these rules by default once we have designed the Tools|Option location and UI for such code quality rules. |
There was a problem hiding this comment.
TODO [](start = 15, length = 4)
issue # #Resolved
| removeUnreadMembersRule, removeUnreadMembersRuleUnnecessaryWithFadingRule); | ||
| } | ||
|
|
||
| private DiagnosticDescriptor RemoveUnusedMemberRule => SupportedDiagnostics[1]; |
There was a problem hiding this comment.
s[1] [](start = 82, length = 4)
Where are those indices defined? Maybe a comment will be helpful. #Resolved
There was a problem hiding this comment.
|
|
||
| var diagnostic = DiagnosticHelper.Create( | ||
| rule, | ||
| member.Locations[0], |
There was a problem hiding this comment.
Locations [](start = 39, length = 9)
Please add a comment regarding partial methods. #Resolved
This flag is used to determine if we need to run any syntax/operation actions for an analyzer, and for symbol start analyzers we cannot determine this at compilation level as analyzer might register these actions per symbol. The change here conservatively assumes that symbol start analyzers can have syntax/operation actions. In reply to: 417534528 [](ancestors = 417534528) Refers to: src/Compilers/Core/Portable/DiagnosticAnalyzer/AnalyzerActionCounts.cs:71 in 2055d1c. [](commit_id = 2055d1c, deletion_comment = False) |
|
@dotnet-bot retest windows_debug_unit32_prtest please |
| <target state="translated">Nie można zastosować zmian — nieoczekiwany błąd: „{0}”</target> | ||
| <note /> | ||
| </trans-unit> | ||
| <trans-unit id="Field_preferences_colon"> |
There was a problem hiding this comment.
❔ Why did these files change? It looks like the only changes are:
- Reordering things
- Removing a translation that was already available
There was a problem hiding this comment.
I added new resources, removed the old ones and then reverted these changes and the tooling re-ordered and removed translation.
| { | ||
| } | ||
|
|
||
| // For testing purposes only. |
There was a problem hiding this comment.
❕ Make sure we have an issue filed to have the test suite always enable rules for testing and remove this constructor.
| | x.Prop++ | ✔️ | ✔️ | | | | | ||
| | Foo(x.Prop) | ✔️ | | | | | | ||
| | Foo(x.Prop), | | | ✔️ | | | | ||
| where void Foo(in T v) |
There was a problem hiding this comment.
what if the Prop returns by value and not by ref, in which case there is a copy made? and other cases where there is a copy instead of a reference when in is skipped in the argument? is this handled properly as a Read as opposed to ReadableRef?
…methods/properties/events)
Analyzer flags two cases: members with no read/writes and members with only writes.
Type '{0}' has an unused private member '{1}' which can be removed.Type '{0}' has a private member '{1}' which can be removed as the value assigned to it is never used.Code fix removes the unused member declaration.
Fixes #24225
Open questions: