File: Microsoft.NetCore.Analyzers\Runtime\PreferReadOnlySpanPropertiesOverReadOnlyArrayFieldsAnalyzer.cs
Web Access
Project: Microsoft.CodeAnalysis.NetAnalyzers.csproj (Microsoft.CodeAnalysis.NetAnalyzers)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Analyzer.Utilities;
using Analyzer.Utilities.Extensions;
using Analyzer.Utilities.PooledObjects;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;
using Microsoft.CodeAnalysis.Text;
using static Microsoft.NetCore.Analyzers.MicrosoftNetCoreAnalyzersResources;

namespace Microsoft.NetCore.Analyzers.Runtime
{
    public abstract class PreferReadOnlySpanPropertiesOverReadOnlyArrayFieldsAnalyzer : DiagnosticAnalyzer
    {
        internal const string RuleId = "CA1878";

        internal static readonly DiagnosticDescriptor Rule = DiagnosticDescriptorHelper.Create(
            RuleId,
            CreateLocalizableResourceString(nameof(PreferReadOnlySpanPropertiesOverReadOnlyArrayFieldsTitle)),
            CreateLocalizableResourceString(nameof(PreferReadOnlySpanPropertiesOverReadOnlyArrayFieldsMessage)),
            DiagnosticCategory.Performance,
            RuleLevel.IdeSuggestion,
            CreateLocalizableResourceString(nameof(PreferReadOnlySpanPropertiesOverReadOnlyArrayFieldsDescription)),
            isPortedFxCopRule: false,
            isDataflowRule: false,
            isReportedAtCompilationEnd: false);

        public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(Rule);

        protected abstract bool IsApplicableToLanguageVersion(ParseOptions options);

        public override void Initialize(AnalysisContext context)
        {
            // References in generated partial declarations must still disqualify fields declared in user code.
            context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze);
            context.EnableConcurrentExecution();
            context.RegisterCompilationStartAction(OnCompilationStart);
        }

        private void OnCompilationStart(CompilationStartAnalysisContext context)
        {
            if (!context.Compilation.SyntaxTrees.All(tree => IsApplicableToLanguageVersion(tree.Options)) ||
                !RequiredSymbols.TryGetRequiredSymbols(context.Compilation, out RequiredSymbols? symbols))
            {
                return;
            }

            var cache = new Cache();
            var fieldReferenceVisitor = new FieldReferenceVisitor(symbols, cache);
            context.RegisterOperationAction(
                AnalyzeOperation,
                OperationKind.FieldInitializer,
                OperationKind.FieldReference);
            context.RegisterSymbolStartAction(OnSymbolStart, SymbolKind.NamedType);
            context.RegisterCompilationEndAction(_ => cache.Dispose());

            return;

            //  Local functions.

            void OnSymbolStart(SymbolStartAnalysisContext context)
            {
                if (!ShouldAnalyze((INamedTypeSymbol)context.Symbol))
                {
                    return;
                }

                context.RegisterSymbolEndAction(OnSymbolEnd);
            }

            bool ShouldAnalyze(INamedTypeSymbol namedType)
            {
                foreach (var member in namedType.GetMembers())
                {
                    if (member is IFieldSymbol field &&
                        IsPotentialField(field, symbols.SupportsMultiBytePrimitiveTypes))
                    {
                        return true;
                    }
                }

                return false;
            }

            //  We analyze two types of operations: IFieldReferenceOperations and IFieldInitializerOperations.
            //  We maintain collections of candidate fields with valid field initializers.
            //  We analyze IFieldReferenceOperations and eliminate candidates that are used in ways that prohibit
            //  conversion to ReadOnlySpan.
            void AnalyzeOperation(OperationAnalysisContext context)
            {
                switch (context.Operation)
                {
                    case IFieldInitializerOperation fieldInitializer:
                        foreach (var field in fieldInitializer.InitializedFields)
                        {
                            if (IsValidCandidate(field, fieldInitializer.Value, symbols))
                            {
                                cache.AddCandidate(field);
                            }
                        }

                        break;
                    case IFieldReferenceOperation fieldReference:
                        if (!IsPotentialField(fieldReference.Field, symbols.SupportsMultiBytePrimitiveTypes))
                        {
                            break;
                        }

                        if (IsWithinExpressionTree(fieldReference, symbols.LinqExpressionTreeType))
                        {
                            //  Eliminate candidates referenced within an expression tree, where a
                            //  ReadOnlySpan<T> property cannot be used (CS8640).
                            cache.EliminateCandidate(fieldReference.Field);
                        }
                        else if (fieldReference.GetValueUsageInfo(context.ContainingSymbol) is
                            ValueUsageInfo.ReadableWritableReference or ValueUsageInfo.WritableReference)
                        {
                            //  Eliminate candidates that are assigned to ref or out variables.
                            cache.EliminateCandidate(fieldReference.Field);
                        }
                        else
                        {
                            // Eliminate candidates that are used in ways that prohibit conversion to ReadOnlySpan.
                            if (fieldReference.Parent is IOperation parent)
                            {
                                                parent.Accept(fieldReferenceVisitor, new VisitContext(fieldReference, fieldReference.Field, context.ContainingSymbol));
                            }
                            else
                            {
                                cache.EliminateCandidate(fieldReference.Field);
                            }
                        }
                        break;
                }
            }

            //  Report diagnostics for all fields declared by this type that survived candidate elimination.
            void OnSymbolEnd(SymbolAnalysisContext context)
            {
                var namedType = (INamedTypeSymbol)context.Symbol;
                foreach (var field in namedType.GetMembers().OfType<IFieldSymbol>())
                {
                    if (!cache.IsCandidate(field))
                    {
                        continue;
                    }

                    var asSpanReferences = cache.GetAsSpanReferences(field);
                    var asSpanLocations = asSpanReferences is null
                        ? ImmutableArray<Location>.Empty
                        : asSpanReferences
                            .OrderBy(location => location.SourceTree?.FilePath, StringComparer.Ordinal)
                            .ThenBy(location => location.SourceSpan.Start)
                            .ToImmutableArray();
                    var messageArgument = ((IArrayTypeSymbol)field.Type).ElementType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
                    var diagnostic = field.Locations[0].CreateDiagnostic(Rule, asSpanLocations, properties: null, messageArgument);
                    context.ReportDiagnostic(diagnostic);
                }
            }
        }

        internal static bool IsValidCandidate(
            IFieldSymbol field,
            IOperation initializerValue,
            INamedTypeSymbol? attributeUsageAttributeType,
            bool supportsMultiBytePrimitiveTypes)
        {
            if (!IsPotentialField(field, supportsMultiBytePrimitiveTypes) ||
                !CanMoveAttributesToProperty(field, attributeUsageAttributeType) ||
                field.Type is not IArrayTypeSymbol)
            {
                return false;
            }

            return initializerValue switch
            {
                IArrayCreationOperation { Initializer: { } initializer }
                    => initializer.ElementValues.All(element => element.ConstantValue.HasValue),
                IArrayInitializerOperation initializer
                    => initializer.ElementValues.All(element => element.ConstantValue.HasValue),
                _ => false,
            };
        }

        private static bool IsWithinExpressionTree(
            IOperation operation,
            INamedTypeSymbol? linqExpressionTreeType)
        {
            if (linqExpressionTreeType is null)
            {
                return false;
            }

            for (IOperation? ancestor = operation.Parent; ancestor is not null; ancestor = ancestor.Parent)
            {
                if (ancestor is IAnonymousFunctionOperation or ILocalFunctionOperation &&
                    ancestor.Parent?.Type?.OriginalDefinition is ITypeSymbol lambdaType &&
                    SymbolEqualityComparer.Default.Equals(linqExpressionTreeType, lambdaType))
                {
                    return true;
                }
            }

            return false;
        }

        private static bool IsValidCandidate(IFieldSymbol field, IOperation initializerValue, RequiredSymbols symbols)
            => IsValidCandidate(
                field,
                initializerValue,
                symbols.AttributeUsageAttributeType,
                symbols.SupportsMultiBytePrimitiveTypes);

        private static bool IsSupportedArrayElementType(ITypeSymbol elementType, bool supportsMultiBytePrimitiveTypes)
            => elementType.IsPrimitiveType() &&
                elementType.SpecialType != SpecialType.System_String &&
                (supportsMultiBytePrimitiveTypes ||
                elementType.SpecialType is
                    SpecialType.System_Boolean or
                    SpecialType.System_Byte or
                    SpecialType.System_SByte);

        private static bool IsPotentialField(IFieldSymbol field, bool supportsMultiBytePrimitiveTypes)
            => field.IsStatic &&
                field.IsReadOnly &&
                field.IsPrivate() &&
                field.Type is IArrayTypeSymbol { Rank: 1 } arrayType &&
                IsSupportedArrayElementType(arrayType.ElementType, supportsMultiBytePrimitiveTypes);

        internal static bool SupportsMultiBytePrimitiveTypes(
            Compilation compilation,
            INamedTypeSymbol readOnlySpanType)
        {
            INamedTypeSymbol? runtimeHelpersType =
                compilation.GetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemRuntimeCompilerServicesRuntimeHelpers);

            return runtimeHelpersType?.GetMembers("CreateSpan").OfType<IMethodSymbol>().Any(
                method =>
                    method.IsStatic &&
                    method.IsGenericMethod &&
                    method.Arity == 1 &&
                    method.DeclaredAccessibility == Accessibility.Public &&
                    method.Parameters is [{ RefKind: RefKind.None, Type.SpecialType: SpecialType.System_RuntimeFieldHandle }] &&
                    method.ReturnType is INamedTypeSymbol
                    {
                        Arity: 1,
                        TypeArguments: [ITypeParameterSymbol returnTypeParameter],
                    } returnType &&
                    SymbolEqualityComparer.Default.Equals(returnType.OriginalDefinition, readOnlySpanType) &&
                    SymbolEqualityComparer.Default.Equals(returnTypeParameter, method.TypeParameters[0])) == true;
        }

        private static bool CanMoveAttributesToProperty(IFieldSymbol field, INamedTypeSymbol? attributeUsageAttributeType)
        {
            foreach (var attribute in field.GetAttributes())
            {
                if (attribute.AttributeClass is INamedTypeSymbol attributeClass &&
                    !IsValidOnProperty(attributeClass, attributeUsageAttributeType))
                {
                    return false;
                }
            }

            return true;
        }

        private static bool IsValidOnProperty(INamedTypeSymbol attributeClass, INamedTypeSymbol? attributeUsageAttributeType)
        {
            // 'AttributeUsageAttribute' is itself inherited, so a derived attribute type takes the
            // targets declared by the nearest base type that declares them.
            for (INamedTypeSymbol? type = attributeClass; type is not null; type = type.BaseType)
            {
                foreach (var usage in type.GetAttributes())
                {
                    if (SymbolEqualityComparer.Default.Equals(usage.AttributeClass, attributeUsageAttributeType) &&
                        usage.ConstructorArguments.Length == 1 &&
                        usage.ConstructorArguments[0].Value is int validOn)
                    {
                        return (validOn & (int)AttributeTargets.Property) != 0;
                    }
                }
            }

            // No '[AttributeUsage]' was found, so the attribute defaults to 'AttributeTargets.All'.
            return true;
        }

        /// <summary>
        /// Visits the parents of <see cref="IFieldReferenceOperation"/>s and eliminates candidates that
        /// are used in ways that prohibit conversion to <see cref="ReadOnlySpan{T}"/>.
        /// </summary>
        private sealed class FieldReferenceVisitor : OperationVisitor<VisitContext, Unit>
        {
            private readonly RequiredSymbols _symbols;
            private readonly Cache _cache;

            public FieldReferenceVisitor(RequiredSymbols symbols, Cache cache)
            {
                _symbols = symbols;
                _cache = cache;
            }

            public override Unit VisitArrayElementReference(IArrayElementReferenceOperation operation, VisitContext argument)
            {
                if (operation.GetValueUsageInfo(argument.ContainingSymbol).IsWrittenTo())
                {
                    _cache.EliminateCandidate(argument.Field);
                }
                else if (operation.Type is IArrayTypeSymbol)
                {
                    // A range index returns an array, so validate its parent as a whole-array use.
                    if (operation.Parent is IOperation parent)
                    {
                        parent.Accept(this, argument);
                    }
                    else
                    {
                        _cache.EliminateCandidate(argument.Field);
                    }
                }

                return default;
            }

            public override Unit VisitPropertyReference(IPropertyReferenceOperation operation, VisitContext argument)
            {
                if (!operation.Property.Equals(_symbols.ArrayLengthProperty, SymbolEqualityComparer.Default))
                {
                    _cache.EliminateCandidate(argument.Field);
                }

                return default;
            }

            public override Unit VisitArgument(IArgumentOperation operation, VisitContext argument)
            {
                if (operation.Parent is IInvocationOperation invocation &&
                    _symbols.IsAsSpanMethod(invocation.TargetMethod) &&
                    invocation.Parent is IConversionOperation conversion &&
                    conversion.Type is not null &&
                    conversion.Type.OriginalDefinition.Equals(_symbols.ReadOnlySpanType, SymbolEqualityComparer.Default))
                {
                    //  Save the field reference itself rather than the wrapping argument so the fixer can
                    //  locate it again by span. For a named argument (e.g. 'MemoryExtensions.AsSpan(array: a)')
                    //  the argument syntax is wider than the field reference and would otherwise resolve to
                    //  the wrong node in the fixer.
                    _cache.AddAsSpanReference(argument.Field, argument.FieldReference.Syntax.GetLocation());
                }
                else
                {
                    _cache.EliminateCandidate(argument.Field);
                }

                return default;
            }

            public override Unit VisitConversion(IConversionOperation operation, VisitContext argument)
            {
                if (operation.Parent is IForEachLoopOperation { Collection: var collection } &&
                    ReferenceEquals(operation, collection) &&
                    operation.IsImplicit &&
                    CanUseReadOnlySpanInForEach(argument))
                {
                    return default;
                }

                if (operation.Type is null ||
                    !operation.Type.OriginalDefinition.Equals(_symbols.ReadOnlySpanType, SymbolEqualityComparer.Default))
                {
                    _cache.EliminateCandidate(argument.Field);
                }

                return default;
            }

            public override Unit VisitNameOf(INameOfOperation operation, VisitContext argument)
            {
                return default;
            }

            public override Unit VisitForEachLoop(IForEachLoopOperation operation, VisitContext argument)
            {
                if (!ReferenceEquals(operation.Collection, argument.FieldReference) ||
                    !CanUseReadOnlySpanInForEach(argument))
                {
                    _cache.EliminateCandidate(argument.Field);
                }

                return default;
            }

            public override Unit DefaultVisit(IOperation operation, VisitContext argument)
            {
                // A whole array can only become a ReadOnlySpan<T> in the explicitly handled contexts above.
                // Conservatively reject new operation shapes so the fixer cannot produce uncompilable code.
                _cache.EliminateCandidate(argument.Field);
                return default;
            }

            private bool CanUseReadOnlySpanInForEach(VisitContext argument)
            {
                if (argument.ContainingSymbol is not IMethodSymbol { IsAsync: false } ||
                    argument.FieldReference.IsWithinLambdaOrLocalFunction(out _))
                {
                    return false;
                }

                IOperation root = argument.FieldReference;
                while (root.Parent is IOperation parent)
                {
                    root = parent;
                }

                return !root.HasAnyOperationDescendant(
                    operation =>
                        operation.Kind is OperationKind.YieldBreak or OperationKind.YieldReturn &&
                        !operation.IsWithinLambdaOrLocalFunction(out _));
            }
        }

        private sealed class RequiredSymbols
        {
            private RequiredSymbols(Compilation compilation, INamedTypeSymbol readOnlySpanType, IPropertySymbol arrayLengthProperty)
            {
                ReadOnlySpanType = readOnlySpanType;
                SupportsMultiBytePrimitiveTypes =
                    PreferReadOnlySpanPropertiesOverReadOnlyArrayFieldsAnalyzer.SupportsMultiBytePrimitiveTypes(
                        compilation,
                        readOnlySpanType);
                ArrayLengthProperty = arrayLengthProperty;
                MemoryExtensionsType = compilation.GetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemMemoryExtensions);
                SpanType = compilation.GetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemSpan1);
                LinqExpressionTreeType = compilation.GetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemLinqExpressionsExpression1);
                AttributeUsageAttributeType = compilation.GetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemAttributeUsageAttribute);
            }
            public static bool TryGetRequiredSymbols(Compilation compilation, [NotNullWhen(true)] out RequiredSymbols? requiredSymbols)
            {
                var arrayLengthProperty = compilation.GetSpecialType(SpecialType.System_Array).GetMembers(nameof(Array.Length)).OfType<IPropertySymbol>().FirstOrDefault();
                if (compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemReadOnlySpan1, out var rosType) &&
                    arrayLengthProperty is not null)
                {
                    requiredSymbols = new(compilation, rosType, arrayLengthProperty);
                    return true;
                }

                requiredSymbols = null;
                return false;
            }

            public INamedTypeSymbol ReadOnlySpanType { get; }
            public bool SupportsMultiBytePrimitiveTypes { get; }
            public IPropertySymbol ArrayLengthProperty { get; }
            public INamedTypeSymbol? MemoryExtensionsType { get; }
            public INamedTypeSymbol? SpanType { get; }
            public INamedTypeSymbol? LinqExpressionTreeType { get; }
            public INamedTypeSymbol? AttributeUsageAttributeType { get; }

            public bool IsAsSpanMethod(IMethodSymbol? method)
                => method is not null &&
                    method.Name == nameof(MemoryExtensions.AsSpan) &&
                    SymbolEqualityComparer.Default.Equals(method.OriginalDefinition.ContainingType, MemoryExtensionsType) &&
                    (SymbolEqualityComparer.Default.Equals(method.ReturnType.OriginalDefinition, ReadOnlySpanType) ||
                    SymbolEqualityComparer.Default.Equals(method.ReturnType.OriginalDefinition, SpanType));
        }

        private sealed class Cache : IDisposable
        {
            public Cache()
            {
                _candidateStates = PooledConcurrentDictionary<IFieldSymbol, bool>.GetInstance(SymbolEqualityComparer.Default);
                _asSpanReferences = PooledConcurrentDictionary<IFieldSymbol, ConcurrentDictionary<(SyntaxTree? SourceTree, TextSpan SourceSpan), Location>>.GetInstance(SymbolEqualityComparer.Default);
            }

            private readonly PooledConcurrentDictionary<IFieldSymbol, bool> _candidateStates;
            private readonly PooledConcurrentDictionary<IFieldSymbol, ConcurrentDictionary<(SyntaxTree? SourceTree, TextSpan SourceSpan), Location>> _asSpanReferences;

            public void AddCandidate(IFieldSymbol field)
                => _candidateStates.TryAdd(field.OriginalDefinition, value: true);

            public void EliminateCandidate(IFieldSymbol field)
                => _candidateStates.AddOrUpdate(field.OriginalDefinition, addValue: false, static (_, _) => false);

            public bool IsCandidate(IFieldSymbol field)
                => _candidateStates.TryGetValue(field.OriginalDefinition, out bool isCandidate) && isCandidate;

            public void AddAsSpanReference(IFieldSymbol field, Location location)
                => _asSpanReferences
                    .GetOrAdd(field.OriginalDefinition, static _ => new ConcurrentDictionary<(SyntaxTree? SourceTree, TextSpan SourceSpan), Location>())
                    .TryAdd((location.SourceTree, location.SourceSpan), location);

            public IEnumerable<Location>? GetAsSpanReferences(IFieldSymbol field)
                => _asSpanReferences.TryGetValue(field.OriginalDefinition, out var references) ? references.Values : null;

            public void Dispose()
            {
                _candidateStates.Dispose();
                _asSpanReferences.Dispose();
            }
        }

        //  Not compared for equality.
#pragma warning disable CA1815 // Override equals and operator equals on value types
        private readonly struct VisitContext
#pragma warning restore CA1815 // Override equals and operator equals on value types
        {
            public VisitContext(IFieldReferenceOperation fieldReference, IFieldSymbol field, ISymbol containingSymbol)
            {
                FieldReference = fieldReference;
                Field = field;
                ContainingSymbol = containingSymbol;
            }

            public IFieldReferenceOperation FieldReference { get; }
            public IFieldSymbol Field { get; }
            public ISymbol ContainingSymbol { get; }
        }
    }
}