691 lines
23 KiB
C#
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();
|
|
}
|