Files
EonaCat.LogStack/EonaCat.LogStack/EonaCatLoggerCore/MessageTemplate.cs
T
EonaCatandJeroen Saey dae624fd45 Added more stats
Added more dependency injection
Made README.md better
2026-06-11 08:17:29 +02:00

691 lines
23 KiB
C#

using EonaCat.LogStack.Core;
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text;
namespace EonaCat.LogStack.Core;
// This file is part of the EonaCat project(s) which is released under the Apache License.
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Hint for how a token's value should be captured.
/// </summary>
public enum TokenDestructureHint : byte
{
/// <summary>ToString() / scalar value</summary>
Default = 0,
/// <summary>{@Property} — deep object destructuring (JSON-like)</summary>
Destructure = 1,
/// <summary>{$Property} — force ToString()</summary>
Stringify = 2
}
/// <summary>
/// A single token parsed from a message template.
/// Supports advanced features:
/// - Nested property access: {Object.Property.SubProperty}
/// - Array indexing: {Array[0]}
/// - Alignment: {Name,10} (right), {Name,-10} (left)
/// - Filters: {Name|uppercase}, {Name|truncate:20}
/// - Conditionals: {?IsActive:Yes|No}
/// - Fallback: {Name??'default'}
/// </summary>
public readonly struct TemplateToken
{
/// <summary>True = literal text; False = property hole.</summary>
public readonly bool IsLiteral;
/// <summary>Literal text segment, or null for property holes.</summary>
public readonly string? Text;
/// <summary>Property name for holes (may be a digit for positional placeholders, supports dot notation).</summary>
public readonly string? Name;
/// <summary>Zero-based positional index for positional placeholders; -1 for named.</summary>
public readonly int Position;
/// <summary>Destructure / stringify hint.</summary>
public readonly TokenDestructureHint Hint;
/// <summary>Optional format string (e.g. "D2").</summary>
public readonly string? Format;
/// <summary>Alignment width (positive=right, negative=left).</summary>
public readonly int Alignment;
/// <summary>Applied filters (e.g., "uppercase", "truncate:10").</summary>
public readonly string[]? Filters;
/// <summary>Fallback value if property is null/missing.</summary>
public readonly string? Fallback;
/// <summary>Optional conditional true/false values for {?PropertyName:True|False}.</summary>
public readonly (string? TrueValue, string? FalseValue) ConditionalValues;
private TemplateToken(string literal)
{
IsLiteral = true;
Text = literal;
Name = null;
Position = -1;
Hint = TokenDestructureHint.Default;
Format = null;
Alignment = 0;
Filters = null;
Fallback = null;
ConditionalValues = (null, null);
}
private TemplateToken(string name, int position, TokenDestructureHint hint, string? format, int alignment = 0, string[]? filters = null, string? fallback = null, (string?, string?) conditionalValues = default)
{
IsLiteral = false;
Text = null;
Name = name;
Position = position;
Hint = hint;
Format = format;
Alignment = alignment;
Filters = filters;
Fallback = fallback;
ConditionalValues = conditionalValues;
}
public static TemplateToken Literal(string text) => new(text);
public static TemplateToken Property(string name, int position, TokenDestructureHint hint, string? format, int alignment = 0, string[]? filters = null, string? fallback = null, (string?, string?) conditionalValues = default)
=> new(name, position, hint, format, alignment, filters, fallback, conditionalValues);
public override string ToString() =>
IsLiteral ? $"Literal({Text})" : $"Hole({Hint}{Name}{(Format != null ? ":" + Format : "")})";
}
/// <summary>
/// Parses and renders Serilog-compatible message templates with advanced features.
///
/// Supported syntax:
/// {PropertyName} — named property (default destructure)
/// {@PropertyName} — named property, deep destructure
/// {$PropertyName} — named property, force-stringify
/// {0}, {1} — positional
/// {PropertyName:format} — with format specifier (e.g., "D2", "C")
/// {PropertyName,10} — right-align with width 10
/// {PropertyName,-10} — left-align with width 10
/// {Object.Property} — nested property access (dot notation)
/// {Array[0]} — array/collection indexing
/// {PropertyName|uppercase} — apply filter (uppercase, lowercase, truncate, etc.)
/// {PropertyName??'default'} — fallback value if null/missing
/// {?IsActive:Yes|No} — conditional rendering
/// {{ }} — escaped braces → literal { }
/// </summary>
public sealed class MessageTemplate
{
private readonly string _raw;
private readonly TemplateToken[] _tokens;
public string Raw => _raw;
public ReadOnlySpan<TemplateToken> Tokens => _tokens;
private MessageTemplate(string raw, TemplateToken[] tokens)
{
_raw = raw;
_tokens = tokens;
}
public static MessageTemplate Parse(string template)
{
if (template == null)
{
throw new ArgumentNullException(nameof(template));
}
var tokens = new List<TemplateToken>(8);
var sb = new StringBuilder(template.Length);
int i = 0;
while (i < template.Length)
{
char c = template[i];
if (c == '{')
{
// Escaped {{ → literal {
if (i + 1 < template.Length && template[i + 1] == '{')
{
sb.Append('{');
i += 2;
continue;
}
// Flush accumulated literal
if (sb.Length > 0)
{
tokens.Add(TemplateToken.Literal(sb.ToString()));
sb.Clear();
}
// Find matching }
int end = FindClosingBrace(template, i + 1);
if (end < 0)
{
// Unclosed brace → treat rest as literal
sb.Append(template, i, template.Length - i);
i = template.Length;
continue;
}
string hole = template.Substring(i + 1, end - i - 1);
tokens.Add(ParseHole(hole));
i = end + 1;
}
else if (c == '}' && i + 1 < template.Length && template[i + 1] == '}')
{
// Escaped }}
sb.Append('}');
i += 2;
}
else
{
sb.Append(c);
i++;
}
}
if (sb.Length > 0)
{
tokens.Add(TemplateToken.Literal(sb.ToString()));
}
return new MessageTemplate(template, tokens.ToArray());
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int FindClosingBrace(string template, int startPos)
{
for (int i = startPos; i < template.Length; i++)
{
if (template[i] == '}')
{
return i;
}
}
return -1;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static TemplateToken ParseHole(string hole)
{
if (string.IsNullOrEmpty(hole))
{
return TemplateToken.Literal("{}");
}
var hint = TokenDestructureHint.Default;
int nameStart = 0;
bool isConditional = false;
// Check for conditional {?Property:True|False}
if (hole[0] == '?')
{
isConditional = true;
nameStart = 1;
}
else if (hole[0] == '@') { hint = TokenDestructureHint.Destructure; nameStart = 1; }
else if (hole[0] == '$') { hint = TokenDestructureHint.Stringify; nameStart = 1; }
// Extract components: name, alignment, format, filters, fallback, conditional
string name;
string? format = null;
int alignment = 0;
string[]? filters = null;
string? fallback = null;
(string?, string?) conditionalValues = (null, null);
// Parse: name[,alignment][|filters][??fallback][:format][:trueval|falseval]
string remaining = hole.Substring(nameStart);
// First, check for conditional values (only for conditional tokens)
if (isConditional && remaining.Contains(":"))
{
int colonIdx = remaining.IndexOf(':');
string beforeColon = remaining.Substring(0, colonIdx);
string afterColon = remaining.Substring(colonIdx + 1);
if (afterColon.Contains("|"))
{
int pipeIdx = afterColon.IndexOf('|');
conditionalValues.Item1 = afterColon.Substring(0, pipeIdx).Trim();
conditionalValues.Item2 = afterColon.Substring(pipeIdx + 1).Trim();
remaining = beforeColon;
}
}
// Parse filters (|uppercase, |truncate:10, etc.)
if (remaining.Contains("|"))
{
int pipeIdx = remaining.IndexOf('|');
string namePart = remaining.Substring(0, pipeIdx);
string filterPart = remaining.Substring(pipeIdx + 1);
remaining = namePart;
filters = filterPart.Split('|');
}
// Parse fallback (??'default')
if (remaining.Contains("??"))
{
int fallbackIdx = remaining.IndexOf("??");
string namePart = remaining.Substring(0, fallbackIdx);
fallback = remaining.Substring(fallbackIdx + 2).Trim();
if (fallback.StartsWith("'") && fallback.EndsWith("'"))
{
fallback = fallback.Substring(1, fallback.Length - 2);
}
remaining = namePart;
}
// Parse alignment (,10 or ,-10)
if (remaining.Contains(","))
{
int commaIdx = remaining.IndexOf(',');
string namePart = remaining.Substring(0, commaIdx);
string alignStr = remaining.Substring(commaIdx + 1).Trim();
if (int.TryParse(alignStr, out int align))
{
alignment = align;
}
remaining = namePart;
}
// Parse format (:D2, :C, etc.)
if (remaining.Contains(":") && !isConditional)
{
int colonIdx = remaining.IndexOf(':');
string namePart = remaining.Substring(0, colonIdx);
format = remaining.Substring(colonIdx + 1);
remaining = namePart;
}
name = remaining.Trim();
// Positional?
int position = -1;
if (name.Length > 0 && IsDigits(name))
{
int.TryParse(name, out position);
}
return TemplateToken.Property(name, position, hint, string.IsNullOrEmpty(format) ? null : format, alignment, filters, fallback, conditionalValues);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsDigits(string s)
{
foreach (var c in s)
{
if (c < '0' || c > '9')
{
return false;
}
}
return true;
}
/// <summary>
/// Renders the template to a string, binding positional args and returning
/// a dictionary of named properties.
/// </summary>
public string Render(object?[]? args, out Dictionary<string, object?> properties)
{
properties = new Dictionary<string, object?>(StringComparer.Ordinal);
var sb = new StringBuilder(_raw.Length + 32);
int positionalIndex = 0;
foreach (ref readonly var token in _tokens.AsSpan())
{
if (token.IsLiteral)
{
sb.Append(token.Text);
continue;
}
object? value;
if (token.Position >= 0)
{
// Positional placeholder {0}, {1} …
value = (args != null && token.Position < args.Length) ? args[token.Position] : null;
}
else if (args != null && positionalIndex < args.Length && IsArgumentDriven(args))
{
value = args[positionalIndex++];
properties[token.Name!] = value;
}
else
{
value = ResolveNestedProperty(token.Name, properties);
}
if (token.Name != null && token.Position < 0)
{
properties[token.Name] = value;
}
AppendValue(sb, value, token.Hint, token.Format, token.Alignment, token.Filters, token.Fallback, token.ConditionalValues);
}
return sb.ToString();
}
/// <summary>
/// Resolves nested property access (e.g., "Object.Property.SubProperty")
/// </summary>
private static object? ResolveNestedProperty(string? propertyPath, Dictionary<string, object?> properties)
{
if (propertyPath == null)
{
return null;
}
// Check for array indexing: Array[0]
if (propertyPath.Contains("["))
{
int bracketIdx = propertyPath.IndexOf('[');
string baseName = propertyPath.Substring(0, bracketIdx);
string indexStr = propertyPath.Substring(bracketIdx + 1);
if (indexStr.EndsWith("]"))
{
indexStr = indexStr.Substring(0, indexStr.Length - 1);
}
if (properties.TryGetValue(baseName, out var collection))
{
return GetCollectionItem(collection, indexStr);
}
return null;
}
// Handle nested property access with dots
if (!propertyPath.Contains("."))
{
return properties.TryGetValue(propertyPath, out var val) ? val : null;
}
string[] parts = propertyPath.Split('.');
object? current = null;
if (!properties.TryGetValue(parts[0], out current))
{
return null;
}
for (int i = 1; i < parts.Length && current != null; i++)
{
current = GetPropertyValue(current, parts[i]);
}
return current;
}
private static object? GetPropertyValue(object? obj, string propertyName)
{
if (obj == null)
{
return null;
}
// Check for array indexing in nested path
if (propertyName.Contains("["))
{
int bracketIdx = propertyName.IndexOf('[');
string actualProp = propertyName.Substring(0, bracketIdx);
string indexStr = propertyName.Substring(bracketIdx + 1);
if (indexStr.EndsWith("]"))
{
indexStr = indexStr.Substring(0, indexStr.Length - 1);
}
var prop = obj.GetType().GetProperty(actualProp, System.Reflection.BindingFlags.IgnoreCase | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
if (prop?.CanRead == true)
{
var collection = prop.GetValue(obj);
return GetCollectionItem(collection, indexStr);
}
return null;
}
var propInfo = obj.GetType().GetProperty(propertyName, System.Reflection.BindingFlags.IgnoreCase | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
return propInfo?.CanRead == true ? propInfo.GetValue(obj) : null;
}
private static object? GetCollectionItem(object? collection, string indexStr)
{
if (collection == null)
{
return null;
}
try
{
if (collection is System.Collections.IList list && int.TryParse(indexStr, out int index))
{
return index >= 0 && index < list.Count ? list[index] : null;
}
}
catch { }
return null;
}
/// <summary>
/// Renders and populates the builder's properties from named holes.
/// </summary>
public string RenderInto(object?[]? args, LogEventBuilder builder)
{
var rendered = Render(args, out var props);
foreach (var kv in props)
{
builder.WithProperty(kv.Key, kv.Value);
}
return rendered;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void AppendValue(StringBuilder sb, object? value, TokenDestructureHint hint, string? format, int alignment, string[]? filters, string? fallback, (string?, string?) conditionalValues)
{
// Handle conditional rendering
if (conditionalValues.Item1 != null || conditionalValues.Item2 != null)
{
bool isTrue = IsTruthy(value);
sb.Append(isTrue ? conditionalValues.Item1 : conditionalValues.Item2);
return;
}
// Handle null/fallback
if (value == null)
{
sb.Append(fallback ?? "null");
return;
}
// Apply filters
if (filters != null && filters.Length > 0)
{
value = ApplyFilters(value, filters);
}
string formatted = FormatValue(value, hint, format);
// Apply alignment
if (alignment != 0)
{
formatted = alignment > 0
? formatted.PadLeft(alignment)
: formatted.PadRight(-alignment);
}
sb.Append(formatted);
}
private static bool IsTruthy(object? value)
{
if (value == null)
{
return false;
}
if (value is bool b)
{
return b;
}
if (value is int i)
{
return i != 0;
}
if (value is long l)
{
return l != 0;
}
if (value is string s)
{
return !string.IsNullOrEmpty(s);
}
return true;
}
private static object? ApplyFilters(object? value, string[] filters)
{
if (value == null)
{
return null;
}
foreach (var filter in filters)
{
string filterName = filter;
string? filterParam = null;
if (filter.Contains(":", StringComparison.Ordinal))
{
int colonIdx = filter.IndexOf(':');
filterName = filter.Substring(0, colonIdx).Trim();
filterParam = filter.Substring(colonIdx + 1).Trim();
}
value = ApplyFilter(value, filterName, filterParam);
}
return value;
}
private static object? ApplyFilter(object? value, string filterName, string? param)
{
if (value == null)
{
return null;
}
string str = value.ToString() ?? "";
if (filterName.Equals("reverse", StringComparison.OrdinalIgnoreCase))
{
var chars = str.ToCharArray();
System.Array.Reverse(chars);
return new string(chars);
}
return filterName.ToLowerInvariant() switch
{
"uppercase" or "upper" => str.ToUpperInvariant(),
"lowercase" or "lower" => str.ToLowerInvariant(),
"trim" => str.Trim(),
"truncate" => param != null && int.TryParse(param, out int len) ? (str.Length > len ? str.Substring(0, len) + "…" : str) : str,
"substr" or "substring" => param != null && int.TryParse(param, out int pos) && pos < str.Length ? str.Substring(pos) : str,
_ => str
};
}
private static string FormatValue(object value, TokenDestructureHint hint, string? format)
{
if (hint == TokenDestructureHint.Destructure)
{
return Destructure(value);
}
if (value is IFormattable formattable && format != null)
{
try
{
return formattable.ToString(format, System.Globalization.CultureInfo.InvariantCulture);
}
catch
{
return value.ToString() ?? "";
}
}
return value.ToString() ?? "";
}
private static string Destructure(object obj)
{
if (obj == null)
{
return "null";
}
var t = obj.GetType();
if (t.IsPrimitive || obj is string || obj is decimal || obj is DateTime || obj is DateTimeOffset || obj is Guid)
{
return obj.ToString()!;
}
// Deep property bag with nesting
var sb = new StringBuilder("{");
bool first = true;
foreach (var prop in t.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance))
{
try
{
if (!first)
{
sb.Append(", ");
}
var val = prop.GetValue(obj);
sb.Append(prop.Name).Append(": ");
if (val == null)
{
sb.Append("null");
}
else if (val.GetType().IsPrimitive || val is string || val is decimal || val is DateTime || val is DateTimeOffset)
{
sb.Append(val);
}
else
{
sb.Append(Destructure(val));
}
first = false;
}
catch { }
}
sb.Append('}');
return sb.ToString();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsArgumentDriven(object?[] args) => args.Length > 0;
// Template cache to avoid re-parsing the same strings
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, MessageTemplate> _cache
= new(StringComparer.Ordinal);
/// <summary>Returns a cached parsed template (recommended for hot paths).</summary>
public static MessageTemplate FromCache(string template) =>
_cache.GetOrAdd(template, static t => Parse(t));
/// <summary>Clears the template parse cache.</summary>
public static void ClearCache() => _cache.Clear();
}