File: Model\TextVisualizerViewModel.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// 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.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using System.Xml;
using System.Xml.Linq;
using Aspire.Dashboard.Utils;
 
namespace Aspire.Dashboard.Model;
 
[DebuggerDisplay("FormatKind = {FormatKind}, Text = {Text}")]
public class TextVisualizerViewModel
{
    public string Text { get; }
    public string FormatKind { get; private set; } = default!;
    public string FormattedText { get; private set; } = default!;
    public List<StringLogLine> Lines { get; set; } = [];
    public List<StringLogLine> FormattedLines { get; set; } = [];
 
    public TextVisualizerViewModel(string text, bool indentText, string? knownFormat = null, string? fallbackFormat = null)
    {
        Text = text;
        Lines = GetLines(Text, DashboardUIHelpers.PlaintextFormat);
 
        if (knownFormat != null)
        {
            ChangeFormattedText(knownFormat, Text);
        }
        else if (TryFormatJson(Text, out var formattedJson))
        {
            ChangeFormattedText(DashboardUIHelpers.JsonFormat, indentText ? formattedJson : Text);
        }
        else if (TryFormatXml(Text, out var formattedXml))
        {
            ChangeFormattedText(DashboardUIHelpers.XmlFormat, indentText ? formattedXml : Text);
        }
        else
        {
            ChangeFormattedText(fallbackFormat ?? DashboardUIHelpers.PlaintextFormat, Text);
        }
    }
 
    private static bool TryFormatXml(string text, [NotNullWhen(true)] out string? formattedText)
    {
        // Avoid throwing when reading non-XML by doing a quick check of the first character.
        // This reduces the number of exceptions we throw when reading invalid text and improves performance.
        if (!CouldBeXml(text))
        {
            formattedText = null;
            return false;
        }
 
        try
        {
            var document = XDocument.Parse(text.Trim());
 
            using var stringWriter = new StringWriter();
            using var xmlWriter = XmlWriter.Create(stringWriter, new XmlWriterSettings
            {
                Indent = true,
                OmitXmlDeclaration = document.Declaration == null
            });
 
            document.Save(xmlWriter);
            xmlWriter.Flush();
            formattedText = stringWriter.ToString();
            return true;
        }
        catch (XmlException)
        {
            formattedText = null;
            return false;
        }
    }
 
    private void ChangeFormattedText(string format, string formattedText)
    {
        FormatKind = format;
        FormattedText = formattedText;
        FormattedLines = GetLines(FormattedText, FormatKind);
    }
 
    private static List<StringLogLine> GetLines(string text, string formatKind)
    {
        var lines = text.Split(["\r\n", "\r", "\n"], StringSplitOptions.None).ToList();
 
        return lines.Select((line, index) => new StringLogLine(index + 1, line, formatKind)).ToList();
    }
 
    private static bool TryFormatJson(string text, [NotNullWhen(true)] out string? formattedText)
    {
        // Avoid throwing when reading non-JSON by doing a quick check of the first character.
        // This reduces the number of exceptions we throw when reading invalid text and improves performance.
        if (!CouldBeJson(text))
        {
            formattedText = null;
            return false;
        }
 
        try
        {
            formattedText = FormatJson(text);
            return true;
        }
        catch (JsonException)
        {
            formattedText = null;
            return false;
        }
    }
 
    private static string FormatJson(string jsonString)
    {
        var jsonData = Encoding.UTF8.GetBytes(jsonString);
 
        // Initialize the Utf8JsonReader
        var reader = new Utf8JsonReader(jsonData, new JsonReaderOptions
        {
            AllowTrailingCommas = true,
            CommentHandling = JsonCommentHandling.Allow,
            // Increase the allowed limit to 1000. This matches the allowed limit of the writer.
            // It's ok to allow recursion here because JSON is read in a flat loop. There isn't a danger
            // of recursive method calls that cause a stack overflow.
            MaxDepth = 1000
        });
 
        // Use a MemoryStream and Utf8JsonWriter to write the formatted JSON
        using var stream = new MemoryStream();
        using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
 
        while (reader.Read())
        {
            switch (reader.TokenType)
            {
                case JsonTokenType.StartObject:
                    writer.WriteStartObject();
                    break;
                case JsonTokenType.EndObject:
                    writer.WriteEndObject();
                    break;
                case JsonTokenType.StartArray:
                    writer.WriteStartArray();
                    break;
                case JsonTokenType.EndArray:
                    writer.WriteEndArray();
                    break;
                case JsonTokenType.PropertyName:
                    writer.WritePropertyName(reader.GetString()!);
                    break;
                case JsonTokenType.String:
                    writer.WriteStringValue(reader.GetString());
                    break;
                case JsonTokenType.Number:
                    if (reader.TryGetInt32(out var intValue))
                    {
                        writer.WriteNumberValue(intValue);
                    }
                    else if (reader.TryGetDouble(out var doubleValue))
                    {
                        writer.WriteNumberValue(doubleValue);
                    }
                    break;
                case JsonTokenType.True:
                    writer.WriteBooleanValue(true);
                    break;
                case JsonTokenType.False:
                    writer.WriteBooleanValue(false);
                    break;
                case JsonTokenType.Null:
                    writer.WriteNullValue();
                    break;
                case JsonTokenType.Comment:
                    writer.WriteCommentValue(reader.GetComment());
                    break;
            }
        }
 
        writer.Flush();
        var formattedJson = Encoding.UTF8.GetString(stream.ToArray());
 
        return formattedJson;
    }
 
    public static bool CouldBeJson(string? input)
    {
        if (!TrySkipLeadingWhitespace(input, out var i))
        {
            return false;
        }
 
        var first = input[i.Value];
 
        return first switch
        {
            '/' => true,   // Comment
            '{' => true,   // Object
            '[' => true,   // Array
            '"' => true,   // String
            '-' => true,   // Negative number
            >= '0' and <= '9' => true, // Number
            't' => input.AsSpan(i.Value).StartsWith("true"),  // true
            'f' => input.AsSpan(i.Value).StartsWith("false"), // false
            'n' => input.AsSpan(i.Value).StartsWith("null"),  // null
            _ => false
        };
    }
 
    public static bool CouldBeXml(string? input)
    {
        if (!TrySkipLeadingWhitespace(input, out var i))
        {
            return false;
        }
 
        // XML must start with '<' after whitespace
        if (input[i.Value] != '<')
        {
            return false;
        }
 
        // Peek ahead for common XML starts
        var span = input.AsSpan(i.Value);
 
        return span.StartsWith("<?xml", StringComparison.OrdinalIgnoreCase)
            || span.StartsWith("<!--", StringComparison.Ordinal)
            || span.StartsWith("<!DOCTYPE", StringComparison.OrdinalIgnoreCase)
            || span.Length > 1 && char.IsLetter(span[1]); // element name
    }
 
    private static bool TrySkipLeadingWhitespace([NotNullWhen(true)] string? input, [NotNullWhen(true)] out int? index)
    {
        if (string.IsNullOrWhiteSpace(input))
        {
            index = null;
            return false;
        }
 
        // Skip leading whitespace
        index = 0;
        while (index < input.Length && char.IsWhiteSpace(input[index.Value]))
        {
            index++;
        }
 
        if (index == input.Length)
        {
            index = null;
            return false;
        }
 
        return true;
    }
 
    internal void UpdateFormat(string newFormat)
    {
        if (newFormat == DashboardUIHelpers.XmlFormat)
        {
            if (TryFormatXml(Text, out var formattedXml))
            {
                ChangeFormattedText(newFormat, formattedXml);
            }
        }
        else if (newFormat == DashboardUIHelpers.JsonFormat)
        {
            if (TryFormatJson(Text, out var formattedJson))
            {
                ChangeFormattedText(newFormat, formattedJson);
            }
        }
        else
        {
            ChangeFormattedText(DashboardUIHelpers.PlaintextFormat, Text);
        }
    }
}