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:
parent
9ea1b54758
commit
679e66b99d
2 changed files with 196 additions and 32 deletions
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue