// 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; } } }