diff --git a/docs/articles/concepts/buffers.md b/docs/articles/concepts/buffers.md new file mode 100644 index 0000000..e1f6fea --- /dev/null +++ b/docs/articles/concepts/buffers.md @@ -0,0 +1,122 @@ +# Buffers and pooled strings + +`SquidStd.Core.Buffers` and `SquidStd.Core.Extensions.Strings` are a small, dependency-free toolkit for allocation-sensitive string and byte work: a single-threaded array pool, a stack-friendly string builder, and a family of ordinal/case-insensitive string helpers that skip culture-aware comparisons entirely. + +## When to reach for these types + +Reach for this toolkit on hot paths that would otherwise allocate a `char[]` or `byte[]` on every call: parsers, network frame encoders/decoders, per-tick game loop work, or any single-threaded pipeline that runs often enough for GC pressure to matter. For one-off, cold-path string work, `string`, `StringBuilder`, and `ArrayPool.Shared` are simpler and perfectly fine. + +## STArrayPool + +`STArrayPool` is a single-threaded adaptation of the .NET runtime's shared `ArrayPool` (`TlsOverPerCoreLockedStacksArrayPool`). It keeps the same bucketing scheme - powers of two, from 16 elements up to roughly 1 GiB - and the same rent/return API, but drops every lock: there is no per-core or thread-local synchronization at all. + +> [!WARNING] +> `STArrayPool.Shared` is NOT thread-safe. It performs no locking, so renting or returning from more than one thread at a time is a race - it can hand out the same array twice or corrupt its internal buckets. Only use it from code that already guarantees exclusive, single-threaded access. + +Use the decision table to pick the right pool: + +| Scenario | Use | +|---|---| +| A single-threaded hot path that owns its buffer exclusively (a parser, formatter, or one tick of a game loop) | `STArrayPool.Shared` | +| Any code that might run concurrently, or where you can't prove exclusive ownership | `ArrayPool.Shared` | + +`STArrayPool` trims itself automatically: the first time it caches a returned array it registers a Gen2 GC callback, and every Gen2 collection runs `Trim()`. `Trim()` reads `GC.GetGCMemoryInfo()` and classifies the current memory load into `Low`, `Medium`, or `High` pressure (at or above 70% and 90% of the high memory-load threshold). Under `High` pressure the pool clears its single-slot fast-path cache immediately and ages arrays out of the per-size overflow stacks after only 10 seconds. Under `Medium` and `Low` pressure it is gentler: the fast-path cache ages out after 10 or 30 seconds of sitting idle, and the overflow stacks age out after 60 seconds. This keeps a short burst of activity from being trimmed away between GCs while still giving memory back once the process is actually under pressure. + +`Return` does not clear the array's contents unless you pass `clearArray: true`. For arrays of a reference type, that means previously stored object references stay reachable through the pool until the array is rented out again (and overwritten) or discarded by a trim - pass `clearArray: true` when that matters. + +```csharp +using SquidStd.Core.Buffers; + +var array = STArrayPool.Shared.Rent(1024); +try +{ + // use array... +} +finally +{ + STArrayPool.Shared.Return(array); +} +``` + +## ValueStringBuilder + +`ValueStringBuilder` is a `ref struct` string builder backed by a single growable `Span` instead of `StringBuilder`'s linked chunks. Being a ref struct, it must stay on the stack: it cannot be boxed, stored in a field of a non-ref struct, or captured by a lambda or async method. + +```csharp +using SquidStd.Core.Buffers; + +using var builder = new ValueStringBuilder(stackalloc char[64]); + +builder.Append("hello "); +builder.Append($"world {42}"); + +var text = builder.ToString(); // copies the written span into a new string +``` + +`ToString()` only reads the builder's contents - it does not release the buffer. Wrap the builder in a `using` declaration (as above) so `Dispose()` returns any rented buffer to the pool once you're done; if the builder never grew past its initial `stackalloc` span, `Dispose()` is a harmless no-op. + +By default (`mt: false`), the builder rents from `STArrayPool.Shared`, so the same single-threaded rule from the previous section applies: create, append to, and dispose the builder from one thread. Pass `mt: true` when the builder - or a buffer it grew into - might cross threads; this switches renting to `ArrayPool.Shared` instead: + +```csharp +var builder = new ValueStringBuilder(64, mt: true); +builder.Append("thread safe pool path"); +var text = builder.ToString(); +builder.Dispose(); +``` + +## Pooled helpers + +`ToPooledArray` copies a string into a buffer rented from `STArrayPool.Shared`. The caller owns the returned array and must return it; the buffer may be longer than the source string, so only the first `str.Length` characters are valid. + +```csharp +using SquidStd.Core.Buffers; +using SquidStd.Core.Extensions.Strings; + +var array = "hello world".ToPooledArray(); +try +{ + // only array[..11] holds the copied characters +} +finally +{ + STArrayPool.Shared.Return(array); +} +``` + +`PooledArraySpanFormattable` wraps a `STArrayPool`-rented buffer as an `ISpanFormattable`, so it can flow through interpolated string handlers and span-formatting APIs without allocating an intermediate string. `TryFormat` is single-use: a successful call copies the characters into the destination and returns the buffer to the pool as part of that same call, so the wrapper must not be used again afterward. `ToString()` is idempotent instead - the first call caches the string and releases the buffer, and later calls return the same cached instance. + +## String helpers + +`OrdinalStringHelpers` and `InsensitiveStringHelpers` mirror each other: one comparison family, ordinal (case-sensitive) on one side and ordinal-ignore-case on the other, both skipping culture lookups entirely. Every member works on `string`, and most also have a `ReadOnlySpan` overload. + +| Family | Ordinal | Insensitive | +|---|---|---| +| Starts with | `StartsWithOrdinal` | `InsensitiveStartsWith` | +| Ends with | `EndsWithOrdinal` | `InsensitiveEndsWith` | +| Equals | `EqualsOrdinal` | `InsensitiveEquals` | +| Contains | `ContainsOrdinal` | `InsensitiveContains` | +| Compare | `CompareOrdinal` | `InsensitiveCompare` | +| Index of | `IndexOfOrdinal` | `InsensitiveIndexOf` | +| Remove | `RemoveOrdinal` | `InsensitiveRemove` | +| Replace | `ReplaceOrdinal` | `InsensitiveReplace` | + +```csharp +using SquidStd.Core.Extensions.Strings; + +"hello world".StartsWithOrdinal("hello"); // true, no culture lookup +"Hello".InsensitiveEquals("hELLO"); // true +``` + +`StringHelpers` rounds out the set with general-purpose utilities: + +- `Capitalize` - title-cases every space-separated word, leaving "the " untouched. +- `Wrap` - word-wraps text into lines of at most N characters, hard-breaking words that don't fit on their own line. +- `IndentMultiline` - prefixes every line of a multiline string with an indent. +- `TrimMultiline` - trims every line of a multiline string independently. +- `IndexOfTerminator` - finds a null terminator in a byte/char/uint buffer. +- `ReplaceAny` - replaces every occurrence of a set of characters with their paired replacements, in place only. + +## Related + +- [SquidStd.Core](../core.md) - the package these types ship in, alongside the rest of the dependency-free helpers. +- [Home](../../index.md) - back to the SquidStd documentation home. diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml index d5cd71b..14ced56 100644 --- a/docs/articles/toc.yml +++ b/docs/articles/toc.yml @@ -10,6 +10,8 @@ href: concepts/abstractions-first.md - name: Messaging models href: concepts/messaging-models.md + - name: Buffers and pooled strings + href: concepts/buffers.md - name: Guides items: - name: Configuration diff --git a/src/SquidStd.Core/Buffers/Gen2GcCallback.cs b/src/SquidStd.Core/Buffers/Gen2GcCallback.cs new file mode 100644 index 0000000..c1b9e80 --- /dev/null +++ b/src/SquidStd.Core/Buffers/Gen2GcCallback.cs @@ -0,0 +1,92 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Runtime.ConstrainedExecution; +using System.Runtime.InteropServices; + +namespace SquidStd.Core.Buffers; + +/// +/// Schedules a callback roughly every gen 2 GC (you may see a Gen 0 an Gen 1 but only once) +/// (We can fix this by capturing the Gen 2 count at startup and testing, but I mostly don't care) +/// +internal sealed class Gen2GcCallback : CriticalFinalizerObject +{ + private readonly Func? _callback0; + private readonly Func? _callback1; + private GCHandle _weakTargetObj; + + private Gen2GcCallback(Func callback) + { + _callback0 = callback; + } + + private Gen2GcCallback(Func callback, object targetObj) + { + _callback1 = callback; + _weakTargetObj = GCHandle.Alloc(targetObj, GCHandleType.Weak); + } + + ~Gen2GcCallback() + { + if (_weakTargetObj.IsAllocated) + { + // Check to see if the target object is still alive. + var targetObj = _weakTargetObj.Target; + + if (targetObj == null) + { + // The target object is dead, so this callback object is no longer needed. + _weakTargetObj.Free(); + + return; + } + + // Execute the callback method. + Debug.Assert(_callback1 != null); + + if (!_callback1(targetObj)) + { + // If the callback returns false, this callback object is no longer needed. + _weakTargetObj.Free(); + + return; + } + } + else + { + // Execute the callback method. + Debug.Assert(_callback0 != null); + + if (!_callback0()) + { + // If the callback returns false, this callback object is no longer needed. + return; + } + } + + // Resurrect ourselves by re-registering for finalization. + GC.ReRegisterForFinalize(this); + } + + /// + /// Schedule 'callback' to be called in the next GC. If the callback returns true it is + /// rescheduled for the next Gen 2 GC, otherwise the callback stops. + /// + public static void Register(Func callback) + + // Create an unreachable object that remembers the callback function and target object. + => new Gen2GcCallback(callback); + + /// + /// Schedule 'callback' to be called in the next GC. If the callback returns true it is + /// rescheduled for the next Gen 2 GC, otherwise the callback stops. + /// NOTE: This callback will be kept alive until either the callback function returns false, + /// or the target object dies. + /// + public static void Register(Func callback, object targetObj) + + // Create a unreachable object that remembers the callback function and target object. + => new Gen2GcCallback(callback, targetObj); +} diff --git a/src/SquidStd.Core/Buffers/PooledArraySpanFormattable.cs b/src/SquidStd.Core/Buffers/PooledArraySpanFormattable.cs new file mode 100644 index 0000000..f5350ed --- /dev/null +++ b/src/SquidStd.Core/Buffers/PooledArraySpanFormattable.cs @@ -0,0 +1,92 @@ +namespace SquidStd.Core.Buffers; + +/// +/// Wraps a -rented char buffer as an so it can be +/// passed through interpolated string handlers without materializing an intermediate string. +/// can only be called once: it returns the buffer to the pool. To read the +/// characters multiple times use ; +/// is idempotent (it caches the string and releases the buffer on first call). +/// +public struct PooledArraySpanFormattable : ISpanFormattable, IDisposable +{ + private char[]? _arrayToReturnToPool; + private readonly int _length; + private string? _value; + + /// + /// Gets the written character slice while the buffer is still owned. + /// + public ReadOnlySpan Chars => _arrayToReturnToPool.AsSpan(0, _length); + + /// + /// Initializes the wrapper over a pooled buffer and the number of valid characters in it. + /// + /// Buffer rented from . + /// Count of valid characters at the start of the buffer. + public PooledArraySpanFormattable(char[] arrayToReturnToPool, int length) + { + _arrayToReturnToPool = arrayToReturnToPool; + _length = length; + _value = null; + } + + /// + /// Converts the wrapper to a string. After the buffer has been released by ToString, the cached string is returned. + /// + public static implicit operator string(PooledArraySpanFormattable formattable) + => formattable._value ?? new string(formattable.Chars); + + /// + public override string ToString() + => ToString(null, null); + + /// + /// Materializes the content as a string. Idempotent: the first call caches the value and + /// returns the buffer to the pool; later calls return the same instance. + /// + public string ToString(string? format, IFormatProvider? formatProvider) + { + if (_value is null) + { + _value = new(Chars); + ReleaseBuffer(); + } + + return _value; + } + + /// + /// Copies the content into and returns the buffer to the pool. + /// Single use: the wrapper must not be used again after a successful call. + /// + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format = default, IFormatProvider? provider = null) + { + if (destination.Length < _length) + { + charsWritten = 0; + + return false; + } + + Chars.CopyTo(destination); + charsWritten = _length; + ReleaseBuffer(); + + return true; + } + + private void ReleaseBuffer() + { + if (_arrayToReturnToPool is not null) + { + STArrayPool.Shared.Return(_arrayToReturnToPool); + _arrayToReturnToPool = null; + } + } + + /// + /// Returns the buffer to the pool if it is still owned. + /// + public void Dispose() + => ReleaseBuffer(); +} diff --git a/src/SquidStd.Core/Buffers/RawInterpolatedStringHandler.cs b/src/SquidStd.Core/Buffers/RawInterpolatedStringHandler.cs new file mode 100644 index 0000000..7870506 --- /dev/null +++ b/src/SquidStd.Core/Buffers/RawInterpolatedStringHandler.cs @@ -0,0 +1,678 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace SquidStd.Core.Buffers; + +/// Provides a handler to interpolate strings which UNSAFELY exposes its internal character span. +[InterpolatedStringHandler] +public ref struct RawInterpolatedStringHandler +{ + // Implementation note: + // As this type lives in CompilerServices and is only intended to be targeted by the compiler, + // public APIs eschew argument validation logic in a variety of places, e.g. allowing a null input + // when one isn't expected to produce a NullReferenceException rather than an ArgumentNullException. + + /// Expected average length of formatted data used for an individual interpolation expression result. + /// + /// This is inherited from string.Format, and could be changed based on further data. + /// string.Format actually uses `format.Length + args.Length * 8`, but format.Length + /// includes the format items themselves, e.g. "{0}", and since it's rare to have double-digit + /// numbers of items, we bump the 8 up to 11 to account for the three extra characters in "{d}", + /// since the compiler-provided base length won't include the equivalent character count. + /// + private const int GuessedLengthPerHole = 11; + + /// Minimum size array to rent from the pool. + /// Same as stack-allocation size used today by string.Format. + private const int MinimumArrayPoolLength = 256; + + /// Optional provider to pass to IFormattable.ToString or ISpanFormattable.TryFormat calls. + private readonly IFormatProvider? _provider; + + /// Array rented from the array pool and used to back . + private char[]? _arrayToReturnToPool; + + /// The span to write into. + private Span _chars; + + /// Position at which to write the next character. + private int _pos; + + /// Whether provides an ICustomFormatter. + /// + /// Custom formatters are very rare. We want to support them, but it's ok if we make them more expensive + /// in order to make them as pay-for-play as possible. So, we avoid adding another reference type field + /// to reduce the size of the handler and to reduce required zero'ing, by only storing whether the provider + /// provides a formatter, rather than actually storing the formatter. This in turn means, if there is a + /// formatter, we pay for the extra interface call on each AppendFormatted that needs it. + /// + private readonly bool _hasCustomFormatter; + + /// Creates a handler used to translate an interpolated string into a . + /// + /// The number of constant characters outside of interpolation expressions in the interpolated + /// string. + /// + /// The number of interpolation expressions in the interpolated string. + /// + /// This is intended to be called only by compiler-generated code. Arguments are not validated as they'd otherwise be + /// for members intended to be used directly. + /// + public RawInterpolatedStringHandler(int literalLength, int formattedCount) + { + _provider = null; + _chars = _arrayToReturnToPool = STArrayPool.Shared.Rent(GetDefaultLength(literalLength, formattedCount)); + _pos = 0; + _hasCustomFormatter = false; + } + + /// Creates a handler used to translate an interpolated string into a . + /// + /// The number of constant characters outside of interpolation expressions in the interpolated + /// string. + /// + /// The number of interpolation expressions in the interpolated string. + /// An object that supplies culture-specific formatting information. + /// + /// This is intended to be called only by compiler-generated code. Arguments are not validated as they'd otherwise be + /// for members intended to be used directly. + /// + public RawInterpolatedStringHandler(int literalLength, int formattedCount, IFormatProvider? provider) + { + _provider = provider; + _chars = _arrayToReturnToPool = STArrayPool.Shared.Rent(GetDefaultLength(literalLength, formattedCount)); + _pos = 0; + _hasCustomFormatter = provider is not null && HasCustomFormatter(provider); + } + + /// Gets a span of the written characters thus far. + public ReadOnlySpan Text => _chars[.._pos]; + + /// Writes the specified string to the handler. + /// The string to write. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLiteral(string value) + { + if (value.Length == 1) + { + var chars = _chars; + var pos = _pos; + + if ((uint)pos < (uint)chars.Length) + { + chars[pos] = value[0]; + _pos = pos + 1; + } + else + { + GrowThenCopyString(value); + } + + return; + } + + AppendStringDirect(value); + } + + /// Clears the handler, returning any rented array to the pool. + [MethodImpl(MethodImplOptions.AggressiveInlining)] // used only on a few hot paths + public void Clear() + { + var toReturn = _arrayToReturnToPool; + this = default; // defensive clear + + if (toReturn is not null) + { + STArrayPool.Shared.Return(toReturn); + } + } + + /// Derives a default length with which to seed the handler. + /// + /// The number of constant characters outside of interpolation expressions in the interpolated + /// string. + /// + /// The number of interpolation expressions in the interpolated string. + [MethodImpl(MethodImplOptions.AggressiveInlining)] // becomes a constant when inputs are constant + internal static int GetDefaultLength(int literalLength, int formattedCount) + => Math.Max(MinimumArrayPoolLength, literalLength + formattedCount * GuessedLengthPerHole); + + /// Gets whether the provider provides a custom formatter. + [MethodImpl(MethodImplOptions.AggressiveInlining)] // only used in a few hot path call sites + internal static bool HasCustomFormatter(IFormatProvider provider) + { + Debug.Assert(provider is not null); + Debug.Assert( + provider is not CultureInfo || provider.GetFormat(typeof(ICustomFormatter)) is null, + "Expected CultureInfo to not provide a custom formatter" + ); + + return + provider.GetType() != typeof(CultureInfo) && // optimization to avoid GetFormat in the majority case + provider.GetFormat(typeof(ICustomFormatter)) != null; + } + + /// Formats the value using the custom formatter from the provider. + /// The value to write. + /// The format string. + [MethodImpl(MethodImplOptions.NoInlining)] + private void AppendCustomFormatter(T value, string? format) + { + // This case is very rare, but we need to handle it prior to the other checks in case + // a provider was used that supplied an ICustomFormatter which wanted to intercept the particular value. + // We do the cast here rather than in the ctor, even though this could be executed multiple times per + // formatting, to make the cast pay for play. + Debug.Assert(_hasCustomFormatter); + Debug.Assert(_provider != null); + + var formatter = (ICustomFormatter?)_provider.GetFormat(typeof(ICustomFormatter)); + Debug.Assert( + formatter != null, + "An incorrectly written provider said it implemented ICustomFormatter, and then didn't" + ); + + if (formatter?.Format(format, value, _provider) is string customFormatted) + { + AppendStringDirect(customFormatted); + } + } + + /// Handles adding any padding required for aligning a formatted value in an interpolation expression. + /// The position at which the written value started. + /// + /// Non-zero 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. + /// + private void AppendOrInsertAlignmentIfNeeded(int startingPos, int alignment) + { + Debug.Assert(startingPos >= 0 && startingPos <= _pos); + Debug.Assert(alignment != 0); + + var charsWritten = _pos - startingPos; + + var leftAlign = false; + + if (alignment < 0) + { + leftAlign = true; + alignment = -alignment; + } + + var paddingNeeded = alignment - charsWritten; + + if (paddingNeeded > 0) + { + EnsureCapacityForAdditionalChars(paddingNeeded); + + if (leftAlign) + { + _chars.Slice(_pos, paddingNeeded).Fill(' '); + } + else + { + _chars.Slice(startingPos, charsWritten).CopyTo(_chars[(startingPos + paddingNeeded)..]); + _chars.Slice(startingPos, paddingNeeded).Fill(' '); + } + + _pos += paddingNeeded; + } + } + + /// Writes the specified string to the handler. + /// The string to write. + private void AppendStringDirect(string value) + { + if (value.TryCopyTo(_chars[_pos..])) + { + _pos += value.Length; + } + else + { + GrowThenCopyString(value); + } + } + + /// + /// Ensures has the capacity to store beyond + /// . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void EnsureCapacityForAdditionalChars(int additionalChars) + { + if (_chars.Length - _pos < additionalChars) + { + Grow(additionalChars); + } + } + + /// + /// Grows to have the capacity to store at least beyond + /// . + /// + [MethodImpl(MethodImplOptions.NoInlining)] // keep consumers as streamlined as possible + private void Grow(int additionalChars) + { + // This method is called when the remaining space (_chars.Length - _pos) is + // insufficient to store a specific number of additional characters. Thus, we + // need to grow to at least that new total. GrowCore will handle growing by more + // than that if possible. + Debug.Assert(additionalChars > _chars.Length - _pos); + GrowCore((uint)_pos + (uint)additionalChars); + } + + /// Grows the size of . + [MethodImpl(MethodImplOptions.NoInlining)] // keep consumers as streamlined as possible + private void Grow() + + // This method is called when the remaining space in _chars isn't sufficient to continue + // the operation. Thus, we need at least one character beyond _chars.Length. GrowCore + // will handle growing by more than that if possible. + => GrowCore((uint)_chars.Length + 1); + + /// Grow the size of to at least the specified . + [MethodImpl( + MethodImplOptions.AggressiveInlining + )] // but reuse this grow logic directly in both of the above grow routines + private void GrowCore(uint requiredMinCapacity) + { + // We want the max of how much space we actually required and doubling our capacity (without going beyond the max allowed length). We + // also want to avoid asking for small arrays, to reduce the number of times we need to grow, and since we're working with unsigned + // ints that could technically overflow if someone tried to, for example, append a huge string to a huge string, we also clamp to int.MaxValue. + // Even if the array creation fails in such a case, we may later fail in ToStringAndClear. + + var newCapacity = Math.Max(requiredMinCapacity, Math.Min((uint)_chars.Length * 2, 0x3FFFFFDF)); + var arraySize = (int)Math.Clamp(newCapacity, MinimumArrayPoolLength, int.MaxValue); + + var newArray = STArrayPool.Shared.Rent(arraySize); + _chars[.._pos].CopyTo(newArray); + + var toReturn = _arrayToReturnToPool; + _chars = _arrayToReturnToPool = newArray; + + if (toReturn is not null) + { + STArrayPool.Shared.Return(toReturn); + } + } + + /// + /// Fallback for for when not enough space exists in the current + /// buffer. + /// + /// The span to write. + [MethodImpl(MethodImplOptions.NoInlining)] + private void GrowThenCopySpan(ReadOnlySpan value) + { + Grow(value.Length); + value.CopyTo(_chars[_pos..]); + _pos += value.Length; + } + + /// Fallback for fast path in when there's not enough space in the destination. + /// The string to write. + [MethodImpl(MethodImplOptions.NoInlining)] + private void GrowThenCopyString(string value) + { + Grow(value.Length); + value.CopyTo(_chars[_pos..]); + _pos += value.Length; + } + + // Design note: + // The compiler requires a AppendFormatted overload for anything that might be within an interpolation expression; + // if it can't find an appropriate overload, for handlers in general it'll simply fail to compile. + // (For target-typing to string where it uses DefaultInterpolatedStringHandler implicitly, it'll instead fall back to + // its other mechanisms, e.g. using string.Format. This fallback has the benefit that if we miss a case, + // interpolated strings will still work, but it has the downside that a developer generally won't know + // if the fallback is happening and they're paying more.) + // + // At a minimum, then, we would need an overload that accepts: + // (object value, int alignment = 0, string? format = null) + // Such an overload would provide the same expressiveness as string.Format. However, this has several + // shortcomings: + // - Every value type in an interpolation expression would be boxed. + // - ReadOnlySpan could not be used in interpolation expressions. + // - Every AppendFormatted call would have three arguments at the call site, bloating the IL further. + // - Every invocation would be more expensive, due to lack of specialization, every call needing to account + // for alignment and format, etc. + // + // To address that, we could just have overloads for T and ReadOnlySpan: + // (T) + // (T, int alignment) + // (T, string? format) + // (T, int alignment, string? format) + // (ReadOnlySpan) + // (ReadOnlySpan, int alignment) + // (ReadOnlySpan, string? format) + // (ReadOnlySpan, int alignment, string? format) + // but this also has shortcomings: + // - Some expressions that would have worked with an object overload will now force a fallback to string.Format + // (or fail to compile if the handler is used in places where the fallback isn't provided), because the compiler + // can't always target type to T, e.g. `b switch { true => 1, false => null }` where `b` is a bool can successfully + // be passed as an argument of type `object` but not of type `T`. + // - Reference types get no benefit from going through the generic code paths, and actually incur some overheads + // from doing so. + // - Nullable value types also pay a heavy price, in particular around interface checks that would generally evaporate + // at compile time for value types but don't (currently) if the Nullable goes through the same code paths + // (see https://github.com/dotnet/runtime/issues/50915). + // + // We could try to take a more elaborate approach for DefaultInterpolatedStringHandler, since it is the most common handler + // and we want to minimize overheads both at runtime and in IL size, e.g. have a complete set of overloads for each of: + // (T, ...) where T : struct + // (T?, ...) where T : struct + // (object, ...) + // (ReadOnlySpan, ...) + // (string, ...) + // but this also has shortcomings, most importantly: + // - If you have an unconstrained T that happens to be a value type, it'll now end up getting boxed to use the object overload. + // This also necessitates the T? overload, since nullable value types don't meet a T : struct constraint, so without those + // they'd all map to the object overloads as well. + // - Any reference type with an implicit cast to ROS will fail to compile due to ambiguities between the overloads. string + // is one such type, hence needing dedicated overloads for it that can be bound to more tightly. + // + // A middle ground we've settled on, which is likely to be the right approach for most other handlers as well, would be the set: + // (T, ...) with no constraint + // (ReadOnlySpan) and (ReadOnlySpan, int) + // (object, int alignment = 0, string? format = null) + // (string) and (string, int) + // This would address most of the concerns, at the expense of: + // - Most reference types going through the generic code paths and so being a bit more expensive. + // - Nullable types being more expensive until https://github.com/dotnet/runtime/issues/50915 is addressed. + // We could choose to add a T? where T : struct set of overloads if necessary. + // Strings don't require their own overloads here, but as they're expected to be very common and as we can + // optimize them in several ways (can copy the contents directly, don't need to do any interface checks, don't + // need to pay the shared generic overheads, etc.) we can add overloads specifically to optimize for them. + // + // Hole values are formatted according to the following policy: + // 1. If an IFormatProvider was supplied and it provides an ICustomFormatter, use ICustomFormatter.Format (even if the value is null). + // 2. If the type implements ISpanFormattable, use ISpanFormattable.TryFormat. + // 3. If the type implements IFormattable, use IFormattable.ToString. + // 4. Otherwise, use object.ToString. + // This matches the behavior of string.Format, StringBuilder.AppendFormat, etc. The only overloads for which this doesn't + // apply is ReadOnlySpan, which isn't supported by either string.Format nor StringBuilder.AppendFormat, but more + // importantly which can't be boxed to be passed to ICustomFormatter.Format. + + /// Writes the specified value to the handler. + /// The value to write. + public void AppendFormatted(T value) + { + // This method could delegate to AppendFormatted with a null format, but explicitly passing + // default as the format to TryFormat helps to improve code quality in some cases when TryFormat is inlined, + // e.g. for Int32 it enables the JIT to eliminate code in the inlined method based on a length check on the format. + + // If there's a custom formatter, always use it. + if (_hasCustomFormatter) + { + AppendCustomFormatter(value, null); + + 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, + default, + _provider + )) // constrained call avoiding boxing for value types + { + Grow(); + } + + _pos += charsWritten; + + return; + } + + s = ((IFormattable)value).ToString(null, _provider); // constrained call avoiding boxing for value types + } + else + { + s = value?.ToString(); + } + + if (s is not null) + { + AppendStringDirect(s); + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// The format string. + public void AppendFormatted(T value, string? format) + { + // 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(); + } + + if (s is not null) + { + AppendStringDirect(s); + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// + /// 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. + /// + public void AppendFormatted(T value, int alignment) + { + var startingPos = _pos; + AppendFormatted(value); + + if (alignment != 0) + { + AppendOrInsertAlignmentIfNeeded(startingPos, alignment); + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// The format string. + /// + /// 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. + /// + public void AppendFormatted(T value, int alignment, string? format) + { + var startingPos = _pos; + AppendFormatted(value, format); + + if (alignment != 0) + { + AppendOrInsertAlignmentIfNeeded(startingPos, alignment); + } + } + + /// Writes the specified character span to the handler. + /// The span to write. + public void AppendFormatted(ReadOnlySpan value) + { + // Fast path for when the value fits in the current buffer + if (value.TryCopyTo(_chars[_pos..])) + { + _pos += value.Length; + } + else + { + GrowThenCopySpan(value); + } + } + + /// Writes the specified string of chars to the handler. + /// The span to write. + /// + /// 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. + /// + /// The format string. + public void AppendFormatted(ReadOnlySpan value, int alignment = 0, string? format = null) + { + var leftAlign = false; + + if (alignment < 0) + { + leftAlign = true; + alignment = -alignment; + } + + var paddingRequired = alignment - value.Length; + + if (paddingRequired <= 0) + { + // The value is as large or larger than the required amount of padding, + // so just write the value. + AppendFormatted(value); + + return; + } + + // Write the value along with the appropriate padding. + EnsureCapacityForAdditionalChars(value.Length + paddingRequired); + + if (leftAlign) + { + value.CopyTo(_chars[_pos..]); + _pos += value.Length; + _chars.Slice(_pos, paddingRequired).Fill(' '); + _pos += paddingRequired; + } + else + { + _chars.Slice(_pos, paddingRequired).Fill(' '); + _pos += paddingRequired; + value.CopyTo(_chars[_pos..]); + _pos += value.Length; + } + } + + /// Writes the specified value to the handler. + /// The value to write. + public void AppendFormatted(string? value) + { + // Fast-path for no custom formatter and a non-null string that fits in the current destination buffer. + if (!_hasCustomFormatter && value?.TryCopyTo(_chars[_pos..]) == true) + { + _pos += value.Length; + } + else + { + AppendFormattedSlow(value); + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// + /// Slow path to handle a custom formatter, potentially null value, + /// or a string that doesn't fit in the current buffer. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private void AppendFormattedSlow(string? value) + { + if (_hasCustomFormatter) + { + AppendCustomFormatter(value, null); + } + else if (value is not null) + { + EnsureCapacityForAdditionalChars(value.Length); + value.CopyTo(_chars[_pos..]); + _pos += value.Length; + } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// + /// 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. + /// + /// The format string. + public void AppendFormatted(string? value, int alignment = 0, string? format = null) + => + + // Format is meaningless for strings and doesn't make sense for someone to specify. We have the overload + // simply to disambiguate between ROS and object, just in case someone does specify a format, as + // string is implicitly convertible to both. Just delegate to the T-based implementation. + AppendFormatted(value, alignment, format); + + /// Writes the specified value to the handler. + /// The value to write. + /// + /// 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. + /// + /// The format string. + public void AppendFormatted(object? value, int alignment = 0, string? format = null) + => + + // This overload is expected to be used rarely, only if either a) something strongly typed as object is + // formatted with both an alignment and a format, or b) the compiler is unable to target type to T. It + // exists purely to help make cases from (b) compile. Just delegate to the T-based implementation. + AppendFormatted(value, alignment, format); +} diff --git a/src/SquidStd.Core/Buffers/STArrayPool.cs b/src/SquidStd.Core/Buffers/STArrayPool.cs new file mode 100644 index 0000000..02ff2f1 --- /dev/null +++ b/src/SquidStd.Core/Buffers/STArrayPool.cs @@ -0,0 +1,315 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Diagnostics; +using System.Numerics; +using System.Runtime.CompilerServices; +using SquidStd.Core.Types.Buffers; + +namespace SquidStd.Core.Buffers; + +/// +/// Adaptation of .Shared (the runtime's TlsOverPerCoreLockedStacksArrayPool) for +/// single-threaded, unsynchronized usage. +/// +/// +/// This pool is single-threaded by design: is a per-, +/// process-wide singleton that is NOT thread-safe and performs no locking or synchronization. Use it only +/// from code that already guarantees exclusive access (e.g. a single-threaded parser or formatter). For +/// concurrent access from multiple threads, use instead. Returned +/// reference-type arrays are not cleared unless clearArray is passed as +/// to , so stale references may remain reachable through a +/// rented-then-returned array until it is rented again or overwritten. +/// +public class STArrayPool : ArrayPool +{ +#if DEBUG_ARRAYPOOL + private static readonly ConditionalWeakTable _rentedArrays = new(); +#endif + + private const int StackArraySize = 32; + private const int BucketCount = 27; // SelectBucketIndex(1024 * 1024 * 1024 + 1) + + private static STArrayPoolBucket[]? _cacheBuckets; + private readonly STArrayPoolStack?[] _buckets = new STArrayPoolStack?[BucketCount]; + private int _trimCallbackCreated; + + /// + /// Gets the process-wide, single-threaded shared instance for . This instance + /// is NOT thread-safe; see the class remarks. + /// + public static STArrayPool Shared { get; } = new(); + + private STArrayPool() { } + + /// + /// Rents an array whose length is at least . The array may contain + /// previously used data and is not cleared unless it is later returned with clearArray: true. + /// + /// The minimum required length of the array. + /// An array whose length is at least . + /// is negative. + public override T[] Rent(int minimumLength) + { + T[]? buffer; + + var bucketIndex = SelectBucketIndex(minimumLength); + var cachedBuckets = _cacheBuckets; + + if (cachedBuckets is not null && (uint)bucketIndex < (uint)cachedBuckets.Length) + { + buffer = cachedBuckets[bucketIndex].Array; + + if (buffer is not null) + { + cachedBuckets[bucketIndex].Array = null; + #if DEBUG_ARRAYPOOL + _rentedArrays.AddOrUpdate( + buffer, + new STArrayPoolRentReturnStatus { IsRented = true } + ); + #endif + return buffer; + } + } + + var buckets = _buckets; + + if ((uint)bucketIndex < (uint)buckets.Length) + { + var b = buckets[bucketIndex]; + + if (b is not null) + { + buffer = b.TryPop(); + + if (buffer is not null) + { + #if DEBUG_ARRAYPOOL + _rentedArrays.AddOrUpdate( + buffer, + new STArrayPoolRentReturnStatus { IsRented = true } + ); + #endif + return buffer; + } + } + + minimumLength = GetMaxSizeForBucket(bucketIndex); + } + + if (minimumLength == 0) + { + // We aren't renting. + return []; + } + + if (minimumLength < 0) + { + throw new ArgumentOutOfRangeException(nameof(minimumLength)); + } + + var array = GC.AllocateUninitializedArray(minimumLength); + + #if DEBUG_ARRAYPOOL + _rentedArrays.AddOrUpdate( + array, + new STArrayPoolRentReturnStatus { IsRented = true, StackTrace = Environment.StackTrace } + ); + #endif + + return array; + } + + /// + /// Returns an array previously rented from this pool. Reference-type contents are not cleared unless + /// is . + /// + /// The array to return. A array is a no-op. + /// Whether to zero the array's contents before caching it. + /// + /// 's length does not match a bucket size produced by this pool. + /// + public override void Return(T[]? array, bool clearArray = false) + { + if (array is null) + { + return; + } + + var bucketIndex = SelectBucketIndex(array.Length); + var cacheBuckets = _cacheBuckets ?? InitializeBuckets(); + + if ((uint)bucketIndex < (uint)cacheBuckets.Length) + { + if (clearArray) + { + Array.Clear(array); + } + + #if DEBUG_ARRAYPOOL + if (array.Length != GetMaxSizeForBucket(bucketIndex) || !_rentedArrays.TryGetValue(array, out var status)) + { + throw new ArgumentException("Buffer is not from the pool", nameof(array)); + } + + if (!status!.IsRented) + { + throw new InvalidOperationException($"Array has already been returned.\nOriginal StackTrace:{status.StackTrace}\n"); + } + + // Mark it as returned + status.IsRented = false; + status.StackTrace = Environment.StackTrace; + #else + if (array.Length != GetMaxSizeForBucket(bucketIndex)) + { + throw new ArgumentException("Buffer is not from the pool", nameof(array)); + } + #endif + + ref var bucketArray = ref cacheBuckets[bucketIndex]; + var prev = bucketArray.Array; + bucketArray = new(array); + + if (prev is not null) + { + var bucket = _buckets[bucketIndex] ?? CreateBucketStack(bucketIndex); + bucket.TryPush(prev); + } + } + } + + /// + /// Trims pooled arrays according to current GC memory pressure. Invoked automatically on Gen2 collections. + /// + public bool Trim() + => TrimCore(Environment.TickCount64, GetMemoryPressure()); + + /// + /// Core trimming logic driven by an explicit monotonic timestamp and memory pressure, so it can be + /// exercised deterministically from tests without waiting on real GC or clock ticks. + /// + /// The current time, in milliseconds, from a monotonic clock such as . + /// The memory pressure to trim under. + /// , always; matches the return contract used by the Gen2 GC callback. + internal bool TrimCore(long milliseconds, STArrayPoolMemoryPressureType pressure) + { + var buckets = _buckets; + + for (var i = 0; i < buckets.Length; i++) + { + buckets[i]?.Trim(milliseconds, pressure, GetMaxSizeForBucket(i)); + } + + var cacheBuckets = _cacheBuckets; + + if (cacheBuckets is null) + { + return true; + } + + // Under high pressure, release all cached buckets + if (pressure == STArrayPoolMemoryPressureType.High) + { + Array.Clear(cacheBuckets); + } + else + { + uint threshold = pressure switch + { + STArrayPoolMemoryPressureType.Medium => 10000, + _ => 30000 + }; + + for (var i = 0; i < cacheBuckets.Length; i++) + { + ref var b = ref cacheBuckets[i]; + + if (b.Array is null) + { + continue; + } + + var lastSeen = b.Ticks; + + if (lastSeen == 0) + { + b.Ticks = milliseconds; + } + else if (milliseconds - lastSeen >= threshold) + { + b.Array = null; + } + } + } + + return true; + } + + private STArrayPoolStack CreateBucketStack(int bucketIndex) + => _buckets[bucketIndex] = new(StackArraySize); + + private STArrayPoolBucket[] InitializeBuckets() + { + Debug.Assert(_cacheBuckets is null, $"Non-null {nameof(_cacheBuckets)}"); + var buckets = new STArrayPoolBucket[BucketCount]; + + if (Interlocked.Exchange(ref _trimCallbackCreated, 1) == 0) + { + Gen2GcCallback.Register(o => ((STArrayPool)o).Trim(), this); + } + + return _cacheBuckets = buckets; + } + + // Buffers are bucketed so that a request between 2^(n-1) + 1 and 2^n is given a buffer of 2^n + // Bucket index is log2(bufferSize - 1) with the exception that buffers between 1 and 16 bytes + // are combined, and the index is slid down by 3 to compensate. + // Zero is a valid bufferSize, and it is assigned the highest bucket index so that zero-length + // buffers are not retained by the pool. The pool will return the Array.Empty singleton for these. + /// + /// Computes the bucket index that would serve a rent request of . + /// + /// The requested minimum buffer length. + /// The index of the smallest bucket able to satisfy the request. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static int SelectBucketIndex(int bufferSize) + => BitOperations.Log2(((uint)bufferSize - 1) | 15) - 3; + + /// + /// Computes the maximum array length served by the given bucket index. + /// + /// The bucket index, as produced by . + /// The maximum array length for that bucket. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static int GetMaxSizeForBucket(int binIndex) + { + var maxSize = 16 << binIndex; + Debug.Assert(maxSize >= 0); + + return maxSize; + } + + /// + /// Reads the current process memory load from the GC and classifies it into a trim pressure level. + /// + /// The current . + internal static STArrayPoolMemoryPressureType GetMemoryPressure() + { + var memoryInfo = GC.GetGCMemoryInfo(); + + if (memoryInfo.MemoryLoadBytes >= memoryInfo.HighMemoryLoadThresholdBytes * 0.90) + { + return STArrayPoolMemoryPressureType.High; + } + + if (memoryInfo.MemoryLoadBytes >= memoryInfo.HighMemoryLoadThresholdBytes * 0.70) + { + return STArrayPoolMemoryPressureType.Medium; + } + + return STArrayPoolMemoryPressureType.Low; + } +} diff --git a/src/SquidStd.Core/Buffers/STArrayPoolBucket.cs b/src/SquidStd.Core/Buffers/STArrayPoolBucket.cs new file mode 100644 index 0000000..c9d49b1 --- /dev/null +++ b/src/SquidStd.Core/Buffers/STArrayPoolBucket.cs @@ -0,0 +1,24 @@ +namespace SquidStd.Core.Buffers; + +/// +/// A single cached array slot with a last-seen timestamp, used by to +/// remember the most recently returned array for a given bucket size. +/// +internal struct STArrayPoolBucket +{ + /// The cached array, or if the slot is empty. + public T[]? Array; + + /// The tick count (in milliseconds) at which the array was last observed idle by trimming. + public long Ticks; + + /// + /// Initializes a new instance of the struct wrapping the given array. + /// + /// The array to cache. + public STArrayPoolBucket(T[] array) + { + Array = array; + Ticks = 0; + } +} diff --git a/src/SquidStd.Core/Buffers/STArrayPoolRentReturnStatus.cs b/src/SquidStd.Core/Buffers/STArrayPoolRentReturnStatus.cs new file mode 100644 index 0000000..adb5448 --- /dev/null +++ b/src/SquidStd.Core/Buffers/STArrayPoolRentReturnStatus.cs @@ -0,0 +1,14 @@ +namespace SquidStd.Core.Buffers; + +/// +/// Debug-only bookkeeping record tracking whether a pooled array is currently rented, used by +/// under the DEBUG_ARRAYPOOL conditional to detect double-returns. +/// +internal sealed class STArrayPoolRentReturnStatus +{ + /// The stack trace captured at the most recent rent or return. + public string? StackTrace { get; set; } + + /// Whether the array is currently considered rented. + public bool IsRented { get; set; } +} diff --git a/src/SquidStd.Core/Buffers/STArrayPoolStack.cs b/src/SquidStd.Core/Buffers/STArrayPoolStack.cs new file mode 100644 index 0000000..5f3ae97 --- /dev/null +++ b/src/SquidStd.Core/Buffers/STArrayPoolStack.cs @@ -0,0 +1,129 @@ +using System.Runtime.CompilerServices; +using SquidStd.Core.Types.Buffers; + +namespace SquidStd.Core.Buffers; + +/// +/// A small fixed-capacity LIFO stack of arrays, used by to hold onto arrays +/// that overflow the single-slot cache bucket for a given size. +/// +internal sealed class STArrayPoolStack +{ + private readonly T[]?[] _arrays; + private int _count; + private long _ticks; + + /// + /// Initializes a new instance of the class with the given capacity. + /// + /// The maximum number of arrays the stack can hold. + public STArrayPoolStack(int stackArraySize) + { + _arrays = new T[]?[stackArraySize]; + } + + /// + /// Trims idle arrays from the stack according to the current memory pressure. + /// + /// The current monotonic tick count, in milliseconds. + /// The current GC memory pressure. + /// The maximum array size for this stack's bucket. + public void Trim(long now, STArrayPoolMemoryPressureType pressure, int bucketSize) + { + if (_count == 0) + { + return; + } + + var threshold = pressure == STArrayPoolMemoryPressureType.High ? 10000 : 60000; + + if (_ticks == 0) + { + _ticks = now; + + return; + } + + if (now - _ticks <= threshold) + { + return; + } + + var trimCount = 1; + + switch (pressure) + { + case STArrayPoolMemoryPressureType.Medium: + trimCount = 2; + + break; + case STArrayPoolMemoryPressureType.High: + if (bucketSize > 16384) + { + trimCount++; + } + + var size = Unsafe.SizeOf(); + + if (size > 32) + { + trimCount += 2; + } + else if (size > 16) + { + trimCount++; + } + + break; + } + + while (_count > 0 && trimCount-- > 0) + { + _arrays[--_count] = null; + } + } + + /// + /// Attempts to pop an array from the top of the stack. + /// + /// The popped array, or if the stack is empty. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public T[]? TryPop() + { + var arrays = _arrays; + var count = _count - 1; + + if ((uint)count < (uint)arrays.Length) + { + var arr = arrays[count]; + arrays[count] = null; + _count = count; + + return arr; + } + + return null; + } + + /// + /// Attempts to push an array onto the stack. + /// + /// The array to push. + /// if the array was pushed; if the stack is full. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryPush(T[] array) + { + var arrays = _arrays; + var count = _count; + + if ((uint)count < (uint)arrays.Length) + { + arrays[count] = array; + _count = count + 1; + + return true; + } + + return false; + } +} diff --git a/src/SquidStd.Core/Buffers/ValueStringBuilder.cs b/src/SquidStd.Core/Buffers/ValueStringBuilder.cs new file mode 100644 index 0000000..5157678 --- /dev/null +++ b/src/SquidStd.Core/Buffers/ValueStringBuilder.cs @@ -0,0 +1,616 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace SquidStd.Core.Buffers; + +/// +/// A single-use, stack-friendly string builder built on a mutable buffer instead of +/// 's linked chunks. +/// +/// +/// This is a : it must stay on the stack and cannot be boxed, stored in a +/// field of a non-ref struct, or captured by a lambda/async method. With mt: false (the default), +/// growth buffers are rented from .Shared, which is NOT thread-safe, so the +/// builder must be created, appended to, and disposed from a single thread. Pass mt: true to switch +/// to .Shared when the builder (or a buffer it grew into) might cross threads. +/// +public ref struct ValueStringBuilder +{ + private char[]? _arrayToReturnToPool; + private Span _chars; + private readonly bool _mt; + + private ArrayPool ArrayPool + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => _mt ? ArrayPool.Shared : STArrayPool.Shared; + } + + /// + /// Initializes a builder whose buffer starts as a copy of . + /// + /// The characters to seed the builder with. + /// + /// When , buffers grow via .Shared instead of the + /// single-threaded .Shared. + /// + /// If this ctor is used, you cannot pass in stackalloc ROS for append/replace. + public ValueStringBuilder(ReadOnlySpan initialString, bool mt = false) : this(initialString.Length, mt) + { + Append(initialString); + } + + /// + /// Initializes a builder over seeded with a copy of + /// . + /// + /// The characters to seed the builder with. + /// The initial backing storage, e.g. a stackalloc'd span. + /// + /// When , buffers grow via .Shared instead of the + /// single-threaded .Shared. + /// + public ValueStringBuilder(ReadOnlySpan initialString, Span initialBuffer, bool mt = false) : this( + initialBuffer, + mt + ) + { + Append(initialString); + } + + /// + /// Initializes an empty builder over , e.g. a stackalloc'd span. + /// + /// The initial backing storage. + /// + /// When , buffers grow via .Shared instead of the + /// single-threaded .Shared. + /// + public ValueStringBuilder(Span initialBuffer, bool mt = false) + { + _mt = mt; + _arrayToReturnToPool = null; + _chars = initialBuffer; + Length = 0; + } + + /// + /// Initializes an empty builder with a buffer rented at the given capacity. + /// + /// The minimum initial buffer capacity. + /// + /// When , the initial buffer and any growth are rented from + /// .Shared instead of the single-threaded .Shared. + /// + /// If this ctor is used, you cannot pass in stackalloc ROS for append/replace. + public ValueStringBuilder(int initialCapacity, bool mt = false) + { + _mt = mt; + Length = 0; + _arrayToReturnToPool = (_mt ? ArrayPool.Shared : STArrayPool.Shared).Rent(initialCapacity); + _chars = _arrayToReturnToPool; + } + + /// Gets the number of characters currently written to the builder. + public int Length { get; private set; } + + /// Gets the current capacity of the underlying buffer. + public int Capacity => _chars.Length; + + /// Gets a mutable reference to the character at the given index. + /// The zero-based character index. + public ref char this[int index] => ref _chars[index]; + + /// Returns the underlying storage of the builder. + public Span RawChars => _chars; + + /// + /// Appends using or + /// when available, falling back to . + /// + /// The value to append. + /// An optional format string passed to the value's formatter. + public void Append(T value, string? format = null) + { + if (value is IFormattable) + { + if (value is ISpanFormattable) + { + var destination = _chars[Length..]; + int charsWritten; + + while (!((ISpanFormattable)value).TryFormat(destination, out charsWritten, format, default)) + { + Grow(1); + destination = _chars[Length..]; + } + + if ((uint)charsWritten > (uint)destination.Length) + { + throw new FormatException("Invalid string"); + } + + Length += charsWritten; + } + else + { + Append(((IFormattable)value).ToString(format, default)); // constrained call avoiding boxing for value types + } + } + else if (value is not null) + { + Append(value.ToString()); + } + } + + /// Appends the text produced by a . + /// The interpolated string handler holding the formatted text. + /// The handler is consumed: its rented buffer is returned to the pool during this call. Do not reuse the handler afterwards or pass the same handler twice. + // Compiler generated + public void Append(RawInterpolatedStringHandler handler) + { + Append(handler.Text); + handler.Clear(); + } + + /// + /// Appends the text produced by a using the given + /// . + /// + /// The format provider passed to the interpolated string handler. + /// The interpolated string handler holding the formatted text. + /// The handler is consumed: its rented buffer is returned to the pool during this call. Do not reuse the handler afterwards or pass the same handler twice. + // Compiler generated + public void Append( + IFormatProvider? formatProvider, + [InterpolatedStringHandlerArgument("formatProvider")] + RawInterpolatedStringHandler handler + ) + { + Append(handler.Text); + handler.Clear(); + } + + /// Appends a string. A no-op if is . + /// The string to append. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(string? s) + { + if (s == null) + { + return; + } + + var pos = Length; + + if (s.Length == 1 && + (uint)pos < + (uint)_chars.Length) // very common case, e.g. appending strings from NumberFormatInfo like separators, percent symbols, etc. + { + _chars[pos] = s[0]; + Length = pos + 1; + } + else + { + AppendSlow(s); + } + } + + /// Appends the same character times. + /// The character to append. + /// The number of times to append it. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(char c, int count) + { + if (Length > _chars.Length - count) + { + Grow(count); + } + + var dst = _chars.Slice(Length, count); + + for (var i = 0; i < dst.Length; i++) + { + dst[i] = c; + } + Length += count; + } + + /// Appends the characters of . + /// The characters to append. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(scoped ReadOnlySpan value) + { + var pos = Length; + + if (pos > _chars.Length - value.Length) + { + Grow(value.Length); + } + + value.CopyTo(_chars[Length..]); + Length += value.Length; + } + + /// + /// Appends followed by . A no-op if + /// is : nothing is appended, not even the newline. + /// + /// The string to append. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLine(string? s) + { + if (s == null) + { + return; + } + + // very common case, e.g. appending strings from NumberFormatInfo like separators, percent symbols, etc. + if (s.Length == 1) + { + Append(s[0]); + } + else + { + AppendSlow(s); + } + + Append(Environment.NewLine); + } + + /// + /// Reserves characters at the end of the builder and returns a span over them + /// for the caller to fill in directly. + /// + /// The number of characters to reserve. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Span AppendSpan(int length) + { + var origPos = Length; + + if (origPos > _chars.Length - length) + { + Grow(length); + } + + Length = origPos + length; + + return _chars.Slice(origPos, length); + } + + /// + /// Returns a span around the contents of the builder. + /// + /// Ensures that the builder has a null char after + public ReadOnlySpan AsSpan(bool terminate) + { + if (terminate) + { + EnsureCapacity(Length + 1); + _chars[Length] = '\0'; + } + + return _chars[..Length]; + } + + /// Returns a span over the written contents of the builder. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ReadOnlySpan AsSpan() + => _chars[..Length]; + + /// Returns a span over the written contents of the builder starting at . + /// The zero-based index to start the span at. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ReadOnlySpan AsSpan(int start) + => _chars[start..]; + + /// Returns a span over a sub-range of the written contents of the builder. + /// The zero-based index to start the span at. + /// The number of characters to include. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public ReadOnlySpan AsSpan(int start, int length) + => _chars.Slice(start, length); + + /// Creates a builder with a rented buffer of at least characters. + /// The minimum initial buffer capacity. + /// + /// When , the buffer is rented from .Shared instead of + /// the single-threaded .Shared. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueStringBuilder Create(int capacity = 64, bool mt = false) + => new(capacity, mt); + + /// Creates a builder whose buffers are rented from .Shared. + /// The minimum initial buffer capacity. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueStringBuilder CreateMT(int capacity = 64) + => new(capacity, true); + + /// Ensures the buffer can hold at least characters, growing it if needed. + /// The minimum required capacity. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void EnsureCapacity(int capacity) + { + if (capacity > _chars.Length) + { + Grow(capacity - Length); + } + } + + /// + /// Get a pinnable reference to the builder. + /// Does not ensure there is a null char after + /// This overload is pattern matched in the C# 7.3+ compiler so you can omit + /// the explicit method call, and write eg "fixed (char* c = builder)" + /// + public ref char GetPinnableReference() + => ref MemoryMarshal.GetReference(_chars); + + /// + /// Get a pinnable reference to the builder. + /// + /// Ensures that the builder has a null char after + public ref char GetPinnableReference(bool terminate) + { + if (terminate) + { + EnsureCapacity(Length + 1); + _chars[Length] = '\0'; + } + + return ref MemoryMarshal.GetReference(_chars); + } + + /// Inserts the same character times at , shifting existing content right. + /// The zero-based index to insert at. + /// The character to insert. + /// The number of times to insert it. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Insert(int index, char value, int count) + { + if (Length > _chars.Length - count) + { + Grow(count); + } + + var remaining = Length - index; + _chars.Slice(index, remaining).CopyTo(_chars[(index + count)..]); + _chars.Slice(index, count).Fill(value); + Length += count; + } + + /// + /// Inserts at , shifting existing content right. A no-op if + /// is . + /// + /// The zero-based index to insert at. + /// The string to insert. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Insert(int index, string? s) + { + if (s == null) + { + return; + } + + var count = s.Length; + + if (Length > _chars.Length - count) + { + Grow(count); + } + + var remaining = Length - index; + _chars.Slice(index, remaining).CopyTo(_chars[(index + count)..]); + s.AsSpan().CopyTo(_chars[index..]); + Length += count; + } + + /// Removes characters starting at . + /// The zero-based index to start removing from. + /// The number of characters to remove. + /// + /// or is negative, or the range extends past + /// . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Remove(int startIndex, int length) + { + if (length < 0) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } + + if (startIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(startIndex)); + } + + if (length > Length - startIndex) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } + + if (startIndex == 0) + { + _chars = _chars[length..]; + } + else if (startIndex + length == Length) + { + _chars = _chars[..startIndex]; + } + else + { + // Somewhere in the middle, this will be slow + _chars[(startIndex + length)..].CopyTo(_chars[startIndex..]); + } + + Length -= length; + } + + /// Replaces all occurrences of with within a range. + /// The character to replace. + /// The replacement character. + /// The zero-based index the range starts at. + /// The number of characters the range covers. + /// + /// or is out of range for the current + /// . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Replace(char oldChar, char newChar, int startIndex, int count) + { + var currentLength = Length; + + if ((uint)startIndex > (uint)currentLength) + { + throw new ArgumentOutOfRangeException(nameof(startIndex)); + } + + if (count < 0 || startIndex > currentLength - count) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + var slice = _chars.Slice(startIndex, count); + + while (true) + { + var indexOf = slice.IndexOf(oldChar); + + if (indexOf == -1) + { + break; + } + + slice[indexOf] = newChar; + slice = slice[(indexOf + 1)..]; + } + } + + /// Replaces occurrences of characters in with the character at the same position in , within a range. + /// The characters to look for. + /// The replacement characters, aligned by index with . + /// The zero-based index the range starts at. + /// The number of characters the range covers. + /// + /// or is out of range for the current + /// . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void ReplaceAny(ReadOnlySpan oldChars, ReadOnlySpan newChars, int startIndex, int count) + { + var currentLength = Length; + + if ((uint)startIndex > (uint)currentLength) + { + throw new ArgumentOutOfRangeException(nameof(startIndex)); + } + + if (count < 0 || startIndex > currentLength - count) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + var slice = _chars.Slice(startIndex, count); + + while (true) + { + var indexOf = slice.IndexOfAny(oldChars); + + if (indexOf == -1) + { + break; + } + + var chr = slice[indexOf]; + + slice[indexOf] = newChars[oldChars.IndexOf(chr)]; + slice = slice[(indexOf + 1)..]; + } + } + + /// Resets to zero without releasing the buffer. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Reset() + => Length = 0; + + /// Returns the written contents as a new . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public override string ToString() + => _chars[..Length].ToString(); + + /// Attempts to copy the written contents into . + /// The span to copy into. + /// The number of characters written, or zero if the copy failed. + /// if the contents fit in ; otherwise . + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool TryCopyTo(Span destination, out int charsWritten) + { + if (_chars[..Length].TryCopyTo(destination)) + { + charsWritten = Length; + + return true; + } + + charsWritten = 0; + + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void AppendSlow(string s) + { + var pos = Length; + + if (pos > _chars.Length - s.Length) + { + Grow(s.Length); + } + + s.AsSpan().CopyTo(_chars[pos..]); + Length += s.Length; + } + + /// + /// Resize the internal buffer either by doubling current buffer size or + /// by adding to + /// whichever is greater. + /// + /// + /// Number of chars requested beyond current position. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private void Grow(int additionalCapacityBeyondPos) + { + var poolArray = ArrayPool.Rent(Math.Max(Length + additionalCapacityBeyondPos, _chars.Length * 2)); + + _chars[..Length].CopyTo(poolArray); + + var toReturn = _arrayToReturnToPool; + _chars = _arrayToReturnToPool = poolArray; + + if (toReturn != null) + { + ArrayPool.Return(toReturn); + } + } + + /// Releases the rented buffer, if any, back to its pool. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + if (_arrayToReturnToPool != null) + { + ArrayPool.Return(_arrayToReturnToPool); + } + + this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again + } +} diff --git a/src/SquidStd.Core/Extensions/Collections/CollectionExtensions.cs b/src/SquidStd.Core/Extensions/Collections/CollectionExtensions.cs new file mode 100644 index 0000000..cd40d25 --- /dev/null +++ b/src/SquidStd.Core/Extensions/Collections/CollectionExtensions.cs @@ -0,0 +1,22 @@ +using System.Runtime.CompilerServices; + +namespace SquidStd.Core.Extensions.Collections; + +/// +/// Small quality-of-life extensions for collections. +/// +public static class CollectionExtensions +{ + /// + /// Adds to the collection only when it is not null. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void AddNotNull(this ICollection collection, T? item) + where T : class + { + if (item is not null) + { + collection.Add(item); + } + } +} diff --git a/src/SquidStd.Core/Extensions/Network/IpAddressExtensions.cs b/src/SquidStd.Core/Extensions/Network/IpAddressExtensions.cs new file mode 100644 index 0000000..60a4f34 --- /dev/null +++ b/src/SquidStd.Core/Extensions/Network/IpAddressExtensions.cs @@ -0,0 +1,39 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; + +namespace SquidStd.Core.Extensions.Network; + +/// +/// Conversions between IP addresses and their raw 32-bit representation. +/// +public static class IpAddressExtensions +{ + /// + /// Returns the IPv4 address of the endpoint as a little-endian 32-bit value. + /// + /// The address has no IPv4 representation. + public static uint ToRawAddress(this IPEndPoint endPoint) + => endPoint.Address.ToRawAddress(); + + /// + /// Returns the IPv4 address as a little-endian 32-bit value. IPv6-mapped IPv4 addresses are unwrapped. + /// + /// The address has no IPv4 representation. + public static uint ToRawAddress(this IPAddress ipAddress) + { + if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6 && !ipAddress.IsIPv4MappedToIPv6) + { + throw new InvalidOperationException("IP address could not be serialized to a 32-bit value."); + } + + Span bytes = stackalloc byte[4]; + + if (!ipAddress.MapToIPv4().TryWriteBytes(bytes, out var bytesWritten) || bytesWritten != 4) + { + throw new InvalidOperationException("IP address could not be serialized to a 32-bit value."); + } + + return BinaryPrimitives.ReadUInt32LittleEndian(bytes); + } +} diff --git a/src/SquidStd.Core/Extensions/Strings/InsensitiveStringHelpers.cs b/src/SquidStd.Core/Extensions/Strings/InsensitiveStringHelpers.cs new file mode 100644 index 0000000..d78a199 --- /dev/null +++ b/src/SquidStd.Core/Extensions/Strings/InsensitiveStringHelpers.cs @@ -0,0 +1,116 @@ +using System.Runtime.CompilerServices; + +namespace SquidStd.Core.Extensions.Strings; + +/// +/// Ordinal case-insensitive comparison helpers for strings and character spans. +/// Instance-style overloads on nullable strings treat a null instance as "no match" (false / -1) +/// except and . +/// +public static class InsensitiveStringHelpers +{ + /// Compares two spans ignoring case. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int InsensitiveCompare(this ReadOnlySpan a, ReadOnlySpan b) + => a.CompareTo(b, StringComparison.OrdinalIgnoreCase); + + /// Compares two strings ignoring case (null sorts first). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int InsensitiveCompare(this string? a, string? b) + => string.Compare(a, b, StringComparison.OrdinalIgnoreCase); + + /// Returns true when the span contains ignoring case. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool InsensitiveContains(this ReadOnlySpan a, string? b) + => b is not null && a.Contains(b.AsSpan(), StringComparison.OrdinalIgnoreCase); + + /// Returns true when the string contains ignoring case. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool InsensitiveContains(this string? a, string? b) + => a is not null && b is not null && a.Contains(b, StringComparison.OrdinalIgnoreCase); + + /// Returns true when the span contains ignoring case. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool InsensitiveContains(this ReadOnlySpan a, ReadOnlySpan b) + => a.Contains(b, StringComparison.OrdinalIgnoreCase); + + /// Returns true when the string contains the character ignoring case. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool InsensitiveContains(this string? a, char b) + => a is not null && a.AsSpan().IndexOf([b], StringComparison.OrdinalIgnoreCase) >= 0; + + /// Returns true when the string ends with ignoring case. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool InsensitiveEndsWith(this string? a, string? b) + => a is not null && b is not null && a.EndsWith(b, StringComparison.OrdinalIgnoreCase); + + /// Returns true when the span ends with ignoring case. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool InsensitiveEndsWith(this ReadOnlySpan a, ReadOnlySpan b) + => a.EndsWith(b, StringComparison.OrdinalIgnoreCase); + + /// Returns true when the spans are equal ignoring case. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool InsensitiveEquals(this ReadOnlySpan a, ReadOnlySpan b) + => a.Equals(b, StringComparison.OrdinalIgnoreCase); + + /// Returns true when the string equals the span ignoring case; a null string never matches. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool InsensitiveEquals(this string? a, ReadOnlySpan b) + => a is not null && a.AsSpan().Equals(b, StringComparison.OrdinalIgnoreCase); + + /// Returns true when the strings are equal ignoring case; two nulls are equal. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool InsensitiveEquals(this string? a, string? b) + => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); + + /// Index of the character ignoring case, or -1 (null-safe). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int InsensitiveIndexOf(this string? a, char b) + => a?.AsSpan().IndexOf([b], StringComparison.OrdinalIgnoreCase) ?? -1; + + /// Index of the substring ignoring case, or -1 (null-safe). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int InsensitiveIndexOf(this string? a, string? b) + => a is null || b is null ? -1 : a.IndexOf(b, StringComparison.OrdinalIgnoreCase); + + /// Index of the substring starting at ignoring case, or -1 (null-safe). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int InsensitiveIndexOf(this string? a, string? b, int startIndex) + => a is null || b is null ? -1 : a.IndexOf(b, startIndex, StringComparison.OrdinalIgnoreCase); + + /// Index of in the span ignoring case, or -1. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int InsensitiveIndexOf(this ReadOnlySpan a, ReadOnlySpan b) + => a.IndexOf(b, StringComparison.OrdinalIgnoreCase); + + /// Copies the span into with every occurrence of removed, ignoring case. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void InsensitiveRemove(this ReadOnlySpan a, ReadOnlySpan b, Span buffer, out int size) + => a.Remove(b, StringComparison.OrdinalIgnoreCase, buffer, out size); + + /// Returns a new string equal to the span with every occurrence of removed, ignoring case. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string InsensitiveRemove(this ReadOnlySpan a, ReadOnlySpan b) + => a.Remove(b, StringComparison.OrdinalIgnoreCase); + + /// Returns a new string with every occurrence of removed, ignoring case (null-safe). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string? InsensitiveRemove(this string? a, string b) + => a?.Replace(b, string.Empty, StringComparison.OrdinalIgnoreCase); + + /// Returns the string with every occurrence of replaced by , ignoring case (null-safe). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string? InsensitiveReplace(this string? a, string o, string? n) + => a?.Replace(o, n, StringComparison.OrdinalIgnoreCase); + + /// Returns true when the span starts with ignoring case. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool InsensitiveStartsWith(this ReadOnlySpan a, ReadOnlySpan b) + => a.StartsWith(b, StringComparison.OrdinalIgnoreCase); + + /// Returns true when the string starts with ignoring case (null-safe). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool InsensitiveStartsWith(this string? a, string? b) + => a is not null && b is not null && a.StartsWith(b, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/SquidStd.Core/Extensions/Strings/OrdinalStringHelpers.cs b/src/SquidStd.Core/Extensions/Strings/OrdinalStringHelpers.cs new file mode 100644 index 0000000..61519a6 --- /dev/null +++ b/src/SquidStd.Core/Extensions/Strings/OrdinalStringHelpers.cs @@ -0,0 +1,127 @@ +using System.Runtime.CompilerServices; + +namespace SquidStd.Core.Extensions.Strings; + +/// +/// Ordinal (culture-independent, case-sensitive) comparison helpers for strings and character spans. +/// Instance-style overloads on nullable strings treat a null instance as "no match" (false / -1) +/// except and , +/// which follow semantics. +/// +public static class OrdinalStringHelpers +{ + /// Compares two spans with ordinal semantics. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int CompareOrdinal(this ReadOnlySpan a, ReadOnlySpan b) + => a.CompareTo(b, StringComparison.Ordinal); + + /// Compares two strings with ordinal semantics (null sorts first). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int CompareOrdinal(this string? a, string? b) + => string.CompareOrdinal(a, b); + + /// Returns true when the span contains (ordinal). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool ContainsOrdinal(this ReadOnlySpan a, string? b) + => b is not null && a.Contains(b.AsSpan(), StringComparison.Ordinal); + + /// Returns true when the string contains (ordinal). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool ContainsOrdinal(this string? a, string? b) + => a is not null && b is not null && a.Contains(b, StringComparison.Ordinal); + + /// Returns true when the span contains (ordinal). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool ContainsOrdinal(this ReadOnlySpan a, ReadOnlySpan b) + => a.Contains(b, StringComparison.Ordinal); + + /// Returns true when the string contains the character. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool ContainsOrdinal(this string? a, char b) + => a is not null && a.Contains(b); + + /// Returns true when the string ends with (ordinal). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool EndsWithOrdinal(this string? a, string? b) + => a is not null && b is not null && a.EndsWith(b, StringComparison.Ordinal); + + /// Returns true when the span ends with the character. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool EndsWithOrdinal(this ReadOnlySpan a, char b) + => a.Length > 0 && a[^1] == b; + + /// Returns true when the span ends with (ordinal). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool EndsWithOrdinal(this ReadOnlySpan a, ReadOnlySpan b) + => a.EndsWith(b, StringComparison.Ordinal); + + /// Returns true when the span equals (ordinal); a null string never matches. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool EqualsOrdinal(this ReadOnlySpan a, string? b) + => b is not null && a.SequenceEqual(b.AsSpan()); + + /// Returns true when the strings are equal (ordinal); two nulls are equal. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool EqualsOrdinal(this string? a, string? b) + => string.Equals(a, b, StringComparison.Ordinal); + + /// Index of the character, or -1 (null-safe). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IndexOfOrdinal(this string? a, char b) + => a?.IndexOf(b) ?? -1; + + /// Index of the substring (ordinal), or -1 (null-safe). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IndexOfOrdinal(this string? a, string? b) + => a is null || b is null ? -1 : a.IndexOf(b, StringComparison.Ordinal); + + /// Index of the substring starting at (ordinal), or -1 (null-safe). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IndexOfOrdinal(this string? a, string? b, int startIndex) + => a is null || b is null ? -1 : a.IndexOf(b, startIndex, StringComparison.Ordinal); + + /// Index of the character in the span, or -1. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IndexOfOrdinal(this ReadOnlySpan a, char b) + => a.IndexOf(b); + + /// Index of in the span (ordinal), or -1. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IndexOfOrdinal(this ReadOnlySpan a, ReadOnlySpan b) + => a.IndexOf(b, StringComparison.Ordinal); + + /// Returns the string with every occurrence of removed (ordinal, null-safe). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string? RemoveOrdinal(this string? a, string b) + => a?.Replace(b, string.Empty, StringComparison.Ordinal); + + /// Copies the span into with every occurrence of removed (ordinal). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void RemoveOrdinal(this ReadOnlySpan a, ReadOnlySpan b, Span buffer, out int size) + => a.Remove(b, StringComparison.Ordinal, buffer, out size); + + /// Returns a new string equal to the span with every occurrence of removed (ordinal). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string RemoveOrdinal(this ReadOnlySpan a, ReadOnlySpan b) + => a.Remove(b, StringComparison.Ordinal); + + /// Returns the string with every occurrence of replaced by (ordinal, null-safe). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string? ReplaceOrdinal(this string? a, string o, string? n) + => a?.Replace(o, n, StringComparison.Ordinal); + + /// Returns true when the span starts with the character. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool StartsWithOrdinal(this ReadOnlySpan a, char b) + => a.Length > 0 && a[0] == b; + + /// Returns true when the span starts with (ordinal). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool StartsWithOrdinal(this ReadOnlySpan a, ReadOnlySpan b) + => a.StartsWith(b, StringComparison.Ordinal); + + /// Returns true when the string starts with (ordinal, null-safe). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool StartsWithOrdinal(this string? a, string? b) + => a is not null && b is not null && a.StartsWith(b, StringComparison.Ordinal); +} diff --git a/src/SquidStd.Core/Extensions/Strings/StringHelpers.cs b/src/SquidStd.Core/Extensions/Strings/StringHelpers.cs new file mode 100644 index 0000000..03ac0a1 --- /dev/null +++ b/src/SquidStd.Core/Extensions/Strings/StringHelpers.cs @@ -0,0 +1,306 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using SquidStd.Core.Buffers; + +namespace SquidStd.Core.Extensions.Strings; + +/// +/// General-purpose string manipulation helpers built on spans and the single-threaded array pool. +/// +public static class StringHelpers +{ + /// + /// Appends to the builder, prefixing "a "/"an " when the builder is + /// empty or a separating space otherwise. + /// + public static void AppendSpaceWithArticle(this ref ValueStringBuilder builder, string text, bool articleAn) + { + if (builder.Length != 0) + { + builder.Append(' '); + } + else + { + builder.Append(articleAn ? "an " : "a "); + } + + builder.Append(text); + } + + /// + /// Uppercases the first letter of every space-separated word, leaving the word "the " untouched + /// (title-style capitalization). + /// + public static string Capitalize(this string value) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + return string.Create( + value.Length, + value, + static (span, source) => + { + source.CopyTo(span); + var index = 0; + + while (index < span.Length) + { + ReadOnlySpan remaining = span[index..]; + + var isThe = remaining.StartsWith("the ", StringComparison.OrdinalIgnoreCase) + || remaining.Equals("the", StringComparison.OrdinalIgnoreCase); + + if (!isThe) + { + span[index] = char.ToUpperInvariant(span[index]); + } + + var nextSpace = remaining.IndexOf(' '); + + if (nextSpace == -1) + { + break; + } + + index += nextSpace + 1; + } + } + ); + } + + /// + /// Returns when the value is null, empty, or whitespace-only. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string DefaultIfNullOrEmpty(this string? value, string def) + => string.IsNullOrWhiteSpace(value) ? def : value; + + /// + /// Prefixes every line of a multiline string with . + /// + public static string IndentMultiline(this string str, string indent = "\t", string lineSeparator = "\n") + { + var parts = str.Split(lineSeparator); + + for (var i = 0; i < parts.Length; i++) + { + parts[i] = indent + parts[i]; + } + + return string.Join(lineSeparator, parts); + } + + /// + /// Finds the byte offset of the null terminator in a buffer of -byte + /// elements (1 = bytes, 2 = UTF-16 chars, 4 = uints). Returns -1 when absent. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IndexOfTerminator(this Span buffer, int sizeT) + => ((ReadOnlySpan)buffer).IndexOfTerminator(sizeT); + + /// + /// Finds the byte offset of the null terminator in a buffer of -byte + /// elements (1 = bytes, 2 = UTF-16 chars, 4 = uints). Returns -1 when absent. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int IndexOfTerminator(this ReadOnlySpan buffer, int sizeT) + { + var index = sizeT switch + { + 2 => MemoryMarshal.Cast(buffer).IndexOf((char)0), + 4 => MemoryMarshal.Cast(buffer).IndexOf(0u), + _ => buffer.IndexOf((byte)0) + }; + + return index == -1 ? -1 : index * sizeT; + } + + /// + /// Copies into with every occurrence of + /// removed; receives the written length. + /// Throws when the buffer is too small. + /// + public static void Remove( + this ReadOnlySpan a, + ReadOnlySpan b, + StringComparison comparison, + Span buffer, + out int size + ) + { + size = 0; + + if (a.IsEmpty) + { + return; + } + + var sliced = a; + + while (true) + { + var indexOf = b.IsEmpty ? -1 : sliced.IndexOf(b, comparison); + var chunk = indexOf == -1 ? sliced : sliced[..indexOf]; + + if (size + chunk.Length > buffer.Length) + { + throw new ArgumentException("The output buffer is too small.", nameof(buffer)); + } + + chunk.CopyTo(buffer[size..]); + size += chunk.Length; + + if (indexOf == -1) + { + break; + } + + sliced = sliced[(indexOf + b.Length)..]; + } + } + + /// + /// Returns a new string equal to with every occurrence of + /// removed. An empty input yields . + /// + public static string Remove(this ReadOnlySpan a, ReadOnlySpan b, StringComparison comparison) + { + if (a.IsEmpty) + { + return string.Empty; + } + + var rented = STArrayPool.Shared.Rent(a.Length); + + a.Remove(b, comparison, rented.AsSpan(0, a.Length), out var size); + + var result = new string(rented, 0, size); + + STArrayPool.Shared.Return(rented); + + return result; + } + + /// + /// Replaces, in place, every character found in with the + /// character at the same index in . + /// + /// has a different length than . + public static void ReplaceAny( + this Span chars, + ReadOnlySpan invalidChars, + ReadOnlySpan replacementChars + ) + { + if (invalidChars.Length != replacementChars.Length) + { + throw new ArgumentException("Replacement characters must have the same length as invalid characters.", nameof(replacementChars)); + } + + while (true) + { + var indexOf = chars.IndexOfAny(invalidChars); + + if (indexOf == -1) + { + break; + } + + chars[indexOf] = replacementChars[invalidChars.IndexOf(chars[indexOf])]; + chars = chars[(indexOf + 1)..]; + } + } + + /// + /// Copies the string into a buffer rented from . The CALLER owns + /// the array and must return it via STArrayPool<char>.Shared.Return(array); the + /// buffer may be longer than the string. + /// + public static char[] ToPooledArray(this string str) + { + var chars = STArrayPool.Shared.Rent(str.Length); + + str.CopyTo(chars); + + return chars; + } + + /// + /// Trims every line of a multiline string. + /// + public static string TrimMultiline(this string str, string lineSeparator = "\n") + { + var parts = str.Split(lineSeparator); + + for (var i = 0; i < parts.Length; i++) + { + parts[i] = parts[i].Trim(); + } + + return string.Join(lineSeparator, parts); + } + + /// + /// Word-wraps the trimmed value into lines of at most characters, + /// hard-breaking words longer than a line, up to lines. + /// Blank input yields an empty list. + /// + public static List Wrap(this string? value, int perLine, int maxLines) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(perLine); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLines); + + var lines = new List(); + var text = value?.Trim(); + + if (string.IsNullOrEmpty(text)) + { + return lines; + } + + var current = new System.Text.StringBuilder(perLine); + + foreach (var word in text.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + var remaining = word.AsSpan(); + + while (!remaining.IsEmpty) + { + var separatorLength = current.Length == 0 ? 0 : 1; + var available = perLine - current.Length - separatorLength; + + if (remaining.Length <= available) + { + current.Append(current.Length == 0 ? string.Empty : " ").Append(remaining); + remaining = default; + } + else if (current.Length == 0) + { + // Hard-break a word longer than a whole line. + current.Append(remaining[..perLine]); + remaining = remaining[perLine..]; + } + else + { + lines.Add(current.ToString()); + current.Clear(); + + if (lines.Count == maxLines) + { + return lines; + } + } + } + } + + if (current.Length > 0 && lines.Count < maxLines) + { + lines.Add(current.ToString()); + } + + return lines; + } +} diff --git a/src/SquidStd.Core/README.md b/src/SquidStd.Core/README.md index 563b1f8..15c9fa0 100644 --- a/src/SquidStd.Core/README.md +++ b/src/SquidStd.Core/README.md @@ -2,7 +2,8 @@ Foundational contracts and utilities for the SquidStd stack. It defines the core service interfaces (configuration, event bus, jobs, timing, metrics, storage) and ships dependency-free helpers - YAML/JSON -serialization, a Serilog event sink, and string/environment/directory extensions - that the other +serialization, a Serilog event sink, string/environment/directory extensions, pooled buffers +(`STArrayPool`, `ValueStringBuilder`), and ordinal/case-insensitive string helpers - that the other SquidStd packages build on. ## Install @@ -47,6 +48,13 @@ var builder = pool.Get(); pool.Return(builder); ``` +```csharp +using SquidStd.Core.Extensions.Strings; + +"hello world".StartsWithOrdinal("hello"); // true, no culture lookup +"Hello".InsensitiveEquals("hELLO"); // true +``` + ## Key types | Type | Purpose | @@ -65,6 +73,7 @@ pool.Return(builder); ## Related - Tutorial: [Events, jobs & scheduling](https://tgiachi.github.io/squid-std/tutorials/events-jobs-scheduling.html) +- Concept: [Buffers and pooled strings](https://tgiachi.github.io/squid-std/articles/concepts/buffers.html) ## License diff --git a/src/SquidStd.Core/SquidStd.Core.csproj b/src/SquidStd.Core/SquidStd.Core.csproj index 33720bd..25dc433 100644 --- a/src/SquidStd.Core/SquidStd.Core.csproj +++ b/src/SquidStd.Core/SquidStd.Core.csproj @@ -18,4 +18,8 @@ + + + + diff --git a/src/SquidStd.Core/Types/Buffers/STArrayPoolMemoryPressureType.cs b/src/SquidStd.Core/Types/Buffers/STArrayPoolMemoryPressureType.cs new file mode 100644 index 0000000..96bb92a --- /dev/null +++ b/src/SquidStd.Core/Types/Buffers/STArrayPoolMemoryPressureType.cs @@ -0,0 +1,16 @@ +namespace SquidStd.Core.Types.Buffers; + +/// +/// GC memory pressure levels used to decide how aggressively trims. +/// +internal enum STArrayPoolMemoryPressureType +{ + /// Memory load is well below the high-load threshold. + Low, + + /// Memory load is approaching the high-load threshold. + Medium, + + /// Memory load is at or above the high-load threshold. + High +} diff --git a/tests/SquidStd.Tests/Core/Buffers/PooledArraySpanFormattableTests.cs b/tests/SquidStd.Tests/Core/Buffers/PooledArraySpanFormattableTests.cs new file mode 100644 index 0000000..7e8e62f --- /dev/null +++ b/tests/SquidStd.Tests/Core/Buffers/PooledArraySpanFormattableTests.cs @@ -0,0 +1,69 @@ +using SquidStd.Core.Buffers; + +namespace SquidStd.Tests.Core.Buffers; + +public class PooledArraySpanFormattableTests +{ + private static PooledArraySpanFormattable Create(string content) + { + var array = STArrayPool.Shared.Rent(content.Length); + content.CopyTo(array); + + return new(array, content.Length); + } + + [Fact] + public void Chars_ExposesTheWrittenSlice() + { + using var formattable = Create("abc"); + + Assert.Equal("abc", formattable.Chars.ToString()); + } + + [Fact] + public void ToString_ReturnsContent_AndIsIdempotent() + { + var formattable = Create("hello"); + + var first = formattable.ToString(null, null); + var second = formattable.ToString(null, null); + + Assert.Equal("hello", first); + Assert.Same(first, second); + } + + [Fact] + public void ImplicitStringConversion_ReturnsContent() + { + var formattable = Create("implicit"); + + string value = formattable; + + Assert.Equal("implicit", value); + } + + [Fact] + public void TryFormat_CopiesIntoDestination_AndReturnsArray() + { + var formattable = Create("copy me"); + Span destination = stackalloc char[16]; + + var ok = formattable.TryFormat(destination, out var written); + + Assert.True(ok); + Assert.Equal(7, written); + Assert.Equal("copy me", destination[..written].ToString()); + } + + [Fact] + public void TryFormat_DestinationTooSmall_ReturnsFalse() + { + using var formattable = Create("too long for this"); + Span destination = stackalloc char[4]; + + var ok = formattable.TryFormat(destination, out var written); + + Assert.False(ok); + Assert.Equal(0, written); + } +} diff --git a/tests/SquidStd.Tests/Core/Buffers/STArrayPoolTests.cs b/tests/SquidStd.Tests/Core/Buffers/STArrayPoolTests.cs new file mode 100644 index 0000000..15662d7 --- /dev/null +++ b/tests/SquidStd.Tests/Core/Buffers/STArrayPoolTests.cs @@ -0,0 +1,142 @@ +using SquidStd.Core.Buffers; +using SquidStd.Core.Types.Buffers; + +namespace SquidStd.Tests.Core.Buffers; + +public class STArrayPoolTests +{ + private readonly record struct RentItem(int Value); + private readonly record struct ReuseItem(int Value); + private readonly record struct StackItem(int Value); + private readonly record struct ClearItem(int Value); + private readonly record struct ForeignItem(int Value); + private readonly record struct TrimHighItem(int Value); + private readonly record struct TrimMediumItem(int Value); + + [Fact] + public void Rent_ReturnsArrayAtLeastRequestedSize() + { + var array = STArrayPool.Shared.Rent(100); + + Assert.True(array.Length >= 100); + Assert.Equal(128, array.Length); // bucket size 16 << index + } + + [Fact] + public void Rent_ZeroLength_ReturnsEmptySingleton() + { + var first = STArrayPool.Shared.Rent(0); + var second = STArrayPool.Shared.Rent(0); + + Assert.Empty(first); + Assert.Same(first, second); + } + + [Fact] + public void Rent_NegativeLength_Throws() + { + Assert.Throws(() => STArrayPool.Shared.Rent(-1)); + } + + [Fact] + public void ReturnThenRent_ReusesSameInstance() + { + var pool = STArrayPool.Shared; + var array = pool.Rent(64); + + pool.Return(array); + var again = pool.Rent(64); + + Assert.Same(array, again); + } + + [Fact] + public void Return_TwoArraysSameBucket_BothAreRecovered() + { + var pool = STArrayPool.Shared; + var first = pool.Rent(64); + var second = pool.Rent(64); + + pool.Return(first); + pool.Return(second); // displaces first from the cache bucket into the stack + + var rentedA = pool.Rent(64); + var rentedB = pool.Rent(64); + + Assert.Same(second, rentedA); // cache bucket hit first + Assert.Same(first, rentedB); // then the stack + + pool.Return(rentedA); + pool.Return(rentedB); + } + + [Fact] + public void Return_WithClearArray_ZeroesContents() + { + var pool = STArrayPool.Shared; + var array = pool.Rent(16); + array[0] = new(42); + + pool.Return(array, clearArray: true); + var again = pool.Rent(16); + + Assert.Same(array, again); + Assert.Equal(default, again[0]); + pool.Return(again); + } + + [Fact] + public void Return_ForeignSizedArray_Throws() + { + // 100 is not a bucket size (16 << n), so it cannot come from this pool. + Assert.Throws(() => STArrayPool.Shared.Return(new ForeignItem[100])); + } + + [Fact] + public void TrimCore_HighPressure_ReleasesCacheAndStacks() + { + var pool = STArrayPool.Shared; + var first = pool.Rent(64); + var second = pool.Rent(64); + pool.Return(first); + pool.Return(second); // cache = second, stack = first + + pool.TrimCore(1_000, STArrayPoolMemoryPressureType.High); // clears cache, stamps stack ticks + pool.TrimCore(21_000, STArrayPoolMemoryPressureType.High); // past the 10s stack threshold -> trims stack + + var rentedA = pool.Rent(64); + var rentedB = pool.Rent(64); + + Assert.NotSame(second, rentedA); + Assert.NotSame(first, rentedA); + Assert.NotSame(first, rentedB); + } + + [Fact] + public void TrimCore_MediumPressure_ReleasesStaleCacheBuckets() + { + var pool = STArrayPool.Shared; + var array = pool.Rent(64); + pool.Return(array); + + pool.TrimCore(1_000, STArrayPoolMemoryPressureType.Medium); // stamps cache bucket ticks + pool.TrimCore(12_000, STArrayPoolMemoryPressureType.Medium); // past the 10s medium threshold -> releases + + var again = pool.Rent(64); + + Assert.NotSame(array, again); + } + + [Theory] + [InlineData(1, 16)] + [InlineData(16, 16)] + [InlineData(17, 32)] + [InlineData(1024, 1024)] + [InlineData(1025, 2048)] + public void BucketMath_RoundsUpToNextBucketSize(int requested, int expected) + { + var index = STArrayPool.SelectBucketIndex(requested); + + Assert.Equal(expected, STArrayPool.GetMaxSizeForBucket(index)); + } +} diff --git a/tests/SquidStd.Tests/Core/Buffers/ValueStringBuilderTests.cs b/tests/SquidStd.Tests/Core/Buffers/ValueStringBuilderTests.cs new file mode 100644 index 0000000..4545bb9 --- /dev/null +++ b/tests/SquidStd.Tests/Core/Buffers/ValueStringBuilderTests.cs @@ -0,0 +1,114 @@ +using SquidStd.Core.Buffers; + +namespace SquidStd.Tests.Core.Buffers; + +public class ValueStringBuilderTests +{ + [Fact] + public void Append_CharsAndStrings_BuildsContent() + { + using var builder = new ValueStringBuilder(stackalloc char[16]); + + builder.Append('a'); + builder.Append("bc"); + builder.Append('d', 3); + + Assert.Equal(6, builder.Length); + Assert.Equal("abcddd", builder.ToString()); + } + + [Fact] + public void Append_GrowsBeyondInitialBuffer_PreservesContent() + { + var builder = new ValueStringBuilder(stackalloc char[4]); + var expected = new string('x', 100); + + builder.Append(expected); + + Assert.Equal(100, builder.Length); + Assert.Equal(expected, builder.ToString()); + } + + [Fact] + public void Constructor_FromInitialString_CopiesIt() + { + var builder = new ValueStringBuilder("hello"); + + Assert.Equal("hello", builder.ToString()); + } + + [Fact] + public void Insert_ShiftsExistingContent() + { + var builder = new ValueStringBuilder(stackalloc char[16]); + builder.Append("held"); + + builder.Insert(2, "llo wor"); + + Assert.Equal("hello world", builder.ToString()); + } + + [Fact] + public void Indexer_AllowsInPlaceMutation() + { + var builder = new ValueStringBuilder(stackalloc char[8]); + builder.Append("cat"); + + builder[0] = 'b'; + + Assert.Equal("bat", builder.ToString()); + } + + [Fact] + public void EnsureCapacity_GrowsCapacity() + { + var builder = new ValueStringBuilder(4); + + builder.EnsureCapacity(64); + + Assert.True(builder.Capacity >= 64); + builder.Dispose(); + } + + [Fact] + public void Append_InterpolatedHandler_FormatsValues() + { + var builder = new ValueStringBuilder(stackalloc char[32]); + + builder.Append($"value {42} and {"text"}"); + + Assert.Equal("value 42 and text", builder.ToString()); + } + + [Fact] + public void MultiThreadedMode_UsesSharedPoolAndRoundTrips() + { + var builder = new ValueStringBuilder(8, mt: true); + + builder.Append("thread safe pool path"); + + Assert.Equal("thread safe pool path", builder.ToString()); + } + + [Fact] + public void Replace_HonorsStartIndexAndCount() + { + var builder = new ValueStringBuilder(stackalloc char[16]); + builder.Append("aaaa"); + + builder.Replace('a', 'b', 1, 2); + + Assert.Equal("abba", builder.ToString()); + } + + [Fact] + public void ReplaceAny_HonorsStartIndexAndCount() + { + var builder = new ValueStringBuilder(stackalloc char[16]); + builder.Append("a/a/"); + + builder.ReplaceAny("/".AsSpan(), "-".AsSpan(), 0, 2); + + Assert.Equal("a-a/", builder.ToString()); + } +} diff --git a/tests/SquidStd.Tests/Core/Extensions/Collections/CollectionExtensionsTests.cs b/tests/SquidStd.Tests/Core/Extensions/Collections/CollectionExtensionsTests.cs new file mode 100644 index 0000000..327af06 --- /dev/null +++ b/tests/SquidStd.Tests/Core/Extensions/Collections/CollectionExtensionsTests.cs @@ -0,0 +1,17 @@ +using SquidStd.Core.Extensions.Collections; + +namespace SquidStd.Tests.Core.Extensions.Collections; + +public class CollectionExtensionsTests +{ + [Fact] + public void AddNotNull_AddsNonNull_SkipsNull() + { + var list = new List(); + + list.AddNotNull("value"); + list.AddNotNull(null); + + Assert.Equal(["value"], list); + } +} diff --git a/tests/SquidStd.Tests/Core/Extensions/Network/IpAddressExtensionsTests.cs b/tests/SquidStd.Tests/Core/Extensions/Network/IpAddressExtensionsTests.cs new file mode 100644 index 0000000..4cfb2b3 --- /dev/null +++ b/tests/SquidStd.Tests/Core/Extensions/Network/IpAddressExtensionsTests.cs @@ -0,0 +1,30 @@ +using System.Net; +using SquidStd.Core.Extensions.Network; + +namespace SquidStd.Tests.Core.Extensions.Network; + +public class IpAddressExtensionsTests +{ + [Fact] + public void ToRawAddress_LoopbackIsLittleEndianOfBytes() + { + // 127.0.0.1 -> bytes { 127, 0, 0, 1 } read little-endian = 0x0100007F + Assert.Equal(0x0100007Fu, IPAddress.Loopback.ToRawAddress()); + } + + [Fact] + public void ToRawAddress_Ipv6MappedIpv4_IsUnwrapped() + => Assert.Equal(0x0100007Fu, IPAddress.Parse("::ffff:127.0.0.1").ToRawAddress()); + + [Fact] + public void ToRawAddress_EndpointDelegatesToAddress() + { + var endPoint = new IPEndPoint(IPAddress.Parse("192.168.1.10"), 2593); + + Assert.Equal(IPAddress.Parse("192.168.1.10").ToRawAddress(), endPoint.ToRawAddress()); + } + + [Fact] + public void ToRawAddress_PureIpv6_Throws() + => Assert.Throws(() => IPAddress.IPv6Loopback.ToRawAddress()); +} diff --git a/tests/SquidStd.Tests/Core/Extensions/Strings/InsensitiveStringHelpersTests.cs b/tests/SquidStd.Tests/Core/Extensions/Strings/InsensitiveStringHelpersTests.cs new file mode 100644 index 0000000..025fcf6 --- /dev/null +++ b/tests/SquidStd.Tests/Core/Extensions/Strings/InsensitiveStringHelpersTests.cs @@ -0,0 +1,67 @@ +using SquidStd.Core.Extensions.Strings; + +namespace SquidStd.Tests.Core.Extensions.Strings; + +public class InsensitiveStringHelpersTests +{ + [Fact] + public void InsensitiveEquals_IgnoresCase() + { + Assert.True("Hello".InsensitiveEquals("hELLO")); + Assert.False("Hello".InsensitiveEquals("world")); + Assert.True("Hello".AsSpan().InsensitiveEquals("hELLO".AsSpan())); + Assert.True("Hello".InsensitiveEquals("hELLO".AsSpan())); + } + + [Fact] + public void InsensitiveStartsWithAndEndsWith() + { + Assert.True("Hello World".InsensitiveStartsWith("hello")); + Assert.True("Hello World".AsSpan().InsensitiveStartsWith("HELLO".AsSpan())); + Assert.True("Hello World".InsensitiveEndsWith("WORLD")); + Assert.True("Hello World".AsSpan().InsensitiveEndsWith("world".AsSpan())); + Assert.False(((string?)null).InsensitiveStartsWith("x")); + } + + [Fact] + public void InsensitiveContains_AllOverloads() + { + Assert.True("Hello World".InsensitiveContains("LO WO")); + Assert.True("Hello".InsensitiveContains('H')); + Assert.True("hello".InsensitiveContains('H')); + Assert.True("Hello".AsSpan().InsensitiveContains("ELL")); + Assert.True("Hello".AsSpan().InsensitiveContains("ell".AsSpan())); + } + + [Fact] + public void InsensitiveCompare_OrdersIgnoringCase() + { + Assert.Equal(0, "ABC".InsensitiveCompare("abc")); + Assert.Equal(0, "ABC".AsSpan().InsensitiveCompare("abc".AsSpan())); + Assert.True("abc".InsensitiveCompare("ABD") < 0); + } + + [Fact] + public void InsensitiveIndexOf_AllOverloads() + { + Assert.Equal(0, "Hello".InsensitiveIndexOf('h')); + Assert.Equal(2, "ababa".InsensitiveIndexOf("AB", 1)); + Assert.Equal(1, "Hello".InsensitiveIndexOf("ELL")); + Assert.Equal(1, "Hello".AsSpan().InsensitiveIndexOf("ELL".AsSpan())); + } + + [Fact] + public void InsensitiveRemove_RemovesAllMatchesIgnoringCase() + { + Assert.Equal("aa", "aBCabc".InsensitiveRemove("bc")); + Assert.Equal("aa", "aBCabc".AsSpan().InsensitiveRemove("bc".AsSpan())); + + Span buffer = stackalloc char[8]; + "aBCabc".AsSpan().InsensitiveRemove("bc".AsSpan(), buffer, out var size); + Assert.Equal("aa", buffer[..size].ToString()); + } + + [Fact] + public void InsensitiveReplace_ReplacesIgnoringCase() + => Assert.Equal("aXaX", "aBcabC".InsensitiveReplace("bc", "X")); +} diff --git a/tests/SquidStd.Tests/Core/Extensions/Strings/OrdinalStringHelpersTests.cs b/tests/SquidStd.Tests/Core/Extensions/Strings/OrdinalStringHelpersTests.cs new file mode 100644 index 0000000..3652c86 --- /dev/null +++ b/tests/SquidStd.Tests/Core/Extensions/Strings/OrdinalStringHelpersTests.cs @@ -0,0 +1,95 @@ +using SquidStd.Core.Extensions.Strings; + +namespace SquidStd.Tests.Core.Extensions.Strings; + +public class OrdinalStringHelpersTests +{ + [Theory] + [InlineData("hello world", "hello", true)] + [InlineData("hello world", "Hello", false)] + [InlineData("hello", "hello world", false)] + public void StartsWithOrdinal_String(string a, string b, bool expected) + => Assert.Equal(expected, a.StartsWithOrdinal(b)); + + [Fact] + public void StartsWithOrdinal_NullInstance_ReturnsFalse() + => Assert.False(((string?)null).StartsWithOrdinal("x")); + + [Fact] + public void StartsWithOrdinal_Span() + { + Assert.True("hello".AsSpan().StartsWithOrdinal("he".AsSpan())); + Assert.True("hello".AsSpan().StartsWithOrdinal('h')); + Assert.False("hello".AsSpan().StartsWithOrdinal('H')); + Assert.False(ReadOnlySpan.Empty.StartsWithOrdinal('h')); + } + + [Theory] + [InlineData("hello world", "world", true)] + [InlineData("hello world", "World", false)] + public void EndsWithOrdinal_String(string a, string b, bool expected) + => Assert.Equal(expected, a.EndsWithOrdinal(b)); + + [Fact] + public void EndsWithOrdinal_Span() + { + Assert.True("hello".AsSpan().EndsWithOrdinal("lo".AsSpan())); + Assert.True("hello".AsSpan().EndsWithOrdinal('o')); + Assert.False(ReadOnlySpan.Empty.EndsWithOrdinal('o')); + } + + [Fact] + public void EqualsOrdinal_IsCaseSensitive_AndNullSafe() + { + Assert.True("abc".EqualsOrdinal("abc")); + Assert.False("abc".EqualsOrdinal("ABC")); + Assert.True(((string?)null).EqualsOrdinal(null)); + Assert.False("abc".EqualsOrdinal(null)); + Assert.True("abc".AsSpan().EqualsOrdinal("abc")); + } + + [Fact] + public void ContainsOrdinal_StringSpanAndChar() + { + Assert.True("hello world".ContainsOrdinal("lo w")); + Assert.False("hello world".ContainsOrdinal("LO W")); + Assert.True("hello".ContainsOrdinal('e')); + Assert.True("hello".AsSpan().ContainsOrdinal("ell")); + Assert.True("hello".AsSpan().ContainsOrdinal("ell".AsSpan())); + } + + [Fact] + public void CompareOrdinal_MatchesStringCompareOrdinal() + { + Assert.Equal(string.CompareOrdinal("a", "b"), "a".CompareOrdinal("b")); + Assert.Equal(0, "abc".AsSpan().CompareOrdinal("abc".AsSpan())); + } + + [Fact] + public void IndexOfOrdinal_AllOverloads() + { + Assert.Equal(1, "hello".IndexOfOrdinal('e')); + Assert.Equal(2, "ababa".IndexOfOrdinal("ab", 1)); + Assert.Equal(-1, "hello".IndexOfOrdinal("L")); + Assert.Equal(1, "hello".AsSpan().IndexOfOrdinal('e')); + Assert.Equal(2, "hello".AsSpan().IndexOfOrdinal("ll".AsSpan())); + } + + [Fact] + public void RemoveOrdinal_RemovesAllMatches() + { + Assert.Equal("aa", "abcabc".RemoveOrdinal("bc")); + Assert.Equal("aa", "abcabc".AsSpan().RemoveOrdinal("bc".AsSpan())); + + Span buffer = stackalloc char[8]; + "abcabc".AsSpan().RemoveOrdinal("bc".AsSpan(), buffer, out var size); + Assert.Equal("aa", buffer[..size].ToString()); + } + + [Fact] + public void ReplaceOrdinal_IsCaseSensitive() + { + Assert.Equal("aXcaXc", "abcabc".ReplaceOrdinal("b", "X")); + Assert.Equal("abcabc", "abcabc".ReplaceOrdinal("B", "X")); + } +} diff --git a/tests/SquidStd.Tests/Core/Extensions/Strings/StringHelpersTests.cs b/tests/SquidStd.Tests/Core/Extensions/Strings/StringHelpersTests.cs new file mode 100644 index 0000000..55125b8 --- /dev/null +++ b/tests/SquidStd.Tests/Core/Extensions/Strings/StringHelpersTests.cs @@ -0,0 +1,133 @@ +using SquidStd.Core.Buffers; +using SquidStd.Core.Extensions.Strings; + +namespace SquidStd.Tests.Core.Extensions.Strings; + +public class StringHelpersTests +{ + [Theory] + [InlineData("hello world", "Hello World")] + [InlineData("the quick fox", "the Quick Fox")] + [InlineData("a", "A")] + [InlineData("", "")] + [InlineData("say the", "Say the")] + [InlineData("the", "the")] + public void Capitalize_UppercasesWordsSkippingLeadingThe(string input, string expected) + => Assert.Equal(expected, input.Capitalize()); + + [Theory] + [InlineData("value", "default", "value")] + [InlineData("", "default", "default")] + [InlineData(" ", "default", "default")] + [InlineData(null, "default", "default")] + public void DefaultIfNullOrEmpty_FallsBackOnBlank(string? input, string fallback, string expected) + => Assert.Equal(expected, input.DefaultIfNullOrEmpty(fallback)); + + [Fact] + public void IndentMultiline_PrefixesEveryLine() + => Assert.Equal("\ta\n\tb", "a\nb".IndentMultiline()); + + [Fact] + public void TrimMultiline_TrimsEveryLine() + => Assert.Equal("a\nb", " a \n b ".TrimMultiline()); + + [Theory] + [InlineData(new byte[] { 65, 66, 0, 67 }, 1, 2)] + [InlineData(new byte[] { 65, 66, 67 }, 1, -1)] + [InlineData(new byte[] { 65, 0, 0, 0, 66, 0 }, 2, 2)] + public void IndexOfTerminator_FindsNullTerminatorScaledBySize(byte[] buffer, int sizeT, int expected) + => Assert.Equal(expected, ((ReadOnlySpan)buffer).IndexOfTerminator(sizeT)); + + [Fact] + public void Remove_WithComparison_RemovesFullMatches() + { + Assert.Equal("aa", "aBCabc".AsSpan().Remove("bc".AsSpan(), StringComparison.OrdinalIgnoreCase)); + Assert.Equal("abcabc", "abcabc".AsSpan().Remove("X".AsSpan(), StringComparison.Ordinal)); + Assert.Equal(string.Empty, ReadOnlySpan.Empty.Remove("x".AsSpan(), StringComparison.Ordinal)); + } + + [Fact] + public void Remove_BufferTooSmall_ThrowsArgumentException() + { + Assert.Throws(() => + { + Span buffer = stackalloc char[2]; + "abcdef".AsSpan().Remove("x".AsSpan(), StringComparison.Ordinal, buffer, out _); + }); + } + + [Fact] + public void ReplaceAny_SwapsInvalidCharsWithReplacements() + { + Span chars = stackalloc char[5]; + "a/b\\c".AsSpan().CopyTo(chars); + + chars.ReplaceAny("/\\".AsSpan(), "--".AsSpan()); + + Assert.Equal("a-b-c", chars.ToString()); + } + + [Fact] + public void ReplaceAny_MismatchedReplacementLength_Throws() + { + Assert.Throws(() => + { + Span chars = stackalloc char[3]; + "a/b".AsSpan().CopyTo(chars); + + chars.ReplaceAny("/:".AsSpan(), "-".AsSpan()); + }); + } + + [Fact] + public void ToPooledArray_CopiesContent_CallerReturnsIt() + { + var array = "pooled".ToPooledArray(); + + Assert.True(array.Length >= 6); + Assert.Equal("pooled", array.AsSpan(0, 6).ToString()); + + STArrayPool.Shared.Return(array); + } + + [Fact] + public void Wrap_WrapsAtWordBoundaries() + { + var lines = "the quick brown fox jumps".Wrap(11, 5); + + Assert.Equal(["the quick", "brown fox", "jumps"], lines); + } + + [Fact] + public void Wrap_BreaksWordsLongerThanLine() + { + var lines = "abcdefghij".Wrap(4, 5); + + Assert.Equal(["abcd", "efgh", "ij"], lines); + } + + [Fact] + public void Wrap_StopsAtMaxLines() + { + var lines = "a b c d e".Wrap(1, 2); + + Assert.Equal(["a", "b"], lines); + } + + [Fact] + public void Wrap_EmptyInput_ReturnsEmptyList() + => Assert.Empty(" ".Wrap(10, 3)); + + [Fact] + public void AppendSpaceWithArticle_PrependsArticleOnlyWhenEmpty() + { + var builder = new ValueStringBuilder(stackalloc char[32]); + builder.AppendSpaceWithArticle("apple", articleAn: true); + Assert.Equal("an apple", builder.ToString()); + + var second = new ValueStringBuilder(stackalloc char[32]); + second.Append("one"); + second.AppendSpaceWithArticle("sword", articleAn: false); + Assert.Equal("one sword", second.ToString()); + } +}