File: UseAutoProperty\AbstractUseAutoPropertyCodeFixProvider.cs
Web Access
Project: src\src\Features\Core\Portable\Microsoft.CodeAnalysis.Features.csproj (Microsoft.CodeAnalysis.Features)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Editing;
using Microsoft.CodeAnalysis.Formatting;
using Microsoft.CodeAnalysis.Formatting.Rules;
using Microsoft.CodeAnalysis.LanguageService;
using Microsoft.CodeAnalysis.Rename;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.UseAutoProperty;
 
using static UseAutoPropertiesHelpers;
 
internal abstract partial class AbstractUseAutoPropertyCodeFixProvider<TProvider, TTypeDeclarationSyntax, TPropertyDeclaration, TVariableDeclarator, TConstructorDeclaration, TExpression>
    : CodeFixProvider
    where TProvider : AbstractUseAutoPropertyCodeFixProvider<TProvider, TTypeDeclarationSyntax, TPropertyDeclaration, TVariableDeclarator, TConstructorDeclaration, TExpression>
    where TTypeDeclarationSyntax : SyntaxNode
    where TPropertyDeclaration : SyntaxNode
    where TVariableDeclarator : SyntaxNode
    where TConstructorDeclaration : SyntaxNode
    where TExpression : SyntaxNode
{
    protected static SyntaxAnnotation SpecializedFormattingAnnotation = new();
 
    public sealed override ImmutableArray<string> FixableDiagnosticIds
        => [IDEDiagnosticIds.UseAutoPropertyDiagnosticId];
 
    public sealed override FixAllProvider GetFixAllProvider()
        => new UseAutoPropertyFixAllProvider((TProvider)this);
 
    protected abstract TPropertyDeclaration GetPropertyDeclaration(SyntaxNode node);
    protected abstract SyntaxNode GetNodeToRemove(TVariableDeclarator declarator);
    protected abstract TPropertyDeclaration RewriteFieldReferencesInProperty(
        TPropertyDeclaration property, LightweightRenameLocations fieldLocations, CancellationToken cancellationToken);
 
    protected abstract ImmutableArray<AbstractFormattingRule> GetFormattingRules(
        Document document, SyntaxNode finalPropertyDeclaration);
 
    protected abstract Task<SyntaxNode> UpdatePropertyAsync(
        Document propertyDocument,
        Compilation compilation,
        IFieldSymbol fieldSymbol,
        IPropertySymbol propertySymbol,
        TVariableDeclarator fieldDeclarator,
        TPropertyDeclaration propertyDeclaration,
        bool isWrittenOutsideConstructor,
        bool isTrivialGetAccessor,
        bool isTrivialSetAccessor,
        CancellationToken cancellationToken);
 
    public sealed override Task RegisterCodeFixesAsync(CodeFixContext context)
    {
        var solution = context.Document.Project.Solution;
 
        foreach (var diagnostic in context.Diagnostics)
        {
            var priority = diagnostic.Severity == DiagnosticSeverity.Hidden
                ? CodeActionPriority.Low
                : CodeActionPriority.Default;
 
            context.RegisterCodeFix(CodeAction.SolutionChangeAction.Create(
                    AnalyzersResources.Use_auto_property,
                    cancellationToken => ProcessResultAsync(solution, solution, diagnostic, cancellationToken),
                    equivalenceKey: nameof(AnalyzersResources.Use_auto_property),
                    priority),
                diagnostic);
        }
 
        return Task.CompletedTask;
    }
 
    private async Task<Solution> ProcessResultAsync(
        Solution originalSolution, Solution currentSolution, Diagnostic diagnostic, CancellationToken cancellationToken)
    {
        var (field, property) = await MapDiagnosticToCurrentSolutionAsync(
            diagnostic, originalSolution, currentSolution, cancellationToken).ConfigureAwait(false);
 
        if (field == null || property == null)
            return currentSolution;
 
        var locations = diagnostic.AdditionalLocations;
 
        var fieldDocument = currentSolution.GetRequiredDocument(field.DeclaringSyntaxReferences[0].GetSyntax(cancellationToken).SyntaxTree);
        var propertyDocument = currentSolution.GetRequiredDocument(property.DeclaringSyntaxReferences[0].GetSyntax(cancellationToken).SyntaxTree);
 
        var isTrivialGetAccessor = diagnostic.Properties.ContainsKey(IsTrivialGetAccessor);
        var isTrivialSetAccessor = diagnostic.Properties.ContainsKey(IsTrivialSetAccessor);
 
        Debug.Assert(fieldDocument.Project == propertyDocument.Project);
        var project = fieldDocument.Project;
        var compilation = await project.GetRequiredCompilationAsync(cancellationToken).ConfigureAwait(false);
 
        var renameOptions = new SymbolRenameOptions();
 
        var fieldLocations = await Renamer.FindRenameLocationsAsync(
            currentSolution, field, renameOptions, cancellationToken).ConfigureAwait(false);
 
        var declarator = (TVariableDeclarator)field.DeclaringSyntaxReferences[0].GetSyntax(cancellationToken);
        var propertyDeclaration = GetPropertyDeclaration(property.DeclaringSyntaxReferences[0].GetSyntax(cancellationToken));
 
        // First, create the updated property we want to replace the old property with
        var isWrittenToOutsideOfConstructor = IsWrittenToOutsideOfConstructorOrProperty(
            field, fieldLocations, propertyDeclaration, cancellationToken);
 
        if (!isTrivialGetAccessor ||
            (property.SetMethod != null && !isTrivialSetAccessor))
        {
            // We have at least a non-trivial getter/setter.  Those will not be rewritten to `get;/set;`.  As such, we
            // need to update the property to reference `field` or itself instead of the actual field.
            propertyDeclaration = RewriteFieldReferencesInProperty(propertyDeclaration, fieldLocations, cancellationToken);
        }
 
        var updatedProperty = await UpdatePropertyAsync(
            propertyDocument, compilation,
            field, property,
            declarator, propertyDeclaration,
            isWrittenToOutsideOfConstructor, isTrivialGetAccessor, isTrivialSetAccessor,
            cancellationToken).ConfigureAwait(false);
 
        // Note: rename will try to update all the references in linked files as well.  However, 
        // this can lead to some very bad behavior as we will change the references in linked files
        // but only remove the field and update the property in a single document.  So, you can
        // end in the state where you do this in one of the linked file:
        //
        //      int Prop { get { return this.field; } } => int Prop { get { return this.Prop } }
        //
        // But in the main file we'll replace:
        //
        //      int Prop { get { return this.field; } } => int Prop { get; }
        //
        // The workspace will see these as two irreconcilable edits.  To avoid this, we disallow
        // any edits to the other links for the files containing the field and property.  i.e.
        // rename will only be allowed to edit the exact same doc we're removing the field from
        // and the exact doc we're updating the property in.  It can't touch the other linked
        // files for those docs.  (It can of course touch any other documents unrelated to the
        // docs that the field and prop are declared in).
        var linkedFiles = new HashSet<DocumentId>();
        linkedFiles.AddRange(fieldDocument.GetLinkedDocumentIds());
        linkedFiles.AddRange(propertyDocument.GetLinkedDocumentIds());
 
        var canEdit = new Dictionary<DocumentId, bool>();
 
        // Now, rename all usages of the field to point at the property.  Except don't actually 
        // rename the field itself.  We want to be able to find it again post rename.
        //
        // We're asking the rename API to update a bunch of references to an existing field to the same name as an
        // existing property.  Rename will often flag this situation as an unresolvable conflict because the new
        // name won't bind to the field anymore.
        //
        // To address this, we let rename know that there is no conflict if the new symbol it resolves to is the
        // same as the property we're trying to get the references pointing to.
 
        var filteredLocations = fieldLocations.Filter(
            (documentId, span) =>
                fieldDocument.Id == documentId ? !span.IntersectsWith(declarator.Span) : true && // The span check only makes sense if we are in the same file
                CanEditDocument(currentSolution, documentId, linkedFiles, canEdit));
 
        var resolution = await filteredLocations.ResolveConflictsAsync(
            field, property.Name,
            nonConflictSymbolKeys: [property.GetSymbolKey(cancellationToken)],
            cancellationToken).ConfigureAwait(false);
 
        Contract.ThrowIfFalse(resolution.IsSuccessful);
 
        currentSolution = resolution.NewSolution;
 
        // Now find the field and property again post rename.
        fieldDocument = currentSolution.GetRequiredDocument(fieldDocument.Id);
        propertyDocument = currentSolution.GetRequiredDocument(propertyDocument.Id);
        Debug.Assert(fieldDocument.Project == propertyDocument.Project);
 
        compilation = await fieldDocument.Project.GetRequiredCompilationAsync(cancellationToken).ConfigureAwait(false);
 
        field = (IFieldSymbol?)field.GetSymbolKey(cancellationToken).Resolve(compilation, cancellationToken: cancellationToken).Symbol;
        property = (IPropertySymbol?)property.GetSymbolKey(cancellationToken).Resolve(compilation, cancellationToken: cancellationToken).Symbol;
        Contract.ThrowIfTrue(field == null || property == null);
 
        declarator = (TVariableDeclarator)field.DeclaringSyntaxReferences[0].GetSyntax(cancellationToken);
        propertyDeclaration = GetPropertyDeclaration(property.DeclaringSyntaxReferences[0].GetSyntax(cancellationToken));
 
        var nodeToRemove = GetNodeToRemove(declarator);
 
        // If we have a situation where the property is the second member in a type, and it
        // would become the first, then remove any leading blank lines from it so we don't have
        // random blanks above it that used to space it from the field that was there.
        //
        // The reason we do this special processing is that the first member of a type tends to
        // be special wrt leading trivia. i.e. users do not normally put blank lines before the
        // first member. And so, when a type now becomes the first member, we want to follow the
        // user's common pattern here.
        //
        // In all other code cases, i.e.when there are multiple fields above, or the field is
        // below the property, then the property isn't now becoming "the first member", and as
        // such, it doesn't want this special behavior about it's leading blank lines. i.e. if
        // the user has:
        //
        //  class C
        //  {
        //      int i;
        //      int j;
        //
        //      int Prop => j;
        //  }
        //
        // Then if we remove 'j' (or even 'i'), then 'Prop' would stay the non-first member, and
        // would definitely want to keep that blank line above it.
        //
        // In essence, the blank line above the property exists for separation from what's above
        // it. As long as something is above it, we keep the separation. However, if the
        // property becomes the first member in the type, the separation is now inappropriate
        // because there's nothing to actually separate it from.
        if (fieldDocument == propertyDocument)
        {
            var syntaxFacts = fieldDocument.GetRequiredLanguageService<ISyntaxFactsService>();
            var bannerService = fieldDocument.GetRequiredLanguageService<IFileBannerFactsService>();
            if (WillRemoveFirstFieldInTypeDirectlyAboveProperty(syntaxFacts, propertyDeclaration, nodeToRemove) &&
                bannerService.GetLeadingBlankLines(nodeToRemove).Length == 0)
            {
                updatedProperty = bannerService.GetNodeWithoutLeadingBlankLines(updatedProperty);
            }
        }
 
        var syntaxRemoveOptions = CreateSyntaxRemoveOptions(nodeToRemove);
        if (fieldDocument == propertyDocument)
        {
            // Same file.  Have to do this in a slightly complicated fashion.
            var declaratorTreeRoot = await fieldDocument.GetRequiredSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
 
            var editor = new SyntaxEditor(declaratorTreeRoot, fieldDocument.Project.Solution.Services);
            editor.ReplaceNode(propertyDeclaration, updatedProperty);
            editor.RemoveNode(nodeToRemove, syntaxRemoveOptions);
 
            var newRoot = editor.GetChangedRoot();
            newRoot = await FormatAsync(newRoot, fieldDocument, updatedProperty, cancellationToken).ConfigureAwait(false);
 
            return currentSolution.WithDocumentSyntaxRoot(fieldDocument.Id, newRoot);
        }
        else
        {
            // In different files.  Just update both files.
            var fieldTreeRoot = await fieldDocument.GetRequiredSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
            var propertyTreeRoot = await propertyDocument.GetRequiredSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
 
            var newFieldTreeRoot = fieldTreeRoot.RemoveNode(nodeToRemove, syntaxRemoveOptions);
            Contract.ThrowIfNull(newFieldTreeRoot);
            var newPropertyTreeRoot = propertyTreeRoot.ReplaceNode(propertyDeclaration, updatedProperty);
 
            newFieldTreeRoot = await FormatAsync(newFieldTreeRoot, fieldDocument, updatedProperty, cancellationToken).ConfigureAwait(false);
            newPropertyTreeRoot = await FormatAsync(newPropertyTreeRoot, propertyDocument, updatedProperty, cancellationToken).ConfigureAwait(false);
 
            var updatedSolution = currentSolution.WithDocumentSyntaxRoot(fieldDocument.Id, newFieldTreeRoot);
            updatedSolution = updatedSolution.WithDocumentSyntaxRoot(propertyDocument.Id, newPropertyTreeRoot);
 
            return updatedSolution;
        }
    }
 
    private async Task<(IFieldSymbol? fieldSymbol, IPropertySymbol? propertySymbol)> MapDiagnosticToCurrentSolutionAsync(
        Diagnostic diagnostic,
        Solution originalSolution,
        Solution currentSolution,
        CancellationToken cancellationToken)
    {
        var locations = diagnostic.AdditionalLocations;
 
        var propertyLocation = locations[0];
        var declaratorLocation = locations[1];
 
        // Look up everything in the original solution.
 
        var declarator = (TVariableDeclarator)declaratorLocation.FindNode(cancellationToken);
        var fieldDocument = originalSolution.GetRequiredDocument(declarator.SyntaxTree);
        var fieldSemanticModel = await fieldDocument.GetRequiredSemanticModelAsync(cancellationToken).ConfigureAwait(false);
        var fieldSymbol = (IFieldSymbol)fieldSemanticModel.GetRequiredDeclaredSymbol(declarator, cancellationToken);
 
        var property = GetPropertyDeclaration(propertyLocation.FindNode(cancellationToken));
        var propertyDocument = originalSolution.GetRequiredDocument(property.SyntaxTree);
        var propertySemanticModel = await propertyDocument.GetRequiredSemanticModelAsync(cancellationToken).ConfigureAwait(false);
        var propertySymbol = (IPropertySymbol)propertySemanticModel.GetRequiredDeclaredSymbol(property, cancellationToken);
 
        Contract.ThrowIfFalse(fieldDocument.Project == propertyDocument.Project);
 
        // If we're just starting, no need to map anything.
        if (originalSolution != currentSolution)
        {
            var currentProject = currentSolution.GetRequiredProject(fieldDocument.Project.Id);
            var currentCompilation = await currentProject.GetRequiredCompilationAsync(cancellationToken).ConfigureAwait(false);
 
            fieldSymbol = fieldSymbol.GetSymbolKey(cancellationToken).Resolve(currentCompilation, cancellationToken: cancellationToken).GetAnySymbol() as IFieldSymbol;
            propertySymbol = propertySymbol.GetSymbolKey(cancellationToken).Resolve(currentCompilation, cancellationToken: cancellationToken).GetAnySymbol() as IPropertySymbol;
        }
 
        return (fieldSymbol, propertySymbol);
    }
 
    private static SyntaxRemoveOptions CreateSyntaxRemoveOptions(SyntaxNode nodeToRemove)
    {
        var syntaxRemoveOptions = SyntaxGenerator.DefaultRemoveOptions;
        var hasDirective = nodeToRemove.GetLeadingTrivia().Any(t => t.IsDirective);
 
        if (hasDirective)
        {
            syntaxRemoveOptions |= SyntaxRemoveOptions.KeepLeadingTrivia;
        }
 
        return syntaxRemoveOptions;
    }
 
    private static bool WillRemoveFirstFieldInTypeDirectlyAboveProperty(
        ISyntaxFactsService syntaxFacts, TPropertyDeclaration property, SyntaxNode fieldToRemove)
    {
        if (fieldToRemove.Parent == property.Parent &&
            fieldToRemove.Parent is TTypeDeclarationSyntax typeDeclaration)
        {
            var members = syntaxFacts.GetMembersOfTypeDeclaration(typeDeclaration);
            return members[0] == fieldToRemove && members[1] == property;
        }
 
        return false;
    }
 
    private static bool CanEditDocument(
        Solution solution,
        DocumentId documentId,
        HashSet<DocumentId> linkedDocuments,
        Dictionary<DocumentId, bool> canEdit)
    {
        if (!canEdit.TryGetValue(documentId, out var canEditDocument))
        {
            var document = solution.GetDocument(documentId);
            canEditDocument = document != null && !linkedDocuments.Contains(document.Id);
            canEdit[documentId] = canEditDocument;
        }
 
        return canEditDocument;
    }
 
    private async Task<SyntaxNode> FormatAsync(
        SyntaxNode newRoot,
        Document document,
        SyntaxNode finalPropertyDeclaration,
        CancellationToken cancellationToken)
    {
        var formattingRules = GetFormattingRules(document, finalPropertyDeclaration);
        if (formattingRules.IsDefault)
            return newRoot;
 
        var options = await document.GetSyntaxFormattingOptionsAsync(cancellationToken).ConfigureAwait(false);
        return Formatter.Format(newRoot, SpecializedFormattingAnnotation, document.Project.Solution.Services, options, formattingRules, cancellationToken);
    }
 
    private static bool IsWrittenToOutsideOfConstructorOrProperty(
        IFieldSymbol field, LightweightRenameLocations renameLocations, TPropertyDeclaration propertyDeclaration, CancellationToken cancellationToken)
    {
        var constructorSpans = field.ContainingType.GetMembers()
                                                   .Where(m => m.IsConstructor())
                                                   .SelectMany(c => c.DeclaringSyntaxReferences)
                                                   .Select(s => s.GetSyntax(cancellationToken))
                                                   .Select(n => n.FirstAncestorOrSelf<TConstructorDeclaration>())
                                                   .WhereNotNull()
                                                   .Select(d => (d.SyntaxTree.FilePath, d.Span))
                                                   .ToSet();
        return renameLocations.Locations.Any(
            loc => IsWrittenToOutsideOfConstructorOrProperty(
                renameLocations.Solution, loc, propertyDeclaration, constructorSpans, cancellationToken));
    }
 
    private static bool IsWrittenToOutsideOfConstructorOrProperty(
        Solution solution,
        RenameLocation location,
        TPropertyDeclaration propertyDeclaration,
        ISet<(string filePath, TextSpan span)> constructorSpans,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        if (!location.IsWrittenTo)
        {
            // We don't need a setter if we're not writing to this field.
            return false;
        }
 
        var syntaxFacts = solution.GetRequiredDocument(location.DocumentId).GetRequiredLanguageService<ISyntaxFactsService>();
        var node = location.Location.FindToken(cancellationToken).Parent;
 
        while (node != null && !syntaxFacts.IsAnonymousOrLocalFunction(node))
        {
            if (node == propertyDeclaration)
            {
                // Not a write outside the property declaration.
                return false;
            }
 
            if (constructorSpans.Contains((node.SyntaxTree.FilePath, node.Span)))
            {
                // Not a write outside a constructor of the field's class
                return false;
            }
 
            node = node.Parent;
        }
 
        // We do need a setter
        return true;
    }
}