File: Microsoft.NetCore.Analyzers\Performance\UseSearchValues.Fixer.cs
Web Access
Project: src\sdk\src\Microsoft.CodeAnalysis.NetAnalyzers\src\Microsoft.CodeAnalysis.NetAnalyzers\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.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Analyzer.Utilities;
using Analyzer.Utilities.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.Editing;
using Microsoft.CodeAnalysis.FindSymbols;
using Microsoft.CodeAnalysis.NetAnalyzers;
using Microsoft.CodeAnalysis.Operations;

namespace Microsoft.NetCore.Analyzers.Performance
{
    using static MicrosoftNetCoreAnalyzersResources;

    /// <summary>
    /// CA1870: <inheritdoc cref="UseSearchValuesTitle"/>
    /// </summary>
    public abstract class UseSearchValuesFixer : CodeFixProvider
    {
        public sealed override ImmutableArray<string> FixableDiagnosticIds { get; } = ImmutableArray.Create(UseSearchValuesAnalyzer.DiagnosticId);

        /// <summary>
        /// Each extraction has to see the field names the extractions before it took, and the
        /// <see langword="System"/> import has to be added at most once, so both are carried per document.
        /// </summary>
        private sealed class FixState
        {
            public HashSet<string> FieldNames { get; } = new(StringComparer.Ordinal);

            public bool ImportedSystemNamespace { get; set; }
        }

        public sealed override FixAllProvider GetFixAllProvider()
            => SyntaxEditorFixAllProvider.Create<FixState>(_ => new FixState(), ConvertToSearchValuesAsync);

        public sealed override Task RegisterCodeFixesAsync(CodeFixContext context)
        {
            Document document = context.Document;
            ImmutableArray<Diagnostic> diagnostics = context.Diagnostics;

            context.RegisterCodeFix(
                CodeAction.Create(
                    UseSearchValuesCodeFixTitle,
                    cancellationToken => ConvertAllToSearchValuesAsync(document, diagnostics, cancellationToken),
                    equivalenceKey: nameof(UseSearchValuesCodeFixTitle)),
                diagnostics);

            return Task.CompletedTask;
        }

        protected abstract ValueTask<(SyntaxNode TypeDeclaration, INamedTypeSymbol? TypeSymbol, bool IsRealType)> GetTypeSymbolAsync(SemanticModel semanticModel, SyntaxNode node, CancellationToken cancellationToken);

        protected abstract SyntaxNode ReplaceSearchValuesFieldName(SyntaxNode node);

        protected abstract SyntaxNode GetDeclaratorInitializer(SyntaxNode syntax);

        protected abstract SyntaxNode? TryReplaceArrayCreationWithInlineLiteralExpression(IOperation operation);

        private Task<Document> ConvertAllToSearchValuesAsync(Document document, ImmutableArray<Diagnostic> diagnostics, CancellationToken cancellationToken)
        {
            FixState state = new();

            return SyntaxEditorFixAllProvider.ApplyFixesAsync(
                document,
                diagnostics,
                (doc, diagnostic, editor, ct) => ConvertToSearchValuesAsync(doc, diagnostic, editor, state, ct),
                cancellationToken);
        }

        private async Task ConvertToSearchValuesAsync(Document document, Diagnostic diagnostic, SyntaxEditor editor, FixState state, CancellationToken cancellationToken)
        {
            SyntaxNode? argumentNode = editor.OriginalRoot.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true);
            if (argumentNode is null)
            {
                return;
            }

            SemanticModel? semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
            SyntaxGenerator generator = editor.Generator;

            if (semanticModel?.Compilation is not { } compilation ||
                !compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemBuffersSearchValues, out INamedTypeSymbol? searchValues) ||
                !compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemBuffersSearchValues1, out INamedTypeSymbol? searchValuesOfT) ||
                !compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemMemoryExtensions, out INamedTypeSymbol? memoryExtensions) ||
                semanticModel.GetOperation(argumentNode, cancellationToken) is not { } argument ||
                GetArgumentOperationAncestorOrSelf(argument) is not { } argumentOperation)
            {
                return;
            }

            bool isByte =
                argumentOperation.Parameter?.Type is INamedTypeSymbol parameterType &&
                parameterType.TypeArguments is [var typeArgument] &&
                typeArgument.SpecialType == SpecialType.System_Byte;

            SyntaxNode createArgument = CreateSearchValuesCreateArgument(argumentOperation.Syntax, argumentOperation.Value, out SyntaxNode? memberToRemove, cancellationToken);

            string? removedMemberName = null;

            // If the member we're relacing is not public, and the argument to IndexOfAny was its only use, remove it.
            if (memberToRemove is not null &&
                semanticModel.GetDeclaredSymbol(memberToRemove, cancellationToken) is { } symbolToRemove &&
                symbolToRemove.DeclaredAccessibility is Accessibility.NotApplicable or Accessibility.Private &&
                !symbolToRemove.IsImplementationOfAnyInterfaceMember() &&
                symbolToRemove.Locations.Length == 1)
            {
                var refs = await SymbolFinder.FindReferencesAsync(symbolToRemove, document.Project.Solution, cancellationToken).ConfigureAwait(false);
                var locations = refs.SelectMany(r => r.Locations);
                var documentLocations = locations.Select(loc => (loc.Document.FilePath, loc.Location.SourceSpan));

                if (documentLocations.Distinct().Count() == 1)
                {
                    // A single location in a single document.
                    editor.RemoveNode(memberToRemove);
                    removedMemberName = symbolToRemove.Name;
                }
            }

            string defaultSearchValuesFieldName = GetSearchValuesFieldName(argumentOperation.Value, isByte, removedOriginalMember: removedMemberName is not null);

            string fieldName = defaultSearchValuesFieldName;

            (var typeDeclaration, var typeSymbol, bool isRealType) = await GetTypeSymbolAsync(semanticModel, argumentNode, cancellationToken).ConfigureAwait(false);

            // Find a unique name for the field that does not conflict with other members in scope, or with a
            // field an earlier fix in this same pass already introduced.
            if (fieldName != removedMemberName)
            {
                var members = GetAllMemberNamesInScope(typeSymbol).Concat(state.FieldNames).ToArray();
                int memberCount = 1;
                while (members.Contains(fieldName, StringComparer.Ordinal))
                {
                    fieldName = $"{defaultSearchValuesFieldName}{memberCount++}";
                }
            }

            state.FieldNames.Add(fieldName);

            // private static readonly SearchValues<T> s_myValues = SearchValues.Create(argument);
            var newField = generator.FieldDeclaration(
                fieldName,
                generator.TypeExpression(searchValuesOfT.Construct(compilation.GetSpecialType(isByte ? SpecialType.System_Byte : SpecialType.System_Char))),
                Accessibility.Private,
                DeclarationModifiers.Static.WithIsReadOnly(true),
                generator.InvocationExpression(
                    generator.MemberAccessExpression(generator.TypeExpressionForStaticMemberAccess(searchValues), "Create"),
                    createArgument));

            // Allow the user to pick a different name for the method.
            newField = ReplaceSearchValuesFieldName(newField);

            // foo.IndexOfAny(argument) => foo.IndexOfAny(s_myValues)
            editor.ReplaceNode(argumentNode, generator.IdentifierName(fieldName));

            if (isRealType)
            {
                // Insert the new field at the top of the parent type.
                editor.InsertMembers(typeDeclaration, 0, new[] { newField });
            }
            else
            {
                // We are in the 'Program' class of a top-level statements file.
                // Create a new partial Program class with the new field.
                editor.AddMember(typeDeclaration, generator.ClassDeclaration("Program", modifiers: DeclarationModifiers.Partial, members: new[] { newField }));
            }

            // If this was a string IndexOfAny call, we must also insert an AsSpan call.
            if (!isByte &&
                argumentOperation.Parent is IInvocationOperation indexOfAnyOperation &&
                indexOfAnyOperation.Instance?.Syntax is { } stringInstance)
            {
                // foo.IndexOfAny => foo.AsSpan().IndexOfAny
                editor.ReplaceNode(stringInstance, (currentInstance, g) => g.InvocationExpression(g.MemberAccessExpression(currentInstance, "AsSpan")));

                // We are now using the MemoryExtensions.AsSpan() extension method. Make sure it's in scope.
                ImportSystemNamespaceIfNeeded(editor, semanticModel, memoryExtensions, stringInstance, state);
            }
        }

        private static void ImportSystemNamespaceIfNeeded(SyntaxEditor editor, SemanticModel semanticModel, INamedTypeSymbol memoryExtensions, SyntaxNode node, FixState state)
        {
            if (state.ImportedSystemNamespace)
            {
                return;
            }

            var symbols = semanticModel.LookupNamespacesAndTypes(node.SpanStart, name: nameof(MemoryExtensions));

            if (!symbols.Contains(memoryExtensions, SymbolEqualityComparer.Default))
            {
                // The import has to be computed from the root as the other fixes left it, not from the root this
                // fix started with, or it re-emits the whole document in its pre-fix form.
                editor.ReplaceNode(
                    editor.OriginalRoot,
                    (currentRoot, generator) => generator.AddNamespaceImports(currentRoot, generator.NamespaceImportDeclaration(nameof(System))));

                state.ImportedSystemNamespace = true;
            }
        }

        private static IArgumentOperation? GetArgumentOperationAncestorOrSelf(IOperation operation) =>
            (operation as IArgumentOperation) ??
            operation.GetAncestor<IArgumentOperation>(OperationKind.Argument);

        private static IEnumerable<string> GetAllMemberNamesInScope(ITypeSymbol? symbol)
        {
            while (symbol != null)
            {
                foreach (ISymbol member in symbol.GetMembers())
                {
                    yield return member.Name;
                }

                symbol = symbol.BaseType;
            }
        }

        private static string GetSearchValuesFieldName(IOperation argument, bool isByte, bool removedOriginalMember)
        {
            if (argument is IConversionOperation conversion)
            {
                if (TryGetNameFromLocalOrFieldReference(conversion.Operand, out string? name))
                {
                    return name;
                }
                else if (conversion.Operand is IInvocationOperation invocation)
                {
                    if (TryGetNameFromLocalOrFieldReference(invocation.Instance, out name))
                    {
                        return name;
                    }
                }
            }
            else if (TryGetNameFromLocalOrFieldReference(argument, out string? name))
            {
                return name;
            }
            else if (argument is IPropertyReferenceOperation propertyReference)
            {
                return CreateFromExistingName(propertyReference.Property.Name);
            }
            else if (argument is IInvocationOperation invocation)
            {
                if (TryGetNameFromLocalOrFieldReference(invocation.Instance, out name))
                {
                    return name;
                }
            }

            return isByte ? "s_myBytes" : "s_myChars";

            bool TryGetNameFromLocalOrFieldReference(IOperation? argument, [NotNullWhen(true)] out string? name)
            {
                if (argument is ILocalReferenceOperation localReference)
                {
                    name = CreateFromExistingName(localReference.Local.Name);
                    return true;
                }
                else if (argument is IFieldReferenceOperation fieldReference)
                {
                    name = CreateFromExistingName(fieldReference.Field.Name);
                    return true;
                }

                name = null;
                return false;
            }

            string CreateFromExistingName(string name)
            {
                if (!name.StartsWith("s_", StringComparison.OrdinalIgnoreCase))
                {
                    if (name.Length >= 2 && IsAsciiLetterUpper(name[0]) && !IsAsciiLetterUpper(name[1]))
                    {
                        name = $"{char.ToLowerInvariant(name[0])}{name[1..]}";
                    }

                    return $"s_{name.TrimStart('_')}";
                }

                return removedOriginalMember
                    ? name
                    : $"{name}SearchValues";

                static bool IsAsciiLetterUpper(char c) => c is >= 'A' and <= 'Z';
            }
        }

        private SyntaxNode CreateSearchValuesCreateArgument(SyntaxNode originalSyntax, IOperation argument, out SyntaxNode? memberToRemove, CancellationToken cancellationToken)
        {
            SyntaxNode createArgument = CreateSearchValuesCreateArgumentCore(originalSyntax, argument, out memberToRemove, cancellationToken);

            // If the argument is an inline array creation, we can transform it into a string literal expression instead.
            if (argument.SemanticModel?.GetOperation(createArgument, cancellationToken) is { } newOperation)
            {
                if (newOperation is IArgumentOperation argumentOperation)
                {
                    newOperation = argumentOperation.Value;
                }

                if (TryReplaceArrayCreationWithInlineLiteralExpression(newOperation) is { } literalExpression)
                {
                    return literalExpression;
                }
            }

            return createArgument;
        }

        private SyntaxNode CreateSearchValuesCreateArgumentCore(SyntaxNode originalSyntax, IOperation argument, out SyntaxNode? memberToRemove, CancellationToken cancellationToken)
        {
            if (argument is IConversionOperation conversion)
            {
                argument = conversion.Operand;
            }

            if (argument is IPropertyReferenceOperation propertyReference)
            {
                if (!propertyReference.Property.IsStatic)
                {
                    // Can't access an instance property from a field initializer.
                    memberToRemove = GetDeclarator(propertyReference.Property);
                    return GetDeclaratorInitializer(memberToRemove);
                }
            }
            else if (TryGetArgumentFromLocalOrFieldReference(argument, out SyntaxNode? createArgument, out memberToRemove))
            {
                return createArgument;
            }
            else if (TryGetArgumentFromStringToCharArray(argument, out createArgument, out memberToRemove))
            {
                return createArgument;
            }

            // Use the original syntax (e.g. string literal, inline array creation, static property reference ...)
            memberToRemove = null;
            return originalSyntax;

            bool TryGetArgumentFromStringToCharArray(IOperation operation, [NotNullWhen(true)] out SyntaxNode? createArgument, out SyntaxNode? memberToRemove)
            {
                if (operation is IInvocationOperation invocation &&
                    invocation.Instance is { } stringInstance)
                {
                    Debug.Assert(invocation.TargetMethod.Name == nameof(string.ToCharArray));

                    if (!TryGetArgumentFromLocalOrFieldReference(stringInstance, out createArgument, out memberToRemove))
                    {
                        // This is a string.ToCharArray call, but the string instance is not something we can refer to by name.
                        // e.g. for '"foo".ToCharArray()', we want to emit '"foo"'.
                        createArgument = stringInstance.Syntax;
                    }

                    return true;
                }

                createArgument = null;
                memberToRemove = null;
                return false;
            }

            bool TryGetArgumentFromLocalOrFieldReference(IOperation operation, [NotNullWhen(true)] out SyntaxNode? createArgument, out SyntaxNode? memberToRemove)
            {
                if (operation is ILocalReferenceOperation localReference)
                {
                    // Local string literal would be out of scope in the field declaration.
                    memberToRemove = GetDeclarator(localReference.Local);
                    createArgument = GetDeclaratorInitializer(memberToRemove);
                    return true;
                }
                else if (operation is IFieldReferenceOperation fieldReference)
                {
                    if (!fieldReference.ConstantValue.HasValue)
                    {
                        // If we were to use the field reference directly, we risk initializing the SearchValues field to an empty
                        // instance depending on field declaration order.
                        memberToRemove = GetDeclarator(fieldReference.Field);
                        createArgument = GetDeclaratorInitializer(memberToRemove);
                        return true;
                    }
                }

                createArgument = null;
                memberToRemove = null;
                return false;
            }

            SyntaxNode GetDeclarator(ISymbol symbol)
            {
                Debug.Assert(symbol.DeclaringSyntaxReferences.Length == 1);

                return symbol.DeclaringSyntaxReferences[0].GetSyntax(cancellationToken);
            }
        }
    }
}