Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 2 additions & 32 deletions src/Seq.Syntax/Expressions/Runtime/RuntimeOperators.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions src/Seq.Syntax/Expressions/Runtime/Values.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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()
};
}
}
63 changes: 22 additions & 41 deletions src/Seq.Syntax/Templates/Compilation/CompiledMessageToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
{
Expand All @@ -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<char> path, out ReadOnlySpan<char> name, out ReadOnlySpan<char> rest)
Expand Down
80 changes: 77 additions & 3 deletions test/Seq.Syntax.Tests/Templates/MessageRenderingTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.Json.Nodes;
using Seq.Syntax.Expressions;
using Seq.Syntax.Templates;
Expand All @@ -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<object?[]> 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()
{
Expand Down
8 changes: 7 additions & 1 deletion test/Seq.Syntax.Tests/Templates/ThemingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<Text>x</Text>" +
"<String>s</String>" +
"<String>c</String>" +
"<Number>1</Number>" +
"<Number>2.5</Number>" +
"<Boolean>True</Boolean>" +
"<Null>null</Null>" +
$"<Scalar>{Guid.Empty}</Scalar>" +
"<Scalar>2026-08-30T01:02:03.0000000Z</Scalar>" +
"<Invalid>{Missing}</Invalid>",
RenderMarked("{@Message}", evt));
}
Expand Down
Loading