File: CSharpUseRangeOperatorDiagnosticAnalyzer.cs
Web Access
Project: ..\..\..\src\CodeStyle\CSharp\Analyzers\Microsoft.CodeAnalysis.CSharp.CodeStyle.csproj (Microsoft.CodeAnalysis.CSharp.CodeStyle)
// 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.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using Microsoft.CodeAnalysis.CodeStyle;
using Microsoft.CodeAnalysis.CSharp.CodeStyle;
using Microsoft.CodeAnalysis.CSharp.Extensions;
using Microsoft.CodeAnalysis.CSharp.LanguageService;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.CSharp.UseIndexOrRangeOperator
{
    using static Helpers;
 
    /// <summary>
    /// <para>Analyzer that looks for several variants of code like <c>s.Slice(start, end - start)</c> and
    /// offers to update to <c>s[start..end]</c> or <c>s.Slice(start..end)</c>.  In order to convert to the
    /// indexer, the type being called on needs a slice-like method that takes two ints, and returns
    /// an instance of the same type. It also needs a <c>Length</c>/<c>Count</c> property, as well as an indexer
    /// that takes a <see cref="T:System.Range"/> instance.  In order to convert between methods, there need to be
    /// two overloads that are equivalent except that one takes two ints, and the other takes a
    /// <see cref="T:System.Range"/>.</para>
    ///
    /// <para>It is assumed that if the type follows this shape that it is well behaved and that this
    /// transformation will preserve semantics.  If this assumption is not good in practice, we
    /// could always limit the feature to only work on an allow list of known safe types.</para>
    /// </summary>
    [DiagnosticAnalyzer(LanguageNames.CSharp)]
    [SuppressMessage("Documentation", "CA1200:Avoid using cref tags with a prefix", Justification = "Required to avoid ambiguous reference warnings.")]
    internal sealed partial class CSharpUseRangeOperatorDiagnosticAnalyzer : AbstractBuiltInCodeStyleDiagnosticAnalyzer
    {
        // public const string UseIndexer = nameof(UseIndexer);
        public const string ComputedRange = nameof(ComputedRange);
        public const string ConstantRange = nameof(ConstantRange);
 
        public CSharpUseRangeOperatorDiagnosticAnalyzer()
            : base(IDEDiagnosticIds.UseRangeOperatorDiagnosticId,
                   EnforceOnBuildValues.UseRangeOperator,
                   CSharpCodeStyleOptions.PreferRangeOperator,
                   new LocalizableResourceString(nameof(CSharpAnalyzersResources.Use_range_operator), CSharpAnalyzersResources.ResourceManager, typeof(CSharpAnalyzersResources)),
                   new LocalizableResourceString(nameof(CSharpAnalyzersResources._0_can_be_simplified), CSharpAnalyzersResources.ResourceManager, typeof(CSharpAnalyzersResources)))
        {
        }
 
        public override DiagnosticAnalyzerCategory GetAnalyzerCategory() => DiagnosticAnalyzerCategory.SemanticSpanAnalysis;
 
        protected override void InitializeWorker(AnalysisContext context)
        {
            context.RegisterCompilationStartAction(context =>
            {
                var compilation = (CSharpCompilation)context.Compilation;
 
                // Check if we're at least on C# 8
                if (compilation.LanguageVersion < LanguageVersion.CSharp8)
                    return;
 
                // We're going to be checking every invocation in the compilation. Cache information
                // we compute in this object so we don't have to continually recompute it.
                if (!InfoCache.TryCreate(context.Compilation, out var infoCache))
                    return;
 
                context.RegisterOperationAction(
                    c => AnalyzeInvocation(c, infoCache),
                    OperationKind.Invocation);
            });
        }
 
        private void AnalyzeInvocation(OperationAnalysisContext context, InfoCache infoCache)
        {
            // Check if the user wants these operators.
            var option = context.GetCSharpAnalyzerOptions().PreferRangeOperator;
            if (!option.Value)
                return;
 
            var operation = context.Operation;
            var semanticModel = operation.SemanticModel;
            Contract.ThrowIfNull(semanticModel);
 
            var result = AnalyzeInvocation((IInvocationOperation)operation, infoCache);
            if (result == null)
                return;
 
            if (CSharpSemanticFacts.Instance.IsInExpressionTree(semanticModel, operation.Syntax, infoCache.ExpressionOfTType, context.CancellationToken))
                return;
 
            context.ReportDiagnostic(CreateDiagnostic(result.Value, option.Notification.Severity));
        }
 
        public static Result? AnalyzeInvocation(IInvocationOperation invocation, InfoCache infoCache)
        {
            // Validate we're on a piece of syntax we expect.  While not necessary for analysis, we
            // want to make sure we're on something the fixer will know how to actually fix.
            if (invocation.Syntax is not InvocationExpressionSyntax invocationSyntax ||
                invocationSyntax.ArgumentList is null)
            {
                return null;
            }
 
            // look for `s.Slice(e1, end - e2)` or `s.Slice(e1)`
            if (invocation.Instance is null)
                return null;
 
            return invocation.Arguments.Length switch
            {
                1 => AnalyzeOneArgumentInvocation(invocation, infoCache, invocationSyntax),
                2 => AnalyzeTwoArgumentInvocation(invocation, infoCache, invocationSyntax),
                _ => null,
            };
        }
 
        private static Result? AnalyzeOneArgumentInvocation(
            IInvocationOperation invocation,
            InfoCache infoCache,
            InvocationExpressionSyntax invocationSyntax)
        {
            var targetMethod = invocation.TargetMethod;
 
            // We are dealing with a call like `.Substring(expr)`.
            // Ensure that there is an overload with signature like `Substring(int start, int length)`
            // and there is a suitable indexer to replace this with `[expr..]`.
            if (!infoCache.TryGetMemberInfoOneArgument(targetMethod, out var memberInfo))
                return null;
 
            var startOperation = invocation.Arguments[0].Value;
            return new Result(
                ResultKind.Computed,
                invocation,
                invocationSyntax,
                targetMethod,
                memberInfo,
                op1: startOperation,
                op2: null); // The range will run to the end.
        }
 
        private static Result? AnalyzeTwoArgumentInvocation(
            IInvocationOperation invocation,
            InfoCache infoCache,
            InvocationExpressionSyntax invocationSyntax)
        {
            Contract.ThrowIfNull(invocation.Instance);
 
            // See if the call is to something slice-like.
            var targetMethod = invocation.TargetMethod;
            if (targetMethod == null)
                return null;
 
            return AnalyzeTwoArgumentSubtractionInvocation(invocation, infoCache, invocationSyntax, targetMethod) ??
                   AnalyzeTwoArgumentFromStartOrToEndInvocation(invocation, infoCache, invocationSyntax, targetMethod);
        }
 
        private static Result? AnalyzeTwoArgumentSubtractionInvocation(
            IInvocationOperation invocation,
            InfoCache infoCache,
            InvocationExpressionSyntax invocationSyntax,
            IMethodSymbol targetMethod)
        {
            Contract.ThrowIfNull(invocation.Instance);
 
            // Second arg needs to be a subtraction for: `end - e2`.  Once we've seen that we have
            // that, try to see if we're calling into some sort of Slice method with a matching
            // indexer or overload
            if (!IsSubtraction(invocation.Arguments[1].Value, out var subtraction) ||
                !infoCache.TryGetMemberInfo(targetMethod, out var memberInfo))
            {
                return null;
            }
 
            if (!IsValidIndexing(invocation, infoCache, targetMethod))
                return null;
 
            // See if we have: (start, end - start).  Specifically where the start operation it the
            // same as the right side of the subtraction.
            var startOperation = invocation.Arguments[0].Value;
 
            if (CSharpSyntaxFacts.Instance.AreEquivalent(startOperation.Syntax, subtraction.RightOperand.Syntax))
            {
                return new Result(
                    ResultKind.Computed,
                    invocation, invocationSyntax,
                    targetMethod, memberInfo,
                    startOperation, subtraction.LeftOperand);
            }
 
            // See if we have: (constant1, s.Length - constant2).  The constants don't have to be
            // the same value.  This will convert over to s[constant1..(constant - constant1)]
            if (IsConstantInt32(startOperation) &&
                IsConstantInt32(subtraction.RightOperand) &&
                IsInstanceLengthCheck(memberInfo.LengthLikeProperty, invocation.Instance, subtraction.LeftOperand))
            {
                return new Result(
                    ResultKind.Constant,
                    invocation, invocationSyntax,
                    targetMethod, memberInfo,
                    startOperation, subtraction.RightOperand);
            }
 
            return null;
        }
 
        private static Result? AnalyzeTwoArgumentFromStartOrToEndInvocation(
            IInvocationOperation invocation,
            InfoCache infoCache,
            InvocationExpressionSyntax invocationSyntax,
            IMethodSymbol targetMethod)
        {
            Contract.ThrowIfNull(invocation.Instance);
 
            // if we have `x.Substring(0, end)` then that can just become `x[..end]`
            // if we have `x.Substring(0, x.Length)` then that can just become `x[..]`
            // if we have `x.Substring(0, x.Length - n)` then that is handled in AnalyzeTwoArgumentSubtractionInvocation
 
            var startOperation = invocation.Arguments[0].Value;
            if (!IsConstantInt32(startOperation, value: 0) ||
                !infoCache.TryGetMemberInfo(targetMethod, out var memberInfo))
            {
                return null;
            }
 
            if (!IsValidIndexing(invocation, infoCache, targetMethod))
                return null;
 
            return new Result(
                ResultKind.Computed,
                invocation,
                invocationSyntax,
                targetMethod,
                memberInfo,
                startOperation,
                invocation.Arguments[1].Value);
        }
 
        private static bool IsValidIndexing(IInvocationOperation invocation, InfoCache infoCache, IMethodSymbol targetMethod)
        {
            var indexer = GetIndexer(targetMethod.ContainingType, infoCache.RangeType, targetMethod.ContainingType);
            // Need to make sure that if the target method is being written to, that the indexer returns a ref, is a read/write property,
            // or the syntax allows for the slice method to be run
            return !invocation.Syntax.IsLeftSideOfAnyAssignExpression() || indexer == null || !IsWriteableIndexer(invocation, indexer);
        }
 
        private Diagnostic CreateDiagnostic(Result result, ReportDiagnostic severity)
        {
            // Keep track of the invocation node
            var invocation = result.Invocation;
            var additionalLocations = ImmutableArray.Create(
                invocation.GetLocation());
 
            // Mark the span under the two arguments to .Slice(..., ...) as what we will be
            // updating.
            var arguments = invocation.ArgumentList.Arguments;
            var location = Location.Create(invocation.SyntaxTree,
                TextSpan.FromBounds(arguments.First().SpanStart, arguments.Last().Span.End));
 
            return DiagnosticHelper.Create(
                Descriptor,
                location,
                severity,
                additionalLocations,
                ImmutableDictionary<string, string?>.Empty,
                result.SliceLikeMethod.Name);
        }
 
        private static bool IsConstantInt32(IOperation operation, int? value = null)
            => operation.ConstantValue.HasValue &&
               operation.ConstantValue.Value is int i &&
               (value == null || i == value);
 
        private static bool IsWriteableIndexer(IInvocationOperation invocation, IPropertySymbol indexer)
        {
            var refReturnMismatch = indexer.ReturnsByRef != invocation.TargetMethod.ReturnsByRef;
            var indexerIsReadWrite = indexer.IsWriteableFieldOrProperty();
            return refReturnMismatch && !indexerIsReadWrite;
        }
    }
}