File: Utils\Markdown\MarkdownToSpectreConverter.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.Text;
using System.Text.RegularExpressions;
using Markdig;
using Markdig.Extensions.Tables;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
 
namespace Aspire.Cli.Utils.Markdown;
 
/// <summary>
/// Converts basic Markdown syntax to Spectre.Console markup for CLI display.
/// </summary>
internal partial class MarkdownToSpectreConverter
{
    private readonly bool _plainTextLinks;
 
    private MarkdownToSpectreConverter(bool plainTextLinks)
    {
        _plainTextLinks = plainTextLinks;
    }
 
    private static readonly MarkdownPipeline s_markdownPipeline = new MarkdownPipelineBuilder()
        .UsePipeTables()
        .UseAutoLinks()
        // This parses additional emphasis forms (for example ==mark== and ++inserted++).
        // We currently only style the common bold/italic/strikethrough cases, but this
        // keeps the AST shape ready if we decide to add richer inline formatting later.
        .UseEmphasisExtras()
        .Build();
 
    private static MarkdownDocument ParseMarkdown(string markdown, out string normalizedMarkdown)
    {
        normalizedMarkdown = markdown.Replace("\r\n", "\n").Replace("\r", "\n");
        return Markdig.Markdown.Parse(normalizedMarkdown, s_markdownPipeline);
    }
 
    private static ReadOnlySpan<char> GetOriginalMarkdownSpan(SourceSpan span, string markdown)
    {
        return span.Start < 0 || span.End < span.Start || span.End >= markdown.Length
            ? ReadOnlySpan<char>.Empty
            : markdown.AsSpan(span.Start, span.End - span.Start + 1);
    }
 
    private static string StripHtmlTags(ReadOnlySpan<char> text)
    {
        if (text.IsEmpty)
        {
            return string.Empty;
        }
 
        return HtmlTagRegex().Replace(text.ToString(), string.Empty);
    }
 
    private static void AppendEscapedMarkup(StringBuilder builder, ReadOnlySpan<char> text)
    {
        var start = 0;
 
        while (start < text.Length)
        {
            var bracketIndex = text[start..].IndexOfAny('[', ']');
            if (bracketIndex < 0)
            {
                builder.Append(text[start..]);
                return;
            }
 
            bracketIndex += start;
 
            if (bracketIndex > start)
            {
                builder.Append(text[start..bracketIndex]);
            }
 
            builder.Append(text[bracketIndex]);
            builder.Append(text[bracketIndex]);
            start = bracketIndex + 1;
        }
    }
 
    private static void TrimAppendedWhitespace(StringBuilder builder, int contentStart)
    {
        // Trim trailing whitespace
        while (builder.Length > contentStart && char.IsWhiteSpace(builder[builder.Length - 1]))
        {
            builder.Length--;
        }
 
        // Trim leading whitespace
        while (builder.Length > contentStart && char.IsWhiteSpace(builder[contentStart]))
        {
            builder.Remove(contentStart, 1);
        }
    }
 
    private static void WrapAppendedLines(StringBuilder builder, int contentStart, string linePrefix, string lineSuffix)
    {
        if (contentStart >= builder.Length)
        {
            builder.Append(linePrefix);
            builder.Append(lineSuffix);
            return;
        }
 
        // Walk backwards so each inserted wrapper leaves earlier newline indexes stable.
        for (var index = builder.Length - 1; index >= contentStart; index--)
        {
            if (builder[index] == '\n')
            {
                builder.Insert(index + 1, linePrefix);
                builder.Insert(index, lineSuffix);
            }
        }
 
        builder.Insert(contentStart, linePrefix);
        builder.Append(lineSuffix);
    }
 
    private static void ApplyHangingIndent(StringBuilder builder, int contentStart, int continuationIndent)
    {
        if (continuationIndent <= 0)
        {
            return;
        }
 
        // The first line already follows the list marker; only continuation lines need padding.
        for (var index = contentStart; index < builder.Length; index++)
        {
            if (builder[index] != '\n')
            {
                continue;
            }
 
            var nextIndex = index + 1;
            if (nextIndex < builder.Length && builder[nextIndex] != '\n')
            {
                builder.Insert(nextIndex, " ", continuationIndent);
                index = nextIndex + continuationIndent - 1;
            }
        }
    }
 
    private static void IndentAppendedLines(StringBuilder builder, int contentStart, int indentation)
    {
        if (indentation <= 0 || contentStart >= builder.Length)
        {
            return;
        }
 
        // Nested blocks are rendered first, then indented in place to avoid another temporary buffer.
        if (builder[contentStart] != '\n')
        {
            builder.Insert(contentStart, " ", indentation);
        }
 
        for (var index = contentStart; index < builder.Length; index++)
        {
            if (builder[index] != '\n')
            {
                continue;
            }
 
            var nextIndex = index + 1;
            if (nextIndex < builder.Length && builder[nextIndex] != '\n')
            {
                builder.Insert(nextIndex, " ", indentation);
                index = nextIndex + indentation - 1;
            }
        }
    }
 
    private static void AppendCodeBlockText(StringBuilder builder, CodeBlock codeBlock, bool escapeMarkup)
    {
        var slices = codeBlock.Lines.Lines;
        if (slices is null)
        {
            return;
        }
 
        var wroteContent = false;
        for (var i = 0; i < slices.Length; i++)
        {
            ref var slice = ref slices[i].Slice;
            if (slice.Text is null)
            {
                break;
            }
 
            if (wroteContent)
            {
                builder.Append('\n');
            }
 
            if (escapeMarkup)
            {
                AppendEscapedMarkup(builder, slice.AsSpan());
            }
            else
            {
                builder.Append(slice.AsSpan());
            }
 
            wroteContent = true;
        }
    }
 
    private static void AppendTableSeparator(StringBuilder builder, IReadOnlyList<int> widths, IReadOnlyList<TableColumnDefinition>? definitions)
    {
        builder.Append('|');
        for (var i = 0; i < widths.Count; i++)
        {
            builder.Append(' ');
            AppendTableSeparatorCell(builder, widths[i], definitions is { Count: > 0 } && i < definitions.Count ? definitions[i].Alignment : null);
            builder.Append(' ');
            builder.Append('|');
        }
    }
 
    private static void AppendTableSeparatorCell(StringBuilder builder, int width, TableColumnAlign? alignment)
    {
        switch (alignment)
        {
            case TableColumnAlign.Left:
                builder.Append(':');
                builder.Append('-', Math.Max(width - 1, 2));
                break;
            case TableColumnAlign.Center:
                builder.Append(':');
                builder.Append('-', Math.Max(width - 2, 1));
                builder.Append(':');
                break;
            case TableColumnAlign.Right:
                builder.Append('-', Math.Max(width - 1, 2));
                builder.Append(':');
                break;
            default:
                builder.Append('-', width);
                break;
        }
    }
 
    private static bool AppendedTextEquals(StringBuilder builder, int startIndex, string value)
    {
        var remainingOffset = startIndex;
        var compared = 0;
        var valueSpan = value.AsSpan();
 
        foreach (var chunk in builder.GetChunks())
        {
            var chunkSpan = chunk.Span;
            if (remainingOffset >= chunkSpan.Length)
            {
                remainingOffset -= chunkSpan.Length;
                continue;
            }
 
            chunkSpan = chunkSpan[remainingOffset..];
            remainingOffset = 0;
 
            var count = Math.Min(chunkSpan.Length, valueSpan.Length - compared);
            if (!chunkSpan[..count].SequenceEqual(valueSpan.Slice(compared, count)))
            {
                return false;
            }
 
            compared += count;
            if (compared == valueSpan.Length)
            {
                return true;
            }
        }
 
        return false;
    }
 
    private enum ReferenceLinkState
    {
        VisibleText,
        OpenBracket,
        Label,
        TrailingText,
    }
 
    private static ReferenceLinkState ProcessReferenceLinkChild(
        StringBuilder builder, Inline child, ReferenceLinkState state, bool appendEscaped)
    {
        if (child is LinkDelimiterInline nestedDelimiter)
        {
            // Nested reference link — process recursively
            var nestedChild = nestedDelimiter.FirstChild;
            var nestedState = ReferenceLinkState.VisibleText;
            while (nestedChild is not null)
            {
                nestedState = ProcessReferenceLinkChild(builder, nestedChild, nestedState, appendEscaped);
                nestedChild = nestedChild.NextSibling;
            }
            return ReferenceLinkState.TrailingText;
        }
 
        if (child is not LiteralInline literal)
        {
            return state;
        }
 
        var content = literal.Content.AsSpan();
 
        switch (state)
        {
            case ReferenceLinkState.VisibleText:
                // First literal: "text]" — emit everything before the trailing ']'
                if (content.Length > 0 && content[^1] == ']')
                {
                    Append(builder, content[..^1], appendEscaped);
                }
                else
                {
                    Append(builder, content, appendEscaped);
                }
                return ReferenceLinkState.OpenBracket;
 
            case ReferenceLinkState.OpenBracket:
                // This should be "[" — skip it
                return ReferenceLinkState.Label;
 
            case ReferenceLinkState.Label:
                // This is "id] trailing text" — skip up to and including first ']', emit rest
                var closeBracket = content.IndexOf(']');
                if (closeBracket >= 0 && closeBracket + 1 < content.Length)
                {
                    Append(builder, content[(closeBracket + 1)..], appendEscaped);
                }
                return ReferenceLinkState.TrailingText;
 
            case ReferenceLinkState.TrailingText:
                Append(builder, content, appendEscaped);
                return ReferenceLinkState.TrailingText;
 
            default:
                return state;
        }
 
        static void Append(StringBuilder sb, ReadOnlySpan<char> text, bool escaped)
        {
            if (escaped)
            {
                AppendEscapedMarkup(sb, text);
            }
            else
            {
                sb.Append(text);
            }
        }
    }
 
    [GeneratedRegex(@"</?[^>]+>", RegexOptions.Singleline)]
    private static partial Regex HtmlTagRegex();
}