-
Notifications
You must be signed in to change notification settings - Fork 4.1k
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
Add Make Field Readonly analyzer/code fix #19067
Conversation
Add a test for when the field is assigned in a lamda defined in the ctor. public class Foo
{
private int a;
public Foo()
{
this.E += (_, __) => this.a = 5;
}
public event EventHandler E;
} |
Test for when another instance is assigned in te constructor. public class Foo
{
private int value;
public Foo()
{
var foo = new Foo();
foo.value = 5;
}
} Remember to check when the field is assigned using object intitailizer style also. |
Tagging @dotnet/roslyn-ide for review. |
} | ||
|
||
[Fact, Trait(Traits.Feature, Traits.Features.CodeActionsMakeFieldReadonly)] | ||
public async Task FieldIsInternal() |
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.
any protected or protected-internal tests?
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, I can add those if you like.
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.
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.
@CyrusNajmabadi This would be a place where I would have used a [Theory]
instead of [Fact]
, in order to highlight that the only change in the individual test cases is the accessibility modifier.
@Hosch250 This is just a comment, no action required.
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.
So i'm amenable to theories if the following is true:
- The theories are entirely self describing.
- The theory only changes one aspect of the test.
The thing i want to avoid (which i've seen in other tests in our codebase) is something like so:
public void Test()
{
Test($".........{ subst1 } ........ { subst2 } .......... { subst3 } ......... { subst4 } .........");
}
When this happens, the test, and the permutated data become so decoupled as to great diminish the ability to understand what's happening. You lose the forest for the trees as it were.
If the test is really only permuting a single thing, i would have less issue with this sort of approach.
class MyClass | ||
{ | ||
private int _bar = 0; | ||
private readonly int _foo = 0; |
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 don't like this. if both could be made readonly, the user is going to want the declarations to stay together. only if one coudl not be made readonly should it be split.
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.
OK. I split them to make the fix simpler, and because I don't like fields being declared together anyway. It should be fairly trivial to move some of the code to a helper file and use it in the code fix 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.
@CyrusNajmabadi I'm not worried. This is a relatively rare case. A follow-up issue could be added to make the Fix All handle the case differently where possible.
❗️ @Hosch250 The part I definitely don't like here is the reordering of the initializers. When splitting multiple initializers, the order should not change. This is required in order to preserve the semantics of the original statement during the change. Make sure to test all three cases:
private int _changed, _extra;
private int _extra, _changed;
private int _extra1, _changed, _extra2;
In the latter case the result should be this:
private int _extra1;
private readonly int _changed;
private int _extra2;
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.
OK. I'll change this.
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.
💡 Would be good to have tests for preserving comments when splitting.
public async Task PassedAsParameter() | ||
{ | ||
await TestMissingInRegularAndScriptAsync( | ||
@"namespace ConsoleApplication1 |
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.
all the namespaces can be removed from all tests.
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.
{ | ||
class MyClass | ||
{ | ||
private int [|_foo|]; |
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.
why can't this be readonly?
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.
Huh, it can be. I must have missed that when I copy/pasted the test.
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 one is missing the attribute. No wonder it didn't fail on me.
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.
|
||
internal override bool CanBeReadonly(SemanticModel model, SyntaxNode node) | ||
{ | ||
if (node.Parent is AssignmentExpressionSyntax assignmentNode && assignmentNode.Left == node) |
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.
We have a helper called IsWrittenTo that you can use for this purpose. It should replace this entire method.
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.
internal override bool IsMemberOfThisInstance(SyntaxNode node) | ||
{ | ||
// if it is a qualified name, make sure it is `this.name` | ||
if (node.Parent is MemberAccessExpressionSyntax memberAccess) |
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.
Consider using IOperation and getting the IFieldReference operation for this, and validating it that way.
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 can't use IOperation because it doesn't fire on field declarations without assignments.
var model = await document.GetSemanticModelAsync().ConfigureAwait(false); | ||
var symbol = (IFieldSymbol)model.GetDeclaredSymbol(declaration); | ||
|
||
var generator = SyntaxGenerator.GetGenerator(document); |
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.
generator available off of the 'editor' instance.
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.
} | ||
} | ||
|
||
private async void MakeFieldReadonly(Document document, SyntaxEditor editor, SyntaxNode root, TVariableDeclaratorSyntax declaration) |
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 don't think you use root.
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.
} | ||
} | ||
|
||
internal abstract SyntaxNode GetInitializerNode(TVariableDeclaratorSyntax declaration); |
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.
internal? can this be protected?
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.
Yes.
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.
internal abstract SyntaxNode GetInitializerNode(TVariableDeclaratorSyntax declaration); | ||
internal abstract int GetVariableDeclaratorCount(TFieldDeclarationSyntax declaration); | ||
|
||
protected override Task FixAllAsync( |
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.
why did you need this override?
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 is required by the base class.
{ | ||
var typeSymbol = (ITypeSymbol)context.SemanticModel.GetDeclaredSymbol(context.Node); | ||
|
||
var nonReadonlyFieldMembers = new HashSet<IFieldSymbol>(); |
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.
pool this set.
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.
Pool it?
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.
@CyrusNajmabadi Would using a List<T>
fix this? It doesn't have to be a HashSet<T>
.
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.
Check out PooledHashSet :)
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.
var membersCanBeReadonly = nonReadonlyFieldMembers; | ||
foreach (var syntaxReference in typeSymbol.DeclaringSyntaxReferences) | ||
{ | ||
var typeNode = syntaxReference.SyntaxTree.GetRoot().FindNode(syntaxReference.Span); |
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.
use cancellation token for GEtRoot.
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.
@@ -613,4 +613,7 @@ | |||
<data name="Changing_document_property_is_not_supported" xml:space="preserve"> | |||
<value>Changing document properties is not supported</value> | |||
</data> | |||
<data name="Make_field_readonly" xml:space="preserve"> |
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 be at feature level, not workspace leve.
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.
OK. Is there any particular reason the Populate Switch message is at workspace level?
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.
} | ||
|
||
[Fact, Trait(Traits.Feature, Traits.Features.CodeActionsMakeFieldReadonly)] | ||
public async Task FieldIsInternal() |
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.
@CyrusNajmabadi This would be a place where I would have used a [Theory]
instead of [Fact]
, in order to highlight that the only change in the individual test cases is the accessibility modifier.
@Hosch250 This is just a comment, no action required.
class MyClass | ||
{ | ||
private int _bar = 0; | ||
private readonly int _foo = 0; |
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.
@CyrusNajmabadi I'm not worried. This is a relatively rare case. A follow-up issue could be added to make the Fix All handle the case differently where possible.
❗️ @Hosch250 The part I definitely don't like here is the reordering of the initializers. When splitting multiple initializers, the order should not change. This is required in order to preserve the semantics of the original statement during the change. Make sure to test all three cases:
private int _changed, _extra;
private int _extra, _changed;
private int _extra1, _changed, _extra2;
In the latter case the result should be this:
private int _extra1;
private readonly int _changed;
private int _extra2;
private int [|_foo|]; | ||
void Foo() | ||
{ | ||
Bar(ref _foo); |
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.
💡 Add a test where this is used only in the constructor (allowed). If not supported, keep the test but file a follow-up issue to support the scenario in the future.
💡 Add matching tests for passing an out
variable.
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.
OK. That should work because I do the ctor test first.
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.
This will need a user facing option if we're going to take this :) Note: i'm not sure if this belongs in Roslyn, or would be better in the Roslyn-analyzers repo. Thoughts @Pilchie ? |
Don't bother checking my last changes--I broke things, then committed without compiling and accidentally synced instead of waiting until I'd fixed everything. |
Would you like the user-facing option in this PR, or would you take it in another PR? I think I'll wait until this is finished before adding that either way. |
It would need to be in this PR. We only really want to add features if they can be considered ready for shipping to users. Thanks! |
@CyrusNajmabadi @sharwell Please check out my latest changes. I followed sharwell's suggestion about splitting the declarations for Fix One and keeping them in the same order, but I do not split them for Fix All if they can all be readonly. When any need to be split, I split them all just to be consistent. |
@Hosch250 Note that this would fall into a category where it may lie outside your area of expertise, and we wouldn't want to reject a feature that comes with tests and all just because of this. If this is a requirement for acceptance, then we would do one of the following to help you through it:
In the next week, we'll have a discussion about the scope of analyzers we want to see here as opposed to separately (e.g. dotnet/roslyn-analyzers, or some other location). For analyzers in those other repositories, the user-facing option is much simpler - it isn't supported so it's not required. 😄 |
@sharwell This would not be outside of my experience--I did it for another feature, but didn't have time to follow it through to completion because college started. It is pretty much just copy/pasting code from other options. |
@sharwell @CyrusNajmabadi The user-facing option is done. |
@Hosch250 Some users may not realize that adding For example, this field is only assigned in the constructor, but if you mark it with |
var syntaxFactsService = GetSyntaxFactsService(); | ||
var root = semanticModel.SyntaxTree.GetRoot(cancellationToken); | ||
var candidateTypes = PooledHashSet<(ITypeSymbol, SyntaxNode)>.GetInstance(); | ||
AddCandidateTypesInCompilationUnit(semanticModel, root, candidateTypes, cancellationToken); |
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.
We would not need this method if the analyzer were to be written as a symbol analyzer which registers for SymbolKind.NamedType
. We can directly check if the symbol has 1 declaring reference/location to consider it as a candidate.
candidateFields.Free(); | ||
} | ||
|
||
private void AddCandidateFieldsInType(SemanticModelAnalysisContext context, ITypeSymbol typeSymbol, SyntaxNode typeSyntax, PooledHashSet<IFieldSymbol> candidateFields) |
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.
static?
Hrm. This doesn't seem necessary. As you mentioned "In the short term (target 15.7) we will leverage the same approach used by the Use Auto Property analyzer". UseAutoProp doesn't need to exclude partial classes. It just analyzes the properties it finds in the parts it finds within a SemanticModel. It feels liek the same would be done for MakeFieldReadOnly. We would analyze in practically the same manner. Both features feel very similar from an implementation perspective. They look at properties or fields in a single part of a partial type at a time. And they also look for things that disallow the conversion from happening. Once hte whole semantic model has been walked, eligible matches are reported. |
Partial classes are excluded to ensure no diagnostic is reported in the following case: // A.cs
partial class MyType
{
private int _value;
}
// B.cs
partial class MyType
{
private void SetValue(int value) { _value = value; }
} |
Note: while UseAutoProperty does necessarily have to examine all parts when deciding on eligibility, it does so batched up at the end. i.e. after finding all the properties htat could be made into an auto-property in a single file, it then goes and batches them, aggregating all their containing types (in case they belonged to different types). It then groups those types by the files their parts are contained in. And it processes each of those files once, examining them in their entirety for things that would make the fix ineligible. This approach seems like it would fit to a T what we need for MakeFieldReadOnly. |
UseAutoProp has no problem with that scenario. There are two passes. The first pass collects the candidates (in your case See: CSharpUseAutoPropertyAnalyzer.RegisterIneligibleFieldsAction roslyn/src/EditorFeatures/CSharp/UseAutoProperty/CSharpUseAutoPropertyAnalyzer.cs Lines 64 to 85 in 5fbba20
|
@CyrusNajmabadi It's possible we could expand support in the future, but we decided to scope limit the analysis to single-file cases only to increase confidence in the runtime performance of the initial implementation given the relatively short time we'll have to evaluate it before release. |
That's reasonable. I can def accept perf concerns vs the false-positive concern. Could we file an issue on that choice? Perhaps linking to this discussion? I perf tested UseAutoProp out on roslyn, and didn't encounter any issues with this approach (and roslyn uses partial types a lot). But i can understand not wanting to risk things. it would be nice though if this could be added later when proper due diligence can be done! Thanks! If you'd like, i can also file it. |
@CyrusNajmabadi Sure, I already filed an issue to migrate to IOperation (where this problem would not occur) but you could separately call out the improvement to handling partial types in a new issue. |
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.
Please remove the unncessary AddCandidateTypesInCompilationUnit
method by changing it to a named type symbol analyzer.
Captured meeting notes from discussion here |
The current implementation avoids analyzing the contents of nested types multiple times. Switching to a symbol analyzer makes it difficult to preserve this functionality without losing the simplicity benefit of switching to a symbol analyzer, and increases the discrepancy between this and the Use Auto Property analyzer. |
I feel this is an extremely rare scenario, especially given our claim here that partial types are not important enough and we are going to skip past large number of types. Personally, I would rather not complicate this already over-complicated implementation to potentially gain perf benefit for corner cases. However, given that we plan to fix the compilation end action bug soon, and delete this implementation, I am okay with this approach.
I don't think that should be relevant. |
It typically wouldn't be. It only became relevant as a secondary benefit when the primary change didn't yield a clear net advantage to either approach. |
Approval pending for 15.7 Preview 4 (new feature ) |
Hooray! 🎆 |
Great job, everyone. |
VSO bug : https://devdiv.visualstudio.com/DevDiv/_workitems/edit/590185
Close #10273
Specs