// Copyright Epic Games, Inc. All Rights Reserved.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
namespace EpicGames.Core
{
///
/// Utility class for dealing with message templates
///
public static class MessageTemplate
{
///
/// The default property name for the message template format string in an enumerable log state parameter
///
public const string FormatPropertyName = "{OriginalFormat}";
///
/// Renders a format string
///
/// The format string
/// Property values to embed
/// The rendered string
public static string Render(string format, IEnumerable>? properties)
{
StringBuilder result = new StringBuilder();
Render(format, properties, result);
return result.ToString();
}
///
/// Renders a format string to the end of a string builder
///
/// The format string to render
/// Sequence of key/value properties
/// Buffer to append the rendered string to
public static void Render(string format, IEnumerable>? properties, StringBuilder result)
{
int nextOffset = 0;
List<(int, int)>? names = ParsePropertyNames(format);
if (names != null && properties != null)
{
foreach((int offset, int length) in names)
{
ReadOnlySpan argument = format.AsSpan(offset, length);
string? formatSpecifier = null;
// Parse the format specifier
int formatSpecifierIdx = argument.IndexOf(':');
if (formatSpecifierIdx != -1)
{
formatSpecifier = argument.Slice(formatSpecifierIdx + 1).ToString();
argument = argument.Slice(0, formatSpecifierIdx);
}
// Parse the property width
int alignment = 0;
int alignmentIdx = argument.IndexOf(',');
if (alignmentIdx != -1 && Int32.TryParse(argument.Slice(alignmentIdx + 1), NumberStyles.Integer | NumberStyles.AllowLeadingSign, null, out alignment))
{
argument = argument.Slice(0, alignmentIdx);
}
// Try to get the property value
object? value;
if (TryGetPropertyValue(argument, properties, out value))
{
// Append the text up to this argument
int startOffset = offset - 1;
if (format[startOffset] == '@' || format[startOffset] == '$')
{
startOffset--;
}
Unescape(format.AsSpan(nextOffset, startOffset - nextOffset), result);
// Render the property
string? rendered;
if (format[offset] == '@')
{
rendered = JsonSerializer.Serialize(value, value?.GetType() ?? typeof(object), new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
}
else if (formatSpecifier != null && value is IFormattable formattable)
{
rendered = formattable.ToString(formatSpecifier, null);
}
else
{
rendered = value?.ToString() ?? "null";
}
// Apply a postive width adjustment (right align)
int leftPad = Math.Max(alignment - rendered.Length, 0);
if (leftPad > 0)
{
result.Append(' ', leftPad);
}
// Append the argument
result.Append(rendered);
// Apply a negative width adjustment (left align)
int rightPad = Math.Max(-alignment - rendered.Length, 0);
if (rightPad > 0)
{
result.Append(' ', rightPad);
}
// Start the next plain-text run after the closing brace
nextOffset = offset + length + 1;
}
}
}
Unescape(format.AsSpan(nextOffset, format.Length - nextOffset), result);
}
///
/// Escapes a string for use in a message template
///
/// Text to escape
/// The escaped string
public static string Escape(string text)
{
StringBuilder result = new StringBuilder();
Escape(text, result);
return result.ToString();
}
///
/// Escapes a span of characters and appends the result to a string
///
/// Span of characters to escape
/// Buffer to receive the escaped string
public static void Escape(ReadOnlySpan text, StringBuilder result)
{
foreach(char character in text)
{
result.Append(character);
if (character == '{' || character == '}')
{
result.Append(character);
}
}
}
///
/// Unescapes a string from a message template
///
/// The text to unescape
/// The unescaped text
public static string Unescape(string text)
{
StringBuilder result = new StringBuilder();
Unescape(text.AsSpan(), result);
return result.ToString();
}
///
/// Unescape a string and append the result to a string builder
///
/// Text to unescape
/// Receives the unescaped text
public static void Unescape(ReadOnlySpan text, StringBuilder result)
{
char lastChar = '\0';
foreach (char character in text)
{
if ((character != '{' && character != '}') || character != lastChar)
{
result.Append(character);
}
lastChar = character;
}
}
///
/// Finds locations of property names from the given format string
///
/// The format string to parse
/// List of offset, length pairs for property names. Null if the string does not contain any property references.
public static List<(int, int)>? ParsePropertyNames(string format)
{
List<(int, int)>? names = null;
for (int idx = 0; idx < format.Length - 1; idx++)
{
if (format[idx] == '{')
{
if (format[idx + 1] == '{')
{
idx++;
}
else
{
int startIdx = idx + 1;
idx = format.IndexOf('}', startIdx);
if (idx == -1)
{
break;
}
names ??= [];
names.Add((startIdx, idx - startIdx));
}
}
}
return names;
}
///
/// Parse the ordered arguments into a dictionary of named properties
///
/// Format string
/// Argument list to parse
///
///
public static void ParsePropertyValues(string format, object[] args, Dictionary properties)
{
List<(int, int)>? offsets = ParsePropertyNames(format);
if (offsets != null)
{
for (int idx = 0; idx < offsets.Count; idx++)
{
string name = format.Substring(offsets[idx].Item1, offsets[idx].Item2);
int number;
if (Int32.TryParse(name, out number))
{
if (number >= 0 && number < args.Length)
{
properties[name] = args[number];
}
}
else
{
if (idx < args.Length)
{
properties[name] = args[idx];
}
}
}
}
}
///
/// Attempts to get a named property value from the given dictionary
///
/// Name of the property
/// Sequence of property name/value pairs
/// On success, receives the property value
/// True if the property was found, false otherwise
public static bool TryGetPropertyValue(ReadOnlySpan name, IEnumerable> properties, out object? value)
{
int number;
if (Int32.TryParse(name, System.Globalization.NumberStyles.Integer, null, out number))
{
foreach (KeyValuePair property in properties)
{
if (number == 0)
{
value = property.Value;
return true;
}
number--;
}
}
else
{
foreach (KeyValuePair property in properties)
{
ReadOnlySpan parameterName = property.Key.AsSpan();
if (name.Equals(parameterName, StringComparison.Ordinal))
{
value = property.Value;
return true;
}
}
}
value = null;
return false;
}
}
}