File: Templating\ConditionalBlockProcessor.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Text.RegularExpressions;
 
namespace Aspire.Cli.Templating;
 
/// <summary>
/// Processes conditional blocks in template content. Blocks are delimited by
/// marker lines of the form <c>{{#name}}</c> / <c>{{/name}}</c>. When a block
/// is included, the marker lines are stripped and the inner content is kept;
/// when excluded, the marker lines and their content are removed entirely.
/// Marker lines may contain leading comment characters (e.g. <c>// {{#name}}</c>
/// or <c># {{#name}}</c>) — the entire line is always removed.
/// </summary>
/// <remarks>
/// Blocks must not overlap or nest across different condition names. Each condition
/// is processed independently in enumeration order. Overlapping blocks produce
/// undefined behavior.
/// </remarks>
internal static partial class ConditionalBlockProcessor
{
    /// <summary>
    /// Processes all conditional blocks for the given set of conditions. Each entry
    /// in <paramref name="conditions"/> maps a block name to whether it should be included.
    /// </summary>
    /// <param name="content">The template content to process.</param>
    /// <param name="conditions">A set of block-name to include/exclude mappings.</param>
    /// <returns>The processed content with conditional blocks resolved.</returns>
    internal static string Process(string content, IReadOnlyDictionary<string, bool> conditions)
    {
        foreach (var (blockName, include) in conditions)
        {
            content = ProcessBlock(content, blockName, include);
        }
 
        Debug.Assert(
            !LeftoverMarkerPattern().IsMatch(content),
            $"Template content contains unprocessed conditional markers. Ensure all block names are included in the conditions dictionary.");
 
        return content;
    }
 
    [GeneratedRegex(@"\{\{[#/][a-zA-Z][\w-]*\}\}")]
    private static partial Regex LeftoverMarkerPattern();
 
    /// <summary>
    /// Processes all occurrences of a single conditional block in the content.
    /// </summary>
    /// <param name="content">The template content to process.</param>
    /// <param name="blockName">The name of the conditional block (e.g. <c>redis</c>).</param>
    /// <param name="include">
    /// When <see langword="true"/>, the block content is kept and only the marker lines
    /// are removed. When <see langword="false"/>, the entire block (markers and content) is removed.
    /// </param>
    /// <returns>The processed content.</returns>
    internal static string ProcessBlock(string content, string blockName, bool include)
    {
        var startPattern = $"{{{{#{blockName}}}}}";
        var endPattern = $"{{{{/{blockName}}}}}";
 
        while (true)
        {
            var startIdx = content.IndexOf(startPattern, StringComparison.Ordinal);
            if (startIdx < 0)
            {
                break;
            }
 
            var endIdx = content.IndexOf(endPattern, startIdx, StringComparison.Ordinal);
            if (endIdx < 0)
            {
                throw new InvalidOperationException(
                    $"Template contains opening marker '{{{{#{blockName}}}}}' without a matching closing marker '{{{{/{blockName}}}}}'.");
            }
 
            // Find the full start marker line (including leading whitespace/comments and trailing newline).
            var startLineBegin = content.LastIndexOf('\n', startIdx);
            startLineBegin = startLineBegin < 0 ? 0 : startLineBegin + 1;
            var startLineEnd = content.IndexOf('\n', startIdx);
            startLineEnd = startLineEnd < 0 ? content.Length : startLineEnd + 1;
 
            // Find the full end marker line.
            var endLineBegin = content.LastIndexOf('\n', endIdx);
            endLineBegin = endLineBegin < 0 ? 0 : endLineBegin + 1;
            var endLineEnd = content.IndexOf('\n', endIdx);
            endLineEnd = endLineEnd < 0 ? content.Length : endLineEnd + 1;
 
            if (include)
            {
                // Keep the block content but remove the marker lines.
                var blockContent = content[startLineEnd..endLineBegin];
                content = string.Concat(content.AsSpan(0, startLineBegin), blockContent, content.AsSpan(endLineEnd));
            }
            else
            {
                // Remove everything from start marker line to end marker line (inclusive).
                content = string.Concat(content.AsSpan(0, startLineBegin), content.AsSpan(endLineEnd));
            }
        }
 
        return content;
    }
}