feat(buffers): add :L lowercase format spec to RawInterpolatedStringHandler (#2440)

## Summary

Adds a custom `:L` format specifier to `RawInterpolatedStringHandler`. When the format string is `"L"`, the handler lowercases the formatted value's chars in-place after the underlying `ISpanFormattable.TryFormat` / `IFormattable.ToString` path completes. Zero allocation, single-pass.

## Usage

```csharp
mob.SendMessage($"You earned a {rank:L} trophy!");           // "gold"
mob.SendMessage($"Welcome, {playerName:L}");                  // lowercased
mob.SendMessage($"{count:L} kills");                          // ints unchanged ("42")
```

## Motivation

Eliminates the `value.ToString().ToLowerInvariant()` two-allocation idiom that appears across the codebase for any type that goes through an interpolation handler. After this lands, content code can use the `:L` specifier directly instead of helper extensions or per-enum lookup tables.

## Coverage

- `AppendFormatted<T>(T value, string? format)` — generic path (covers IFormattable, ISpanFormattable, .ToString fallback)
- `AppendFormatted(ReadOnlySpan<char> value, int alignment, string? format)` — span path with alignment-aware lowercase range (only the value range is lowercased, not padding)
- `AppendFormatted<T>(T value, int alignment, string? format)` and `AppendFormatted(string? value, int alignment, string? format)` and `AppendFormatted(object? value, int alignment, string? format)` — inherit via delegation

The `format == "L"` comparison is case-sensitive — `:l` (lowercase L) is NOT recognized. `:L` matches the convention of e.g. `:N0` / `:F2` (numeric format specifiers traditionally use uppercase). `char.ToLowerInvariant` is used (not locale-dependent) for predictable game text.

## Future cleanup

Phase 3.3 (#2438) introduced a per-enum `TrophyRank.LowerName()` extension to eliminate `rank.ToString().ToLower()` allocations at 10 ConPVP sites. Once this PR lands, those sites can be simplified to `{rank:L}` and the `TrophyRankExtensions` helper can be removed. Tracked as a follow-up.
This commit is contained in:
Kamron Batman 2026-05-03 18:24:57 -07:00 committed by GitHub
parent 9ea1b54758
commit 679e66b99d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 196 additions and 32 deletions

View file

@ -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();
}
}

View file

@ -261,49 +261,72 @@ public ref struct RawInterpolatedStringHandler
} }
/// <summary>Writes the specified value to the handler.</summary> /// <summary>Writes the specified value to the handler.</summary>
/// <param name="value">The value to write.</param> /// <param name="value">The value to write.</param>
/// <param name="format">The format string.</param> /// <param name="format">
/// The format string. Pass <c>"L"</c> to lowercase the formatted hole's output
/// using <see cref="char.ToLowerInvariant(char)"/>; the underlying value's
/// <c>TryFormat</c>/<c>ToString</c> never sees <c>"L"</c>.
/// </param>
public void AppendFormatted<T>(T value, string? format) public void AppendFormatted<T>(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 there's a custom formatter, always use it.
if (_hasCustomFormatter) if (_hasCustomFormatter)
{ {
AppendCustomFormatter(value, format); 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 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
/// <summary>Writes the specified string of chars to the handler.</summary> /// <summary>Writes the specified string of chars to the handler.</summary>
/// <param name="value">The span to write.</param> /// <param name="value">The span to write.</param>
/// <param name="alignment">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.</param> /// <param name="alignment">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.</param>
/// <param name="format">The format string.</param> /// <param name="format">
/// The format string. Pass <c>"L"</c> to lowercase the value's chars (only the
/// value range, not any alignment padding) using <see cref="char.ToLowerInvariant(char)"/>.
/// </param>
public void AppendFormatted(ReadOnlySpan<char> value, int alignment = 0, string? format = null) public void AppendFormatted(ReadOnlySpan<char> value, int alignment = 0, string? format = null)
{ {
var lowercase = format == "L";
var leftAlign = false; var leftAlign = false;
if (alignment < 0) if (alignment < 0)
{ {
@ -369,7 +397,12 @@ public ref struct RawInterpolatedStringHandler
{ {
// The value is as large or larger than the required amount of padding, // The value is as large or larger than the required amount of padding,
// so just write the value. // so just write the value.
var startPos = _pos;
AppendFormatted(value); AppendFormatted(value);
if (lowercase)
{
LowercaseRange(_chars.Slice(startPos, _pos - startPos));
}
return; return;
} }
@ -377,8 +410,13 @@ public ref struct RawInterpolatedStringHandler
EnsureCapacityForAdditionalChars(value.Length + paddingRequired); EnsureCapacityForAdditionalChars(value.Length + paddingRequired);
if (leftAlign) if (leftAlign)
{ {
var valueStart = _pos;
value.CopyTo(_chars[_pos..]); value.CopyTo(_chars[_pos..]);
_pos += value.Length; _pos += value.Length;
if (lowercase)
{
LowercaseRange(_chars.Slice(valueStart, _pos - valueStart));
}
_chars.Slice(_pos, paddingRequired).Fill(' '); _chars.Slice(_pos, paddingRequired).Fill(' ');
_pos += paddingRequired; _pos += paddingRequired;
} }
@ -386,8 +424,13 @@ public ref struct RawInterpolatedStringHandler
{ {
_chars.Slice(_pos, paddingRequired).Fill(' '); _chars.Slice(_pos, paddingRequired).Fill(' ');
_pos += paddingRequired; _pos += paddingRequired;
var valueStart = _pos;
value.CopyTo(_chars[_pos..]); value.CopyTo(_chars[_pos..]);
_pos += value.Length; _pos += value.Length;
if (lowercase)
{
LowercaseRange(_chars.Slice(valueStart, _pos - valueStart));
}
} }
} }
#endregion #endregion
@ -522,6 +565,32 @@ public ref struct RawInterpolatedStringHandler
} }
} }
/// <summary>
/// Lowercases the chars in <see cref="_chars"/> in the half-open range <c>[start, end)</c>
/// using the BCL's vectorized <see cref="MemoryExtensions.ToLowerInvariant(ReadOnlySpan{char}, Span{char})"/>.
/// Copies through a stackalloc temp for ranges up to 256 chars, otherwise rents from
/// <see cref="STArrayPool{T}.Shared"/>. 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.
/// </summary>
private static void LowercaseRange(Span<char> dest)
{
if (dest.Length <= MinimumArrayPoolLength)
{
Span<char> temp = stackalloc char[MinimumArrayPoolLength];
var t = temp[..dest.Length];
dest.CopyTo(t);
((ReadOnlySpan<char>)t).ToLowerInvariant(dest);
return;
}
var rented = STArrayPool<char>.Shared.Rent(dest.Length);
var rentedSlice = rented.AsSpan(0, dest.Length);
dest.CopyTo(rentedSlice);
((ReadOnlySpan<char>)rentedSlice).ToLowerInvariant(dest);
STArrayPool<char>.Shared.Return(rented);
}
/// <summary>Ensures <see cref="_chars"/> has the capacity to store <paramref name="additionalChars"/> beyond <see cref="_pos"/>.</summary> /// <summary>Ensures <see cref="_chars"/> has the capacity to store <paramref name="additionalChars"/> beyond <see cref="_pos"/>.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private void EnsureCapacityForAdditionalChars(int additionalChars) private void EnsureCapacityForAdditionalChars(int additionalChars)