File: Language\TagHelpers\Producers\BindTagHelperProducer.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.Globalization;
using System.Linq;
using System.Threading;
using Microsoft.AspNetCore.Razor.Language.Components;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Razor;
 
namespace Microsoft.AspNetCore.Razor.Language.TagHelpers.Producers;
 
internal sealed partial class BindTagHelperProducer : TagHelperProducer
{
    // This provider returns tag helper information for 'bind' which doesn't necessarily
    // map to any real component. Bind behaves more like a macro, which can map a single LValue to
    // both a 'value' attribute and a 'value changed' attribute.
    //
    // User types:
    //      <input type="text" @bind="@FirstName"/>
    //
    // We generate:
    //      <input type="text"
    //          value="@BindMethods.GetValue(FirstName)"
    //          onchange="@EventCallbackFactory.CreateBinder(this, __value => FirstName = __value, FirstName)"/>
    //
    // This isn't very different from code the user could write themselves - thus the pronouncement
    // that @bind is very much like a macro.
    //
    // A lot of the value that provide in this case is that the associations between the
    // elements, and the attributes aren't straightforward.
    //
    // For instance on <input type="text" /> we need to listen to 'value' and 'onchange',
    // but on <input type="checked" we need to listen to 'checked' and 'onchange'.
    //
    // We handle a few different cases here:
    //
    //  1.  When given an attribute like **anywhere**'@bind-value="@FirstName"' and '@bind-value:event="onchange"' we will
    //      generate the 'value' attribute and 'onchange' attribute.
    //
    //      We don't do any transformation or inference for this case, because the developer has
    //      told us exactly what to do. This is the *full* form of @bind, and should support any
    //      combination of element, component, and attributes.
    //
    //      This is the most general case, and is implemented with a built-in tag helper that applies
    //      to everything, and binds to a dictionary of attributes that start with @bind-.
    //
    //  2.  We also support cases like '@bind-value="@FirstName"' where we will generate the 'value'
    //      attribute and another attribute based for a changed handler based on the metadata.
    //
    //      These mappings are provided by attributes that tell us what attributes, suffixes, and
    //      elements to map.
    //
    //  3.  When given an attribute like '@bind="@FirstName"' we will generate a value and change
    //      attribute solely based on the context. We need the context of an HTML tag to know
    //      what attributes to generate.
    //
    //      Similar to case #2, this should 'just work' from the users point of view. We expect
    //      using this syntax most frequently with input elements.
    //
    //      These mappings are also provided by attributes. Primarily these are used by <input />
    //      and so we have a special case for input elements and their type attributes.
    //
    //      Additionally, our mappings tell us about cases like <input type="number" ... /> where
    //      we need to treat the value as an invariant culture value. In general the HTML5 field
    //      types use invariant culture values when interacting with the DOM, in contrast to
    //      <input type="text" ... /> which is free-form text and is most likely to be
    //      culture-sensitive.
    //
    //  4.  For components, we have a bit of a special case. We can infer a syntax that matches
    //      case #2 based on property names. So if a component provides both 'Value' and 'ValueChanged'
    //      we will turn that into an instance of bind.
    //
    // So case #1 here is the most general case. Case #2 and #3 are data-driven based on attribute data
    // we have. Case #4 is data-driven based on component definitions.
    //
    // We provide a good set of attributes that map to the HTML dom. This set is user extensible.
 
    private static readonly Lazy<TagHelperDescriptor> s_fallbackTagHelper = new(CreateFallbackBindTagHelper);
 
    private readonly INamedTypeSymbol _bindConverterType;
    private readonly INamedTypeSymbol? _bindElementAttributeType;
    private readonly INamedTypeSymbol? _bindInputElementAttributeType;
 
    private BindTagHelperProducer(
        INamedTypeSymbol bindConverterType,
        INamedTypeSymbol? bindElementAttributeType,
        INamedTypeSymbol? bindInputElementAttributeType)
    {
        _bindConverterType = bindConverterType;
        _bindElementAttributeType = bindElementAttributeType;
        _bindInputElementAttributeType = bindInputElementAttributeType;
    }
 
    public override TagHelperProducerKind Kind => TagHelperProducerKind.Bind;
 
    public override bool SupportsStaticTagHelpers => true;
 
    public override void AddStaticTagHelpers(IAssemblySymbol assembly, ref TagHelperCollection.RefBuilder results)
    {
        if (!SymbolEqualityComparer.Default.Equals(assembly, _bindConverterType.ContainingAssembly))
        {
            return;
        }
 
        // Tag Helper definition for case #1. This is the most general case.
        results.Add(s_fallbackTagHelper.Value);
    }
 
    public override bool SupportsTypes
        => _bindElementAttributeType is not null && _bindInputElementAttributeType is not null;
 
    public override bool IsCandidateType(INamedTypeSymbol type)
        => type.DeclaredAccessibility == Accessibility.Public &&
           type.Name == "BindAttributes";
 
    public override void AddTagHelpersForType(
        INamedTypeSymbol type,
        ref TagHelperCollection.RefBuilder results,
        CancellationToken cancellationToken)
    {
        // Not handling duplicates here for now since we're the primary ones extending this.
        // If we see users adding to the set of 'bind' constructs we will want to add deduplication
        // and potentially diagnostics.
        foreach (var attribute in type.GetAttributes())
        {
            var constructorArguments = attribute.ConstructorArguments;
 
            TagHelperDescriptor? tagHelper = null;
 
            // For case #2 & #3 we have a whole bunch of attribute entries on BindMethods that we can use
            // to data-drive the definitions of these tag helpers.
 
            // We need to check the constructor argument length here, because this can show up as 0
            // if the language service fails to initialize. This is an invalid case, so skip it.
            if (constructorArguments.Length == 4 && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, _bindElementAttributeType))
            {
                tagHelper = CreateElementBindTagHelper(
                    typeName: type.GetDefaultDisplayString(),
                    typeNamespace: type.ContainingNamespace.GetFullName(),
                    typeNameIdentifier: type.Name,
                    element: (string?)constructorArguments[0].Value,
                    typeAttribute: null,
                    suffix: (string?)constructorArguments[1].Value,
                    valueAttribute: (string?)constructorArguments[2].Value,
                    changeAttribute: (string?)constructorArguments[3].Value);
            }
            else if (constructorArguments.Length == 4 && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, _bindInputElementAttributeType))
            {
                tagHelper = CreateElementBindTagHelper(
                    typeName: type.GetDefaultDisplayString(),
                    typeNamespace: type.ContainingNamespace.GetFullName(),
                    typeNameIdentifier: type.Name,
                    element: "input",
                    typeAttribute: (string?)constructorArguments[0].Value,
                    suffix: (string?)constructorArguments[1].Value,
                    valueAttribute: (string?)constructorArguments[2].Value,
                    changeAttribute: (string?)constructorArguments[3].Value);
            }
            else if (constructorArguments.Length == 6 && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, _bindInputElementAttributeType))
            {
                tagHelper = CreateElementBindTagHelper(
                    typeName: type.GetDefaultDisplayString(),
                    typeNamespace: type.ContainingNamespace.GetFullName(),
                    typeNameIdentifier: type.Name,
                    element: "input",
                    typeAttribute: (string?)constructorArguments[0].Value,
                    suffix: (string?)constructorArguments[1].Value,
                    valueAttribute: (string?)constructorArguments[2].Value,
                    changeAttribute: (string?)constructorArguments[3].Value,
                    isInvariantCulture: (bool?)constructorArguments[4].Value ?? false,
                    format: (string?)constructorArguments[5].Value);
            }
 
            if (tagHelper is not null)
            {
                results.Add(tagHelper);
            }
        }
    }
 
    private static TagHelperDescriptor CreateElementBindTagHelper(
        string typeName,
        string typeNamespace,
        string typeNameIdentifier,
        string? element,
        string? typeAttribute,
        string? suffix,
        string? valueAttribute,
        string? changeAttribute,
        bool isInvariantCulture = false,
        string? format = null)
    {
        string name, attributeName, formatName, formatAttributeName, eventName;
 
        if (suffix is { } s)
        {
            name = "Bind_" + s;
            attributeName = "@bind-" + s;
            formatName = "Format_" + s;
            formatAttributeName = "format-" + s;
            eventName = "Event_" + s;
        }
        else
        {
            name = "Bind";
            attributeName = "@bind";
 
            suffix = valueAttribute;
            formatName = "Format_" + suffix;
            formatAttributeName = "format-" + suffix;
            eventName = "Event_" + suffix;
        }
 
        using var _ = TagHelperDescriptorBuilder.GetPooledInstance(
            TagHelperKind.Bind, name, ComponentsApi.AssemblyName,
            out var builder);
 
        builder.SetTypeName(typeName, typeNamespace, typeNameIdentifier);
 
        builder.CaseSensitive = true;
        builder.ClassifyAttributesOnly = true;
        builder.SetDocumentation(
            DocumentationDescriptor.From(
                DocumentationId.BindTagHelper_Element,
                valueAttribute,
                changeAttribute));
 
        var metadata = new BindMetadata.Builder
        {
            ValueAttribute = valueAttribute,
            ChangeAttribute = changeAttribute,
            IsInvariantCulture = isInvariantCulture,
            Format = format
        };
 
        if (typeAttribute != null)
        {
            // For entries that map to the <input /> element, we need to be able to know
            // the difference between <input /> and <input type="text" .../> for which we
            // want to use the same attributes.
            //
            // We provide a tag helper for <input /> that should match all input elements,
            // but we only want it to be used when a more specific one is used.
            //
            // Therefore we use this metadata to know which one is more specific when two
            // tag helpers match.
            metadata.TypeAttribute = typeAttribute;
        }
 
        builder.SetMetadata(metadata.Build());
 
        builder.TagMatchingRule(rule =>
        {
            rule.TagName = element;
            if (typeAttribute != null)
            {
                rule.Attribute(a =>
                {
                    a.Name = "type";
                    a.NameComparison = RequiredAttributeNameComparison.FullMatch;
                    a.Value = typeAttribute;
                    a.ValueComparison = RequiredAttributeValueComparison.FullMatch;
                });
            }
 
            rule.Attribute(a =>
            {
                a.Name = attributeName;
                a.NameComparison = RequiredAttributeNameComparison.FullMatch;
                a.IsDirectiveAttribute = true;
            });
        });
 
        builder.TagMatchingRule(rule =>
        {
            rule.TagName = element;
            if (typeAttribute != null)
            {
                rule.Attribute(a =>
                {
                    a.Name = "type";
                    a.NameComparison = RequiredAttributeNameComparison.FullMatch;
                    a.Value = typeAttribute;
                    a.ValueComparison = RequiredAttributeValueComparison.FullMatch;
                });
            }
 
            rule.Attribute(a =>
            {
                a.Name = $"{attributeName}:get";
                a.NameComparison = RequiredAttributeNameComparison.FullMatch;
                a.IsDirectiveAttribute = true;
            });
 
            rule.Attribute(a =>
            {
                a.Name = $"{attributeName}:set";
                a.NameComparison = RequiredAttributeNameComparison.FullMatch;
                a.IsDirectiveAttribute = true;
            });
        });
 
        builder.BindAttribute(a =>
        {
            a.SetDocumentation(
                DocumentationDescriptor.From(
                    DocumentationId.BindTagHelper_Element,
                    valueAttribute,
                    changeAttribute));
 
            a.Name = attributeName;
            a.TypeName = typeof(object).FullName;
            a.IsDirectiveAttribute = true;
            a.PropertyName = name;
 
            a.BindAttributeParameter(parameter =>
            {
                parameter.Name = "format";
                parameter.PropertyName = formatName;
                parameter.TypeName = typeof(string).FullName;
                parameter.SetDocumentation(
                    DocumentationDescriptor.From(
                        DocumentationId.BindTagHelper_Element_Format,
                        attributeName));
            });
 
            a.BindAttributeParameter(parameter =>
            {
                parameter.Name = "event";
                parameter.PropertyName = eventName;
                parameter.TypeName = typeof(string).FullName;
                parameter.SetDocumentation(
                    DocumentationDescriptor.From(
                        DocumentationId.BindTagHelper_Element_Event,
                        attributeName));
            });
 
            a.BindAttributeParameter(parameter =>
            {
                parameter.Name = "culture";
                parameter.PropertyName = "Culture";
                parameter.TypeName = typeof(CultureInfo).FullName;
                parameter.SetDocumentation(DocumentationDescriptor.BindTagHelper_Element_Culture);
            });
 
            a.BindAttributeParameter(parameter =>
            {
                parameter.Name = "get";
                parameter.PropertyName = "Get";
                parameter.TypeName = typeof(object).FullName;
                parameter.SetDocumentation(DocumentationDescriptor.BindTagHelper_Element_Get);
                parameter.BindAttributeGetSet = true;
            });
 
            a.BindAttributeParameter(parameter =>
            {
                parameter.Name = "set";
                parameter.PropertyName = "Set";
                parameter.TypeName = typeof(Delegate).FullName;
                parameter.SetDocumentation(DocumentationDescriptor.BindTagHelper_Element_Set);
            });
 
            a.BindAttributeParameter(parameter =>
            {
                parameter.Name = "after";
                parameter.PropertyName = "After";
                parameter.TypeName = typeof(Delegate).FullName;
                parameter.SetDocumentation(DocumentationDescriptor.BindTagHelper_Element_After);
            });
        });
 
        // This is no longer supported. This is just here so we can add a diagnostic later on when this matches.
        builder.BindAttribute(attribute =>
        {
            attribute.Name = formatAttributeName;
            attribute.TypeName = "System.String";
            attribute.SetDocumentation(
                DocumentationDescriptor.From(
                    DocumentationId.BindTagHelper_Element_Format,
                    attributeName));
 
            attribute.PropertyName = formatName;
        });
 
        return builder.Build();
    }
 
    public void AddTagHelpersForComponent(TagHelperDescriptor tagHelper, ref TagHelperCollection.RefBuilder results)
    {
        if (tagHelper.Kind != TagHelperKind.Component || !SupportsTypes)
        {
            return;
        }
 
        // We want to create a 'bind' tag helper everywhere we see a pair of properties like `Foo`, `FooChanged`
        // where `FooChanged` is a delegate and `Foo` is not.
        //
        // The easiest way to figure this out without a lot of backtracking is to look for `FooChanged` and then
        // try to find a matching "Foo".
        //
        // We also look for a corresponding FooExpression attribute, though its presence is optional.
        foreach (var changeAttribute in tagHelper.BoundAttributes)
        {
            if (!changeAttribute.Name.EndsWith("Changed", StringComparison.Ordinal) ||
 
                // Allow the ValueChanged attribute to be a delegate or EventCallback<>.
                //
                // We assume that the Delegate or EventCallback<> has a matching type, and the C# compiler will help
                // you figure figure it out if you did it wrongly.
                (!changeAttribute.IsDelegateProperty() && !changeAttribute.IsEventCallbackProperty()))
            {
                continue;
            }
 
            BoundAttributeDescriptor? valueAttribute = null;
            BoundAttributeDescriptor? expressionAttribute = null;
 
            var valueAttributeName = changeAttribute.Name[..^"Changed".Length];
            var expressionAttributeName = valueAttributeName + "Expression";
 
            foreach (var attribute in tagHelper.BoundAttributes)
            {
                if (attribute.Name == valueAttributeName)
                {
                    valueAttribute = attribute;
                }
 
                if (attribute.Name == expressionAttributeName)
                {
                    expressionAttribute = attribute;
                }
 
                if (valueAttribute != null && expressionAttribute != null)
                {
                    // We found both, so we can stop looking now
                    break;
                }
            }
 
            if (valueAttribute == null)
            {
                // No matching attribute found.
                continue;
            }
 
            using var _ = TagHelperDescriptorBuilder.GetPooledInstance(
                TagHelperKind.Bind, tagHelper.Name, tagHelper.AssemblyName,
                out var builder);
 
            builder.SetTypeName(tagHelper.TypeNameObject);
 
            builder.DisplayName = tagHelper.DisplayName;
            builder.CaseSensitive = true;
            builder.SetDocumentation(
                DocumentationDescriptor.From(
                    DocumentationId.BindTagHelper_Component,
                    valueAttribute.Name,
                    changeAttribute.Name));
 
            var metadata = new BindMetadata.Builder
            {
                ValueAttribute = valueAttribute.Name,
                ChangeAttribute = changeAttribute.Name
            };
 
            if (expressionAttribute != null)
            {
                metadata.ExpressionAttribute = expressionAttribute.Name;
            }
 
            // Match the component and attribute name
            builder.TagMatchingRule(rule =>
            {
                rule.TagName = tagHelper.TagMatchingRules.Single().TagName;
                rule.Attribute(attribute =>
                {
                    attribute.Name = "@bind-" + valueAttribute.Name;
                    attribute.NameComparison = RequiredAttributeNameComparison.FullMatch;
                    attribute.IsDirectiveAttribute = true;
                });
            });
 
            builder.TagMatchingRule(rule =>
            {
                rule.TagName = tagHelper.TagMatchingRules.Single().TagName;
                rule.Attribute(attribute =>
                {
                    attribute.Name = "@bind-" + valueAttribute.Name + ":get";
                    attribute.NameComparison = RequiredAttributeNameComparison.FullMatch;
                    attribute.IsDirectiveAttribute = true;
                });
                rule.Attribute(attribute =>
                {
                    attribute.Name = "@bind-" + valueAttribute.Name + ":set";
                    attribute.NameComparison = RequiredAttributeNameComparison.FullMatch;
                    attribute.IsDirectiveAttribute = true;
                });
            });
 
            builder.BindAttribute(attribute =>
            {
                attribute.SetDocumentation(
                    DocumentationDescriptor.From(
                        DocumentationId.BindTagHelper_Component,
                        valueAttribute.Name,
                        changeAttribute.Name));
 
                attribute.Name = "@bind-" + valueAttribute.Name;
                attribute.TypeName = changeAttribute.TypeName;
                attribute.IsEnum = valueAttribute.IsEnum;
                attribute.ContainingType = valueAttribute.ContainingType;
                attribute.IsDirectiveAttribute = true;
                attribute.PropertyName = valueAttribute.PropertyName;
 
                attribute.BindAttributeParameter(parameter =>
                {
                    parameter.Name = "get";
                    parameter.PropertyName = "Get";
                    parameter.TypeName = typeof(object).FullName;
                    parameter.SetDocumentation(DocumentationDescriptor.BindTagHelper_Element_Get);
                    parameter.BindAttributeGetSet = true;
                });
 
                attribute.BindAttributeParameter(parameter =>
                {
                    parameter.Name = "set";
                    parameter.PropertyName = "Set";
                    parameter.TypeName = typeof(Delegate).FullName;
                    parameter.SetDocumentation(DocumentationDescriptor.BindTagHelper_Element_Set);
                });
 
                attribute.BindAttributeParameter(parameter =>
                {
                    parameter.Name = "after";
                    parameter.PropertyName = "After";
                    parameter.TypeName = typeof(Delegate).FullName;
                    parameter.SetDocumentation(DocumentationDescriptor.BindTagHelper_Element_After);
                });
            });
 
            if (tagHelper.IsFullyQualifiedNameMatch)
            {
                builder.IsFullyQualifiedNameMatch = true;
            }
 
            builder.SetMetadata(metadata.Build());
 
            results.Add(builder.Build());
        }
    }
 
    private static TagHelperDescriptor CreateFallbackBindTagHelper()
    {
        using var _ = TagHelperDescriptorBuilder.GetPooledInstance(
            TagHelperKind.Bind, "Bind", ComponentsApi.AssemblyName,
            out var builder);
 
        builder.SetTypeName(
            fullName: "Microsoft.AspNetCore.Components.Bind",
            typeNamespace: "Microsoft.AspNetCore.Components",
            typeNameIdentifier: "Bind");
 
        builder.CaseSensitive = true;
        builder.ClassifyAttributesOnly = true;
        builder.SetDocumentation(DocumentationDescriptor.BindTagHelper_Fallback);
 
        builder.SetMetadata(new BindMetadata() { IsFallback = true });
 
        builder.TagMatchingRule(rule =>
        {
            rule.TagName = "*";
            rule.Attribute(attribute =>
            {
                attribute.Name = "@bind-";
                attribute.NameComparison = RequiredAttributeNameComparison.PrefixMatch;
                attribute.IsDirectiveAttribute = true;
            });
        });
 
        builder.BindAttribute(attribute =>
        {
            attribute.SetDocumentation(DocumentationDescriptor.BindTagHelper_Fallback);
 
            var attributeName = "@bind-...";
            attribute.Name = attributeName;
            attribute.AsDictionary("@bind-", typeof(object).FullName);
            attribute.IsDirectiveAttribute = true;
 
            attribute.PropertyName = "Bind";
 
            attribute.TypeName = "System.Collections.Generic.Dictionary<string, object>";
 
            attribute.BindAttributeParameter(parameter =>
            {
                parameter.Name = "format";
                parameter.PropertyName = "Format";
                parameter.TypeName = typeof(string).FullName;
                parameter.SetDocumentation(DocumentationDescriptor.BindTagHelper_Fallback_Format);
            });
 
            attribute.BindAttributeParameter(parameter =>
            {
                parameter.Name = "event";
                parameter.PropertyName = "Event";
                parameter.TypeName = typeof(string).FullName;
                parameter.SetDocumentation(
                    DocumentationDescriptor.From(
                        DocumentationId.BindTagHelper_Fallback_Event, attributeName));
            });
 
            attribute.BindAttributeParameter(parameter =>
            {
                parameter.Name = "culture";
                parameter.PropertyName = "Culture";
                parameter.TypeName = typeof(CultureInfo).FullName;
                parameter.SetDocumentation(DocumentationDescriptor.BindTagHelper_Element_Culture);
            });
 
            attribute.BindAttributeParameter(parameter =>
            {
                parameter.Name = "get";
                parameter.PropertyName = "Get";
                parameter.TypeName = typeof(object).FullName;
                parameter.SetDocumentation(DocumentationDescriptor.BindTagHelper_Element_Get);
                parameter.BindAttributeGetSet = true;
            });
 
            attribute.BindAttributeParameter(parameter =>
            {
                parameter.Name = "set";
                parameter.PropertyName = "Set";
                parameter.TypeName = typeof(Delegate).FullName;
                parameter.SetDocumentation(DocumentationDescriptor.BindTagHelper_Element_Set);
            });
 
            attribute.BindAttributeParameter(parameter =>
            {
                parameter.Name = "after";
                parameter.PropertyName = "After";
                parameter.TypeName = typeof(Delegate).FullName;
                parameter.SetDocumentation(DocumentationDescriptor.BindTagHelper_Element_After);
            });
        });
 
        return builder.Build();
    }
}