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

Add Make Field Readonly analyzer/code fix #19067

Merged
merged 25 commits into from
Mar 28, 2018
Merged

Add Make Field Readonly analyzer/code fix #19067

merged 25 commits into from
Mar 28, 2018

Conversation

Hosch250
Copy link
Contributor

@Hosch250 Hosch250 commented Apr 28, 2017

VSO bug : https://devdiv.visualstudio.com/DevDiv/_workitems/edit/590185

Close #10273

Specs

  • Only works on private fields (more accessible fields should be replaced with properties anyway)
  • Quick fix separates any fields declared in a single statement
  • Fields are split for Fix One and declared in the same order as originally
  • For Fix All, if all the fields in a declaration can be readonly, they are not split
  • User-facing option allows user to enable/disable and set the severity for this analyzer

@sharwell sharwell added the Community The pull request was submitted by a contributor who is not a Microsoft employee. label Apr 28, 2017
@JohanLarsson
Copy link

JohanLarsson commented Apr 28, 2017

Add a test for when the field is assigned in a lamda defined in the ctor.
Pseudo code:

public class Foo
{
    private int a;

    public Foo()
    {
        this.E += (_, __) => this.a = 5;
    }

    public event EventHandler E;
}

@JohanLarsson
Copy link

Test for when another instance is assigned in te constructor.
Pseudo code:

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.

@Pilchie
Copy link
Member

Pilchie commented Apr 28, 2017

Tagging @dotnet/roslyn-ide for review.

}

[Fact, Trait(Traits.Feature, Traits.Features.CodeActionsMakeFieldReadonly)]
public async Task FieldIsInternal()
Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed.

Copy link
Member

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.

Copy link
Member

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:

  1. The theories are entirely self describing.
  2. 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;
Copy link
Member

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.

Copy link
Contributor Author

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.

Copy link
Member

@sharwell sharwell Apr 28, 2017

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:

  1. private int _changed, _extra;
  2. private int _extra, _changed;
  3. private int _extra1, _changed, _extra2;

In the latter case the result should be this:

private int _extra1;
private readonly int _changed;
private int _extra2;

Copy link
Contributor Author

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.

Copy link
Member

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
Copy link
Member

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.

Copy link
Contributor Author

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|];
Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Contributor Author

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.

Copy link
Contributor Author

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)
Copy link
Member

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.

Copy link
Contributor Author

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)
Copy link
Member

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.

Copy link
Contributor Author

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);
Copy link
Member

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.

Copy link
Contributor Author

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)
Copy link
Member

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.

Copy link
Contributor Author

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);
Copy link
Member

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes.

Copy link
Contributor Author

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(
Copy link
Member

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?

Copy link
Contributor Author

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>();
Copy link
Member

Choose a reason for hiding this comment

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

pool this set.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Pool it?

Copy link
Contributor Author

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>.

Copy link
Member

Choose a reason for hiding this comment

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

Check out PooledHashSet :)

Copy link
Contributor Author

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);
Copy link
Member

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.

Copy link
Contributor Author

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">
Copy link
Member

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.

Copy link
Contributor Author

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?

Copy link
Contributor Author

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()
Copy link
Member

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;
Copy link
Member

@sharwell sharwell Apr 28, 2017

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:

  1. private int _changed, _extra;
  2. private int _extra, _changed;
  3. 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);
Copy link
Member

@sharwell sharwell Apr 28, 2017

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.

Copy link
Contributor Author

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed.

@CyrusNajmabadi
Copy link
Member

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 ?

@Hosch250
Copy link
Contributor Author

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.

@Hosch250
Copy link
Contributor Author

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.

@CyrusNajmabadi
Copy link
Member

Would you like the user-facing option in this PR,

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!

@Hosch250
Copy link
Contributor Author

@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.

@sharwell
Copy link
Member

It would need to be in this PR.

@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:

  1. Find time to help you through the details in a timely manner, or
  2. Create a feature branch in the repository so this could be incorporated without the user-facing option there, and finally merged into master after someone adds the option.

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. 😄

@Hosch250
Copy link
Contributor Author

@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.

@Hosch250
Copy link
Contributor Author

@sharwell @CyrusNajmabadi The user-facing option is done.

@sharwell
Copy link
Member

sharwell commented Apr 29, 2017

@Hosch250 Some users may not realize that adding readonly to a field whose type is a mutable value type (struct) can change semantics and possibly break the behavior of an application. Have we taken any steps in the analysis or the code fix (especially the Fix All) to help avoid quietly breaking the behavior in ways that a user is likely to not understand?

For example, this field is only assigned in the constructor, but if you mark it with readonly it will break the behavior of the type.

var syntaxFactsService = GetSyntaxFactsService();
var root = semanticModel.SyntaxTree.GetRoot(cancellationToken);
var candidateTypes = PooledHashSet<(ITypeSymbol, SyntaxNode)>.GetInstance();
AddCandidateTypesInCompilationUnit(semanticModel, root, candidateTypes, cancellationToken);
Copy link
Contributor

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)
Copy link
Contributor

Choose a reason for hiding this comment

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

static?

@CyrusNajmabadi
Copy link
Member

In addition to the restriction to only consider private fields, we will avoid false positives by excluding partial classes from consideration, except where they are nested within a non-partial class.

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.

@sharwell
Copy link
Member

Hrm. This doesn't seem necessary.

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

@CyrusNajmabadi
Copy link
Member

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.

@CyrusNajmabadi
Copy link
Member

Partial classes are excluded to ensure no diagnostic is reported in the following case:

UseAutoProp has no problem with that scenario. There are two passes. The first pass collects the candidates (in your case _value). The second pass computes eligibility. The second pass would see here that _value was written in a disallowed location and would remove _value from the candidate set.

See:

CSharpUseAutoPropertyAnalyzer.RegisterIneligibleFieldsAction

protected override void RegisterIneligibleFieldsAction(
List<AnalysisResult> analysisResults, HashSet<IFieldSymbol> ineligibleFields,
Compilation compilation, CancellationToken cancellationToken)
{
var groups = analysisResults.Select(r => (TypeDeclarationSyntax)r.PropertyDeclaration.Parent)
.Distinct()
.GroupBy(n => n.SyntaxTree);
foreach (var group in groups)
{
var tree = group.Key;
var semanticModel = compilation.GetSemanticModel(tree);
foreach (var typeDeclaration in group)
{
foreach (var argument in typeDeclaration.DescendantNodesAndSelf().OfType<ArgumentSyntax>())
{
AnalyzeArgument(semanticModel, argument, ineligibleFields, cancellationToken);
}
}
}
}

@sharwell
Copy link
Member

@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.

@CyrusNajmabadi
Copy link
Member

CyrusNajmabadi commented Mar 23, 2018

@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.

@sharwell
Copy link
Member

sharwell commented Mar 23, 2018

@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.

Copy link
Contributor

@mavasani mavasani left a 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.

@heejaechang
Copy link
Contributor

heejaechang commented Mar 24, 2018

Captured meeting notes from discussion here

@sharwell
Copy link
Member

Please remove the unncessary AddCandidateTypesInCompilationUnit method by changing it to a named type symbol analyzer.

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.

@mavasani
Copy link
Contributor

The current implementation avoids analyzing the contents of nested types multiple times. Switching to a symbol analyzer makes it difficult to preserve this functionality

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.

increases the discrepancy between this and the Use Auto Property analyzer.

I don't think that should be relevant.

@sharwell
Copy link
Member

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.

@jinujoseph
Copy link
Contributor

Approval pending for 15.7 Preview 4 (new feature )

@jinujoseph
Copy link
Contributor

jinujoseph commented Mar 27, 2018

Approved to merge the feature in 15.7 Preview 4 via Link ( make sure to address review feedback and conflict ) cc @Pilchie for info

@Pilchie
Copy link
Member

Pilchie commented Mar 28, 2018

Hooray! 🎆

@sharwell sharwell merged commit 09bafa5 into dotnet:dev15.7.x Mar 28, 2018
@Hosch250
Copy link
Contributor Author

Hosch250 commented Apr 2, 2018

Great job, everyone.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Approved to merge Area-IDE cla-already-signed Community The pull request was submitted by a contributor who is not a Microsoft employee.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants