File: VirtualizeSpacerElementAnalyzer.cs
Web Access
Project: ..\..\..\src\aspnetcore\src\Components\Analyzers\src\Microsoft.AspNetCore.Components.Analyzers.csproj (Microsoft.AspNetCore.Components.Analyzers)
// 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.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;
 
#nullable enable
 
namespace Microsoft.AspNetCore.Components.Analyzers;
 
/// <summary>
/// Analyzer that detects Virtualize spacer elements that are invalid for their HTML parent.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class VirtualizeSpacerElementAnalyzer : DiagnosticAnalyzer
{
    private const string VirtualizeTypeName = "Microsoft.AspNetCore.Components.Web.Virtualization.Virtualize`1";
    private const string RenderTreeBuilderTypeName = "Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder";
 
    private static readonly ImmutableDictionary<string, ImmutableArray<string>> AllowedSpacerElements =
        new Dictionary<string, ImmutableArray<string>>(StringComparer.OrdinalIgnoreCase)
        {
            ["tbody"] = ImmutableArray.Create("tr"),
            ["thead"] = ImmutableArray.Create("tr"),
            ["tfoot"] = ImmutableArray.Create("tr"),
            ["ul"] = ImmutableArray.Create("li"),
            ["ol"] = ImmutableArray.Create("li"),
        }.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
 
    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
        ImmutableArray.Create(DiagnosticDescriptors.VirtualizeSpacerElementIsInvalid);
 
    public override void Initialize(AnalysisContext context)
    {
        context.EnableConcurrentExecution();
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics);
 
        context.RegisterCompilationStartAction(compilationContext =>
        {
            var virtualizeType = compilationContext.Compilation.GetTypeByMetadataName(VirtualizeTypeName);
            var renderTreeBuilderType = compilationContext.Compilation.GetTypeByMetadataName(RenderTreeBuilderTypeName);
 
            if (virtualizeType is null || renderTreeBuilderType is null)
            {
                return;
            }
 
            compilationContext.RegisterOperationBlockAction(blockContext =>
            {
                foreach (var operationBlock in blockContext.OperationBlocks)
                {
                    var renderTreeStacks = new Dictionary<ISymbol, Stack<RenderTreeFrame>>(SymbolEqualityComparer.Default);
 
                    foreach (var operation in operationBlock.DescendantsAndSelf())
                    {
                        if (operation is not IInvocationOperation invocation)
                        {
                            continue;
                        }
 
                        if (!SymbolEqualityComparer.Default.Equals(invocation.TargetMethod.ContainingType, renderTreeBuilderType))
                        {
                            AnalyzeGeneratedHelperInvocation(
                                blockContext,
                                invocation,
                                renderTreeStacks,
                                virtualizeType,
                                renderTreeBuilderType);
                            continue;
                        }
 
                        if (GetReferencedSymbol(invocation.Instance) is not { } builderSymbol)
                        {
                            continue;
                        }
 
                        if (!renderTreeStacks.TryGetValue(builderSymbol, out var renderTreeStack))
                        {
                            renderTreeStack = new Stack<RenderTreeFrame>();
                            renderTreeStacks.Add(builderSymbol, renderTreeStack);
                        }
 
                        switch (invocation.TargetMethod.Name)
                        {
                            case "OpenElement":
                                renderTreeStack.Push(new RenderTreeFrame
                                {
                                    ElementName = GetConstantStringArgument(invocation, 1),
                                });
                                break;
 
                            case "OpenComponent":
                                var isVirtualize = invocation.TargetMethod.IsGenericMethod &&
                                    invocation.TargetMethod.TypeArguments.Length == 1 &&
                                    invocation.TargetMethod.TypeArguments[0] is INamedTypeSymbol componentType &&
                                    SymbolEqualityComparer.Default.Equals(componentType.OriginalDefinition, virtualizeType);
 
                                renderTreeStack.Push(new RenderTreeFrame
                                {
                                    IsComponent = true,
                                    IsVirtualize = isVirtualize,
                                    ParentElementName = isVirtualize && renderTreeStack.Count > 0 && !renderTreeStack.Peek().IsComponent
                                        ? renderTreeStack.Peek().ElementName
                                        : null,
                                    Location = GetDiagnosticLocation(invocation),
                                });
                                break;
 
                            case "AddAttribute":
                            case "AddComponentParameter":
                                if (renderTreeStack.Count > 0 &&
                                    renderTreeStack.Peek() is { IsVirtualize: true } virtualizeFrame &&
                                    string.Equals(GetConstantStringArgument(invocation, 1), "SpacerElement", StringComparison.Ordinal))
                                {
                                    virtualizeFrame.HasSpacerElement = true;
                                    virtualizeFrame.SpacerElement = GetConstantStringArgument(invocation, 2);
                                }
                                break;
 
                            case "AddMultipleAttributes":
                                if (renderTreeStack.Count > 0 &&
                                    renderTreeStack.Peek() is { IsVirtualize: true } splattedVirtualizeFrame &&
                                    !IsConstantNull(GetArgumentValue(invocation, 1)))
                                {
                                    splattedVirtualizeFrame.HasSpacerElement = true;
                                    splattedVirtualizeFrame.SpacerElement = null;
                                }
                                break;
 
                            case "CloseComponent":
                                if (renderTreeStack.Count > 0)
                                {
                                    var componentFrame = renderTreeStack.Pop();
                                    ReportInvalidSpacerElement(blockContext, componentFrame);
                                }
                                break;
 
                            case "CloseElement":
                                if (renderTreeStack.Count > 0)
                                {
                                    renderTreeStack.Pop();
                                }
                                break;
                        }
                    }
                }
            });
        });
    }
 
    private static void AnalyzeGeneratedHelperInvocation(
        OperationBlockAnalysisContext context,
        IInvocationOperation invocation,
        Dictionary<ISymbol, Stack<RenderTreeFrame>> renderTreeStacks,
        INamedTypeSymbol virtualizeType,
        INamedTypeSymbol renderTreeBuilderType)
    {
        if (!TryGetVirtualizeHelperInfo(
                context.Compilation,
                invocation.TargetMethod,
                virtualizeType,
                renderTreeBuilderType,
                context.CancellationToken,
                out var helperInfo) ||
            GetArgumentValue(invocation, helperInfo.BuilderParameterOrdinal) is not { } builderArgument ||
            GetReferencedSymbol(builderArgument) is not { } builderSymbol ||
            !renderTreeStacks.TryGetValue(builderSymbol, out var renderTreeStack) ||
            renderTreeStack.Count == 0 ||
            renderTreeStack.Peek().IsComponent ||
            renderTreeStack.Peek().ElementName is not { } parentElementName ||
            !AllowedSpacerElements.ContainsKey(parentElementName))
        {
            return;
        }
 
        var hasSpacerElement = false;
        string? spacerElement = null;
        foreach (var attributeUpdate in helperInfo.AttributeUpdates)
        {
            IOperation? value = null;
            if (attributeUpdate.ParameterOrdinal is { } parameterOrdinal)
            {
                value = GetArgumentValue(invocation, parameterOrdinal);
            }
 
            var constantValue = value is not null
                ? UnwrapConversion(value).ConstantValue
                : attributeUpdate.ConstantValue;
 
            if (attributeUpdate.IsSplat)
            {
                if (constantValue.HasValue && constantValue.Value is null)
                {
                    continue;
                }
 
                hasSpacerElement = true;
                spacerElement = null;
                continue;
            }
 
            hasSpacerElement = true;
            if (!constantValue.HasValue || constantValue.Value is not string valueString)
            {
                return;
            }
 
            spacerElement = valueString;
        }
 
        ReportInvalidSpacerElement(context, new RenderTreeFrame
        {
            IsVirtualize = true,
            HasSpacerElement = hasSpacerElement,
            ParentElementName = parentElementName,
            SpacerElement = spacerElement,
            Location = GetDiagnosticLocation(invocation),
        });
    }
 
    private static bool TryGetVirtualizeHelperInfo(
        Compilation compilation,
        IMethodSymbol method,
        INamedTypeSymbol virtualizeType,
        INamedTypeSymbol renderTreeBuilderType,
        CancellationToken cancellationToken,
        out VirtualizeHelperInfo helperInfo)
    {
        helperInfo = default;
 
        if (!string.Equals(method.ContainingType.Name, "TypeInference", StringComparison.Ordinal) ||
            !method.Name.StartsWith("CreateVirtualize_", StringComparison.Ordinal) ||
            method.DeclaringSyntaxReferences.Length != 1 ||
            method.DeclaringSyntaxReferences[0].GetSyntax(cancellationToken) is not MethodDeclarationSyntax methodDeclaration ||
            methodDeclaration.Body is null)
        {
            return false;
        }
 
        var semanticModel = compilation.GetSemanticModel(methodDeclaration.SyntaxTree);
        if (semanticModel.GetOperation(methodDeclaration.Body, cancellationToken) is not { } methodBody)
        {
            return false;
        }
 
        IParameterSymbol? builderParameter = null;
        foreach (var parameter in method.OriginalDefinition.Parameters)
        {
            if (SymbolEqualityComparer.Default.Equals(parameter.Type, renderTreeBuilderType))
            {
                builderParameter = parameter;
                break;
            }
        }
 
        if (builderParameter is null)
        {
            return false;
        }
 
        var createsVirtualize = false;
        var attributeUpdates = ImmutableArray.CreateBuilder<VirtualizeAttributeUpdate>();
        var renderTreeDepth = 0;
 
        foreach (var operation in methodBody.DescendantsAndSelf())
        {
            if (operation is not IInvocationOperation helperInvocation ||
                !SymbolEqualityComparer.Default.Equals(helperInvocation.TargetMethod.ContainingType, renderTreeBuilderType) ||
                GetReferencedSymbol(helperInvocation.Instance) is not { } helperBuilderSymbol ||
                !SymbolEqualityComparer.Default.Equals(helperBuilderSymbol, builderParameter))
            {
                continue;
            }
 
            switch (helperInvocation.TargetMethod.Name)
            {
                case "OpenElement":
                    renderTreeDepth++;
                    break;
 
                case "OpenComponent":
                    if (renderTreeDepth == 0 &&
                        helperInvocation.TargetMethod.IsGenericMethod &&
                        helperInvocation.TargetMethod.TypeArguments.Length == 1 &&
                        helperInvocation.TargetMethod.TypeArguments[0] is INamedTypeSymbol componentType &&
                        SymbolEqualityComparer.Default.Equals(componentType.OriginalDefinition, virtualizeType))
                    {
                        createsVirtualize = true;
                    }
 
                    renderTreeDepth++;
                    break;
 
                case "AddAttribute":
                case "AddComponentParameter":
                    if (createsVirtualize &&
                        renderTreeDepth == 1 &&
                        string.Equals(GetConstantStringArgument(helperInvocation, 1), "SpacerElement", StringComparison.Ordinal))
                    {
                        if (GetArgumentValue(helperInvocation, 2) is { } spacerValue)
                        {
                            attributeUpdates.Add(VirtualizeAttributeUpdate.CreateSpacerElement(spacerValue));
                        }
                    }
                    break;
 
                case "AddMultipleAttributes":
                    if (createsVirtualize &&
                        renderTreeDepth == 1 &&
                        GetArgumentValue(helperInvocation, 1) is { } attributesValue &&
                        !IsConstantNull(attributesValue))
                    {
                        attributeUpdates.Add(VirtualizeAttributeUpdate.CreateSplat(attributesValue));
                    }
                    break;
 
                case "CloseComponent":
                case "CloseElement":
                    renderTreeDepth--;
                    break;
            }
        }
 
        if (!createsVirtualize)
        {
            return false;
        }
 
        helperInfo = new VirtualizeHelperInfo(builderParameter.Ordinal, attributeUpdates.ToImmutable());
        return true;
    }
 
    private static Location GetDiagnosticLocation(IInvocationOperation invocation)
    {
        foreach (var argument in invocation.Arguments)
        {
            var location = UnwrapConversion(argument.Value).Syntax.GetLocation();
            if (location.GetMappedLineSpan().HasMappedPath)
            {
                return location;
            }
        }
 
        foreach (var syntax in invocation.Syntax.DescendantNodes())
        {
            var location = syntax.GetLocation();
            if (location.GetMappedLineSpan().HasMappedPath)
            {
                return location;
            }
        }
 
        return invocation.Syntax.GetLocation();
    }
 
    private static IOperation? GetArgumentValue(IInvocationOperation invocation, int parameterOrdinal)
    {
        foreach (var argument in invocation.Arguments)
        {
            if (argument.Parameter?.Ordinal == parameterOrdinal)
            {
                return argument.Value;
            }
        }
 
        return null;
    }
 
    private static ISymbol? GetReferencedSymbol(IOperation? operation)
    {
        if (operation is null)
        {
            return null;
        }
 
        return UnwrapConversion(operation) switch
        {
            IParameterReferenceOperation parameterReference => parameterReference.Parameter,
            ILocalReferenceOperation localReference => localReference.Local,
            IFieldReferenceOperation fieldReference => fieldReference.Field,
            IPropertyReferenceOperation propertyReference => propertyReference.Property,
            _ => null,
        };
    }
 
    private static bool IsConstantNull(IOperation? operation)
    {
        if (operation is null)
        {
            return false;
        }
 
        var constantValue = UnwrapConversion(operation).ConstantValue;
        return constantValue.HasValue && constantValue.Value is null;
    }
 
    private static IOperation UnwrapConversion(IOperation operation)
    {
        while (operation is IConversionOperation conversion)
        {
            operation = conversion.Operand;
        }
 
        return operation;
    }
 
    private static string? GetConstantStringArgument(IInvocationOperation invocation, int argumentIndex)
    {
        if (invocation.Arguments.Length <= argumentIndex)
        {
            return null;
        }
 
        var constantValue = UnwrapConversion(invocation.Arguments[argumentIndex].Value).ConstantValue;
        return constantValue.HasValue ? constantValue.Value as string : null;
    }
 
    private static void ReportInvalidSpacerElement(OperationBlockAnalysisContext context, RenderTreeFrame frame)
    {
        if (!frame.IsVirtualize ||
            frame.ParentElementName is null ||
            !AllowedSpacerElements.TryGetValue(frame.ParentElementName, out var allowedSpacerElements) ||
            (frame.HasSpacerElement && frame.SpacerElement is null))
        {
            return;
        }
 
        var spacerElement = frame.SpacerElement ?? "div";
        foreach (var allowedSpacerElement in allowedSpacerElements)
        {
            if (string.Equals(spacerElement, allowedSpacerElement, StringComparison.OrdinalIgnoreCase))
            {
                return;
            }
        }
 
        var allowedSpacerElementsMessage = allowedSpacerElements.Length == 1
            ? $"SpacerElement=\"{allowedSpacerElements[0]}\""
            : $"SpacerElement=\"{allowedSpacerElements[0]}\" or SpacerElement=\"{allowedSpacerElements[1]}\"";
 
        context.ReportDiagnostic(Diagnostic.Create(
            DiagnosticDescriptors.VirtualizeSpacerElementIsInvalid,
            frame.Location,
            frame.ParentElementName,
            spacerElement,
            allowedSpacerElementsMessage));
    }
 
    private sealed class RenderTreeFrame
    {
        public bool IsComponent { get; set; }
        public bool IsVirtualize { get; set; }
        public bool HasSpacerElement { get; set; }
        public string? ElementName { get; set; }
        public string? ParentElementName { get; set; }
        public string? SpacerElement { get; set; }
        public Location? Location { get; set; }
    }
 
    private readonly struct VirtualizeHelperInfo
    {
        public VirtualizeHelperInfo(int builderParameterOrdinal, ImmutableArray<VirtualizeAttributeUpdate> attributeUpdates)
        {
            BuilderParameterOrdinal = builderParameterOrdinal;
            AttributeUpdates = attributeUpdates;
        }
 
        public int BuilderParameterOrdinal { get; }
        public ImmutableArray<VirtualizeAttributeUpdate> AttributeUpdates { get; }
    }
 
    private readonly struct VirtualizeAttributeUpdate
    {
        private VirtualizeAttributeUpdate(bool isSplat, int? parameterOrdinal, Optional<object?> constantValue)
        {
            IsSplat = isSplat;
            ParameterOrdinal = parameterOrdinal;
            ConstantValue = constantValue;
        }
 
        public bool IsSplat { get; }
        public int? ParameterOrdinal { get; }
        public Optional<object?> ConstantValue { get; }
 
        public static VirtualizeAttributeUpdate CreateSpacerElement(IOperation value) => Create(value, isSplat: false);
 
        public static VirtualizeAttributeUpdate CreateSplat(IOperation value) => Create(value, isSplat: true);
 
        private static VirtualizeAttributeUpdate Create(IOperation value, bool isSplat)
        {
            value = UnwrapConversion(value);
            return value is IParameterReferenceOperation parameterReference
                ? new VirtualizeAttributeUpdate(isSplat, parameterReference.Parameter.Ordinal, default)
                : new VirtualizeAttributeUpdate(isSplat, null, value.ConstantValue);
        }
    }
}