File: Language\Extensions\DefaultTagHelperTargetExtension.cs
Web Access
Project: src\src\roslyn\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.

#nullable disable

using System;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using Microsoft.AspNetCore.Razor.Language.CodeGeneration;
using Microsoft.AspNetCore.Razor.Language.Intermediate;

namespace Microsoft.AspNetCore.Razor.Language.Extensions;

internal sealed class DefaultTagHelperTargetExtension : IDefaultTagHelperTargetExtension
{
    private static readonly ImmutableArray<string> s_fieldUninitializedModifiers = ["0649"];
    private static readonly ImmutableArray<string> s_fieldUnusedModifiers = ["0169"];
    private static readonly ImmutableArray<string> s_privateModifiers = ["private"];

    public string RunnerVariableName { get; set; } = "__tagHelperRunner";

    public string StringValueBufferVariableName { get; set; } = "__tagHelperStringValueBuffer";

    public string CreateTagHelperMethodName { get; set; } = "CreateTagHelper";

    public string ExecutionContextTypeName { get; set; } = "global::Microsoft.AspNetCore.Razor.Runtime.TagHelpers.TagHelperExecutionContext";

    public string ExecutionContextVariableName { get; set; } = "__tagHelperExecutionContext";

    public string ExecutionContextAddMethodName { get; set; } = "Add";

    public string TagHelperRunnerTypeName { get; set; } = "global::Microsoft.AspNetCore.Razor.Runtime.TagHelpers.TagHelperRunner";

    public string ExecutionContextOutputPropertyName { get; set; } = "Output";

    public string ExecutionContextSetOutputContentAsyncMethodName { get; set; } = "SetOutputContentAsync";

    public string ExecutionContextAddHtmlAttributeMethodName { get; set; } = "AddHtmlAttribute";

    public string ExecutionContextAddTagHelperAttributeMethodName { get; set; } = "AddTagHelperAttribute";

    public string RunnerRunAsyncMethodName { get; set; } = "RunAsync";

    public string ScopeManagerTypeName { get; set; } = "global::Microsoft.AspNetCore.Razor.Runtime.TagHelpers.TagHelperScopeManager";

    public string ScopeManagerVariableName { get; set; } = "__tagHelperScopeManager";

    public string ScopeManagerBeginMethodName { get; set; } = "Begin";

    public string ScopeManagerEndMethodName { get; set; } = "End";

    public string StartTagHelperWritingScopeMethodName { get; set; } = "StartTagHelperWritingScope";

    public string EndTagHelperWritingScopeMethodName { get; set; } = "EndTagHelperWritingScope";

    public string TagModeTypeName { get; set; } = "global::Microsoft.AspNetCore.Razor.TagHelpers.TagMode";

    public string HtmlAttributeValueStyleTypeName { get; set; } = "global::Microsoft.AspNetCore.Razor.TagHelpers.HtmlAttributeValueStyle";

    public string TagHelperOutputIsContentModifiedPropertyName { get; set; } = "IsContentModified";

    public string BeginAddHtmlAttributeValuesMethodName { get; set; } = "BeginAddHtmlAttributeValues";

    public string EndAddHtmlAttributeValuesMethodName { get; set; } = "EndAddHtmlAttributeValues";

    public string BeginWriteTagHelperAttributeMethodName { get; set; } = "BeginWriteTagHelperAttribute";

    public string EndWriteTagHelperAttributeMethodName { get; set; } = "EndWriteTagHelperAttribute";

    public string MarkAsHtmlEncodedMethodName { get; set; } = "Html.Raw";

    public string FormatInvalidIndexerAssignmentMethodName { get; set; } = "InvalidTagHelperIndexerAssignment";

    public string WriteTagHelperOutputMethod { get; set; } = "Write";

    public void WriteTagHelperBody(CodeRenderingContext context, DefaultTagHelperBodyIntermediateNode node)
    {
        if (context.Parent as TagHelperIntermediateNode == null)
        {
            var message = Resources.FormatIntermediateNodes_InvalidParentNode(node.GetType(), typeof(TagHelperIntermediateNode));
            throw new InvalidOperationException(message);
        }

        // Call into the tag helper scope manager to start a new tag helper scope.
        // Also capture the value as the current execution context.
        context.CodeWriter
            .WriteStartAssignment(ExecutionContextVariableName)
            .WriteStartInstanceMethodInvocation(
                ScopeManagerVariableName,
                ScopeManagerBeginMethodName);

        // Assign a unique ID for this instance of the source HTML tag. This must be unique
        // per call site, e.g. if the tag is on the view twice, there should be two IDs.
        var uniqueId = GetDeterministicId(context);

        context.CodeWriter.WriteStringLiteral(node.TagName)
            .WriteParameterSeparator()
            .Write($"{TagModeTypeName}.{node.TagMode}")
            .WriteParameterSeparator()
            .WriteStringLiteral(uniqueId)
            .WriteParameterSeparator();

        using (context.CodeWriter.BuildAsyncLambda())
        {
            // We remove and redirect writers so TagHelper authors can retrieve content.
            context.RenderChildren(node, IntermediateNodeWriter.Instance);
        }

        context.CodeWriter.WriteEndMethodInvocation();
    }

    public void WriteTagHelperCreate(CodeRenderingContext context, DefaultTagHelperCreateIntermediateNode node)
    {
        if (context.Parent as TagHelperIntermediateNode == null)
        {
            var message = Resources.FormatIntermediateNodes_InvalidParentNode(node.GetType(), typeof(TagHelperIntermediateNode));
            throw new InvalidOperationException(message);
        }

        context.CodeWriter
            .WriteStartAssignment(node.FieldName)
            .Write(CreateTagHelperMethodName)
            .WriteLine($"<global::{node.TypeName}>();");

        context.CodeWriter.WriteInstanceMethodInvocation(
            ExecutionContextVariableName,
            ExecutionContextAddMethodName,
            node.FieldName);
    }

    public void WriteTagHelperExecute(CodeRenderingContext context, DefaultTagHelperExecuteIntermediateNode node)
    {
        if (context.Parent as TagHelperIntermediateNode == null)
        {
            var message = Resources.FormatIntermediateNodes_InvalidParentNode(node.GetType(), typeof(TagHelperIntermediateNode));
            throw new InvalidOperationException(message);
        }

        // We always render `await __tagHelperRunner.RunAsync(__tagHelperExecutionContext);` to notify users of the requirement for a method
        // to be asynchronous.

        context.CodeWriter
            .Write("await ")
            .WriteStartInstanceMethodInvocation(
                RunnerVariableName,
                RunnerRunAsyncMethodName)
            .Write(ExecutionContextVariableName)
            .WriteEndMethodInvocation();

        var tagHelperOutputAccessor = $"{ExecutionContextVariableName}.{ExecutionContextOutputPropertyName}";

        context.CodeWriter
            .WriteLine($"if (!{tagHelperOutputAccessor}.{TagHelperOutputIsContentModifiedPropertyName})");

        using (context.CodeWriter.BuildScope())
        {
            context.CodeWriter
                .Write("await ")
                .WriteInstanceMethodInvocation(
                    ExecutionContextVariableName,
                    ExecutionContextSetOutputContentAsyncMethodName);
        }

        context.CodeWriter
            .WriteStartMethodInvocation(WriteTagHelperOutputMethod)
            .Write(tagHelperOutputAccessor)
            .WriteEndMethodInvocation()
            .WriteStartAssignment(ExecutionContextVariableName)
            .WriteInstanceMethodInvocation(
                ScopeManagerVariableName,
                ScopeManagerEndMethodName);
    }

    public void WriteTagHelperHtmlAttribute(CodeRenderingContext context, DefaultTagHelperHtmlAttributeIntermediateNode node)
    {
        if (context.Parent as TagHelperIntermediateNode == null)
        {
            var message = Resources.FormatIntermediateNodes_InvalidParentNode(node.GetType(), typeof(TagHelperIntermediateNode));
            throw new InvalidOperationException(message);
        }

        var attributeValueStyleParameter = $"{HtmlAttributeValueStyleTypeName}.{node.AttributeStructure}";
        var isConditionalAttributeValue = node.Children.Any(
            child => child is CSharpExpressionAttributeValueIntermediateNode || child is CSharpCodeAttributeValueIntermediateNode);

        // All simple text and minimized attributes will be pre-allocated.
        if (isConditionalAttributeValue)
        {
            // Dynamic attribute value should be run through the conditional attribute removal system. It's
            // unbound and contains C#.

            // TagHelper attribute rendering is buffered by default. We do not want to write to the current
            // writer.
            var valuePieceCount = node.Children.Count(
                child =>
                    child is HtmlAttributeValueIntermediateNode ||
                    child is CSharpExpressionAttributeValueIntermediateNode ||
                    child is CSharpCodeAttributeValueIntermediateNode ||
                    child is ExtensionIntermediateNode);

            context.CodeWriter
                .WriteStartMethodInvocation(BeginAddHtmlAttributeValuesMethodName)
                .Write(ExecutionContextVariableName)
                .WriteParameterSeparator()
                .WriteStringLiteral(node.AttributeName)
                .WriteParameterSeparator()
                .WriteIntegerLiteral(valuePieceCount)
                .WriteParameterSeparator()
                .Write(attributeValueStyleParameter)
                .WriteEndMethodInvocation();

            context.RenderChildren(node, TagHelperHtmlAttributeRuntimeNodeWriter.Instance);

            context.CodeWriter
                .WriteMethodInvocation(
                    EndAddHtmlAttributeValuesMethodName,
                    ExecutionContextVariableName);
        }
        else
        {
            // This is a data-* attribute which includes C#. Do not perform the conditional attribute removal or
            // other special cases used when IsDynamicAttributeValue(). But the attribute must still be buffered to
            // determine its final value.

            // Attribute value is not plain text, must be buffered to determine its final value.
            context.CodeWriter.WriteMethodInvocation(BeginWriteTagHelperAttributeMethodName);

            // We're building a writing scope around the provided chunks which captures everything written from the
            // page. Therefore, we do not want to write to any other buffer since we're using the pages buffer to
            // ensure we capture all content that's written, directly or indirectly.
            context.RenderChildren(node, IntermediateNodeWriter.Instance);

            context.CodeWriter
                .WriteStartAssignment(StringValueBufferVariableName)
                .WriteMethodInvocation(EndWriteTagHelperAttributeMethodName)
                .WriteStartInstanceMethodInvocation(
                    ExecutionContextVariableName,
                    ExecutionContextAddHtmlAttributeMethodName)
                .WriteStringLiteral(node.AttributeName)
                .WriteParameterSeparator()
                .WriteStartMethodInvocation(MarkAsHtmlEncodedMethodName)
                .Write(StringValueBufferVariableName)
                .WriteEndMethodInvocation(endLine: false)
                .WriteParameterSeparator()
                .Write(attributeValueStyleParameter)
                .WriteEndMethodInvocation();
        }
    }

    public void WriteTagHelperProperty(CodeRenderingContext context, DefaultTagHelperPropertyIntermediateNode node)
    {
        var tagHelperNode = context.Parent as TagHelperIntermediateNode;
        if (context.Parent == null)
        {
            var message = Resources.FormatIntermediateNodes_InvalidParentNode(node.GetType(), typeof(TagHelperIntermediateNode));
            throw new InvalidOperationException(message);
        }

        // Ensure that the property we're trying to set has initialized its dictionary bound properties.
        if (node.IsIndexerNameMatch &&
            object.ReferenceEquals(FindFirstUseOfIndexer(tagHelperNode, node), node))
        {
            // Throw a reasonable Exception at runtime if the dictionary property is null.
            context.CodeWriter
                .WriteLine($"if ({node.FieldName}.{node.PropertyName} == null)");
            using (context.CodeWriter.BuildScope())
            {
                // System is in Host.NamespaceImports for all MVC scenarios. No need to generate FullName
                // of InvalidOperationException type.
                context.CodeWriter
                    .Write("throw ")
                    .WriteStartNewObject(nameof(InvalidOperationException))
                    .WriteStartMethodInvocation(FormatInvalidIndexerAssignmentMethodName)
                    .WriteStringLiteral(node.AttributeName)
                    .WriteParameterSeparator()
                    .WriteStringLiteral(node.TagHelper.TypeName)
                    .WriteParameterSeparator()
                    .WriteStringLiteral(node.PropertyName)
                    .WriteEndMethodInvocation(endLine: false)   // End of method call
                    .WriteEndMethodInvocation();   // End of new expression / throw statement
            }
        }

        // If this is not the first use of the attribute value, we need to evaluate the expression and assign it to
        // the tag helper property.
        //
        // Otherwise, the value has already been computed and assigned to another tag helper. We just need to
        // copy from that tag helper to this one.
        //
        // This is important because we can't evaluate the expression twice due to side-effects.
        var firstUseOfAttribute = FindFirstUseOfAttribute(tagHelperNode, node);
        if (!object.ReferenceEquals(firstUseOfAttribute, node))
        {
            // If we get here, this value has already been used. We just need to copy the value.
            WritePropertyAccessorStartAssignment(context.CodeWriter, node);

            WritePropertyAccessor(context.CodeWriter, firstUseOfAttribute)
                .WriteLine(";");

            return;
        }

        // If we get there, this is the first time seeing this property so we need to evaluate the expression.
        if (node.BoundAttribute.ExpectsStringValue(node.AttributeName))
        {
            context.CodeWriter.WriteMethodInvocation(BeginWriteTagHelperAttributeMethodName);

            context.RenderChildren(node, LiteralRuntimeNodeWriter.Instance);

            context.CodeWriter
                .WriteStartAssignment(StringValueBufferVariableName)
                .WriteMethodInvocation(EndWriteTagHelperAttributeMethodName);

            WritePropertyAccessorStartAssignment(context.CodeWriter, node)
                .WriteLine($"{StringValueBufferVariableName};");
        }
        else
        {
            WritePropertyAccessorStartAssignment(context.CodeWriter, node);

            if (node.BoundAttribute.IsEnum &&
                node.Children is [CSharpIntermediateToken token])
            {
                context.CodeWriter
                    .Write($"global::{node.BoundAttribute.TypeName}.");
            }

            if (node.Children.Count == 0 &&
                node.AttributeStructure == AttributeStructure.Minimized &&
                node.BoundAttribute.ExpectsBooleanValue(node.AttributeName))
            {
                // If this is a minimized boolean attribute, set the value to true.
                context.CodeWriter.Write("true");
            }
            else
            {
                RenderTagHelperAttributeInline(context, node, node.Source);
            }

            context.CodeWriter.WriteLine(";");
        }

        // We need to inform the context of the attribute value.
        context.CodeWriter
            .WriteStartInstanceMethodInvocation(
                ExecutionContextVariableName,
                ExecutionContextAddTagHelperAttributeMethodName)
            .WriteStringLiteral(node.AttributeName)
            .WriteParameterSeparator();

        WritePropertyAccessor(context.CodeWriter, node)
            .WriteParameterSeparator()
            .Write($"global::Microsoft.AspNetCore.Razor.TagHelpers.HtmlAttributeValueStyle.{node.AttributeStructure}")
            .WriteEndMethodInvocation();
    }

    public void WriteTagHelperRuntime(CodeRenderingContext context, DefaultTagHelperRuntimeIntermediateNode node)
    {
        context.CodeWriter.WriteLine("#line hidden");
        context.CodeWriter.WriteField(s_fieldUninitializedModifiers, s_privateModifiers, ExecutionContextTypeName, ExecutionContextVariableName);

        context.CodeWriter
            .WriteLine($"private {TagHelperRunnerTypeName} {RunnerVariableName} = new {TagHelperRunnerTypeName}();");

        context.CodeWriter.WriteField(s_fieldUnusedModifiers, s_privateModifiers, "string", StringValueBufferVariableName);

        var backedScopeManageVariableName = "__backed" + ScopeManagerVariableName;
        context.CodeWriter
            .Write("private ")
            .WriteVariableDeclaration(
                ScopeManagerTypeName,
                backedScopeManageVariableName,
                value: null);

        context.CodeWriter
            .WriteLine($"private {ScopeManagerTypeName} {ScopeManagerVariableName}");

        using (context.CodeWriter.BuildScope())
        {
            context.CodeWriter.WriteLine("get");
            using (context.CodeWriter.BuildScope())
            {
                context.CodeWriter
                    .WriteLine($"if ({backedScopeManageVariableName} == null)");

                using (context.CodeWriter.BuildScope())
                {
                    context.CodeWriter
                        .WriteStartAssignment(backedScopeManageVariableName)
                        .WriteStartNewObject(ScopeManagerTypeName)
                        .Write(StartTagHelperWritingScopeMethodName)
                        .WriteParameterSeparator()
                        .Write(EndTagHelperWritingScopeMethodName)
                        .WriteEndMethodInvocation();
                }

                context.CodeWriter
                    .WriteLine($"return {backedScopeManageVariableName};");
            }
        }
    }

    private void RenderTagHelperAttributeInline(
        CodeRenderingContext context,
        DefaultTagHelperPropertyIntermediateNode property,
        SourceSpan? span)
    {
        for (var i = 0; i < property.Children.Count; i++)
        {
            RenderTagHelperAttributeInline(context, property, property.Children[i], span);
        }
    }

    // Internal for testing
    internal void RenderTagHelperAttributeInline(
        CodeRenderingContext context,
        DefaultTagHelperPropertyIntermediateNode property,
        IntermediateNode node,
        SourceSpan? span)
    {
        if (node is CSharpExpressionIntermediateNode || node is HtmlContentIntermediateNode)
        {
            for (var i = 0; i < node.Children.Count; i++)
            {
                RenderTagHelperAttributeInline(context, property, node.Children[i], span);
            }
        }
        else if (node is IntermediateToken token)
        {
            using (context.BuildEnhancedLinePragma(token.Source))
            {
                context.CodeWriter.Write(token.Content);
            }
        }
        else if (node is CSharpCodeIntermediateNode)
        {
            var diagnostic = RazorDiagnosticFactory.CreateTagHelper_CodeBlocksNotSupportedInAttributes(span);
            context.AddDiagnostic(diagnostic);
        }
        else if (node is TemplateIntermediateNode)
        {
            var expectedTypeName = property.IsIndexerNameMatch ? property.BoundAttribute.IndexerTypeName : property.BoundAttribute.TypeName;
            var diagnostic = RazorDiagnosticFactory.CreateTagHelper_InlineMarkupBlocksNotSupportedInAttributes(span, expectedTypeName);
            context.AddDiagnostic(diagnostic);
        }
    }

    private static DefaultTagHelperPropertyIntermediateNode FindFirstUseOfIndexer(
        TagHelperIntermediateNode tagHelperNode,
        DefaultTagHelperPropertyIntermediateNode propertyNode)
    {
        Debug.Assert(tagHelperNode.Children.Contains(propertyNode));
        Debug.Assert(propertyNode.IsIndexerNameMatch);

        for (var i = 0; i < tagHelperNode.Children.Count; i++)
        {
            if (tagHelperNode.Children[i] is DefaultTagHelperPropertyIntermediateNode otherPropertyNode &&
                otherPropertyNode.TagHelper.Equals(propertyNode.TagHelper) &&
                otherPropertyNode.BoundAttribute.Equals(propertyNode.BoundAttribute) &&
                otherPropertyNode.IsIndexerNameMatch)
            {
                return otherPropertyNode;
            }
        }

        // This is unreachable, we should find 'propertyNode' in the list of children.
        throw new InvalidOperationException();
    }

    private static DefaultTagHelperPropertyIntermediateNode FindFirstUseOfAttribute(
        TagHelperIntermediateNode tagHelperNode,
        DefaultTagHelperPropertyIntermediateNode propertyNode)
    {
        for (var i = 0; i < tagHelperNode.Children.Count; i++)
        {
            if (tagHelperNode.Children[i] is DefaultTagHelperPropertyIntermediateNode otherPropertyNode &&
                string.Equals(otherPropertyNode.AttributeName, propertyNode.AttributeName, StringComparison.Ordinal))
            {
                return otherPropertyNode;
            }
        }

        // This is unreachable, we should find 'propertyNode' in the list of children.
        throw new InvalidOperationException();
    }

    // Internal for testing
    internal static string GetDeterministicId(CodeRenderingContext context)
    {
        var uniqueId = context.Options.SuppressUniqueIds;
        if (uniqueId is null)
        {
            // Use the file checksum along with the absolute position in the generated code to create a unique id for each tag helper call site.
            var checksum = ChecksumUtilities.BytesToString(context.SourceDocument.Text.GetChecksum());
            uniqueId = checksum + context.CodeWriter.Location.AbsoluteIndex;
        }
        return uniqueId;
    }

    private static CodeWriter WritePropertyAccessor(CodeWriter writer, DefaultTagHelperPropertyIntermediateNode node)
    {
        writer
            .Write($"{node.FieldName}.{node.PropertyName}");

        if (node.IsIndexerNameMatch)
        {
            var dictionaryKey = node.AttributeName.AsMemory()[node.BoundAttribute.IndexerNamePrefix.Length..];

            writer
                .Write($"[\"{dictionaryKey}\"]");
        }

        return writer;
    }

    private static CodeWriter WritePropertyAccessorStartAssignment(CodeWriter writer, DefaultTagHelperPropertyIntermediateNode node)
    {
        return WritePropertyAccessor(writer, node)
            .Write(" = ");
    }
}