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

@ -261,49 +261,72 @@ public ref struct RawInterpolatedStringHandler
}
/// <summary>Writes the specified value to the handler.</summary>
/// <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)
{
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
/// <summary>Writes the specified string of chars to the handler.</summary>
/// <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="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)
{
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
}
}
/// <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>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void EnsureCapacityForAdditionalChars(int additionalChars)