File: src\Analyzers\CSharp\Analyzers\UsePrimaryConstructor\CSharpUsePrimaryConstructorDiagnosticAnalyzer.cs
Web Access
Project: src\src\Features\CSharp\Portable\Microsoft.CodeAnalysis.CSharp.Features.csproj (Microsoft.CodeAnalysis.CSharp.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.
 
// Ignore Spelling: kvp
 
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using Microsoft.CodeAnalysis.CodeStyle;
using Microsoft.CodeAnalysis.CSharp.CodeStyle;
using Microsoft.CodeAnalysis.CSharp.Extensions;
using Microsoft.CodeAnalysis.CSharp.Shared.Extensions;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Collections;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.CSharp.UsePrimaryConstructor;
 
/// <summary>
/// Looks for code of the forms:
/// <code>
///     class Point
///     {
///         private int x;
///         private int y;
/// 
///         public C(int x, int y)
///         {
///             this.x = x;
///             this.y = y;
///         }
///     }
/// </code>
/// and converts it to:
/// <code>
///     class Point(int x, int y)
///     {
///     }
/// </code>
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
internal sealed class CSharpUsePrimaryConstructorDiagnosticAnalyzer()
    : AbstractBuiltInCodeStyleDiagnosticAnalyzer(
        IDEDiagnosticIds.UsePrimaryConstructorDiagnosticId,
        EnforceOnBuildValues.UsePrimaryConstructor,
        CSharpCodeStyleOptions.PreferPrimaryConstructors,
        new LocalizableResourceString(
            nameof(CSharpAnalyzersResources.Use_primary_constructor), CSharpAnalyzersResources.ResourceManager, typeof(CSharpAnalyzersResources)))
{
    // Deliberately using names that could not be actual field/property names in the properties dictionary.
    public const string AllFieldsName = "<>AllFields";
    public const string AllPropertiesName = "<>AllProperties";
 
    private static readonly ObjectPool<ConcurrentSet<ISymbol>> s_concurrentSetPool = new(() => []);
 
    public override DiagnosticAnalyzerCategory GetAnalyzerCategory()
        => DiagnosticAnalyzerCategory.SemanticDocumentAnalysis;
 
    protected override void InitializeWorker(AnalysisContext context)
    {
        context.RegisterCompilationStartAction(context =>
        {
            if (!context.Compilation.LanguageVersion().SupportsPrimaryConstructors())
                return;
 
            // Mapping from a named type to a particular analyzer we have created for it. Needed because nested
            // types need to update the information for their containing types while they themselves are being
            // analyzed.
            var namedTypeToAnalyzer = new ConcurrentDictionary<INamedTypeSymbol, Analyzer>();
            context.RegisterSymbolStartAction(context => Analyzer.AnalyzeNamedTypeStart(this, context, namedTypeToAnalyzer), SymbolKind.NamedType);
        });
    }
 
    public static bool IsViableMemberToAssignTo(
        INamedTypeSymbol namedType,
        [NotNullWhen(true)] ISymbol? member,
        [NotNullWhen(true)] out MemberDeclarationSyntax? memberNode,
        [NotNullWhen(true)] out SyntaxNode? nodeToRemove,
        CancellationToken cancellationToken)
    {
        memberNode = null;
        nodeToRemove = null;
        if (member is not IFieldSymbol and not IPropertySymbol)
            return false;
 
        if (member.IsImplicitlyDeclared)
            return false;
 
        if (member.IsStatic)
            return false;
 
        if (!namedType.Equals(member.ContainingType))
            return false;
 
        if (member.DeclaringSyntaxReferences is not [var memberReference, ..])
            return false;
 
        nodeToRemove = memberReference.GetSyntax(cancellationToken);
        if (nodeToRemove is not VariableDeclaratorSyntax and not PropertyDeclarationSyntax)
            return false;
 
        // If it's a property, then it has to be an auto property in order for us to be able to initialize is
        // directly outside of a constructor.
        if (nodeToRemove is PropertyDeclarationSyntax property)
        {
            if (property.AccessorList is null ||
                property.AccessorList.Accessors.Any(static a => a.ExpressionBody != null || a.Body != null))
            {
                return false;
            }
 
            memberNode = property;
            return true;
        }
        else if (nodeToRemove is VariableDeclaratorSyntax { Parent: VariableDeclarationSyntax { Parent: FieldDeclarationSyntax field } })
        {
            memberNode = field;
            return true;
        }
        else
        {
            return false;
        }
    }
 
    /// <summary>
    /// Helper type we create that encapsulates all the state we need while processing.
    /// </summary>
    private sealed class Analyzer(
        CSharpUsePrimaryConstructorDiagnosticAnalyzer diagnosticAnalyzer,
        CodeStyleOption2<bool> styleOption,
        INamedTypeSymbol namedType,
        ConstructorDeclarationSyntax primaryConstructorDeclaration,
        PooledDictionary<ISymbol, IParameterSymbol> candidateMembersToRemove,
        ConcurrentDictionary<INamedTypeSymbol, Analyzer> namedTypeToAnalyzer)
    {
        private readonly CSharpUsePrimaryConstructorDiagnosticAnalyzer _diagnosticAnalyzer = diagnosticAnalyzer;
 
        private readonly CodeStyleOption2<bool> _styleOption = styleOption;
        private readonly INamedTypeSymbol _namedType = namedType;
        private readonly ConstructorDeclarationSyntax _primaryConstructorDeclaration = primaryConstructorDeclaration;
 
        private readonly PooledDictionary<ISymbol, IParameterSymbol> _candidateMembersToRemove = candidateMembersToRemove;
        private readonly ConcurrentDictionary<INamedTypeSymbol, Analyzer> _namedTypeToAnalyzer = namedTypeToAnalyzer;
 
        /// <summary>
        /// Needs to be concurrent as we can process members in parallel in <see
        /// cref="AnalyzeFieldOrPropertyReference"/>.
        /// </summary>
        private readonly ConcurrentSet<ISymbol> _membersThatCannotBeRemoved = s_concurrentSetPool.Allocate();
 
        public bool HasCandidateMembersToRemove => _candidateMembersToRemove.Count > 0;
 
        private void OnSymbolEnd(SymbolAnalysisContext context)
        {
            // Pass along a mapping of field/property name to the constructor parameter name that will replace it.
            var properties = _candidateMembersToRemove
                .Where(kvp => !_membersThatCannotBeRemoved.Contains(kvp.Key))
                .ToImmutableDictionary(
                    static kvp => kvp.Key.Name,
                    static kvp => (string?)kvp.Value.Name);
 
            // To provide better user-facing-strings, keep track of whether or not all the members we'd be
            // removing are all fields or all properties.
            if (_candidateMembersToRemove.Any(kvp => kvp.Key is IFieldSymbol) &&
                _candidateMembersToRemove.All(kvp => kvp.Key is IFieldSymbol))
            {
                properties = properties.Add(AllFieldsName, AllFieldsName);
            }
            else if (
                _candidateMembersToRemove.Any(kvp => kvp.Key is IPropertySymbol) &&
                _candidateMembersToRemove.All(kvp => kvp.Key is IPropertySymbol))
            {
                properties = properties.Add(AllPropertiesName, AllPropertiesName);
            }
 
            context.ReportDiagnostic(DiagnosticHelper.Create(
                _diagnosticAnalyzer.Descriptor,
                _primaryConstructorDeclaration.Identifier.GetLocation(),
                _styleOption.Notification,
                context.Options,
                [_primaryConstructorDeclaration.GetLocation()],
                properties));
 
            _candidateMembersToRemove.Free();
            s_concurrentSetPool.ClearAndFree(_membersThatCannotBeRemoved);
 
            _namedTypeToAnalyzer.TryRemove(_namedType, out _);
        }
 
        public static void AnalyzeNamedTypeStart(
            CSharpUsePrimaryConstructorDiagnosticAnalyzer diagnosticAnalyzer,
            SymbolStartAnalysisContext context,
            ConcurrentDictionary<INamedTypeSymbol, Analyzer> namedTypeToAnalyzer)
        {
            var compilation = context.Compilation;
            var cancellationToken = context.CancellationToken;
 
            var startSymbol = (INamedTypeSymbol)context.Symbol;
            var options = context.Options;
 
            // Ensure that any analyzers for containing types are created and they hear about any reference to their
            // fields in this nested type.
 
            for (var containingType = startSymbol.ContainingType; containingType != null; containingType = containingType.ContainingType)
            {
                var containingTypeAnalyzer = TryGetOrCreateAnalyzer(containingType);
                RegisterFieldOrPropertyAnalysisIfNecessary(containingTypeAnalyzer);
            }
 
            // Now try to make the analyzer for this type.
            var analyzer = TryGetOrCreateAnalyzer(startSymbol);
            if (analyzer != null)
            {
                RegisterFieldOrPropertyAnalysisIfNecessary(analyzer);
                context.RegisterSymbolEndAction(analyzer.OnSymbolEnd);
            }
 
            return;
 
            void RegisterFieldOrPropertyAnalysisIfNecessary(Analyzer? analyzer)
            {
                if (analyzer is { HasCandidateMembersToRemove: true })
                {
                    // Look to see if we have trivial `_x = x` or `this.x = x` assignments.  If so, then the field/prop
                    // could be a candidate for removal (as long as we determine that all use sites of the field/prop would
                    // be able to use the captured primary constructor parameter).
                    context.RegisterOperationAction(
                        analyzer.AnalyzeFieldOrPropertyReference,
                        OperationKind.FieldReference, OperationKind.PropertyReference);
                }
            }
 
            Analyzer? TryGetOrCreateAnalyzer(
                INamedTypeSymbol namedType)
            {
                if (!namedTypeToAnalyzer.TryGetValue(namedType, out var analyzer))
                {
                    analyzer = TryCreateAnalyzer(namedType);
                    if (analyzer != null)
                        namedTypeToAnalyzer.TryAdd(namedType, analyzer);
 
                    // If another thread beat us, defer to that.
                    namedTypeToAnalyzer.TryGetValue(namedType, out analyzer);
                }
 
                return analyzer;
            }
 
            Analyzer? TryCreateAnalyzer(INamedTypeSymbol namedType)
            {
                // Bail immediately if the user has disabled this feature.
                if (namedType.DeclaringSyntaxReferences is not [var reference, ..])
                    return null;
 
                var styleOption = options.GetCSharpAnalyzerOptions(reference.SyntaxTree).PreferPrimaryConstructors;
                if (!styleOption.Value
                    || diagnosticAnalyzer.ShouldSkipAnalysis(reference.SyntaxTree, context.Options, context.Compilation.Options, styleOption.Notification, cancellationToken))
                {
                    return null;
                }
 
                // only classes/structs can have primary constructors (not interfaces, enums or delegates).
                if (namedType.TypeKind is not (TypeKind.Class or TypeKind.Struct))
                    return null;
 
                // Don't want to offer on records.  It's completely fine for records to not use primary constructs and
                // instead use init-properties.
                if (namedType.IsRecord)
                    return null;
 
                // No need to offer this if the type already has a primary constructor.
                if (namedType.TryGetPrimaryConstructor(out _))
                    return null;
 
                // Need to see if there is a single constructor that either calls `base(...)` or has no constructor
                // initializer (and thus implicitly calls `base()`), and that all other constructors call `this(...)`.
                if (!TryFindPrimaryConstructorCandidate(namedType, out var primaryConstructor, out var primaryConstructorDeclaration))
                    return null;
 
                // If we have no parameters and no attributes, then this is a trivial constructor.  We don't want to
                // spam the user to convert this to a primary constructor.  What would likely be better would be to
                // offer to remove the constructor entirely.  We do offer when there are attributes to help with cases
                // like simple DI constructors that have no parameters, but would still be nicer with the attributes
                // moved to the type instead.
                if (primaryConstructor.Parameters.Length == 0 && primaryConstructorDeclaration.AttributeLists.Count == 0)
                    return null;
 
                // protected constructor in an abstract type is fine.  It will stay protected even as a primary constructor.
                // otherwise it has to be public.
                if (primaryConstructor.DeclaredAccessibility == Accessibility.Protected)
                {
                    if (!namedType.IsAbstract)
                        return null;
                }
                else if (primaryConstructor.DeclaredAccessibility != Accessibility.Public)
                {
                    return null;
                }
 
                // Constructor has to have a real body (can't be extern/etc.).
                if (primaryConstructorDeclaration is { Body: null, ExpressionBody: null })
                    return null;
 
                if (primaryConstructorDeclaration.Parent is not TypeDeclarationSyntax)
                    return null;
 
                if (primaryConstructor.Parameters.Any(static p => p.RefKind is RefKind.Ref or RefKind.Out))
                    return null;
 
                // Now ensure the constructor body is something that could be converted to a primary constructor (i.e.
                // only assignments to instance fields/props on this).
                var candidateMembersToRemove = PooledDictionary<ISymbol, IParameterSymbol>.GetInstance();
                if (!AnalyzeConstructorBody(namedType, primaryConstructorDeclaration, candidateMembersToRemove))
                {
                    candidateMembersToRemove.Free();
                    return null;
                }
 
                return new Analyzer(
                    diagnosticAnalyzer,
                    styleOption,
                    namedType,
                    primaryConstructorDeclaration,
                    candidateMembersToRemove,
                    namedTypeToAnalyzer);
            }
 
            bool TryFindPrimaryConstructorCandidate(
                INamedTypeSymbol namedType,
                [NotNullWhen(true)] out IMethodSymbol? primaryConstructor,
                [NotNullWhen(true)] out ConstructorDeclarationSyntax? primaryConstructorDeclaration)
            {
                primaryConstructor = null;
                primaryConstructorDeclaration = null;
 
                var constructors = namedType.InstanceConstructors;
 
                foreach (var constructor in constructors)
                {
                    // Can ignore the implicit struct constructor.  It doesn't block us making a real constructor
                    // into a primary constructor.
                    if (namedType.IsStructType() && constructor.IsImplicitlyDeclared)
                        continue;
 
                    // Needs to just have a single declaration
                    if (constructor.DeclaringSyntaxReferences is not [var constructorReference])
                        return false;
 
                    if (constructorReference.GetSyntax(cancellationToken) is not ConstructorDeclarationSyntax constructorDeclaration)
                        return false;
 
                    if (constructorDeclaration.Initializer is null or (kind: SyntaxKind.BaseConstructorInitializer))
                    {
                        // Can only have one candidate
                        if (primaryConstructor != null)
                            return false;
 
                        if (constructor.Parameters.Any(p => p.Type.IsRefLikeType))
                            return false;
 
                        primaryConstructor = constructor;
                        primaryConstructorDeclaration = constructorDeclaration;
                    }
                    else
                    {
                        Debug.Assert(constructorDeclaration.Initializer.Kind() == SyntaxKind.ThisConstructorInitializer);
                    }
                }
 
                return primaryConstructor != null;
            }
 
            bool AnalyzeConstructorBody(
                INamedTypeSymbol namedType,
                ConstructorDeclarationSyntax primaryConstructorDeclaration,
                Dictionary<ISymbol, IParameterSymbol> candidateMembersToRemove)
            {
                var semanticModel = compilation.GetSemanticModel(primaryConstructorDeclaration.SyntaxTree);
 
                var body = primaryConstructorDeclaration.ExpressionBody ?? (SyntaxNode?)primaryConstructorDeclaration.Body;
                if (body?.ContainsDirectives is true)
                    return false;
 
                return primaryConstructorDeclaration switch
                {
                    { ExpressionBody.Expression: AssignmentExpressionSyntax assignmentExpression }
                        => IsAssignmentToInstanceMember(namedType, semanticModel, assignmentExpression, candidateMembersToRemove, orderedParameterAssignments: null, out _),
                    { Body: { } block }
                        => AnalyzeBlockBody(namedType, semanticModel, block, candidateMembersToRemove),
                    _ => false,
                };
            }
 
            bool AnalyzeBlockBody(
                INamedTypeSymbol namedType,
                SemanticModel semanticModel,
                BlockSyntax block,
                Dictionary<ISymbol, IParameterSymbol> candidateMembersToRemove)
            {
                // Quick pass.  Must all be assignment expressions.  Don't have to do any more analysis if we see anything beyond that.
                if (!block.Statements.All(static s => s is ExpressionStatementSyntax { Expression: AssignmentExpressionSyntax }))
                    return false;
 
                using var _1 = PooledHashSet<ISymbol>.GetInstance(out var assignedMembers);
                using var _2 = ArrayBuilder<(IParameterSymbol parameter, SyntaxNode assignedMemberDeclaration, bool parameterIsWrittenTo)>.GetInstance(out var orderedParameterAssignments);
 
                foreach (var statement in block.Statements)
                {
                    if (statement is not ExpressionStatementSyntax { Expression: AssignmentExpressionSyntax assignmentExpression } ||
                        !IsAssignmentToInstanceMember(
                            namedType, semanticModel, assignmentExpression, candidateMembersToRemove, orderedParameterAssignments, out var member))
                    {
                        return false;
                    }
 
                    // Only allow a single write to the same member
                    if (!assignedMembers.Add(member))
                        return false;
                }
 
                // If we have a mutation of one of the parameters, and the parameter was referenced in multiple
                // assignments, then we can't convert this over if the order of actual members in the type is not the
                // same as the order of members we were assigning to.
                foreach (var group in orderedParameterAssignments.GroupBy(t => t.parameter))
                {
                    var parameter = group.Key;
                    if (group.Any(t => t.parameterIsWrittenTo) && group.Count() > 1)
                    {
                        var lastAssignedMemberDeclaration = group.First().assignedMemberDeclaration;
                        foreach (var (_, currentAssignedMemberDeclaration, _) in group.Skip(1))
                        {
                            // All the member decls have to be in the same containing type decl for this to be safe.
                            if (lastAssignedMemberDeclaration.FirstAncestorOrSelf<BaseTypeDeclarationSyntax>() !=
                                currentAssignedMemberDeclaration.FirstAncestorOrSelf<BaseTypeDeclarationSyntax>())
                            {
                                return false;
                            }
 
                            // They all have to be in order for this to be safe.
                            if (lastAssignedMemberDeclaration.SpanStart >= currentAssignedMemberDeclaration.SpanStart)
                                return false;
 
                            lastAssignedMemberDeclaration = currentAssignedMemberDeclaration;
                        }
                    }
                }
 
                return true;
            }
 
            bool IsAssignmentToInstanceMember(
                INamedTypeSymbol namedType,
                SemanticModel semanticModel,
                AssignmentExpressionSyntax assignmentExpression,
                Dictionary<ISymbol, IParameterSymbol> candidateMembersToRemove,
                ArrayBuilder<(IParameterSymbol parameter, SyntaxNode assignedMemberDeclaration, bool parameterIsWrittenTo)>? orderedParameterAssignments,
                [NotNullWhen(true)] out ISymbol? member)
            {
                member = null;
 
                if (assignmentExpression.Kind() != SyntaxKind.SimpleAssignmentExpression)
                    return false;
 
                // has to be of the form:
                //
                // x = ...      // or
                // this.x = ...
                var leftIdentifier = assignmentExpression.Left switch
                {
                    IdentifierNameSyntax identifierName => identifierName,
                    MemberAccessExpressionSyntax(kind: SyntaxKind.SimpleMemberAccessExpression) { Expression: (kind: SyntaxKind.ThisExpression), Name: IdentifierNameSyntax identifierName } => identifierName,
                    _ => null,
                };
 
                if (leftIdentifier is null)
                    return false;
 
                // Quick syntactic lookup.
                if (namedType.GetMembers(leftIdentifier.Identifier.ValueText).IsEmpty)
                    return false;
 
                // Has to bind to a field/prop on this type.
                member = semanticModel.GetSymbolInfo(leftIdentifier, cancellationToken).GetAnySymbol()?.OriginalDefinition;
                if (!IsViableMemberToAssignTo(namedType, member, out _, out var assignedMemberDeclaration, cancellationToken))
                    return false;
 
                // Left side looks good.  Now check the right side.  It cannot reference 'this' (as that is not
                // legal once we move this to initialize the field/prop in the rewrite).
                //
                // Note: we have to walk down suppressions as the IOp tree gives back nothing for them.
                var rightOperation = semanticModel.GetOperation(assignmentExpression.Right.WalkDownSuppressions());
                if (rightOperation is null)
                    return false;
 
                foreach (var operation in rightOperation.DescendantsAndSelf())
                {
                    if (operation is IInstanceReferenceOperation)
                        return false;
 
                    if (orderedParameterAssignments != null &&
                        operation is IParameterReferenceOperation { Syntax: IdentifierNameSyntax parameterName } parameterReference)
                    {
                        var isWrittenTo = parameterName.IsWrittenTo(semanticModel, cancellationToken);
                        orderedParameterAssignments.Add((parameterReference.Parameter, assignedMemberDeclaration, isWrittenTo));
                    }
 
                    // If we're referencing a local, then it must have been a local generated by an out-var, or a
                    // pattern.  And, if so, it's only safe to move this if the local we're referencing was produced
                    // under the same expression we're moving (not a prior statement).
                    if (operation is ILocalReferenceOperation { Local.DeclaringSyntaxReferences: [var syntaxRef, ..] } &&
                        !syntaxRef.GetSyntax(cancellationToken).AncestorsAndSelf().Any(a => a == assignmentExpression))
                    {
                        return false;
                    }
                }
 
                // Looks good, both the left and right sides are legal.
 
                // If we have an assignment of the form `private_member = param`, then that member can be a candidate for removal.
                if (member.DeclaredAccessibility == Accessibility.Private &&
                    !member.GetAttributes().Any() &&
                    semanticModel.GetSymbolInfo(assignmentExpression.Right, cancellationToken).GetAnySymbol() is IParameterSymbol parameter &&
                    parameter.Type.Equals(member.GetMemberType(), SymbolEqualityComparer.IncludeNullability))
                {
                    candidateMembersToRemove[member] = parameter;
                }
 
                return true;
            }
        }
 
        private void AnalyzeFieldOrPropertyReference(OperationAnalysisContext context)
        {
            var operation = (IMemberReferenceOperation)context.Operation;
            var semanticModel = operation.SemanticModel;
            Contract.ThrowIfNull(semanticModel);
 
            var instance = operation.Instance;
 
            // static field/property.  not something we're interested in.
            if (instance is null)
                return;
 
            var member = operation.Member.OriginalDefinition;
 
            // Don't need to analyze this again, if it's already in the list of members we can't remove.
            if (_membersThatCannotBeRemoved.Contains(member))
                return;
 
            // we only care about reference to members in our candidate-removal set.  Can ignore everything else.
            if (!_candidateMembersToRemove.TryGetValue(member, out var parameter))
                return;
 
            // not a reference off of 'this'.  e.g. `== other.fieldName`, we could not remove this member.
            if (instance is not IInstanceReferenceOperation)
            {
                _membersThatCannotBeRemoved.Add(member);
                return;
            }
 
            // it's either `this.field` or just `field`.  We can replace with a reference to 'paramName' *if* that
            // name in the current location doesn't bind to something else.
            var symbols = semanticModel.LookupSymbols(operation.Syntax.SpanStart, name: parameter.Name);
            if (symbols.Any(s => !Equals(s, parameter) && !Equals(s, member)))
            {
                _membersThatCannotBeRemoved.Add(member);
                return;
            }
 
            // looks good.  We should be able to remove this.
        }
    }
}