docs(messages): document interpolation anti-patterns and :L format spec (#2441)

## Summary

Captures the durable learnings from the message-interpolation work (PRs #2434, #2436, #2437, #2438, #2440) as reference documentation. **Doc-only PR — no code changes.**

The original Phase 2 audit (PR #2435) was development scaffolding and was closed unmerged once Phase 3 consumed it. This PR replaces it with proper reference docs that future authors can consult.

## What's added

### `dev-docs/string-handling.md`
- Promote `RawInterpolatedStringHandler` from a one-line note to a proper section listing all APIs that accept it (messages, OPL, gumps, packets).
- Document the `:L` lowercase format specifier.
- New comprehensive **"Interpolation Anti-Patterns"** section covering 8 patterns with before/after examples — applies to any handler-aware API:
  1. Ternary with interpolated branches
  2. Switch expression with interpolated arms
  3. Pre-built local typed as `string`
  4. `.ToString()` (or any string-returning method) inside a hole
  5. String concatenation inside a hole
  6. `string.Format` feeding a handler-aware API
  7. LINQ-built strings inside a hole
  8. Pre-built concat var

### `dev-docs/networking-packets.md`
- Add **"Player-Facing Message APIs"** section listing `Mobile` / `Item` / `NetState` message methods with their handler overloads.
- Note the `IBroadcastFilter` pattern for new spatial-broadcast helpers.

### `dev-docs/property-lists.md`, `dev-docs/gump-system.md`
- Cross-reference the new anti-patterns section.
- Add explicit `.ToString()` inside holes warning to property-lists (it had no such guidance before).

### `dev-docs/claude-skills/`
- Mirror the same content (condensed) in `modernuo-string-handling.md`, `modernuo-networking.md`, `modernuo-property-lists.md`, `modernuo-gump-system.md`.
- Add audit rule #17 to `modernuo-code-audit.md` covering all 8 anti-patterns with severity WARNING, plus the `:L` format spec.

### `CLAUDE.md`
- Add audit rule #18 summarizing the interpolation anti-patterns + `:L`, pointing to `dev-docs/string-handling.md` for details.

## Why this matters

Before this PR there was no documentation explaining when an interpolated string call site silently allocates a string despite the receiving API providing a handler overload. The Phase 3 cleanup (PRs #2436/#2437/#2438) discovered ~28 such sites in the codebase; without these docs the same patterns would re-emerge. The new audit rule + CLAUDE.md entry will catch them at write time.
This commit is contained in:
Kamron Batman 2026-05-03 18:23:50 -07:00 committed by GitHub
parent 5f9fa88220
commit 9ea1b54758
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 431 additions and 6 deletions

View file

@ -5,9 +5,10 @@ This document covers the string building utilities in `Projects/Server/Text/` an
## Table of Contents
1. [ValueStringBuilder](#valuestringbuilder)
2. [RawInterpolatedStringHandler](#rawinterpolatedstringhandler)
3. [StringHelpers](#stringhelpers)
4. [TextEncoding](#textencoding)
5. [Decision Guide](#decision-guide)
3. [Interpolation Anti-Patterns](#interpolation-anti-patterns)
4. [StringHelpers](#stringhelpers)
5. [TextEncoding](#textencoding)
6. [Decision Guide](#decision-guide)
---
@ -122,7 +123,188 @@ For stackalloc-only builders that never grow, `Dispose()` is a no-op. But always
**Location**: `Projects/Server/Buffers/RawInterpolatedStringHandler.cs`
**Namespace**: `Server.Buffers`
A `[InterpolatedStringHandler]` ref struct used internally by `ValueStringBuilder`'s interpolation support. You should not need to use this directly — use `sb.Append($"...")` instead.
A `[InterpolatedStringHandler]` ref struct that renders an interpolated string directly into a `char[]` rented from `STArrayPool<char>.Shared`. Used as a parameter type to make zero-allocation interpolation overloads possible — when the caller writes `$"..."`, the compiler synthesizes the handler, fills it with the formatted chars, and the receiving method passes `handler.Text` to its underlying span path.
**You normally don't construct this directly**: it's used as a parameter type. Most ModernUO APIs that accept formatted text already provide a `ref RawInterpolatedStringHandler` overload alongside the `string` / `ReadOnlySpan<char>` overload. The compiler picks the handler overload automatically when the argument is a `$"..."` literal.
### APIs that accept `ref RawInterpolatedStringHandler`
- `SpanWriter.WriteAscii`, `WriteLatin1`, `Write(Encoding, …)` — packet building
- `Mobile.SendMessage`, `SendLocalizedMessage`, `SendAsciiMessage`, `Public/Local/Nonlocal/PrivateOverheadMessage`, `Say`, `Emote`, `Whisper`, `Yell` — player-facing chat
- `Item.PublicOverheadMessage`, `SendLocalizedMessageTo`, `SendMessageTo` — item-attributed messages
- `OutgoingMessagePackets.SendMessageLocalized`, `SendMessageLocalizedAffix`, `SendMessage` — direct NetState extensions
- `Html.Center`, `Html.Color`, `Html.Right` — gump HTML helpers
When in doubt, just write `$"..."` at the call site — if a handler overload exists, the compiler picks it.
### `:L` Lowercase Format Specifier
`RawInterpolatedStringHandler` recognizes `:L` as a custom format specifier that lowercases the value's output in-place using `char.ToLowerInvariant`. Handles surrogate pairs correctly via the BCL's vectorized `MemoryExtensions.ToLowerInvariant`.
```csharp
mob.SendMessage($"You earned a {rank:L} trophy!"); // "gold" instead of "Gold"
mob.SendMessage($"Welcome, {playerName:L}"); // lowercased name
mob.SendMessage($"{count:L} kills"); // ints unchanged ("42")
```
This eliminates the `value.ToString().ToLowerInvariant()` two-allocation idiom. Works for any type that goes through the handler (enums, strings, anything `ISpanFormattable`).
The format string is case-sensitive — `:l` is not recognized. Match the convention of standard format specifiers (`:N0`, `:F2`, etc.) and use uppercase `:L`.
### Pooled buffer lifecycle
`RawInterpolatedStringHandler` rents a `char[]` from `STArrayPool<char>.Shared` on construction (sized by the literal length + an estimate of formatted chars per hole). Methods that take `ref RawInterpolatedStringHandler` are responsible for calling `handler.Clear()` after consuming `handler.Text`, which returns the buffer to the pool. The rent is single-threaded and lock-free (~tens of nanoseconds), so the cost is negligible compared to the `string` allocation it replaces.
---
## Interpolation Anti-Patterns
ModernUO has many APIs with `ref RawInterpolatedStringHandler` overloads (messages, gumps, packets, OPL — see the list above). The handler overload is **only selected when the call-site argument is a `$"..."` literal directly in position**. Several patterns silently defeat handler binding and fall back to a `string`-allocating path. Each pattern below has a "before" and "after" — apply the "after" form when writing or reviewing code that interpolates into any handler-aware API.
### 1. Ternary with interpolated branches
```csharp
// BAD — the ternary unifies branches as `string`; handler overload not selected
mob.SendMessage(cond ? $"a {x}" : $"b {y}");
```
```csharp
// GOOD — each branch is a separate call, each binds to the handler overload
if (cond)
{
mob.SendMessage($"a {x}");
}
else
{
mob.SendMessage($"b {y}");
}
```
`RawInterpolatedStringHandler` is a `ref struct` and cannot appear in a conditional expression result type — the C# compiler unifies the ternary branches to `string`, and the call binds to the `string` / `ROS<char>` overload, allocating the message text per call.
### 2. Switch expression with interpolated arms
```csharp
// BAD — switch expression branches unify as `string`
mob.SendMessage(thing switch
{
1 => $"a {x}",
_ => $"b"
});
```
```csharp
// GOOD — switch statement, each arm calls the handler-aware API directly
switch (thing)
{
case 1:
mob.SendMessage($"a {x}");
break;
default:
mob.SendMessage($"b");
break;
}
```
Same root cause as the ternary case.
### 3. Pre-built local typed as `string`
```csharp
// BAD — `msg` is a `string`; ROS<char> overload picked, not the handler
var msg = $"foo {x}";
mob.SendMessage(msg);
```
```csharp
// GOOD — inline at the call site so the compiler sees the literal
mob.SendMessage($"foo {x}");
```
If the local is reused (multiple calls, multiple branches), keep the local — pre-building avoids re-interpolating per send. Inline only when the local is single-use.
### 4. `.ToString()` (or any string-returning method) inside a hole
```csharp
// BAD — .ToString() allocates a string before the handler copies the chars
mob.SendMessage($"You are now {accessLevel.ToString()}.");
mob.SendMessage($"Your guild is {td.String()}.");
```
```csharp
// GOOD — drop the .ToString() and let the handler format the value directly
mob.SendMessage($"You are now {accessLevel}.");
mob.SendMessage($"Your guild is {td}.");
```
The handler's `AppendFormatted<T>` calls `ISpanFormattable.TryFormat` on the value directly, with zero intermediate `string`. An explicit `.ToString()` defeats this. The same applies to `.String()` (TextDefinition), `.GetValue()`, `.AsHexString()`, and any other method that returns a freshly allocated `string`.
For values that don't implement `ISpanFormattable`, the handler falls back to `value.ToString()` internally — same allocation as the explicit call, but at least the call site is consistent.
For lowercase output, use the `:L` format specifier instead of `.ToString().ToLowerInvariant()` (see [RawInterpolatedStringHandler](#rawinterpolatedstringhandler)).
### 5. String concatenation inside a hole
```csharp
// BAD — `+` on strings allocates an intermediate string
mob.SendMessage($"Total: {a + b}");
mob.SendMessage($"Title: {string.Concat(prefix, name)}");
```
```csharp
// GOOD — multiple holes, each formatted directly into the buffer
mob.SendMessage($"Total: {a}{b}");
mob.SendMessage($"Title: {prefix}{name}");
```
Note: `int + int` inside a hole is arithmetic, not concatenation — that's fine. The anti-pattern is `string + string` or `string + value`.
### 6. `string.Format` feeding a handler-aware API
```csharp
// BAD — string.Format allocates a string the handler then re-buffers
mob.SendMessage(string.Format("You earned {0:N0} gold", amount));
```
```csharp
// GOOD — the handler formats `amount` directly into its buffer
mob.SendMessage($"You earned {amount:N0} gold");
```
### 7. LINQ-built strings inside a hole
```csharp
// BAD — Select/Aggregate/Join on strings allocates a chain of intermediates
mob.SendMessage($"Allies: {names.Aggregate((a, b) => $"{a}, {b}")}");
```
```csharp
// GOOD — build via ValueStringBuilder, pass the span
using var sb = new ValueStringBuilder(stackalloc char[256]);
sb.Append("Allies: ");
for (var i = 0; i < names.Count; i++)
{
if (i > 0)
{
sb.Append(", ");
}
sb.Append(names[i]);
}
mob.SendMessage(sb.AsSpan());
```
For unbounded inputs, use `ValueStringBuilder.Create()` and call `mob.SendMessage(sb.ToString())` if the consumer needs a `string` (one allocation, vs LINQ's many).
### 8. Pre-built concat var
```csharp
// BAD — concatenation allocates, then the local picks the ROS overload
var s = obj.Name + " says hi";
mob.SendMessage(s);
```
```csharp
// GOOD — interpolation literal at the call site
mob.SendMessage($"{obj.Name} says hi");
```
### Why these matter
These patterns aren't bugs — they produce correct output. But they each leak a `string` per call, and message/gump/OPL APIs are called constantly during gameplay. The handler overload exists specifically to eliminate that allocation, but only when the call-site argument is a direct `$"..."` literal.
When in doubt, ask: "is the handler overload selected here?" — and if the argument is anything other than a top-level `$"..."` literal in the parameter slot, the answer is no.
---