diff --git a/CHANGELOG.md b/CHANGELOG.md index a1e1ee3f..fb51308f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to **ValueStringBuilder** will be documented in this file. T ## [Unreleased] +### Added + +- `FixedSizeValueStringBuilder`: a non-growing `ref struct` string builder backed by a caller-supplied buffer that never allocates on the heap. Appends are atomic and the first one that does not fit latches `Overflowed`, which `ClearOverflow` resets. +- `FixedSizeValueStringBuilder.MoveToValueStringBuilder`: hands the buffer and its content over to a `ValueStringBuilder` which can grow beyond the fixed capacity. The move copies nothing and rents nothing, and consumes the source so both builders can never write into the same memory. + ## [3.6.1] - 2026-09-12 ### Changed diff --git a/README.md b/README.md index e597867e..1936f0b1 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,23 @@ using ValueStringBuilder stringBuilder = new(stackalloc char[128]); ``` Note that this will prevent you from returning `stringBuilder` or assigning it to an `out` parameter. +### A buffer that is never replaced: `FixedSizeValueStringBuilder` + +If the content *outgrows* that stack buffer, `ValueStringBuilder` quietly rents a larger one from `ArrayPool.Shared`. +When you need a hard guarantee that this never happens, use `FixedSizeValueStringBuilder`: +```csharp +var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + +builder.Append("123456789"); // does not fit -> nothing is written + +string result = builder.ToString(); // "" - never a truncated "12345678" +bool overflowed = builder.Overflowed; // true +``` +Appends are atomic: one either fits completely or is dropped, so a formatted number or a surrogate pair is never cut in +half. The first drop latches `Overflowed`, and further appends are ignored until you call `ClearOverflow()` to carry on +deliberately or `Clear()` to start over. There is no `Dispose` - nothing is ever rented. +See the [documentation](https://linkdotnet.github.io/StringBuilder/articles/fixed_size.html) for details. + ## What does it solve? The dotnet version of the `StringBuilder` is an all-purpose version that normally fits a wide variety of needs. But sometimes, low allocation is key. Therefore I created the `ValueStringBuilder`. It is not a class but a `ref struct` that tries to allocate as little as possible. diff --git a/docs/site/articles/best_practices.md b/docs/site/articles/best_practices.md index 7adb50c0..2e37019d 100644 --- a/docs/site/articles/best_practices.md +++ b/docs/site/articles/best_practices.md @@ -40,6 +40,33 @@ using var stringBuilder = new ValueStringBuilder(buffer); You should only skip `using` when you can prove the builder will never grow. +## Reach for `FixedSizeValueStringBuilder` when allocation is not an option + +The previous rule is a judgement call you have to get right yourself. If instead you need the compiler and the type to +enforce it, use [`FixedSizeValueStringBuilder`](xref:fixed_size): it has no pool fallback, so there is nothing to +dispose and no way for it to allocate. + +```csharp +var stringBuilder = new FixedSizeValueStringBuilder(stackalloc char[64]); +``` + +Two things behave differently from `ValueStringBuilder`, and both are deliberate: + +* An append that does not fit writes **nothing at all** - a formatted value is never truncated into a different, + valid-looking value. +* After the first such append, `Overflowed` is set and every further append is ignored, even one that would still fit. + Call `ClearOverflow()` to carry on anyway, or `Clear()` to start over. + +**Always check `Overflowed` before you trust the result**, and decide there whether to fall back to a growing builder: + +```csharp +var stringBuilder = new FixedSizeValueStringBuilder(stackalloc char[64]); +stringBuilder.Append(prefix); +stringBuilder.Append(id); + +return stringBuilder.Overflowed ? BuildWithValueStringBuilder() : stringBuilder.ToString(); +``` + ## Prefer `new ValueStringBuilder(capacity)` for predictable medium-sized output If you can estimate the final size but don't want stack-only restrictions, use the capacity constructor: diff --git a/docs/site/articles/comparison.md b/docs/site/articles/comparison.md index eb185de7..df11577c 100644 --- a/docs/site/articles/comparison.md +++ b/docs/site/articles/comparison.md @@ -166,4 +166,48 @@ about 30% of `StringBuilder` for every multi-match case while allocating roughly optimized and previous algorithms perform about the same, since the optimization mainly pays off once there are several matches to batch together. +## Fixed-size string building + +[`FixedSizeValueStringBuilder`](xref:fixed_size) drops the array-pool fallback entirely, which also removes the +capacity check and the rented-buffer field from every append. The first four rows below build the same +`"Hello World1337"`; the last two use an 8-character buffer that is deliberately too small. + +```no-class +BenchmarkDotNet v0.15.8, macOS 27.0 (26A428) [Darwin 27.0.0] +Apple M2 Pro, 1 CPU, 12 logical and 12 physical cores +.NET SDK 11.0.100-rc.1.26425.128 + [Host] : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a + DefaultJob : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a +``` + +| Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated | Alloc Ratio | +|---------------------------------------- |----------:|----------:|----------:|------:|-------:|----------:|------------:| +| StringBuilderFits | 17.756 ns | 0.3315 ns | 0.2939 ns | 1.00 | 0.0191 | 160 B | 1.00 | +| ValueStringBuilderFits | 14.032 ns | 0.2064 ns | 0.1930 ns | 0.79 | 0.0067 | 56 B | 0.35 | +| ValueStringBuilderFitsWithoutGrowing | 8.410 ns | 0.2035 ns | 0.2499 ns | 0.47 | 0.0067 | 56 B | 0.35 | +| FixedSizeValueStringBuilderFits | 7.214 ns | 0.0424 ns | 0.0376 ns | 0.41 | 0.0067 | 56 B | 0.35 | +| FixedSizeValueStringBuilderInterpolated | 7.280 ns | 0.0387 ns | 0.0362 ns | 0.41 | 0.0067 | 56 B | 0.35 | +| ValueStringBuilderOverflows | 18.475 ns | 0.0570 ns | 0.0533 ns | 1.04 | 0.0067 | 56 B | 0.35 | +| FixedSizeValueStringBuilderOverflows | 1.190 ns | 0.0101 ns | 0.0089 ns | 0.07 | - | - | 0.00 | + +Read the two `ValueStringBuilder` "Fits" rows together, because the difference between them is not the fixed-size +builder's doing. Both use `stackalloc`, but the second row gets 64 characters and the first only 32. +`ValueStringBuilder.Append` reserves `bufferSize` (36 by default) characters *before* formatting, so with a +32-character buffer appending the `int` grows the builder even though the finished string is 15 characters long - it +rents 64 chars from `ArrayPool.Shared`, copies, and returns them on `Dispose`. That round trip, not the capacity +check, is most of the 14.0 ns. + +Against the row that does not grow, the honest margin is the fourth one: about 15% for identical work, which is the +`Dispose` call, the pool field, and the per-append capacity check. The 56 B that every "Fits" row allocates is the +returned `string` itself, which no builder can avoid. + +The interpolated row matches the manual one byte for byte, which is the point of measuring it: the interpolated string +handler formats value-type holes without boxing them, so `$"{Text} {Id}"` costs no more than appending the two parts by +hand. + +The last row is *not* the same work done faster. At 1.2 ns and 0 B it is the cost of the latch short-circuiting +everything, because nothing was written and `ToString()` returned `string.Empty`. It is included to show the bounded +worst case: overflowing a `FixedSizeValueStringBuilder` costs nothing and touches no pool, whereas the row above it +shows `ValueStringBuilder` renting, copying, and returning a larger buffer. + Checkout the [Benchmark](https://github.com/linkdotnet/StringBuilder/tree/main/tests/LinkDotNet.StringBuilder.Benchmarks) for more detailed comparison and setup. \ No newline at end of file diff --git a/docs/site/articles/exceptions_and_edge_cases.md b/docs/site/articles/exceptions_and_edge_cases.md index a3e703d9..0827315b 100644 --- a/docs/site/articles/exceptions_and_edge_cases.md +++ b/docs/site/articles/exceptions_and_edge_cases.md @@ -30,4 +30,20 @@ A few operations are intentionally lenient: `EnsureCapacity(int newCapacity)` is a no-op if the current `Capacity` already satisfies `newCapacity`. Otherwise it rents a new array sized to the **smallest power of two that is `>= newCapacity`**, copies the existing content over, and returns the previous pooled array (if any) to `ArrayPool.Shared`. This means capacity can grow in large jumps (e.g. requesting one more character than a full 64-character buffer rents a 128-character array), which is a deliberate trade-off to keep the number of pool rents low - see [How does it work?](xref:concepts) for the broader buffer strategy. +## Running out of room in `FixedSizeValueStringBuilder` + +[`FixedSizeValueStringBuilder`](xref:fixed_size) has a hard capacity and no pool fallback, yet still throws nothing +when you exceed it. An append that does not fit is dropped whole, `Overflowed` is set, and every further append becomes +a no-op until `ClearOverflow()` or `Clear()` is called. + +Two consequences are worth knowing before they surprise you: + +* **`Remaining` can be greater than zero while `Overflowed` is `true`.** That is expected, not a bug - the latch, not + the free space, decides whether anything more is written. +* **An append that would comfortably fit is still dropped** once the builder has overflowed. This keeps the content a + valid prefix of what you intended instead of a string with a hole in the middle. + +Reading members never throw either: `AsSpan()`, `ToString()` and the indexer all see only the characters that were +actually written, and `TryCopyTo` returns `false` rather than throwing when the destination is too small. + For more on `Dispose()` behavior around the pooled array, including what happens on double-dispose, see [Known limitations](xref:known_limitations#dispose-guarantees). diff --git a/docs/site/articles/fixed_size.md b/docs/site/articles/fixed_size.md new file mode 100644 index 00000000..e168f79d --- /dev/null +++ b/docs/site/articles/fixed_size.md @@ -0,0 +1,262 @@ +--- +uid: fixed_size +--- + +# Fixed-size string building + +[`FixedSizeValueStringBuilder`](xref:LinkDotNet.StringBuilder.FixedSizeValueStringBuilder) is a `ref struct` backed by a +fixed, caller-supplied buffer. It never grows or rents a replacement buffer. Formatting arbitrary custom values and +converting nonempty content to a `string` can still allocate on the heap. + +```csharp +var builder = new FixedSizeValueStringBuilder(stackalloc char[32]); +builder.Append("Hello World"); +return builder.ToString(); +``` + +## When to use it + +| Scenario | Type | +|---|---| +| Unknown or large output | [`ValueStringBuilder`](xref:LinkDotNet.StringBuilder.ValueStringBuilder) | +| Small output, you *expect* it to fit but growing is acceptable | `ValueStringBuilder(stackalloc char[N])` | +| Hard upper bound, the buffer must never be replaced | `FixedSizeValueStringBuilder(stackalloc char[N])` | + +`new ValueStringBuilder(stackalloc char[128])` already avoids allocation *while the content fits*. The moment it +doesn't, it rents a larger buffer from `ArrayPool.Shared` and copies into it - silently, and with nothing to tell +you afterwards that it happened. `FixedSizeValueStringBuilder` removes that fallback: the buffer you hand it is the +whole story. + +Note the deliberate absence of `Dispose`. Nothing is ever rented, so there is nothing to return, and +`using var builder = new FixedSizeValueStringBuilder(...)` will not compile. That is the intended signal. + +## The two rules + +Everything about this type follows from two rules: + +1. **Every append is atomic.** It either fits entirely or writes nothing at all. +2. **Overflow latches.** The first append that does not fit sets + [`Overflowed`](xref:LinkDotNet.StringBuilder.FixedSizeValueStringBuilder.Overflowed*), and every further append is a + no-op. + +```csharp +var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + +builder.Append("123456789"); + +builder.ToString(); // "" - empty, not "12345678" +builder.Overflowed; // true +``` + +Rule 1 is why the result is empty rather than truncated. Truncation would be far more dangerous than it looks: the +buffer above would have held `"12345678"`, and had you appended the *number* `123456789` you would have ended up with a +different number that looks completely valid. Formatted values, and surrogate pairs, must not be cut in half. The same +reasoning is why `ISpanFormattable.TryFormat`, `Span.TryCopyTo` and `MemoryExtensions.TryWrite` in .NET itself are +all-or-nothing. + +## Why a later append can be dropped + +This is the part that surprises people, so it is worth stating plainly: + +```csharp +var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + +builder.Append("1234"); // fits +builder.Append("56789"); // does not fit -> dropped, and latches +builder.Append("!"); // WOULD fit, but is dropped as well + +builder.ToString(); // "1234" +builder.Overflowed; // true +builder.Remaining; // 4 - there is room, but nothing more will be written +``` + +`Remaining` being greater than zero while `Overflowed` is `true` is expected, not a bug. + +The reason is rule 2. Without it, the last line would produce `"1234!"` - a string that reads as though `"56789"` was +never part of your code at all. With the latch, whatever you get back is always a *prefix* of what you intended to +build. A prefix can be recognised as incomplete; a scrambled string cannot. + +## Carrying on anyway + +The latch is a default, not a cage. Call +[`ClearOverflow`](xref:LinkDotNet.StringBuilder.FixedSizeValueStringBuilder.ClearOverflow*) to keep going with whatever +room is left: + +```csharp +var builder = new FixedSizeValueStringBuilder(stackalloc char[16]); +var truncated = false; + +builder.Append("name="); +builder.Append(veryLongName); + +if (builder.Overflowed) +{ + truncated = true; + builder.ClearOverflow(); // deliberate: skip this field, keep building +} + +builder.Append(" id="); +builder.Append(id); + +return (builder.ToString(), truncated); +``` + +`ClearOverflow` resets the flag and keeps the content. Read `Overflowed` *before* calling it if you need to know +whether anything was actually dropped. [`Clear`](xref:LinkDotNet.StringBuilder.FixedSizeValueStringBuilder.Clear*) +resets the flag *and* the content, so the same buffer can be reused from the start: + +```csharp +Span buffer = stackalloc char[32]; +var builder = new FixedSizeValueStringBuilder(buffer); + +builder.Append("First"); +var first = builder.ToString(); + +builder.Clear(); + +builder.Append("Second"); +var second = builder.ToString(); +``` + +## Checking the result + +Always check `Overflowed` before trusting the output: + +```csharp +var builder = new FixedSizeValueStringBuilder(stackalloc char[64]); +builder.Append("id="); +builder.Append(userId); +builder.Append(" ts="); +builder.Append(timestamp, "O"); + +if (builder.Overflowed) +{ + return BuildWithoutLimit(); // fall back to ValueStringBuilder +} + +return builder.ToString(); +``` + +There is no exception anywhere on this path. A buffer that is too small is an ordinary, expected outcome which you +handle with a branch, not a `catch`. + +## Interpolated strings + +Interpolated strings work as you would expect: + +```csharp +var builder = new FixedSizeValueStringBuilder(stackalloc char[32]); +builder.Append($"user {userId} at {timestamp:O}"); +``` + +Atomicity applies **per literal and per hole**, not to the interpolated string as a whole. The first part that does not +fit latches and the remaining parts are skipped, so the content is still a valid prefix: + +```csharp +var builder = new FixedSizeValueStringBuilder(stackalloc char[5]); + +builder.Append($"ab{42}cd"); + +builder.ToString(); // "ab42" - "cd" no longer fit +builder.Overflowed; // true +``` + +If the literal parts alone already exceed the buffer, nothing is written at all and the whole interpolation is skipped. + +## Formatting values + +Any `ISpanFormattable` can be appended, with an optional format string and format provider: + +```csharp +var builder = new FixedSizeValueStringBuilder(stackalloc char[32]); + +builder.Append(3.14159f, "F2"); +builder.Append(' '); +builder.Append(DateTime.UtcNow, "yyyy-MM-dd"); +``` + +Unlike [`ValueStringBuilder.Append`](xref:LinkDotNet.StringBuilder.ValueStringBuilder.Append*) there is no +`bufferSize` parameter. The value is formatted straight into the remaining space; if it does not fit, the append is +dropped. No intermediate buffer is needed, which is one of the places where the fixed-size builder is simply cheaper. + +## Growing out of the fixed buffer + +Sometimes a hard limit is right for the common case but you still need a fallback for the rare oversized one. +[`MoveToValueStringBuilder`](xref:LinkDotNet.StringBuilder.FixedSizeValueStringBuilder.MoveToValueStringBuilder*) hands +the buffer *and* its content over to a [`ValueStringBuilder`](xref:LinkDotNet.StringBuilder.ValueStringBuilder), which +can grow: + +```csharp +var builder = new FixedSizeValueStringBuilder(stackalloc char[64]); +builder.Append("id="); +builder.Append(userId); + +if (builder.Remaining < worstCaseTail) +{ + using var grown = builder.MoveToValueStringBuilder(); + grown.Append(tail); + return grown.ToString(); +} + +builder.Append(tail); +return builder.ToString(); +``` + +Nothing is copied and nothing is rented: the `ValueStringBuilder` starts out pointing at the very same stack buffer +with `Length` already set, so the move itself costs nothing. It only rents from the array pool once you exceed the +buffer, exactly as it would have anyway - which is why the result must be disposed. + +Both builders would otherwise write into the same memory, so the move **consumes the source**. What is left behind is +an empty builder with `Capacity` of zero and `Overflowed` set to `true`. Reading it is safe and any further append is +a no-op, so a stale use cannot corrupt the buffer its new owner is writing into: + +```csharp +var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); +builder.Append("1234"); + +using var grown = builder.MoveToValueStringBuilder(); + +builder.Append("XYZ"); // dropped - the buffer is not his anymore +grown.ToString(); // "1234" +``` + +That reset can only neutralize the *one variable* it is called on. `FixedSizeValueStringBuilder` is a struct and the +`Span` it was handed is copyable, so **the caller has to own the buffer uniquely at the point of the move**: + +```csharp +Span buffer = stackalloc char[16]; +var builder = new FixedSizeValueStringBuilder(buffer); +builder.Append("hello"); + +using var grown = builder.MoveToValueStringBuilder(); + +buffer[0] = 'X'; // still aliases the same memory +grown.ToString(); // "Xello" - corrupted +``` + +The same goes for a struct copy taken before the move. Neither the original span nor such a copy may be written to +afterwards; the compiler cannot detect it, so the discipline is yours. If you cannot guarantee that, copy the content +out with `ToString` or `TryCopyTo` instead of moving it. + +Moving an *overflowed* builder throws an `InvalidOperationException`. The content is an incomplete prefix and +`ValueStringBuilder` has nowhere to carry that fact, so this is the last point at which the truncation can be caught. +If it was deliberate, call `ClearOverflow` first - that is what it is for. + +## Available members + +The type deliberately carries a smaller surface than `ValueStringBuilder`: + +* `Append` for `char`, `string`, `ReadOnlySpan`, `bool`, `Rune`, any `ISpanFormattable` and interpolated strings + (including alignment holes such as `$"{value,10}"`, where value and padding are written together or not at all) +* `AppendLine`, which writes the text and the newline together or not at all +* `Clear`, `ClearOverflow` +* `MoveToValueStringBuilder` to continue in a growable builder +* `Length`, `Capacity`, `Remaining`, `IsEmpty`, `Overflowed`, and an indexer +* `AsSpan`, `TryCopyTo`, `ToString` + +`Insert`, `Replace`, `Trim`, `Pad`, `AppendJoin` and `AppendFormat` are not available - against a hard capacity limit +each of them needs its own answer to "what happens when it does not fit". Use `ValueStringBuilder` when you need them. + +## Performance + +See the [comparison](xref:comparison) article for the numbers. diff --git a/docs/site/articles/known_limitations.md b/docs/site/articles/known_limitations.md index f69fbf38..2f39b38d 100644 --- a/docs/site/articles/known_limitations.md +++ b/docs/site/articles/known_limitations.md @@ -39,6 +39,8 @@ return stringBuilder.ToString(); See the [advanced usage](xref:advanced_usage) article for more on `stackalloc`-backed buffers, including what happens if the content outgrows them. +If you need that guarantee enforced rather than assumed, use [`FixedSizeValueStringBuilder`](xref:fixed_size) instead. It has no array-pool fallback at all, so there is nothing to dispose. The trade-off is that content which does not fit is dropped rather than accommodated. + ## `Dispose()` guarantees `Dispose()` returns the rented array to `ArrayPool.Shared` (only if one was actually rented - a builder that never grew beyond its `stackalloc` buffer has nothing to return) and then resets the instance to its default value (`Length` and `Capacity` become `0`). diff --git a/docs/site/articles/toc.yml b/docs/site/articles/toc.yml index aa97b922..289e7686 100644 --- a/docs/site/articles/toc.yml +++ b/docs/site/articles/toc.yml @@ -9,6 +9,8 @@ href: pass_to_method.md - name: Trimming href: trimming.md + - name: Fixed-size string building + href: fixed_size.md - name: Advanced topics items: - name: Advanced usage diff --git a/src/LinkDotNet.StringBuilder/FixedSizeValueStringBuilder.InterpolatedStringHandler.cs b/src/LinkDotNet.StringBuilder/FixedSizeValueStringBuilder.InterpolatedStringHandler.cs new file mode 100644 index 00000000..bac16671 --- /dev/null +++ b/src/LinkDotNet.StringBuilder/FixedSizeValueStringBuilder.InterpolatedStringHandler.cs @@ -0,0 +1,181 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace LinkDotNet.StringBuilder; + +public ref partial struct FixedSizeValueStringBuilder +{ + /// + /// Appends an interpolated string to the builder. + /// + /// The interpolated string handler. + /// + /// Atomicity applies per literal and per hole, not to the interpolated string as a whole. The first part which + /// does not fit sets and the remaining parts are skipped, so the content stays a valid + /// prefix of the interpolated string. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append([InterpolatedStringHandlerArgument("")] ref AppendInterpolatedStringHandler handler) + { + this = handler.Builder; + } + + /// + /// Appends an interpolated string followed by to the builder. + /// + /// The interpolated string handler. + /// + /// Atomicity applies per literal and per hole, not to the interpolated string as a whole. The first part which + /// does not fit sets and the remaining parts - including the new line - are skipped. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLine([InterpolatedStringHandlerArgument("")] ref AppendInterpolatedStringHandler handler) + { + this = handler.Builder; + AppendLine(); + } + + /// + /// Nested struct which handles interpolated strings for . + /// + [EditorBrowsable(EditorBrowsableState.Never)] + [InterpolatedStringHandler] + public ref struct AppendInterpolatedStringHandler + { + internal FixedSizeValueStringBuilder Builder; + + /// + /// Initializes a new instance of the struct. + /// + /// The length of the literal part of the interpolated string. + /// The number of formatted segments in the interpolated string. + /// The builder to append to. + /// Set to when the literals alone already do not fit, in + /// which case the compiler skips the whole interpolation. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public AppendInterpolatedStringHandler( + int literalLength, + int formattedCount, + FixedSizeValueStringBuilder builder, + out bool shouldAppend) + { + _ = formattedCount; + Builder = builder; + + if (Builder.overflowed) + { + shouldAppend = false; + return; + } + + if (literalLength > Builder.Remaining) + { + Builder.overflowed = true; + shouldAppend = false; + return; + } + + shouldAppend = true; + } + + /// + /// Appends a literal string to the handler. + /// + /// The literal string. + /// if it fit; otherwise, , which makes the compiler + /// skip the rest of the interpolated string. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool AppendLiteral(string value) => Builder.TryAppend(value.AsSpan()); + + /// + /// Appends a formatted value to the handler. + /// + /// The value to format. + /// The type of the value. + /// if it fit; otherwise, , which makes the compiler + /// skip the rest of the interpolated string. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool AppendFormatted(T value) => Builder.TryAppendFormatted(value, default); + + /// + /// Appends a formatted value to the handler. + /// + /// The value to format. + /// The format string. + /// The type of the value. + /// if it fit; otherwise, , which makes the compiler + /// skip the rest of the interpolated string. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool AppendFormatted(T value, string? format) => Builder.TryAppendFormatted(value, format.AsSpan()); + + /// + /// Appends a value padded to the given alignment. + /// + /// The value to format. + /// Minimum width. Positive right-aligns the value, negative left-aligns it. + /// The type of the value. + /// if it fit; otherwise, , which makes the compiler + /// skip the rest of the interpolated string. + /// Value and padding are written together or not at all. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool AppendFormatted(T value, int alignment) => Builder.TryAppendFormatted(value, default, alignment); + + /// + /// Appends a formatted value padded to the given alignment. + /// + /// The value to format. + /// Minimum width. Positive right-aligns the value, negative left-aligns it. + /// The format string. + /// The type of the value. + /// if it fit; otherwise, , which makes the compiler + /// skip the rest of the interpolated string. + /// Value and padding are written together or not at all. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool AppendFormatted(T value, int alignment, string? format) + => Builder.TryAppendFormatted(value, format.AsSpan(), alignment); + + /// + /// Appends a span to the handler. + /// + /// The span to append. + /// if it fit; otherwise, , which makes the compiler + /// skip the rest of the interpolated string. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool AppendFormatted(scoped ReadOnlySpan value) => Builder.TryAppend(value); + + /// + /// Appends a span padded to the given alignment. + /// + /// The span to append. + /// Minimum width. Positive right-aligns the value, negative left-aligns it. + /// Ignored - a span has no format. + /// if it fit; otherwise, , which makes the compiler + /// skip the rest of the interpolated string. + /// Value and padding are written together or not at all. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool AppendFormatted(scoped ReadOnlySpan value, int alignment, string? format = null) + => Builder.TryAppend(value, alignment); + + /// + /// Appends a string to the handler. + /// + /// The string to append. + /// if it fit; otherwise, , which makes the compiler + /// skip the rest of the interpolated string. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool AppendFormatted(string? value) => Builder.TryAppend(value.AsSpan()); + + /// + /// Appends a string padded to the given alignment. + /// + /// The string to append. + /// Minimum width. Positive right-aligns the value, negative left-aligns it. + /// Ignored - a string has no format. + /// if it fit; otherwise, , which makes the compiler + /// skip the rest of the interpolated string. + /// Value and padding are written together or not at all. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool AppendFormatted(string? value, int alignment, string? format = null) + => Builder.TryAppend(value.AsSpan(), alignment); + } +} diff --git a/src/LinkDotNet.StringBuilder/FixedSizeValueStringBuilder.KnownTypes.cs b/src/LinkDotNet.StringBuilder/FixedSizeValueStringBuilder.KnownTypes.cs new file mode 100644 index 00000000..5bd0827f --- /dev/null +++ b/src/LinkDotNet.StringBuilder/FixedSizeValueStringBuilder.KnownTypes.cs @@ -0,0 +1,126 @@ +using System.Runtime.CompilerServices; + +namespace LinkDotNet.StringBuilder; + +public ref partial struct FixedSizeValueStringBuilder +{ + /// + /// Appends directly for a fixed set of well known value types without boxing it. + /// + /// The value to append. + /// Optional formatter. + /// Whether the value fit into the remaining buffer. Only meaningful if this method + /// returns . + /// if is one of the well known types and was handled. + /// + /// Casting an unconstrained generic value to an interface (like ) boxes it. For the + /// handful of value types that are used the vast majority of the time, we instead reinterpret the bits of + /// via and dispatch to the constrained, + /// boxing-free overload. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryAppendKnownSpanFormattable(T value, scoped ReadOnlySpan format, out bool appended) => + TryAppendKnownIntegralType(value, format, out appended) || TryAppendKnownOtherType(value, format, out appended); + + private bool TryAppendKnownIntegralType(T value, scoped ReadOnlySpan format, out bool appended) + { + if (typeof(T) == typeof(bool)) + { + appended = TryAppend(Unsafe.As(ref value) ? bool.TrueString : bool.FalseString); + } + else if (typeof(T) == typeof(char)) + { + appended = TryAppend(Unsafe.As(ref value)); + } + else if (typeof(T) == typeof(byte)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else if (typeof(T) == typeof(sbyte)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else if (typeof(T) == typeof(short)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else if (typeof(T) == typeof(ushort)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else if (typeof(T) == typeof(int)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else if (typeof(T) == typeof(uint)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else if (typeof(T) == typeof(long)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else if (typeof(T) == typeof(ulong)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else if (typeof(T) == typeof(Int128)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else if (typeof(T) == typeof(UInt128)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else + { + appended = false; + return false; + } + + return true; + } + + private bool TryAppendKnownOtherType(T value, scoped ReadOnlySpan format, out bool appended) + { + if (typeof(T) == typeof(float)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else if (typeof(T) == typeof(double)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else if (typeof(T) == typeof(decimal)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else if (typeof(T) == typeof(DateTime)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else if (typeof(T) == typeof(DateTimeOffset)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else if (typeof(T) == typeof(TimeSpan)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else if (typeof(T) == typeof(Guid)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else if (typeof(T) == typeof(Half)) + { + appended = TryAppend(Unsafe.As(ref value), format, null); + } + else + { + appended = false; + return false; + } + + return true; + } +} diff --git a/src/LinkDotNet.StringBuilder/FixedSizeValueStringBuilder.cs b/src/LinkDotNet.StringBuilder/FixedSizeValueStringBuilder.cs new file mode 100644 index 00000000..9531559e --- /dev/null +++ b/src/LinkDotNet.StringBuilder/FixedSizeValueStringBuilder.cs @@ -0,0 +1,459 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; + +namespace LinkDotNet.StringBuilder; + +/// +/// A string builder backed by a fixed-size, caller-supplied buffer which never grows and never rents a replacement +/// buffer. Formatting arbitrary custom values and converting nonempty content to a can still +/// allocate on the heap. +/// +/// +/// This is a ref struct which has certain limitations. You can only store it in a local variable or another ref struct. +///

+/// Unlike , this type never rents from an array pool. Appending follows two rules: +/// +/// Every append is atomic - it either fits entirely or writes nothing at all. +/// The first append that does not fit sets , after which every further +/// append is a no-op. The content is therefore always a valid prefix of what was intended. +/// +/// Because of the second rule a later, smaller append is dropped even when it would still fit. Call +/// to deliberately carry on regardless. +///

+/// There is no implementation: nothing is ever rented, so there is nothing to return. +/// +/// var builder = new FixedSizeValueStringBuilder(stackalloc char[32]); +/// builder.Append("Hello World"); +/// var result = builder.ToString(); +/// +///
+[StructLayout(LayoutKind.Sequential)] +public ref partial struct FixedSizeValueStringBuilder +{ + private Span buffer; + private int bufferPosition; + private bool overflowed; + + /// + /// Initializes a new instance of the struct. + /// + /// The buffer to write into. It is never replaced or resized, so its length is the hard + /// upper bound for the content. Typically stack-allocated via stackalloc. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public FixedSizeValueStringBuilder(Span buffer) => this.buffer = buffer; + + /// + /// Gets the number of characters written so far. + /// + /// + /// The number of characters written so far. + /// + public readonly int Length + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => bufferPosition; + } + + /// + /// Gets the length of the buffer this instance was created with. + /// + /// + /// The length of the buffer this instance was created with. + /// + public readonly int Capacity + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => buffer.Length; + } + + /// + /// Gets the number of characters which still fit into the buffer. + /// + /// + /// The number of characters which still fit into the buffer. This can be greater than zero while + /// is , in which case nothing more will be written until + /// or is called. + /// + public readonly int Remaining + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => buffer.Length - bufferPosition; + } + + /// + /// Gets a value indicating whether nothing has been written yet. + /// + /// + /// if nothing has been written yet; otherwise, . + /// + public readonly bool IsEmpty + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => bufferPosition == 0; + } + + /// + /// Gets a value indicating whether an append did not fit and was therefore dropped. + /// + /// + /// if an append was dropped; otherwise, . Once set, every further + /// append is a no-op until or is called. + /// + public readonly bool Overflowed + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => overflowed; + } + + /// + /// Returns the character at the given index. + /// + /// Character position to retrieve. + /// Thrown when is negative or not smaller + /// than . Only characters which were actually written are addressable, never the unwritten + /// remainder of the buffer. + public readonly ref char this[int index] + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => ref buffer[..bufferPosition][index]; + } + + /// + /// Appends a string. Dropped if it does not fit completely. + /// + /// String to be added to this builder. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(scoped ReadOnlySpan str) => TryAppend(str); + + /// + /// Appends a string. Dropped if it does not fit completely. + /// + /// The string to be added to this builder. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(string? value) => TryAppend(value.AsSpan()); + + /// + /// Appends a single character. Dropped if the buffer has no room left. + /// + /// Character to add. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(char value) => TryAppend(value); + + /// + /// Appends the string representation of a boolean. Dropped if it does not fit completely. + /// + /// Bool value to add. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(bool value) => TryAppend(value ? bool.TrueString : bool.FalseString); + + /// + /// Appends a single rune. Dropped if it does not fit completely, so a surrogate pair is never split. + /// + /// Rune to add. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(Rune value) => TryAppend(value); + + /// + /// Appends the string representation of the value. Dropped if it does not fit completely, so a formatted value is + /// never written out half-way. + /// + /// Formattable span to add. + /// Optional formatter. If not provided the default of the given instance is taken. + /// Optional format provider. + /// Any . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(T value, scoped ReadOnlySpan format = default, IFormatProvider? formatProvider = null) + where T : ISpanFormattable => TryAppend(value, format, formatProvider); + + /// + /// Appends . Dropped if it does not fit completely. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLine() => TryAppend(Environment.NewLine); + + /// + /// Appends a string followed by . Both are dropped together unless both fit. + /// + /// String to be added to this builder. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLine(scoped ReadOnlySpan str) => TryAppendLine(str); + + /// + /// Appends a string followed by . Both are dropped together unless both fit. + /// + /// The string to be added to this builder. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLine(string? value) => TryAppendLine(value.AsSpan()); + + /// + /// Discards the written content and resets so the buffer can be reused. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Clear() + { + bufferPosition = 0; + overflowed = false; + } + + /// + /// Resets while keeping the written content, so appending continues into whatever room is + /// left. + /// + /// + /// Read before calling this if you need to know whether anything was actually dropped. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void ClearOverflow() => overflowed = false; + + /// + /// Returns the written content as a . + /// + /// The written content as a . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly ReadOnlySpan AsSpan() => buffer[..bufferPosition]; + + /// + /// Tries to copy the written content into the given . + /// + /// The destination to copy the content into. + /// if the copy succeeded; otherwise, . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly bool TryCopyTo(Span destination) => buffer[..bufferPosition].TryCopyTo(destination); + + /// + /// Creates a instance from the written content. + /// + /// The instance. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public readonly override string ToString() => AsSpan().ToString(); + + /// + /// Hands the buffer and its content over to a which can grow beyond the fixed + /// capacity, and consumes this instance. + /// + /// + /// A continuing where this instance left off. Nothing is copied and nothing is + /// rented, so the move itself never allocates. Dispose the result as usual. + /// + /// + /// is . The content is an incomplete prefix and + /// has nowhere to carry that information. Call first + /// if the truncation was intended. + /// + /// + /// + /// Both builders would otherwise write into the same memory, so this instance is left consumed: an empty builder + /// with zero capacity whose is . Reading it is safe, and any + /// further append is a no-op rather than a write into a buffer somebody else now owns. + /// + /// + /// Consuming this instance can only neutralize this one variable. The caller must own the buffer uniquely at the + /// point of the move: neither the passed to the constructor nor any struct copy taken before + /// the move may be written to afterwards. Both still alias the same memory and would corrupt the content of the + /// returned - the compiler cannot detect it, so the discipline is yours. + /// + /// + public ValueStringBuilder MoveToValueStringBuilder() + { + if (overflowed) + { + throw new InvalidOperationException( + "Cannot move an overflowed FixedSizeValueStringBuilder. Call ClearOverflow() first if the dropped content is acceptable."); + } + + var moved = new ValueStringBuilder(buffer, bufferPosition); + + buffer = default; + bufferPosition = 0; + overflowed = true; + + return moved; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryAppend(char value) + { + if (overflowed) + { + return false; + } + + if (bufferPosition == buffer.Length) + { + overflowed = true; + return false; + } + + buffer[bufferPosition] = value; + bufferPosition++; + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryAppend(scoped ReadOnlySpan str) + { + if (overflowed) + { + return false; + } + + if (str.Length > Remaining) + { + overflowed = true; + return false; + } + + str.CopyTo(buffer[bufferPosition..]); + bufferPosition += str.Length; + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryAppendLine(scoped ReadOnlySpan str) + { + if (overflowed) + { + return false; + } + + var newLine = Environment.NewLine.AsSpan(); + if (str.Length + newLine.Length > Remaining) + { + overflowed = true; + return false; + } + + str.CopyTo(buffer[bufferPosition..]); + bufferPosition += str.Length; + newLine.CopyTo(buffer[bufferPosition..]); + bufferPosition += newLine.Length; + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryAppend(Rune value) + { + if (overflowed) + { + return false; + } + + if (!value.TryEncodeToUtf16(buffer[bufferPosition..], out var written)) + { + overflowed = true; + return false; + } + + bufferPosition += written; + return true; + } + + private bool TryAppendFormatted(T value, scoped ReadOnlySpan format, int alignment) + { + if (alignment == 0) + { + return TryAppendFormatted(value, format); + } + + var start = bufferPosition; + return TryAppendFormatted(value, format) && TryPad(start, alignment); + } + + private bool TryAppend(scoped ReadOnlySpan str, int alignment) + { + if (alignment == 0) + { + return TryAppend(str); + } + + var start = bufferPosition; + return TryAppend(str) && TryPad(start, alignment); + } + + /// + /// Pads the content written since to the requested width. A padding which does not fit + /// rolls the value back as well, so an aligned hole is written whole or not at all. + /// + private bool TryPad(int start, int alignment) + { + var leftAligned = alignment < 0; + var width = leftAligned ? -alignment : alignment; + var written = bufferPosition - start; + var padding = width - written; + + if (padding <= 0) + { + return true; + } + + if (padding > Remaining) + { + bufferPosition = start; + overflowed = true; + return false; + } + + if (leftAligned) + { + buffer.Slice(bufferPosition, padding).Fill(' '); + } + else + { + buffer.Slice(start, written).CopyTo(buffer.Slice(start + padding, written)); + buffer.Slice(start, padding).Fill(' '); + } + + bufferPosition += padding; + return true; + } + + private bool TryAppendFormatted(T value, scoped ReadOnlySpan format) + { + if (TryAppendKnownSpanFormattable(value, format, out var appended)) + { + return appended; + } + + // Reaching here means T is neither a well known value type nor a string, so the interface call below does box + // a value type once. Reference types are unaffected. + if (value is ISpanFormattable formattable) + { + if (overflowed) + { + return false; + } + + if (!formattable.TryFormat(buffer[bufferPosition..], out var written, format, null)) + { + overflowed = true; + return false; + } + + bufferPosition += written; + return true; + } + + var text = value?.ToString(); + return TryAppend(text.AsSpan()); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryAppend(T value, scoped ReadOnlySpan format, IFormatProvider? formatProvider) + where T : ISpanFormattable + { + if (overflowed) + { + return false; + } + + if (!value.TryFormat(buffer[bufferPosition..], out var written, format, formatProvider)) + { + overflowed = true; + return false; + } + + bufferPosition += written; + return true; + } +} diff --git a/src/LinkDotNet.StringBuilder/ValueStringBuilder.cs b/src/LinkDotNet.StringBuilder/ValueStringBuilder.cs index 9ae997c5..2de59155 100644 --- a/src/LinkDotNet.StringBuilder/ValueStringBuilder.cs +++ b/src/LinkDotNet.StringBuilder/ValueStringBuilder.cs @@ -61,6 +61,19 @@ public ValueStringBuilder(int initialCapacity) EnsureCapacity(initialCapacity); } + /// + /// Initializes a new instance of the struct which adopts a buffer that is already + /// filled up to characters. + /// + /// Buffer to take over. + /// Number of characters already written into . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal ValueStringBuilder(Span buffer, int length) + { + this.buffer = buffer; + bufferPosition = length; + } + /// /// Gets the current length of the represented string. /// diff --git a/tests/LinkDotNet.StringBuilder.Benchmarks/FixedSizeBenchmark.cs b/tests/LinkDotNet.StringBuilder.Benchmarks/FixedSizeBenchmark.cs new file mode 100644 index 00000000..f795bd93 --- /dev/null +++ b/tests/LinkDotNet.StringBuilder.Benchmarks/FixedSizeBenchmark.cs @@ -0,0 +1,72 @@ +using BenchmarkDotNet.Attributes; + +namespace LinkDotNet.StringBuilder.Benchmarks; + +[MemoryDiagnoser] +public class FixedSizeBenchmark +{ + private const string Text = "Hello World"; + private const int Id = 1337; + + [Benchmark(Baseline = true)] + public string StringBuilderFits() + { + var builder = new System.Text.StringBuilder(); + builder.Append(Text); + builder.Append(Id); + return builder.ToString(); + } + + [Benchmark] + public string ValueStringBuilderFits() + { + using var builder = new ValueStringBuilder(stackalloc char[32]); + builder.Append(Text); + builder.Append(Id); + return builder.ToString(); + } + + [Benchmark] + public string ValueStringBuilderFitsWithoutGrowing() + { + using var builder = new ValueStringBuilder(stackalloc char[64]); + builder.Append(Text); + builder.Append(Id); + return builder.ToString(); + } + + [Benchmark] + public string FixedSizeValueStringBuilderFits() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[32]); + builder.Append(Text); + builder.Append(Id); + return builder.ToString(); + } + + [Benchmark] + public string FixedSizeValueStringBuilderInterpolated() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[32]); + builder.Append($"{Text}{Id}"); + return builder.ToString(); + } + + [Benchmark] + public string ValueStringBuilderOverflows() + { + using var builder = new ValueStringBuilder(stackalloc char[8]); + builder.Append(Text); + builder.Append(Id); + return builder.ToString(); + } + + [Benchmark] + public string FixedSizeValueStringBuilderOverflows() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + builder.Append(Text); + builder.Append(Id); + return builder.ToString(); + } +} diff --git a/tests/LinkDotNet.StringBuilder.UnitTests/FixedSizeValueStringBuilder.InterpolatedStringHandler.Tests.cs b/tests/LinkDotNet.StringBuilder.UnitTests/FixedSizeValueStringBuilder.InterpolatedStringHandler.Tests.cs new file mode 100644 index 00000000..9905e38d --- /dev/null +++ b/tests/LinkDotNet.StringBuilder.UnitTests/FixedSizeValueStringBuilder.InterpolatedStringHandler.Tests.cs @@ -0,0 +1,104 @@ +namespace LinkDotNet.StringBuilder.UnitTests; + +public class FixedSizeValueStringBuilderInterpolatedStringHandlerTests +{ + [Fact] + public void ShouldAppendInterpolatedStringWhenItFits() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[16]); + + builder.Append($"ab{42}cd"); + + builder.ToString().ShouldBe("ab42cd"); + builder.Overflowed.ShouldBeFalse(); + } + + [Fact] + public void GivenLiteralsAloneDoNotFit_WhenAppendingInterpolatedString_ThenNothingIsWritten() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[3]); + + builder.Append($"ab{42}cd"); + + builder.ToString().ShouldBe(string.Empty); + builder.Overflowed.ShouldBeTrue(); + } + + [Fact] + public void GivenALaterPartDoesNotFit_WhenAppendingInterpolatedString_ThenEarlierPartsAreKept() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[5]); + + builder.Append($"ab{42}cd"); + + builder.ToString().ShouldBe("ab42"); + builder.Overflowed.ShouldBeTrue(); + } + + [Fact] + public void GivenAHoleDoesNotFit_WhenAppendingInterpolatedString_ThenItIsNotTruncated() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[6]); + + builder.Append($"ab{12345}"); + + builder.ToString().ShouldBe("ab"); + builder.Overflowed.ShouldBeTrue(); + } + + [Fact] + public void ShouldRespectFormatInInterpolatedString() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[16]); + + builder.Append($"{3.5:F2}"); + + builder.ToString().ShouldBe(3.5.ToString("F2", null)); + } + + [Fact] + public void ShouldAppendNonFormattableValue() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[16]); + var value = new object(); + + builder.Append($"{value}"); + + builder.ToString().ShouldBe(value.ToString()); + } + + [Fact] + public void ShouldAppendNullValueAsEmpty() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[16]); + object? value = null; + + builder.Append($"a{value}b"); + + builder.ToString().ShouldBe("ab"); + builder.Overflowed.ShouldBeFalse(); + } + + [Fact] + public void ShouldAppendLineWithInterpolatedString() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[16]); + + builder.AppendLine($"ab{42}"); + + builder.ToString().ShouldBe("ab42" + Environment.NewLine); + builder.Overflowed.ShouldBeFalse(); + } + + [Fact] + public void GivenOverflow_WhenAppendingInterpolatedString_ThenNothingIsWritten() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + builder.Append("123456789"); + + builder.Append($"ab{42}"); + + builder.ToString().ShouldBe(string.Empty); + builder.Overflowed.ShouldBeTrue(); + } +} diff --git a/tests/LinkDotNet.StringBuilder.UnitTests/FixedSizeValueStringBuilder.Tests.cs b/tests/LinkDotNet.StringBuilder.UnitTests/FixedSizeValueStringBuilder.Tests.cs new file mode 100644 index 00000000..7e401bbe --- /dev/null +++ b/tests/LinkDotNet.StringBuilder.UnitTests/FixedSizeValueStringBuilder.Tests.cs @@ -0,0 +1,469 @@ +using System.Globalization; +using System.Text; + +namespace LinkDotNet.StringBuilder.UnitTests; + +public class FixedSizeValueStringBuilderTests +{ + [Fact] + public void ShouldAppendWhenContentFits() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[16]); + + builder.Append("Hello"); + builder.Append(' '); + builder.Append("World"); + + builder.ToString().ShouldBe("Hello World"); + builder.Overflowed.ShouldBeFalse(); + } + + [Fact] + public void ShouldFillBufferExactlyWithoutOverflowing() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[5]); + + builder.Append("Hello"); + + builder.ToString().ShouldBe("Hello"); + builder.Overflowed.ShouldBeFalse(); + builder.Remaining.ShouldBe(0); + } + + [Fact] + public void GivenSingleAppendIsTooLarge_WhenAppending_ThenNothingIsWritten() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + + builder.Append("123456789"); + + builder.ToString().ShouldBe(string.Empty); + builder.Length.ShouldBe(0); + builder.Overflowed.ShouldBeTrue(); + } + + [Fact] + public void GivenOverflow_WhenAppendingSomethingThatWouldFit_ThenItIsStillDropped() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + + builder.Append("1234"); + builder.Append("56789"); + builder.Append("!"); + + builder.ToString().ShouldBe("1234"); + builder.Overflowed.ShouldBeTrue(); + builder.Remaining.ShouldBe(4); + } + + [Fact] + public void GivenOverflow_WhenClearing_ThenBuilderIsReusable() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + builder.Append("123456789"); + + builder.Clear(); + builder.Append("Hello"); + + builder.ToString().ShouldBe("Hello"); + builder.Overflowed.ShouldBeFalse(); + builder.Length.ShouldBe(5); + } + + [Fact] + public void GivenOverflow_WhenClearingOverflow_ThenContentIsKeptAndAppendingContinues() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + builder.Append("1234"); + builder.Append("56789"); + + builder.ClearOverflow(); + builder.Append("!"); + + builder.ToString().ShouldBe("1234!"); + builder.Overflowed.ShouldBeFalse(); + } + + [Fact] + public void GivenNoOverflow_WhenClearingOverflow_ThenNothingChanges() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + builder.Append("1234"); + + builder.ClearOverflow(); + + builder.ToString().ShouldBe("1234"); + builder.Overflowed.ShouldBeFalse(); + } + + [Fact] + public void GivenFullBuffer_WhenClearingOverflow_ThenNextAppendOverflowsAgain() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[4]); + builder.Append("1234"); + builder.Append('!'); + + builder.ClearOverflow(); + builder.Append('!'); + + builder.ToString().ShouldBe("1234"); + builder.Overflowed.ShouldBeTrue(); + } + + [Fact] + public void GivenNewLineDoesNotFit_WhenAppendLine_ThenNeitherPartIsWritten() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[5 + Environment.NewLine.Length - 1]); + + builder.AppendLine("Hello"); + + builder.ToString().ShouldBe(string.Empty); + builder.Overflowed.ShouldBeTrue(); + } + + [Fact] + public void ShouldAppendLineWhenItFits() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[5 + Environment.NewLine.Length]); + + builder.AppendLine("Hello"); + + builder.ToString().ShouldBe("Hello" + Environment.NewLine); + builder.Overflowed.ShouldBeFalse(); + } + + [Fact] + public void GivenSurrogatePairDoesNotFit_WhenAppendingRune_ThenNoLoneSurrogateIsWritten() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[2]); + builder.Append('a'); + + builder.Append(new Rune(0x1F600)); + + builder.ToString().ShouldBe("a"); + builder.Overflowed.ShouldBeTrue(); + } + + [Fact] + public void ShouldAppendRuneWhenItFits() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[2]); + + builder.Append(new Rune(0x1F600)); + + builder.ToString().ShouldBe("\U0001F600"); + builder.Overflowed.ShouldBeFalse(); + } + + [Fact] + public void GivenFormattedValueDoesNotFit_WhenAppending_ThenItIsNotTruncated() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[4]); + + builder.Append(12345); + + builder.ToString().ShouldBe(string.Empty); + builder.Overflowed.ShouldBeTrue(); + } + + [Fact] + public void ShouldAppendFormattableWithFormatAndProvider() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + + builder.Append(3.14159f, "F2", CultureInfo.InvariantCulture); + + builder.ToString().ShouldBe("3.14"); + } + + [Theory] + [InlineData(true, "True")] + [InlineData(false, "False")] + public void ShouldAppendBool(bool value, string expected) + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[5]); + + builder.Append(value); + + builder.ToString().ShouldBe(expected); + } + + [Fact] + public void GivenBoolDoesNotFit_WhenAppending_ThenNothingIsWritten() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[3]); + + builder.Append(true); + + builder.ToString().ShouldBe(string.Empty); + builder.Overflowed.ShouldBeTrue(); + } + + [Fact] + public void GivenEmptyOrNullInput_WhenAppending_ThenNothingOverflows() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[1]); + builder.Append('x'); + + builder.Append(string.Empty); + builder.Append((string?)null); + builder.Append(ReadOnlySpan.Empty); + + builder.ToString().ShouldBe("x"); + builder.Overflowed.ShouldBeFalse(); + } + + [Fact] + public void GivenOverflow_WhenAppendingEmptyString_ThenItStaysOverflowed() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[2]); + builder.Append("abc"); + + builder.Append(string.Empty); + + builder.Overflowed.ShouldBeTrue(); + } + + [Fact] + public void GivenZeroLengthBuffer_WhenAppending_ThenItOverflowsWithoutThrowing() + { + var builder = new FixedSizeValueStringBuilder([]); + + builder.Append('a'); + + builder.Length.ShouldBe(0); + builder.Capacity.ShouldBe(0); + builder.Overflowed.ShouldBeTrue(); + } + + [Fact] + public void GivenDefaultInstance_WhenAppending_ThenItOverflowsWithoutThrowing() + { + var builder = default(FixedSizeValueStringBuilder); + + builder.Append("Hello"); + + builder.ToString().ShouldBe(string.Empty); + builder.IsEmpty.ShouldBeTrue(); + builder.Overflowed.ShouldBeTrue(); + } + + [Fact] + public void ShouldExposeOnlyWrittenContent() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + builder.Append("1234"); + builder.Append("56789"); + + Span destination = stackalloc char[4]; + + builder.AsSpan().ToString().ShouldBe("1234"); + builder.TryCopyTo(destination).ShouldBeTrue(); + destination.ToString().ShouldBe("1234"); + builder[0].ShouldBe('1'); + } + + [Fact] + public void GivenDestinationIsTooSmall_WhenTryCopyTo_ThenItReturnsFalse() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + builder.Append("1234"); + + Span destination = stackalloc char[2]; + + builder.TryCopyTo(destination).ShouldBeFalse(); + } + + [Fact] + public void ShouldReportCapacityAndRemaining() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[10]); + + builder.Append("abc"); + + builder.Capacity.ShouldBe(10); + builder.Length.ShouldBe(3); + builder.Remaining.ShouldBe(7); + builder.IsEmpty.ShouldBeFalse(); + } + + [Fact] + public void ShouldContinueInValueStringBuilderWhenMoved() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + builder.Append("1234"); + + using var grown = builder.MoveToValueStringBuilder(); + grown.Append("567890123456"); + + grown.ToString().ShouldBe("1234567890123456"); + } + + [Fact] + public void ShouldKeepContentWithoutCopyWhenMoved() + { + Span buffer = stackalloc char[8]; + var builder = new FixedSizeValueStringBuilder(buffer); + builder.Append("1234"); + + using var grown = builder.MoveToValueStringBuilder(); + + grown.Length.ShouldBe(4); + grown.Capacity.ShouldBe(8); + grown[0].ShouldBe('1'); + } + + [Fact] + public void ShouldConsumeSourceWhenMoved() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + builder.Append("1234"); + + using var grown = builder.MoveToValueStringBuilder(); + + builder.Length.ShouldBe(0); + builder.Capacity.ShouldBe(0); + builder.Overflowed.ShouldBeTrue(); + builder.ToString().ShouldBeEmpty(); + } + + [Fact] + public void ShouldNotWriteIntoMovedBufferWhenSourceIsUsedAgain() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + builder.Append("1234"); + + using var grown = builder.MoveToValueStringBuilder(); + builder.Append("XYZ"); + + grown.ToString().ShouldBe("1234"); + } + + [Fact] + public void ShouldThrowWhenMovingOverflowedBuilder() + { + Should.Throw(() => + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[4]); + builder.Append("12345"); + + using var grown = builder.MoveToValueStringBuilder(); + }); + } + + [Fact] + public void ShouldMoveDeliberatelyTruncatedBuilderWhenOverflowWasCleared() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[4]); + builder.Append("12345"); + builder.ClearOverflow(); + + using var grown = builder.MoveToValueStringBuilder(); + grown.Append("ab"); + + grown.ToString().ShouldBe("ab"); + } + + [Fact] + public void ShouldIndexOnlyWrittenCharacters() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + builder.Append("a"); + + builder[0].ShouldBe('a'); + Should.Throw(() => + { + var b = new FixedSizeValueStringBuilder(stackalloc char[8]); + b.Append("a"); + _ = b[1]; + }); + } + + [Fact] + public void ShouldNotAllocateForInterpolatedValueTypeHoles() + { + var guid = Guid.NewGuid(); + Append(); + + GC.Collect(); + var before = GC.GetAllocatedBytesForCurrentThread(); + Append(); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + allocated.ShouldBe(0); + + void Append() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[128]); + builder.Append($"{1} {2L} {3.5:F2} {true} {'c'} {guid}"); + builder.Overflowed.ShouldBeFalse(); + } + } + + [Fact] + public void ShouldRespectAlignmentInInterpolatedHoles() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[64]); + var name = "ab"; + + builder.Append($"[{42,6}][{name,-5}][{3.5,8:F2}][{'x',3}]"); + + builder.ToString().ShouldBe("[ 42][ab ][ 3.50][ x]"); + builder.Overflowed.ShouldBeFalse(); + } + + [Fact] + public void ShouldIgnoreAlignmentSmallerThanTheValue() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[16]); + + builder.Append($"{12345,2}"); + + builder.ToString().ShouldBe("12345"); + } + + [Fact] + public void ShouldDropValueAndPaddingTogetherWhenAlignmentDoesNotFit() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[8]); + builder.Append("12345"); + + builder.Append($"{7,6}"); + + builder.ToString().ShouldBe("12345"); + builder.Overflowed.ShouldBeTrue(); + } + + [Fact] + public void ShouldNotAllocateForAlignedInterpolatedHoles() + { + Append(); + + GC.Collect(); + var before = GC.GetAllocatedBytesForCurrentThread(); + Append(); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + allocated.ShouldBe(0); + + void Append() + { + var builder = new FixedSizeValueStringBuilder(stackalloc char[64]); + builder.Append($"{1,8}{2L,-8}{3.5,6:F1}"); + builder.Overflowed.ShouldBeFalse(); + } + } + + [Fact] + public void ShouldLetTheOriginalSpanCorruptTheMovedBuilderWhenOwnershipIsNotUnique() + { + Span shared = stackalloc char[16]; + var builder = new FixedSizeValueStringBuilder(shared); + builder.Append("hello"); + + using var grown = builder.MoveToValueStringBuilder(); + shared[0] = 'X'; + + grown.ToString().ShouldBe("Xello"); + } +}