File: Language\TagHelpers\Producers\ComponentTagHelperProducer.cs
Web Access
Project: src\src\Razor\src\Compiler\Microsoft.CodeAnalysis.Razor.Compiler\src\Microsoft.CodeAnalysis.Razor.Compiler.csproj (Microsoft.CodeAnalysis.Razor.Compiler)
// 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 Microsoft.AspNetCore.Razor.Language.Components;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Razor;
 
namespace Microsoft.AspNetCore.Razor.Language.TagHelpers.Producers;
 
internal sealed partial class ComponentTagHelperProducer : TagHelperProducer
{
    private readonly BindTagHelperProducer? _bindTagHelperProducer;
 
    private ComponentTagHelperProducer(BindTagHelperProducer? bindTagHelperProducer)
    {
        _bindTagHelperProducer = bindTagHelperProducer;
    }
 
    public override TagHelperProducerKind Kind => TagHelperProducerKind.Component;
 
    public override bool SupportsTypes => true;
 
    public override bool IsCandidateType(INamedTypeSymbol type)
        => ComponentDetectionConventions.IsComponent(type, ComponentsApi.IComponent.MetadataName);
 
    public override void AddTagHelpersForType(
        INamedTypeSymbol type,
        ref TagHelperCollection.RefBuilder results,
        CancellationToken cancellationToken)
    {
        // Components have very simple matching rules.
        // 1. The type name (short) matches the tag name.
        // 2. The fully qualified name matches the tag name.
 
        // First, compute the relevant properties for this type so that we
        // don't need to compute them twice.
        var properties = GetProperties(type);
 
        var shortNameMatchingDescriptor = CreateShortNameMatchingDescriptor(type, properties);
        results.Add(shortNameMatchingDescriptor);
 
        // If the component is in the global namespace, skip adding this descriptor which will be the same as the short name one.
        TagHelperDescriptor? fullyQualifiedNameMatchingDescriptor = null;
        if (!type.ContainingNamespace.IsGlobalNamespace)
        {
            fullyQualifiedNameMatchingDescriptor = CreateFullyQualifiedNameMatchingDescriptor(type, properties);
            results.Add(fullyQualifiedNameMatchingDescriptor);
        }
 
        // Produce bind tag helpers for the component.
        if (_bindTagHelperProducer is { SupportsTypes: true })
        {
            _bindTagHelperProducer.AddTagHelpersForComponent(shortNameMatchingDescriptor, ref results);
 
            if (fullyQualifiedNameMatchingDescriptor is not null)
            {
                _bindTagHelperProducer.AddTagHelpersForComponent(fullyQualifiedNameMatchingDescriptor, ref results);
            }
        }
 
        foreach (var childContent in shortNameMatchingDescriptor.GetChildContentProperties())
        {
            // Synthesize a separate tag helper for each child content property that's declared.
            results.Add(CreateChildContentDescriptor(shortNameMatchingDescriptor, childContent));
            if (fullyQualifiedNameMatchingDescriptor is not null)
            {
                results.Add(CreateChildContentDescriptor(fullyQualifiedNameMatchingDescriptor, childContent));
            }
        }
    }
 
    private static TagHelperDescriptor CreateShortNameMatchingDescriptor(
        INamedTypeSymbol type,
        ImmutableArray<(IPropertySymbol property, PropertyKind kind)> properties)
        => CreateNameMatchingDescriptor(type, properties, fullyQualified: false);
 
    private static TagHelperDescriptor CreateFullyQualifiedNameMatchingDescriptor(
        INamedTypeSymbol type,
        ImmutableArray<(IPropertySymbol property, PropertyKind kind)> properties)
        => CreateNameMatchingDescriptor(type, properties, fullyQualified: true);
 
    private static TagHelperDescriptor CreateNameMatchingDescriptor(
        INamedTypeSymbol type,
        ImmutableArray<(IPropertySymbol property, PropertyKind kind)> properties,
        bool fullyQualified)
    {
        var typeName = TypeNameObject.From(type);
        var assemblyName = type.ContainingAssembly.Identity.Name;
 
        using var _ = TagHelperDescriptorBuilder.GetPooledInstance(
            TagHelperKind.Component, typeName.FullName.AssumeNotNull(), assemblyName, out var builder);
 
        builder.RuntimeKind = RuntimeKind.IComponent;
        builder.SetTypeName(typeName);
 
        var metadata = new ComponentMetadata.Builder();
 
        builder.CaseSensitive = true;
 
        if (fullyQualified)
        {
            var fullName = type.ContainingNamespace.IsGlobalNamespace
                ? type.Name
                : $"{type.ContainingNamespace.GetFullName()}.{type.Name}";
 
            builder.TagMatchingRule(r =>
            {
                r.TagName = fullName;
            });
 
            builder.IsFullyQualifiedNameMatch = true;
        }
        else
        {
            builder.TagMatchingRule(r =>
            {
                r.TagName = type.Name;
            });
        }
 
        if (type.IsGenericType)
        {
            metadata.IsGeneric = true;
 
            using var cascadeGenericTypeAttributes = new PooledHashSet<string>(StringComparer.Ordinal);
 
            foreach (var attribute in type.GetAttributes())
            {
                if (attribute.HasFullName(ComponentsApi.CascadingTypeParameterAttribute.MetadataName) &&
                    attribute.ConstructorArguments.FirstOrDefault() is { Value: string value })
                {
                    cascadeGenericTypeAttributes.Add(value);
                }
            }
 
            foreach (var typeArgument in type.TypeArguments)
            {
                if (typeArgument is ITypeParameterSymbol typeParameter)
                {
                    var cascade = cascadeGenericTypeAttributes.Contains(typeParameter.Name);
                    CreateTypeParameterProperty(builder, typeParameter, cascade);
                }
            }
        }
 
        if (HasRenderModeDirective(type))
        {
            metadata.HasRenderModeDirective = true;
        }
 
        var xml = type.GetDocumentationCommentXml();
        if (!string.IsNullOrEmpty(xml))
        {
            builder.SetDocumentation(xml);
        }
 
        foreach (var (property, kind) in properties)
        {
            if (kind == PropertyKind.Ignored)
            {
                continue;
            }
 
            CreateProperty(builder, type, property, kind);
        }
 
        if (builder.BoundAttributes.Any(static a => a.IsParameterizedChildContentProperty()) &&
            !builder.BoundAttributes.Any(static a => string.Equals(a.Name, ComponentHelpers.ChildContent.ParameterAttributeName, StringComparison.OrdinalIgnoreCase)))
        {
            // If we have any parameterized child content parameters, synthesize a 'Context' parameter to be
            // able to set the variable name (for all child content). If the developer defined a 'Context' parameter
            // already, then theirs wins.
            CreateContextParameter(builder, childContentName: null);
        }
 
        builder.SetMetadata(metadata.Build());
 
        return builder.Build();
    }
 
    private static void CreateProperty(TagHelperDescriptorBuilder builder, INamedTypeSymbol containingSymbol, IPropertySymbol property, PropertyKind kind)
    {
        builder.BindAttribute(pb =>
        {
            var builder = new PropertyMetadata.Builder();
 
            pb.Name = property.Name;
            pb.ContainingType = containingSymbol.GetFullName();
            pb.TypeName = property.Type.GetFullName();
            pb.PropertyName = property.Name;
            pb.IsEditorRequired = property.GetAttributes().Any(
                static a => a.HasFullName("Microsoft.AspNetCore.Components.EditorRequiredAttribute"));
 
            pb.CaseSensitive = false;
 
            builder.GloballyQualifiedTypeName = property.Type.GetGloballyQualifiedFullName();
 
            if (kind == PropertyKind.Enum)
            {
                pb.IsEnum = true;
            }
            else if (kind == PropertyKind.ChildContent)
            {
                builder.IsChildContent = true;
            }
            else if (kind == PropertyKind.EventCallback)
            {
                builder.IsEventCallback = true;
            }
            else if (kind == PropertyKind.Delegate)
            {
                builder.IsDelegateSignature = true;
                builder.IsDelegateWithAwaitableResult = IsAwaitable(property);
            }
 
            if (HasTypeParameter(property.Type))
            {
                builder.IsGenericTyped = true;
            }
 
            if (property.SetMethod.AssumeNotNull().IsInitOnly)
            {
                builder.IsInitOnlyProperty = true;
            }
 
            pb.SetMetadata(builder.Build());
 
            var xml = property.GetDocumentationCommentXml();
            if (!string.IsNullOrEmpty(xml))
            {
                pb.SetDocumentation(xml);
            }
        });
 
        static bool HasTypeParameter(ITypeSymbol type)
        {
            if (type is ITypeParameterSymbol)
            {
                return true;
            }
 
            // We need to check for cases like:
            // [Parameter] public List<T> MyProperty { get; set; }
            // AND
            // [Parameter] public List<string> MyProperty { get; set; }
            //
            // We need to inspect the type arguments to tell the difference between a property that
            // uses the containing class' type parameter(s) and a vanilla usage of generic types like
            // List<> and Dictionary<,>
            //
            // Since we need to handle cases like RenderFragment<List<T>>, this check must be recursive.
            if (type is INamedTypeSymbol namedType && namedType.IsGenericType)
            {
                foreach (var typeArgument in namedType.TypeArguments)
                {
                    if (HasTypeParameter(typeArgument))
                    {
                        return true;
                    }
                }
 
                // Another case to handle - if the type being inspected is a nested type
                // inside a generic containing class. The common usage for this would be a case
                // where a generic templated component defines a 'context' nested class.
                if (namedType.ContainingType != null && HasTypeParameter(namedType.ContainingType))
                {
                    return true;
                }
            }
            // Also check for cases like:
            // [Parameter] public T[] MyProperty { get; set; }
            else if (type is IArrayTypeSymbol array && HasTypeParameter(array.ElementType))
            {
                return true;
            }
 
            return false;
        }
    }
 
    private static bool IsAwaitable(IPropertySymbol prop)
    {
        var methodSymbol = ((INamedTypeSymbol)prop.Type).DelegateInvokeMethod.AssumeNotNull();
        if (methodSymbol.ReturnsVoid)
        {
            return false;
        }
 
        var members = methodSymbol.ReturnType.GetMembers();
        foreach (var candidate in members)
        {
            if (candidate is not IMethodSymbol method || !string.Equals(candidate.Name, "GetAwaiter", StringComparison.Ordinal))
            {
                continue;
            }
 
            if (!VerifyGetAwaiter(method))
            {
                continue;
            }
 
            return true;
        }
 
        return methodSymbol.IsAsync;
 
        static bool VerifyGetAwaiter(IMethodSymbol getAwaiter)
        {
            var returnType = getAwaiter.ReturnType;
            if (returnType == null)
            {
                return false;
            }
 
            var foundIsCompleted = false;
            var foundOnCompleted = false;
            var foundGetResult = false;
 
            foreach (var member in returnType.GetMembers())
            {
                if (!foundIsCompleted &&
                    member is IPropertySymbol property &&
                    IsProperty_IsCompleted(property))
                {
                    foundIsCompleted = true;
                }
 
                if (!(foundOnCompleted && foundGetResult) && member is IMethodSymbol method)
                {
                    if (IsMethod_OnCompleted(method))
                    {
                        foundOnCompleted = true;
                    }
                    else if (IsMethod_GetResult(method))
                    {
                        foundGetResult = true;
                    }
                }
 
                if (foundIsCompleted && foundOnCompleted && foundGetResult)
                {
                    return true;
                }
            }
 
            return false;
 
            static bool IsProperty_IsCompleted(IPropertySymbol property)
            {
                return property is
                {
                    Name: WellKnownMemberNames.IsCompleted,
                    Type.SpecialType: SpecialType.System_Boolean,
                    GetMethod: not null
                };
            }
 
            static bool IsMethod_OnCompleted(IMethodSymbol method)
            {
                return method is
                {
                    Name: WellKnownMemberNames.OnCompleted,
                    ReturnsVoid: true,
                    Parameters: [{ Type.TypeKind: TypeKind.Delegate }]
                };
            }
 
            static bool IsMethod_GetResult(IMethodSymbol method)
            {
                return method is
                {
                    Name: WellKnownMemberNames.GetResult,
                    Parameters: []
                };
            }
        }
    }
 
    private static void CreateTypeParameterProperty(TagHelperDescriptorBuilder builder, ITypeParameterSymbol typeParameter, bool cascade)
    {
        builder.BindAttribute(pb =>
        {
            pb.DisplayName = typeParameter.Name;
            pb.Name = typeParameter.Name;
            pb.TypeName = typeof(Type).FullName;
            pb.PropertyName = typeParameter.Name;
 
            var metadata = new TypeParameterMetadata.Builder
            {
                IsCascading = cascade
            };
 
            // Type constraints (like "Image" or "Foo") are stored independently of
            // things like constructor constraints and not null constraints in the
            // type parameter so we create a single string representation of all the constraints
            // here.
            using var constraints = new PooledList<string>();
 
            // CS0449: The 'class', 'struct', 'unmanaged', 'notnull', and 'default' constraints
            // cannot be combined or duplicated, and must be specified first in the constraints list.
            if (typeParameter.HasReferenceTypeConstraint)
            {
                constraints.Add("class");
            }
 
            if (typeParameter.HasNotNullConstraint)
            {
                constraints.Add("notnull");
            }
 
            if (typeParameter.HasUnmanagedTypeConstraint)
            {
                constraints.Add("unmanaged");
            }
            else if (typeParameter.HasValueTypeConstraint)
            {
                // `HasValueTypeConstraint` is also true when `unmanaged` constraint is present.
                constraints.Add("struct");
            }
 
            foreach (var constraintType in typeParameter.ConstraintTypes)
            {
                constraints.Add(constraintType.GetGloballyQualifiedFullName());
            }
 
            // CS0401: The new() constraint must be the last constraint specified.
            if (typeParameter.HasConstructorConstraint)
            {
                constraints.Add("new()");
            }
 
            if (TryGetWhereClauseText(typeParameter, constraints, out var whereClauseText))
            {
                metadata.Constraints = whereClauseText;
            }
 
            // Collect attributes that should be propagated to the type inference method.
            using var _ = StringBuilderPool.GetPooledObject(out var withAttributes);
            foreach (var attribute in typeParameter.GetAttributes())
            {
                if (attribute.HasFullName("System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembersAttribute"))
                {
                    Debug.Assert(attribute.AttributeClass != null);
 
                    if (withAttributes.Length > 0)
                    {
                        withAttributes.Append(", ");
                    }
                    else
                    {
                        withAttributes.Append('[');
                    }
 
                    withAttributes.Append(attribute.AttributeClass.GetGloballyQualifiedFullName());
                    withAttributes.Append('(');
 
                    var first = true;
                    foreach (var arg in attribute.ConstructorArguments)
                    {
                        if (first)
                        {
                            first = false;
                        }
                        else
                        {
                            withAttributes.Append(", ");
                        }
 
                        if (arg.Kind == TypedConstantKind.Enum)
                        {
                            withAttributes.Append("unchecked((");
                            withAttributes.Append(arg.Type!.GetGloballyQualifiedFullName());
                            withAttributes.Append(')');
                            withAttributes.Append(SymbolDisplay.FormatPrimitive(arg.Value!, quoteStrings: true, useHexadecimalNumbers: true));
                            withAttributes.Append(')');
                        }
                        else
                        {
                            Debug.Assert(false, $"Need to add support for '{arg.Kind}' and make sure the output is 'global::' prefixed.");
                            withAttributes.Append(arg.ToCSharpString());
                        }
                    }
 
                    withAttributes.Append(')');
                }
            }
 
            if (withAttributes.Length > 0)
            {
                withAttributes.Append("] ");
                withAttributes.Append(typeParameter.Name);
                metadata.NameWithAttributes = withAttributes.ToString();
            }
 
            pb.SetMetadata(metadata.Build());
 
            pb.SetDocumentation(
                DocumentationDescriptor.From(
                    DocumentationId.ComponentTypeParameter,
                    typeParameter.Name,
                    builder.Name));
        });
 
        static bool TryGetWhereClauseText(ITypeParameterSymbol typeParameter, PooledList<string> constraints, [NotNullWhen(true)] out string? constraintsText)
        {
            if (constraints.Count == 0)
            {
                constraintsText = null;
                return false;
            }
 
            using var _ = StringBuilderPool.GetPooledObject(out var builder);
 
            builder.Append("where ");
            builder.Append(typeParameter.Name);
            builder.Append(" : ");
 
            var addComma = false;
 
            foreach (var item in constraints)
            {
                if (addComma)
                {
                    builder.Append(", ");
                }
                else
                {
                    addComma = true;
                }
 
                builder.Append(item);
            }
 
            constraintsText = builder.ToString();
            return true;
        }
    }
 
    private static TagHelperDescriptor CreateChildContentDescriptor(TagHelperDescriptor component, BoundAttributeDescriptor attribute)
    {
        var typeName = component.TypeName + "." + attribute.Name;
        var assemblyName = component.AssemblyName;
 
        using var _ = TagHelperDescriptorBuilder.GetPooledInstance(
            TagHelperKind.ChildContent, typeName, assemblyName,
            out var builder);
 
        builder.SetTypeName(typeName, component.TypeNamespace, component.TypeNameIdentifier);
 
        builder.CaseSensitive = true;
 
        var xml = attribute.Documentation;
        if (!string.IsNullOrEmpty(xml))
        {
            builder.SetDocumentation(xml);
        }
 
        // Child content matches the property name, but only as a direct child of the component.
        builder.TagMatchingRule(r =>
        {
            r.TagName = attribute.Name;
            r.ParentTag = component.TagMatchingRules[0].TagName;
        });
 
        if (attribute.IsParameterizedChildContentProperty())
        {
            // For child content attributes with a parameter, synthesize an attribute that allows you to name
            // the parameter.
            CreateContextParameter(builder, attribute.Name);
        }
 
        if (component.IsFullyQualifiedNameMatch)
        {
            builder.IsFullyQualifiedNameMatch = true;
        }
 
        var descriptor = builder.Build();
 
        return descriptor;
    }
 
    private static void CreateContextParameter(TagHelperDescriptorBuilder builder, string? childContentName)
    {
        builder.BindAttribute(b =>
        {
            b.Name = ComponentHelpers.ChildContent.ParameterAttributeName;
            b.TypeName = typeof(string).FullName;
            b.PropertyName = b.Name;
            b.SetMetadata(ChildContentParameterMetadata.Default);
 
            var documentation = childContentName == null
                ? DocumentationDescriptor.ChildContentParameterName_TopLevel
                : DocumentationDescriptor.From(DocumentationId.ChildContentParameterName, childContentName);
 
            b.SetDocumentation(documentation);
        });
    }
 
    // Does a walk up the inheritance chain to determine the set of parameters by using
    // a dictionary keyed on property name.
    //
    // We consider parameters to be defined by properties satisfying all of the following:
    // - are public
    // - are visible (not shadowed)
    // - have the [Parameter] attribute
    // - have a setter, even if private
    // - are not indexers
    private static ImmutableArray<(IPropertySymbol property, PropertyKind kind)> GetProperties(INamedTypeSymbol type)
    {
        using var names = new PooledHashSet<string>(StringComparer.Ordinal);
        using var results = new PooledArrayBuilder<(IPropertySymbol, PropertyKind)>();
 
        var currentType = type;
        do
        {
            if (currentType.HasFullName(ComponentsApi.ComponentBase.MetadataName))
            {
                // The ComponentBase base class doesn't have any [Parameter].
                // Bail out now to avoid walking through its many members, plus the members
                // of the System.Object base class.
                break;
            }
 
            foreach (var member in currentType.GetMembers())
            {
                if (member is not IPropertySymbol property)
                {
                    // Not a property
                    continue;
                }
 
                if (names.Contains(property.Name))
                {
                    // Not visible
                    continue;
                }
 
                var kind = PropertyKind.Default;
                if (property.DeclaredAccessibility != Accessibility.Public // Not public
                    || property.Parameters.Length != 0 // Indexer
                    || property.SetMethod == null // No setter
                    || property.SetMethod.DeclaredAccessibility != Accessibility.Public // No public setter
                    || property.IsStatic)
                {
                    // For non-override properties, skip the expensive GetAttributes() call
                    // since the property will be ignored regardless. GetAttributes() triggers
                    // attribute binding and nullable analysis in the compiler, which is a
                    // significant cost during tag helper discovery. Override properties still
                    // need the check because [Parameter] determines whether to shadow or pass
                    // through to the base class.
                    if (!property.IsOverride)
                    {
                        names.Add(property.Name);
                        results.Add((property, PropertyKind.Ignored));
                        continue;
                    }
 
                    kind = PropertyKind.Ignored;
                }
 
                if (!property.GetAttributes().Any(static a => a.HasFullName(ComponentsApi.ParameterAttribute.MetadataName)))
                {
                    if (property.IsOverride)
                    {
                        // This property does not contain [Parameter] attribute but it was overridden. Don't ignore it for now.
                        // We can ignore it if the base class does not contains a [Parameter] as well.
                        continue;
                    }
 
                    // Does not have [Parameter]
                    kind = PropertyKind.Ignored;
                }
 
                if (kind == PropertyKind.Default)
                {
                    kind = property switch
                    {
                        var p when IsEnum(p) => PropertyKind.Enum,
                        var p when IsRenderFragment(p) => PropertyKind.ChildContent,
                        var p when IsEventCallback(p) => PropertyKind.EventCallback,
                        var p when IsDelegate(p) => PropertyKind.Delegate,
                        _ => PropertyKind.Default
                    };
                }
 
                names.Add(property.Name);
                results.Add((property, kind));
            }
 
            currentType = currentType.BaseType;
        }
        while (currentType != null);
 
        return results.ToImmutableAndClear();
 
        static bool IsEnum(IPropertySymbol property)
        {
            return property.Type.TypeKind == TypeKind.Enum;
        }
 
        static bool IsRenderFragment(IPropertySymbol property)
        {
            return property.Type.HasFullName(ComponentsApi.RenderFragment.MetadataName) ||
                  (property.Type is INamedTypeSymbol { IsGenericType: true } namedType &&
                   namedType.ConstructedFrom.HasFullName(ComponentsApi.RenderFragmentOfT.DisplayName));
        }
 
        static bool IsEventCallback(IPropertySymbol property)
        {
            return property.Type.HasFullName(ComponentsApi.EventCallback.MetadataName) ||
                  (property.Type is INamedTypeSymbol { IsGenericType: true } namedType &&
                   namedType.ConstructedFrom.HasFullName(ComponentsApi.EventCallbackOfT.DisplayName));
        }
 
        static bool IsDelegate(IPropertySymbol property)
        {
            return property.Type.TypeKind == TypeKind.Delegate;
        }
    }
 
    private static bool HasRenderModeDirective(INamedTypeSymbol type)
    {
        var attributes = type.GetAttributes();
        foreach (var attribute in attributes)
        {
            var attributeClass = attribute.AttributeClass;
            while (attributeClass is not null)
            {
                if (attributeClass.HasFullName(ComponentsApi.RenderModeAttribute.FullTypeName))
                {
                    return true;
                }
 
                attributeClass = attributeClass.BaseType;
            }
        }
        return false;
    }
 
    private enum PropertyKind
    {
        Ignored,
        Default,
        Enum,
        ChildContent,
        Delegate,
        EventCallback,
    }
}