From efc096d6cb520d204b5ad418230d02fb4a26778c Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Wed, 2 Sep 2026 17:15:30 +1000 Subject: [PATCH] Handle all (most) JsonValue primitive types in the message template renderer, avoiding footguns when constructing JsonObjects programmatically. Assisted-by: Claude:claude-fable-5-1 --- .../Expressions/Runtime/RuntimeOperators.cs | 34 +------- src/Seq.Syntax/Expressions/Runtime/Values.cs | 33 ++++++++ .../Compilation/CompiledMessageToken.cs | 63 +++++---------- .../Templates/MessageRenderingTests.cs | 80 ++++++++++++++++++- .../Templates/ThemingTests.cs | 8 +- 5 files changed, 141 insertions(+), 77 deletions(-) diff --git a/src/Seq.Syntax/Expressions/Runtime/RuntimeOperators.cs b/src/Seq.Syntax/Expressions/Runtime/RuntimeOperators.cs index 0d3a7d9..f7b94b8 100644 --- a/src/Seq.Syntax/Expressions/Runtime/RuntimeOperators.cs +++ b/src/Seq.Syntax/Expressions/Runtime/RuntimeOperators.cs @@ -492,40 +492,10 @@ public static EvaluationResult ToString(IFormatProvider? formatProvider, JsonNod return EvaluationResult.Undefined; } - string? toString; - try - { - toString = FormatScalarValue(Values.Underlying(scalar), fmt, formatProvider); - } - catch (FormatException) - { - // An invalid format specifier degrades to default formatting rather than throwing. - Diagnostics.RecordSuppressedError(Diagnostics.ErrorKinds.InvalidFormat); - toString = FormatScalarValue(Values.Underlying(scalar), null, formatProvider); - } - + var toString = Values.FormatScalarValue(Values.Underlying(scalar), fmt, formatProvider); return toString == null ? EvaluationResult.Undefined : JsonValue.Create(toString); } - - static string? FormatScalarValue(object underlying, string? fmt, IFormatProvider? formatProvider) - { - return underlying switch - { - JsonElement { ValueKind: JsonValueKind.String } element => Values.TryGetElementString(element, out var s) ? s : null, - JsonElement { ValueKind: JsonValueKind.True } => "true", - JsonElement { ValueKind: JsonValueKind.False } => "false", - JsonElement { ValueKind: JsonValueKind.Number } element => - (element.TryGetDecimal(out var dec) ? dec : (IFormattable)element.GetDouble()).ToString(fmt, formatProvider), - bool boolean => boolean ? "true" : "false", - DateTimeOffset dto when dto.Offset == TimeSpan.Zero && fmt == null => dto.UtcDateTime.ToString("O"), - DateTimeOffset dto when fmt == null => dto.ToString("O"), - DateTime dt when fmt == null => dt.ToString("O"), - LevelValue level => LevelRenderer.GetLevelMoniker(level, fmt), - IFormattable formattable => formattable.ToString(fmt, formatProvider), - var other => other.ToString() - }; - } - + public static EvaluationResult UtcDateTime(DateTimeOffset dateTime) { return JsonValue.Create(dateTime.UtcDateTime); diff --git a/src/Seq.Syntax/Expressions/Runtime/Values.cs b/src/Seq.Syntax/Expressions/Runtime/Values.cs index 0d4cabe..c50edc8 100644 --- a/src/Seq.Syntax/Expressions/Runtime/Values.cs +++ b/src/Seq.Syntax/Expressions/Runtime/Values.cs @@ -17,6 +17,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using Seq.Syntax.Templates.Encoding; +using Seq.Syntax.Templates.Rendering; namespace Seq.Syntax.Expressions.Runtime; @@ -266,4 +267,36 @@ public static string KindOf(JsonNode? node) _ => "null" }; } + + public static string? FormatScalarValue(object underlying, string? fmt, IFormatProvider? formatProvider) + { + try + { + return FormatScalarValueUnchecked(underlying, fmt, formatProvider); + } + catch (FormatException) + { + Diagnostics.RecordSuppressedError(Diagnostics.ErrorKinds.InvalidFormat); + return FormatScalarValueUnchecked(underlying, null, formatProvider); + } + } + + static string? FormatScalarValueUnchecked(object underlying, string? fmt, IFormatProvider? formatProvider) + { + return underlying switch + { + JsonElement { ValueKind: JsonValueKind.String } element => Values.TryGetElementString(element, out var s) ? s : null, + JsonElement { ValueKind: JsonValueKind.True } => "true", + JsonElement { ValueKind: JsonValueKind.False } => "false", + JsonElement { ValueKind: JsonValueKind.Number } element => + (element.TryGetDecimal(out var dec) ? dec : (IFormattable)element.GetDouble()).ToString(fmt, formatProvider), + bool boolean => boolean ? "true" : "false", + DateTimeOffset dto when dto.Offset == TimeSpan.Zero && fmt == null => dto.UtcDateTime.ToString("O"), + DateTimeOffset dto when fmt == null => dto.ToString("O"), + DateTime dt when fmt == null => dt.ToString("O"), + LevelValue level => LevelRenderer.GetLevelMoniker(level, fmt), + IFormattable formattable => formattable.ToString(fmt, formatProvider), + var other => other.ToString() + }; + } } diff --git a/src/Seq.Syntax/Templates/Compilation/CompiledMessageToken.cs b/src/Seq.Syntax/Templates/Compilation/CompiledMessageToken.cs index d0d336d..ea05ac7 100644 --- a/src/Seq.Syntax/Templates/Compilation/CompiledMessageToken.cs +++ b/src/Seq.Syntax/Templates/Compilation/CompiledMessageToken.cs @@ -201,7 +201,8 @@ void EvaluatePropertyUnaligned(JsonNode? propertyValue, TextWriter output, strin return; } - switch (Values.Underlying(scalar)) + var underlying = Values.Underlying(scalar); + switch (underlying) { case JsonElement { ValueKind: JsonValueKind.String } element: { @@ -213,70 +214,50 @@ void EvaluatePropertyUnaligned(JsonNode? propertyValue, TextWriter output, strin } break; } - case JsonElement { ValueKind: JsonValueKind.True }: - { - using var _ = _boolean.Open(output, ref invisibleCharacterCount); - _encoder.WriteContent(output, bool.TrueString); - break; - } - case JsonElement { ValueKind: JsonValueKind.False }: - { - using var _ = _boolean.Open(output, ref invisibleCharacterCount); - _encoder.WriteContent(output, bool.FalseString); - break; - } - case JsonElement { ValueKind: JsonValueKind.Number } element: + case string str: { - using var _ = _number.Open(output, ref invisibleCharacterCount); - _encoder.WriteContent(output, element.TryGetDecimal(out var dec) - ? SafeFormat(dec, format, _formatProvider) - : SafeFormat(element.GetDouble(), format, _formatProvider)); + using var _ = _string.Open(output, ref invisibleCharacterCount); + _encoder.WriteContent(output, str); break; } - case string str: + case char c: { using var _ = _string.Open(output, ref invisibleCharacterCount); - _encoder.WriteContent(output, str); + _encoder.WriteContent(output, c.ToString()); break; } - case bool b: + case JsonElement { ValueKind: JsonValueKind.True } or true: { using var _ = _boolean.Open(output, ref invisibleCharacterCount); - _encoder.WriteContent(output, b ? bool.TrueString : bool.FalseString); + _encoder.WriteContent(output, bool.TrueString); break; } - case int or uint or long or ulong or decimal or byte or sbyte or short or ushort or double or float: + case JsonElement { ValueKind: JsonValueKind.False } or false: { - using var _ = _number.Open(output, ref invisibleCharacterCount); - _encoder.WriteContent(output, SafeFormat((IFormattable)Values.Underlying(scalar), format, _formatProvider)); + using var _ = _boolean.Open(output, ref invisibleCharacterCount); + _encoder.WriteContent(output, bool.FalseString); break; } - case IFormattable formattable: + case JsonElement { ValueKind: JsonValueKind.Number } + or int or uint or long or ulong or decimal or byte or sbyte or short or ushort or double or float: { - using var _ = _scalar.Open(output, ref invisibleCharacterCount); - _encoder.WriteContent(output, SafeFormat(formattable, format, _formatProvider)); + WriteFormattedScalar(_number, underlying, format, output, ref invisibleCharacterCount); break; } - case var other: + default: { - using var _ = _scalar.Open(output, ref invisibleCharacterCount); - _encoder.WriteContent(output, other.ToString() ?? ""); + // DateTime, DateTimeOffset, TimeSpan, Guid, and anything else. No case is needed for + // LevelValue: it's produced only by keyword properties, never captured by a message template. + WriteFormattedScalar(_scalar, underlying, format, output, ref invisibleCharacterCount); break; } } } - static string SafeFormat(IFormattable value, string? format, IFormatProvider? formatProvider) + void WriteFormattedScalar(Run run, object underlying, string? format, TextWriter output, ref int invisibleCharacterCount) { - try - { - return value.ToString(format, formatProvider); - } - catch (FormatException) - { - Diagnostics.RecordSuppressedError(Diagnostics.ErrorKinds.InvalidFormat); - return value.ToString(null, formatProvider); - } + using var _ = run.Open(output, ref invisibleCharacterCount); + _encoder.WriteContent(output, Values.FormatScalarValue(underlying, format, _formatProvider) ?? ""); } static bool TryGetNextStep(ReadOnlySpan path, out ReadOnlySpan name, out ReadOnlySpan rest) diff --git a/test/Seq.Syntax.Tests/Templates/MessageRenderingTests.cs b/test/Seq.Syntax.Tests/Templates/MessageRenderingTests.cs index 53a5582..a3f1543 100644 --- a/test/Seq.Syntax.Tests/Templates/MessageRenderingTests.cs +++ b/test/Seq.Syntax.Tests/Templates/MessageRenderingTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json.Nodes; using Seq.Syntax.Expressions; using Seq.Syntax.Templates; @@ -13,14 +14,87 @@ public class MessageRenderingTests ["@mt"] = messageTemplate }; - static string Render(JsonObject evt) + static string Render(JsonObject evt, string template = "{@Message}") { - var template = new ExpressionTemplate("{@Message}"); var output = new StringWriter(); - template.Format(evt, output); + new ExpressionTemplate(template, culture: CultureInfo.InvariantCulture).Format(evt, output); return output.ToString(); } + static string RenderScalar(JsonNode? value, string? format = null) + { + var evt = MessageEvent(format == null ? "{V}" : $"{{V:{format}}}"); + evt["V"] = value; + return Render(evt); + } + + public static IEnumerable TypedScalarCases() + { + // Every CLR type accepted by JsonValue.Create(), plus the runtime's own typed values. + yield return [JsonValue.Create("s"), "s"]; + yield return [JsonValue.Create('c'), "c"]; + yield return [JsonValue.Create(true), "True"]; + yield return [JsonValue.Create(false), "False"]; + yield return [JsonValue.Create((byte)1), "1"]; + yield return [JsonValue.Create((sbyte)-1), "-1"]; + yield return [JsonValue.Create((short)-2), "-2"]; + yield return [JsonValue.Create((ushort)2), "2"]; + yield return [JsonValue.Create(-3), "-3"]; + yield return [JsonValue.Create(3u), "3"]; + yield return [JsonValue.Create(-4L), "-4"]; + yield return [JsonValue.Create(4UL), "4"]; + yield return [JsonValue.Create(1.5f), "1.5"]; + yield return [JsonValue.Create(2.5), "2.5"]; + yield return [JsonValue.Create(3.5m), "3.5"]; + yield return [JsonValue.Create(Guid.Parse("0e2f7a0c-0b7e-4a3e-9f2a-6a4b2f6f2e1d")), "0e2f7a0c-0b7e-4a3e-9f2a-6a4b2f6f2e1d"]; + yield return [JsonValue.Create(new DateTime(2026, 8, 27, 1, 2, 3, DateTimeKind.Utc)), "2026-08-27T01:02:03.0000000Z"]; + yield return [JsonValue.Create(new DateTime(2026, 8, 27, 1, 2, 3, DateTimeKind.Unspecified)), "2026-08-27T01:02:03.0000000"]; + yield return [JsonValue.Create(new DateTimeOffset(2026, 8, 27, 1, 2, 3, TimeSpan.Zero)), "2026-08-27T01:02:03.0000000Z"]; + yield return [JsonValue.Create(new DateTimeOffset(2026, 8, 27, 1, 2, 3, TimeSpan.FromHours(10))), "2026-08-27T01:02:03.0000000+10:00"]; + yield return [JsonValue.Create(new TimeSpan(1, 2, 3, 4, 500)), "1.02:03:04.5000000"]; + yield return [JsonValue.Create(TimeSpan.FromMinutes(-90)), "-01:30:00"]; + yield return [null, "null"]; + } + + [Theory] + [MemberData(nameof(TypedScalarCases))] + public void TypedScalarsHaveReasonableDefaultRendering(JsonNode? value, string expected) + { + Assert.Equal(expected, RenderScalar(value)); + } + + [Theory] + [InlineData("yyyy-MM-dd", "2026-08-27")] + [InlineData("HH:mm", "01:02")] + public void DateTimeValuesHonorFormatSpecifiers(string format, string expected) + { + Assert.Equal(expected, RenderScalar(JsonValue.Create(new DateTimeOffset(2026, 8, 27, 1, 2, 3, TimeSpan.Zero)), format)); + Assert.Equal(expected, RenderScalar(JsonValue.Create(new DateTime(2026, 8, 27, 1, 2, 3, DateTimeKind.Utc)), format)); + } + + [Fact] + public void OtherFormattableScalarsHonorFormatSpecifiers() + { + Assert.Equal("00000000000000000000000000000000", RenderScalar(JsonValue.Create(Guid.Empty), "N")); + Assert.Equal("1:02", RenderScalar(JsonValue.Create(new TimeSpan(1, 2, 0)), "h\\:mm")); + Assert.Equal("0042", RenderScalar(JsonValue.Create(42), "0000")); + Assert.Equal("0042", RenderScalar(JsonValue.Create(42m), "0000")); + } + + [Fact] + public void InvalidFormatSpecifiersFallBackToDefaultRendering() + { + // A trailing escape character is an invalid date/time format string. + var value = JsonValue.Create(new DateTimeOffset(2026, 8, 27, 1, 2, 3, TimeSpan.Zero)); + Assert.Equal("2026-08-27T01:02:03.0000000Z", RenderScalar(value, "\\")); + } + + [Fact] + public void UnknownScalarTypesRenderViaToString() + { + Assert.Equal("(1, 2)", RenderScalar(JsonValue.Create((1, 2)))); + } + [Fact] public void MessageRenderingSupportsNestedProperties() { diff --git a/test/Seq.Syntax.Tests/Templates/ThemingTests.cs b/test/Seq.Syntax.Tests/Templates/ThemingTests.cs index 7f7ac20..1ba34a4 100644 --- a/test/Seq.Syntax.Tests/Templates/ThemingTests.cs +++ b/test/Seq.Syntax.Tests/Templates/ThemingTests.cs @@ -28,20 +28,26 @@ static JsonObject MessageEvent(string messageTemplate) => [Fact] public void MessageHoleScalarKindsSelectStyles() { - var evt = MessageEvent("x{S}{N}{B}{Z}{G}{Missing}"); + var evt = MessageEvent("x{S}{C}{N}{D}{B}{Z}{G}{T}{Missing}"); evt["S"] = "s"; + evt["C"] = JsonValue.Create('c'); evt["N"] = 1; + evt["D"] = JsonValue.Create(2.5); evt["B"] = true; evt["Z"] = null; evt["G"] = JsonValue.Create(Guid.Empty); + evt["T"] = JsonValue.Create(new DateTimeOffset(2026, 8, 30, 1, 2, 3, TimeSpan.Zero)); Assert.Equal( "x" + "s" + + "c" + "1" + + "2.5" + "True" + "null" + $"{Guid.Empty}" + + "2026-08-30T01:02:03.0000000Z" + "{Missing}", RenderMarked("{@Message}", evt)); }