diff --git a/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs b/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs
new file mode 100644
index 000000000..0b76d3f94
--- /dev/null
+++ b/Projects/Server.Tests/Tests/Buffers/RawInterpolatedStringHandlerTests.cs
@@ -0,0 +1,95 @@
+using System;
+using Server.Buffers;
+using Xunit;
+
+namespace Server.Tests.Buffers;
+
+public class RawInterpolatedStringHandlerTests
+{
+ [Fact]
+ public void TestLowercaseFormatString()
+ {
+ var name = "Hello WORLD";
+ var handler = new RawInterpolatedStringHandler(0, 1);
+ handler.AppendFormatted(name, format: "L");
+ Assert.Equal("hello world", handler.Text.ToString());
+ handler.Clear();
+ }
+
+ [Fact]
+ public void TestLowercaseFormatEnum()
+ {
+ var handler = new RawInterpolatedStringHandler(0, 1);
+ handler.AppendFormatted(DayOfWeek.Wednesday, format: "L");
+ Assert.Equal("wednesday", handler.Text.ToString());
+ handler.Clear();
+ }
+
+ [Fact]
+ public void TestLowercaseFormatInt()
+ {
+ // Numerics have no uppercase chars; :L should be a no-op
+ var handler = new RawInterpolatedStringHandler(0, 1);
+ handler.AppendFormatted(42, format: "L");
+ Assert.Equal("42", handler.Text.ToString());
+ handler.Clear();
+ }
+
+ [Fact]
+ public void TestLowercaseFormatSpan()
+ {
+ var span = "MIXED Case TEXT".AsSpan();
+ var handler = new RawInterpolatedStringHandler(0, 1);
+ handler.AppendFormatted(span, alignment: 0, format: "L");
+ Assert.Equal("mixed case text", handler.Text.ToString());
+ handler.Clear();
+ }
+
+ [Fact]
+ public void TestLowercaseFormatWithAlignment()
+ {
+ // Right-aligned: "Gold" in width 8 with :L -> " gold"
+ var handler = new RawInterpolatedStringHandler(0, 1);
+ handler.AppendFormatted("Gold", alignment: 8, format: "L");
+ Assert.Equal(" gold", handler.Text.ToString());
+ handler.Clear();
+ }
+
+ [Fact]
+ public void TestNoFormatPreservesCase()
+ {
+ var handler = new RawInterpolatedStringHandler(0, 1);
+ handler.AppendFormatted("Hello WORLD");
+ Assert.Equal("Hello WORLD", handler.Text.ToString());
+ handler.Clear();
+ }
+
+ [Fact]
+ public void TestUnicodeLowercase()
+ {
+ // ToLowerInvariant on Greek letter
+ var handler = new RawInterpolatedStringHandler(0, 1);
+ handler.AppendFormatted("ΑΒΓ", format: "L");
+ Assert.Equal("αβγ", handler.Text.ToString());
+ handler.Clear();
+ }
+
+ [Fact]
+ public void TestLowercaseFormatSurrogatePairAtChunkBoundary()
+ {
+ // The chunked lowercase path uses a 256-char stackalloc temp buffer. Place a
+ // supplementary-plane code point (U+10400 DESERET CAPITAL LONG I, encoded as
+ // surrogate pair "𐐀") so its high half lands at offset 255 and its
+ // low half at offset 256 — straddling the chunk boundary. Without the
+ // surrogate-aware boundary trim, ToLowerInvariant would see two lone surrogates
+ // and pass them through unchanged, leaving the capital code point intact.
+ // With the trim, the chunk shrinks to 255 chars and the pair stays together
+ // in the next chunk, lowercasing correctly to U+10428 ("𐐨").
+ var input = new string('a', 255) + "𐐀" + new string('b', 10);
+ var handler = new RawInterpolatedStringHandler(0, 1);
+ handler.AppendFormatted(input, format: "L");
+ var expected = new string('a', 255) + "𐐨" + new string('b', 10);
+ Assert.Equal(expected, handler.Text.ToString());
+ handler.Clear();
+ }
+}
diff --git a/Projects/Server/Buffers/RawInterpolatedStringHandler.cs b/Projects/Server/Buffers/RawInterpolatedStringHandler.cs
index 4dcbbccbe..e6d2e88fa 100644
--- a/Projects/Server/Buffers/RawInterpolatedStringHandler.cs
+++ b/Projects/Server/Buffers/RawInterpolatedStringHandler.cs
@@ -261,49 +261,72 @@ public ref struct RawInterpolatedStringHandler
}
/// Writes the specified value to the handler.
/// The value to write.
- /// The format string.
+ ///
+ /// The format string. Pass "L" to lowercase the formatted hole's output
+ /// using ; the underlying value's
+ /// TryFormat/ToString never sees "L".
+ ///
public void AppendFormatted(T value, string? format)
{
+ var lowercase = format == "L";
+ if (lowercase)
+ {
+ format = null;
+ }
+
+ var startPos = _pos;
+
// If there's a custom formatter, always use it.
if (_hasCustomFormatter)
{
AppendCustomFormatter(value, format);
- return;
- }
-
- // Check first for IFormattable, even though we'll prefer to use ISpanFormattable, as the latter
- // requires the former. For value types, it won't matter as the type checks devolve into
- // JIT-time constants. For reference types, they're more likely to implement IFormattable
- // than they are to implement ISpanFormattable: if they don't implement either, we save an
- // interface check over first checking for ISpanFormattable and then for IFormattable, and
- // if it only implements IFormattable, we come out even: only if it implements both do we
- // end up paying for an extra interface check.
- string? s;
- if (value is IFormattable)
- {
- // If the value can format itself directly into our buffer, do so.
- if (value is ISpanFormattable)
- {
- int charsWritten;
- while (!((ISpanFormattable)value).TryFormat(_chars[_pos..], out charsWritten, format, _provider)) // constrained call avoiding boxing for value types
- {
- Grow();
- }
-
- _pos += charsWritten;
- return;
- }
-
- s = ((IFormattable)value).ToString(format, _provider); // constrained call avoiding boxing for value types
}
else
{
- s = value?.ToString();
+ // Check first for IFormattable, even though we'll prefer to use ISpanFormattable, as the latter
+ // requires the former. For value types, it won't matter as the type checks devolve into
+ // JIT-time constants. For reference types, they're more likely to implement IFormattable
+ // than they are to implement ISpanFormattable: if they don't implement either, we save an
+ // interface check over first checking for ISpanFormattable and then for IFormattable, and
+ // if it only implements IFormattable, we come out even: only if it implements both do we
+ // end up paying for an extra interface check.
+ string? s;
+ if (value is IFormattable)
+ {
+ // If the value can format itself directly into our buffer, do so.
+ if (value is ISpanFormattable)
+ {
+ int charsWritten;
+ while (!((ISpanFormattable)value).TryFormat(_chars[_pos..], out charsWritten, format, _provider)) // constrained call avoiding boxing for value types
+ {
+ Grow();
+ }
+
+ _pos += charsWritten;
+
+ if (lowercase)
+ {
+ LowercaseRange(_chars.Slice(startPos, _pos - startPos));
+ }
+ return;
+ }
+
+ s = ((IFormattable)value).ToString(format, _provider); // constrained call avoiding boxing for value types
+ }
+ else
+ {
+ s = value?.ToString();
+ }
+
+ if (s is not null)
+ {
+ AppendStringDirect(s);
+ }
}
- if (s is not null)
+ if (lowercase)
{
- AppendStringDirect(s);
+ LowercaseRange(_chars.Slice(startPos, _pos - startPos));
}
}
@@ -354,9 +377,14 @@ public ref struct RawInterpolatedStringHandler
/// Writes the specified string of chars to the handler.
/// The span to write.
/// Minimum number of characters that should be written for this value. If the value is negative, it indicates left-aligned and the required minimum is the absolute value.
- /// The format string.
+ ///
+ /// The format string. Pass "L" to lowercase the value's chars (only the
+ /// value range, not any alignment padding) using .
+ ///
public void AppendFormatted(ReadOnlySpan value, int alignment = 0, string? format = null)
{
+ var lowercase = format == "L";
+
var leftAlign = false;
if (alignment < 0)
{
@@ -369,7 +397,12 @@ public ref struct RawInterpolatedStringHandler
{
// The value is as large or larger than the required amount of padding,
// so just write the value.
+ var startPos = _pos;
AppendFormatted(value);
+ if (lowercase)
+ {
+ LowercaseRange(_chars.Slice(startPos, _pos - startPos));
+ }
return;
}
@@ -377,8 +410,13 @@ public ref struct RawInterpolatedStringHandler
EnsureCapacityForAdditionalChars(value.Length + paddingRequired);
if (leftAlign)
{
+ var valueStart = _pos;
value.CopyTo(_chars[_pos..]);
_pos += value.Length;
+ if (lowercase)
+ {
+ LowercaseRange(_chars.Slice(valueStart, _pos - valueStart));
+ }
_chars.Slice(_pos, paddingRequired).Fill(' ');
_pos += paddingRequired;
}
@@ -386,8 +424,13 @@ public ref struct RawInterpolatedStringHandler
{
_chars.Slice(_pos, paddingRequired).Fill(' ');
_pos += paddingRequired;
+ var valueStart = _pos;
value.CopyTo(_chars[_pos..]);
_pos += value.Length;
+ if (lowercase)
+ {
+ LowercaseRange(_chars.Slice(valueStart, _pos - valueStart));
+ }
}
}
#endregion
@@ -522,6 +565,32 @@ public ref struct RawInterpolatedStringHandler
}
}
+ ///
+ /// Lowercases the chars in in the half-open range [start, end)
+ /// using the BCL's vectorized .
+ /// Copies through a stackalloc temp for ranges up to 256 chars, otherwise rents from
+ /// . The BCL overload throws on overlapping source/destination
+ /// spans, so a temp is always required; running one big SIMD pass beats chunking on long inputs
+ /// and avoids splitting surrogate pairs at chunk boundaries.
+ ///
+ private static void LowercaseRange(Span dest)
+ {
+ if (dest.Length <= MinimumArrayPoolLength)
+ {
+ Span temp = stackalloc char[MinimumArrayPoolLength];
+ var t = temp[..dest.Length];
+ dest.CopyTo(t);
+ ((ReadOnlySpan)t).ToLowerInvariant(dest);
+ return;
+ }
+
+ var rented = STArrayPool.Shared.Rent(dest.Length);
+ var rentedSlice = rented.AsSpan(0, dest.Length);
+ dest.CopyTo(rentedSlice);
+ ((ReadOnlySpan)rentedSlice).ToLowerInvariant(dest);
+ STArrayPool.Shared.Return(rented);
+ }
+
/// Ensures has the capacity to store beyond .
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void EnsureCapacityForAdditionalChars(int additionalChars)