diff --git a/ZString.sln b/ZString.sln index e176b99..34f651a 100644 --- a/ZString.sln +++ b/ZString.sln @@ -25,6 +25,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ZString.Tests", "tests\ZStr EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PerfBenchmark", "sandbox\PerfBenchmark\PerfBenchmark.csproj", "{D766AEB3-3609-4F1D-8D81-5549F748F372}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ZString.NetTests", "tests\ZString.NetTests\ZString.NetTests.csproj", "{ED2423B1-0069-427B-B378-75BD8654F99E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -47,6 +49,10 @@ Global {D766AEB3-3609-4F1D-8D81-5549F748F372}.Debug|Any CPU.Build.0 = Debug|Any CPU {D766AEB3-3609-4F1D-8D81-5549F748F372}.Release|Any CPU.ActiveCfg = Release|Any CPU {D766AEB3-3609-4F1D-8D81-5549F748F372}.Release|Any CPU.Build.0 = Release|Any CPU + {ED2423B1-0069-427B-B378-75BD8654F99E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ED2423B1-0069-427B-B378-75BD8654F99E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED2423B1-0069-427B-B378-75BD8654F99E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ED2423B1-0069-427B-B378-75BD8654F99E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -56,6 +62,7 @@ Global {9ADF67E1-1872-43D3-882E-607071726FE7} = {A7D7AA7D-9A79-48A8-978D-0C98EBD81ED0} {62090C00-9727-4375-BE40-ABE2F4D41571} = {0803618F-C4E8-4D37-831E-5D26C5574F49} {D766AEB3-3609-4F1D-8D81-5549F748F372} = {A7D7AA7D-9A79-48A8-978D-0C98EBD81ED0} + {ED2423B1-0069-427B-B378-75BD8654F99E} = {0803618F-C4E8-4D37-831E-5D26C5574F49} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DF39BF43-3E0E-4F7D-9943-7E50D301234D} diff --git a/src/ZString.Unity/Assets/Scripts/ZString/Utf16ValueStringBuilder.cs b/src/ZString.Unity/Assets/Scripts/ZString/Utf16ValueStringBuilder.cs index df6ec3b..44675b3 100644 --- a/src/ZString.Unity/Assets/Scripts/ZString/Utf16ValueStringBuilder.cs +++ b/src/ZString.Unity/Assets/Scripts/ZString/Utf16ValueStringBuilder.cs @@ -260,6 +260,30 @@ public void AppendLine(ReadOnlySpan value) AppendLine(); } +#if NET6_0_OR_GREATER + /// Appends a contiguous region of arbitrary memory to this instance. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(ZStringInterpolatedStringHandler value) + { + AppendCore(value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLine(ZStringInterpolatedStringHandler value) + { + AppendCore(value); + AppendLine(); + } + + /// Appends a contiguous region of arbitrary memory to this instance. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void AppendCore(ZStringInterpolatedStringHandler value) + { + value.GetString(ref this); + value.Dispose(); + } +#endif + /// Appends the string representation of a specified value to this instance. public void Append(T value) { @@ -350,6 +374,53 @@ public void Insert(int index, ReadOnlySpan value, int count) this.index = newBufferIndex + remainLnegth; } +#if NET6_0_OR_GREATER + public void Insert(int index, ZStringInterpolatedStringHandler value, int count) + { + if (count < 0) + { + ExceptionUtil.ThrowArgumentOutOfRangeException(nameof(count)); + } + + int currentLength = Length; + if ((uint)index > (uint)currentLength) + { + ExceptionUtil.ThrowArgumentOutOfRangeException(nameof(index)); + } + + if (value.Length == 0 || count == 0) + { + return; + } + + var newSize = index + value.Length * count; + var newBuffer = ArrayPool.Shared.Rent(Math.Max(DefaultBufferSize, newSize)); + + buffer.AsSpan(0, index).CopyTo(newBuffer); + int newBufferIndex = index; + + for (int i = 0; i < count; i++) + { + value.CopyTo(newBuffer.AsSpan(newBufferIndex)); + newBufferIndex += value.Length; + } + + int remainLnegth = this.index - index; + buffer.AsSpan(index, remainLnegth).CopyTo(newBuffer.AsSpan(newBufferIndex)); + + if (buffer!.Length != ThreadStaticBufferSize) + { + if (buffer != null) + { + ArrayPool.Shared.Return(buffer); + } + } + + buffer = newBuffer; + this.index = newBufferIndex + remainLnegth; + } +#endif + /// /// Replaces all instances of one character with another in this builder. /// @@ -401,6 +472,12 @@ public void Replace(char oldChar, char newChar, int startIndex, int count) public void Replace(ReadOnlySpan oldValue, ReadOnlySpan newValue) => Replace(oldValue, newValue, 0, Length); +#if NET6_0_OR_GREATER + public void Replace(string oldValue, ZStringInterpolatedStringHandler newValue) => Replace(oldValue, newValue, 0, Length); + + public void Replace(ReadOnlySpan oldValue, ZStringInterpolatedStringHandler newValue) => Replace(oldValue, newValue, 0, Length); +#endif + /// /// Replaces all instances of one string with another in part of this builder. /// @@ -422,6 +499,18 @@ public void Replace(string oldValue, string newValue, int startIndex, int count) Replace(oldValue.AsSpan(), newValue.AsSpan(), startIndex, count); } +#if NET6_0_OR_GREATER + public void Replace(string oldValue, ZStringInterpolatedStringHandler newValue, int startIndex, int count) + { + if (oldValue == null) + { + throw new ArgumentNullException(nameof(oldValue)); + } + + Replace(oldValue.AsSpan(), newValue, startIndex, count); + } +#endif + public void Replace(ReadOnlySpan oldValue, ReadOnlySpan newValue, int startIndex, int count) { int currentLength = Length; @@ -489,7 +578,77 @@ public void Replace(ReadOnlySpan oldValue, ReadOnlySpan newValue, in buffer = newBuffer; index = newBufferIndex; } - + +#if NET6_0_OR_GREATER + public void Replace(ReadOnlySpan oldValue, ZStringInterpolatedStringHandler newValue, int startIndex, int count) + { + int currentLength = Length; + + if ((uint)startIndex > (uint)currentLength) + { + ExceptionUtil.ThrowArgumentOutOfRangeException(nameof(startIndex)); + } + + if (count < 0 || startIndex > currentLength - count) + { + ExceptionUtil.ThrowArgumentOutOfRangeException(nameof(count)); + } + + if (oldValue.Length == 0) + { + throw new ArgumentException("oldValue.Length is 0", nameof(oldValue)); + } + + var readOnlySpan = AsSpan(); + int endIndex = startIndex + count; + int matchCount = 0; + + for (int i = startIndex; i < endIndex; i += oldValue.Length) + { + var span = readOnlySpan.Slice(i, endIndex - i); + var pos = span.IndexOf(oldValue, StringComparison.Ordinal); + if (pos == -1) + { + break; + } + i += pos; + matchCount++; + } + + if (matchCount == 0) + return; + + var newBuffer = ArrayPool.Shared.Rent(Math.Max(DefaultBufferSize, Length + (newValue.Length - oldValue.Length) * matchCount)); + + buffer.AsSpan(0, startIndex).CopyTo(newBuffer); + int newBufferIndex = startIndex; + + for (int i = startIndex; i < endIndex; i += oldValue.Length) + { + var span = readOnlySpan.Slice(i, endIndex - i); + var pos = span.IndexOf(oldValue, StringComparison.Ordinal); + if (pos == -1) + { + var remain = readOnlySpan.Slice(i); + remain.CopyTo(newBuffer.AsSpan(newBufferIndex)); + newBufferIndex += remain.Length; + break; + } + readOnlySpan.Slice(i, pos).CopyTo(newBuffer.AsSpan(newBufferIndex)); + newValue.CopyTo(newBuffer.AsSpan(newBufferIndex + pos)); + newBufferIndex += pos + newValue.Length; + i += pos; + } + + if (buffer!.Length != ThreadStaticBufferSize) + { + ArrayPool.Shared.Return(buffer); + } + buffer = newBuffer; + index = newBufferIndex; + } +#endif + /// /// Replaces the contents of a single position within the builder. /// @@ -502,7 +661,7 @@ public void ReplaceAt(char newChar, int replaceIndex) { ExceptionUtil.ThrowArgumentOutOfRangeException(nameof(replaceIndex)); } - + buffer![replaceIndex] = newChar; } diff --git a/src/ZString.Unity/Assets/Scripts/ZString/Utf8ValueStringBuilder.cs b/src/ZString.Unity/Assets/Scripts/ZString/Utf8ValueStringBuilder.cs index 19df488..a84bec2 100644 --- a/src/ZString.Unity/Assets/Scripts/ZString/Utf8ValueStringBuilder.cs +++ b/src/ZString.Unity/Assets/Scripts/ZString/Utf8ValueStringBuilder.cs @@ -278,6 +278,30 @@ public void AppendLine(ReadOnlySpan value) AppendLine(); } +#if NET6_0_OR_GREATER + /// Appends a contiguous region of arbitrary memory to this instance. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(ZStringInterpolatedStringHandler value) + { + AppendCore(value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLine(ZStringInterpolatedStringHandler value) + { + AppendCore(value); + AppendLine(); + } + + /// Appends a contiguous region of arbitrary memory to this instance. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void AppendCore(ZStringInterpolatedStringHandler value) + { + value.GetString(ref this); + value.Dispose(); + } +#endif + public void AppendLiteral(ReadOnlySpan value) { if ((buffer!.Length - index) < value.Length) diff --git a/src/ZString.Unity/Assets/Scripts/ZString/ZStringInterpolatedStringHandler.cs b/src/ZString.Unity/Assets/Scripts/ZString/ZStringInterpolatedStringHandler.cs new file mode 100644 index 0000000..6bad822 --- /dev/null +++ b/src/ZString.Unity/Assets/Scripts/ZString/ZStringInterpolatedStringHandler.cs @@ -0,0 +1,514 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.Globalization; +using System.Runtime.CompilerServices; + +// ReSharper disable MergeCastWithTypeCheck + +#if NET6_0_OR_GREATER + +namespace Cysharp.Text +{ + [DebuggerDisplay("{_buffer[.._index]}")] + [InterpolatedStringHandler] + public ref struct ZStringInterpolatedStringHandler + { + private const int ThreadStaticBufferSize = 31111; + private const int DefaultBufferSize = 32768; // use 32K default buffer. + + [ThreadStatic] + private static char[]? _scratchBuffer; + + private readonly bool _disposeImmediately; + private static bool _scratchBufferUsed; + + private char[] _bufferArray; + private Span _buffer; + private int _index; + private readonly IFormatProvider? _provider; + private readonly bool _hasCustomFormatter; + + public int Length => _index; + + public ZStringInterpolatedStringHandler( + int literalLength, + int formattedCount, + IFormatProvider? provider = null, + bool disposeImmediately = true) + { + if (disposeImmediately && _scratchBufferUsed) { ThrowNestedException(); } + + _provider = provider; + _index = 0; + + if (disposeImmediately && literalLength <= ThreadStaticBufferSize) + { + _bufferArray = _scratchBuffer ??= new char[ThreadStaticBufferSize]; + _scratchBufferUsed = true; + } + else { _bufferArray = ArrayPool.Shared.Rent(DefaultBufferSize); } + + _buffer = formattedCount == 0 ? _bufferArray.AsSpan()[..literalLength] : _bufferArray.AsSpan(); + + _disposeImmediately = disposeImmediately; + _hasCustomFormatter = provider is not null && HasCustomFormatter(provider); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLiteral(string s) => AppendLiteral(s.AsSpan()); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLiteral(ReadOnlySpan value) + { + if (value.Length == 0) { return; } + + if (_buffer.Length - _index < value.Length) { Grow(value.Length); } + + value.CopyTo(_buffer[_index..]); + _index += value.Length; + } + + #region AppendFormatted + + // 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. + + #region AppendFormatted T + + /// Writes the specified value to the handler. + /// The value to write. + /// The type of 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; + } + + if (value is 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(_buffer[_index..], + out charsWritten, + default, + _provider)) // constrained call avoiding boxing for value types + { + Grow(0); + } + + _index += charsWritten; + return; + } + + // constrained call avoiding boxing for value types + s = ((IFormattable)value).ToString(null, _provider); + } + else { s = value.ToString(); } + + if (s is not null) { AppendLiteral(s); } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// The format string. + /// The type of the value to write. + public void AppendFormatted(T value, string? format) + { + // If there's a custom formatter, always use it. + if (_hasCustomFormatter) + { + AppendCustomFormatter(value, format); + return; + } + + if (value is 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(_buffer[_index..], + out charsWritten, + format, + _provider)) // constrained call avoiding boxing for value types + { + Grow(0); + } + + _index += charsWritten; + return; + } + + s = ((IFormattable)value).ToString(format, + _provider); // constrained call avoiding boxing for value types + } + else { s = value.ToString(); } + + if (s is not null) { AppendLiteral(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. + /// + /// The type of the value to write. + public void AppendFormatted(T value, int alignment) + { + var startingPos = _index; + 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. + /// + /// The type of the value to write. + public void AppendFormatted(T value, int alignment, string? format) + { + var startingPos = _index; + AppendFormatted(value, format); + if (alignment != 0) { AppendOrInsertAlignmentIfNeeded(startingPos, alignment); } + } + + #endregion + + #region AppendFormatted ReadOnlySpan + + /// 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(_buffer[_index..])) { _index += value.Length; } + else { AppendLiteral(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, 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(_buffer[_index..]); + _index += value.Length; + _buffer.Slice(_index, paddingRequired).Fill(' '); + _index += paddingRequired; + } + else + { + _buffer.Slice(_index, paddingRequired).Fill(' '); + _index += paddingRequired; + value.CopyTo(_buffer[_index..]); + _index += value.Length; + } + } + + #endregion + + #region AppendFormatted string + + /// 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 is not null && value.TryCopyTo(_buffer[_index..])) + { + _index += value.Length; + } + else { AppendFormattedSlow(value); } + } + + [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) + { + AppendLiteral(customFormatted); + } + } + + /// 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(_buffer[_index..]); + _index += 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, 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); + + #endregion + + #region AppendFormatted object + + /// 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); + + #endregion + + #endregion + + private void Grow(int sizeHint) + { + var nextSize = _buffer.Length * 2; + if (sizeHint != 0) { nextSize = Math.Max(nextSize, _index + sizeHint); } + + var newBufferArray = ArrayPool.Shared.Rent(nextSize); + var newBuffer = _bufferArray.AsSpan(); + + _buffer.CopyTo(newBuffer); + + if (!_disposeImmediately || _bufferArray.Length != ThreadStaticBufferSize) + { + _bufferArray.AsSpan().Clear(); + ArrayPool.Shared.Return(_bufferArray); + } + + _bufferArray = newBufferArray; + _buffer = _bufferArray.AsSpan(); + } + + internal void CopyTo(Span span) { _buffer[.._index].CopyTo(span); } + + internal void GetString(ref Utf8ValueStringBuilder builder) { builder.Append(_buffer[.._index]); } + + internal void GetString(ref Utf16ValueStringBuilder builder) { builder.Append(_buffer[.._index]); } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + if (_disposeImmediately) { _scratchBufferUsed = false; } + else { ArrayPool.Shared.Return(_bufferArray); } + + _scratchBuffer?.AsSpan().Clear(); + } + + private static void ThrowNestedException() + { + throw new NestedStringBuilderCreationException(nameof(Utf16ValueStringBuilder)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] // only used in a few hot path call sites + private 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; + } + + private void AppendOrInsertAlignmentIfNeeded(int startingPos, int alignment) + { + Debug.Assert(startingPos >= 0 && startingPos <= _index); + Debug.Assert(alignment != 0); + + var charsWritten = _index - startingPos; + + var leftAlign = false; + if (alignment < 0) + { + leftAlign = true; + alignment = -alignment; + } + + var paddingNeeded = alignment - charsWritten; + if (paddingNeeded <= 0) { return; } + + EnsureCapacityForAdditionalChars(paddingNeeded); + + if (leftAlign) { _buffer.Slice(_index, paddingNeeded).Fill(' '); } + else + { + _buffer.Slice(startingPos, charsWritten).CopyTo(_buffer.Slice(startingPos + paddingNeeded)); + _buffer.Slice(startingPos, paddingNeeded).Fill(' '); + } + + _index += paddingNeeded; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void EnsureCapacityForAdditionalChars(int additionalChars) + { + if (_buffer.Length - _index < additionalChars) { Grow(additionalChars); } + } + + } +} + +#endif diff --git a/src/ZString/Utf16ValueStringBuilder.cs b/src/ZString/Utf16ValueStringBuilder.cs index df6ec3b..44675b3 100644 --- a/src/ZString/Utf16ValueStringBuilder.cs +++ b/src/ZString/Utf16ValueStringBuilder.cs @@ -260,6 +260,30 @@ public void AppendLine(ReadOnlySpan value) AppendLine(); } +#if NET6_0_OR_GREATER + /// Appends a contiguous region of arbitrary memory to this instance. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(ZStringInterpolatedStringHandler value) + { + AppendCore(value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLine(ZStringInterpolatedStringHandler value) + { + AppendCore(value); + AppendLine(); + } + + /// Appends a contiguous region of arbitrary memory to this instance. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void AppendCore(ZStringInterpolatedStringHandler value) + { + value.GetString(ref this); + value.Dispose(); + } +#endif + /// Appends the string representation of a specified value to this instance. public void Append(T value) { @@ -350,6 +374,53 @@ public void Insert(int index, ReadOnlySpan value, int count) this.index = newBufferIndex + remainLnegth; } +#if NET6_0_OR_GREATER + public void Insert(int index, ZStringInterpolatedStringHandler value, int count) + { + if (count < 0) + { + ExceptionUtil.ThrowArgumentOutOfRangeException(nameof(count)); + } + + int currentLength = Length; + if ((uint)index > (uint)currentLength) + { + ExceptionUtil.ThrowArgumentOutOfRangeException(nameof(index)); + } + + if (value.Length == 0 || count == 0) + { + return; + } + + var newSize = index + value.Length * count; + var newBuffer = ArrayPool.Shared.Rent(Math.Max(DefaultBufferSize, newSize)); + + buffer.AsSpan(0, index).CopyTo(newBuffer); + int newBufferIndex = index; + + for (int i = 0; i < count; i++) + { + value.CopyTo(newBuffer.AsSpan(newBufferIndex)); + newBufferIndex += value.Length; + } + + int remainLnegth = this.index - index; + buffer.AsSpan(index, remainLnegth).CopyTo(newBuffer.AsSpan(newBufferIndex)); + + if (buffer!.Length != ThreadStaticBufferSize) + { + if (buffer != null) + { + ArrayPool.Shared.Return(buffer); + } + } + + buffer = newBuffer; + this.index = newBufferIndex + remainLnegth; + } +#endif + /// /// Replaces all instances of one character with another in this builder. /// @@ -401,6 +472,12 @@ public void Replace(char oldChar, char newChar, int startIndex, int count) public void Replace(ReadOnlySpan oldValue, ReadOnlySpan newValue) => Replace(oldValue, newValue, 0, Length); +#if NET6_0_OR_GREATER + public void Replace(string oldValue, ZStringInterpolatedStringHandler newValue) => Replace(oldValue, newValue, 0, Length); + + public void Replace(ReadOnlySpan oldValue, ZStringInterpolatedStringHandler newValue) => Replace(oldValue, newValue, 0, Length); +#endif + /// /// Replaces all instances of one string with another in part of this builder. /// @@ -422,6 +499,18 @@ public void Replace(string oldValue, string newValue, int startIndex, int count) Replace(oldValue.AsSpan(), newValue.AsSpan(), startIndex, count); } +#if NET6_0_OR_GREATER + public void Replace(string oldValue, ZStringInterpolatedStringHandler newValue, int startIndex, int count) + { + if (oldValue == null) + { + throw new ArgumentNullException(nameof(oldValue)); + } + + Replace(oldValue.AsSpan(), newValue, startIndex, count); + } +#endif + public void Replace(ReadOnlySpan oldValue, ReadOnlySpan newValue, int startIndex, int count) { int currentLength = Length; @@ -489,7 +578,77 @@ public void Replace(ReadOnlySpan oldValue, ReadOnlySpan newValue, in buffer = newBuffer; index = newBufferIndex; } - + +#if NET6_0_OR_GREATER + public void Replace(ReadOnlySpan oldValue, ZStringInterpolatedStringHandler newValue, int startIndex, int count) + { + int currentLength = Length; + + if ((uint)startIndex > (uint)currentLength) + { + ExceptionUtil.ThrowArgumentOutOfRangeException(nameof(startIndex)); + } + + if (count < 0 || startIndex > currentLength - count) + { + ExceptionUtil.ThrowArgumentOutOfRangeException(nameof(count)); + } + + if (oldValue.Length == 0) + { + throw new ArgumentException("oldValue.Length is 0", nameof(oldValue)); + } + + var readOnlySpan = AsSpan(); + int endIndex = startIndex + count; + int matchCount = 0; + + for (int i = startIndex; i < endIndex; i += oldValue.Length) + { + var span = readOnlySpan.Slice(i, endIndex - i); + var pos = span.IndexOf(oldValue, StringComparison.Ordinal); + if (pos == -1) + { + break; + } + i += pos; + matchCount++; + } + + if (matchCount == 0) + return; + + var newBuffer = ArrayPool.Shared.Rent(Math.Max(DefaultBufferSize, Length + (newValue.Length - oldValue.Length) * matchCount)); + + buffer.AsSpan(0, startIndex).CopyTo(newBuffer); + int newBufferIndex = startIndex; + + for (int i = startIndex; i < endIndex; i += oldValue.Length) + { + var span = readOnlySpan.Slice(i, endIndex - i); + var pos = span.IndexOf(oldValue, StringComparison.Ordinal); + if (pos == -1) + { + var remain = readOnlySpan.Slice(i); + remain.CopyTo(newBuffer.AsSpan(newBufferIndex)); + newBufferIndex += remain.Length; + break; + } + readOnlySpan.Slice(i, pos).CopyTo(newBuffer.AsSpan(newBufferIndex)); + newValue.CopyTo(newBuffer.AsSpan(newBufferIndex + pos)); + newBufferIndex += pos + newValue.Length; + i += pos; + } + + if (buffer!.Length != ThreadStaticBufferSize) + { + ArrayPool.Shared.Return(buffer); + } + buffer = newBuffer; + index = newBufferIndex; + } +#endif + /// /// Replaces the contents of a single position within the builder. /// @@ -502,7 +661,7 @@ public void ReplaceAt(char newChar, int replaceIndex) { ExceptionUtil.ThrowArgumentOutOfRangeException(nameof(replaceIndex)); } - + buffer![replaceIndex] = newChar; } diff --git a/src/ZString/Utf8ValueStringBuilder.cs b/src/ZString/Utf8ValueStringBuilder.cs index 19df488..a84bec2 100644 --- a/src/ZString/Utf8ValueStringBuilder.cs +++ b/src/ZString/Utf8ValueStringBuilder.cs @@ -278,6 +278,30 @@ public void AppendLine(ReadOnlySpan value) AppendLine(); } +#if NET6_0_OR_GREATER + /// Appends a contiguous region of arbitrary memory to this instance. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Append(ZStringInterpolatedStringHandler value) + { + AppendCore(value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLine(ZStringInterpolatedStringHandler value) + { + AppendCore(value); + AppendLine(); + } + + /// Appends a contiguous region of arbitrary memory to this instance. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void AppendCore(ZStringInterpolatedStringHandler value) + { + value.GetString(ref this); + value.Dispose(); + } +#endif + public void AppendLiteral(ReadOnlySpan value) { if ((buffer!.Length - index) < value.Length) diff --git a/src/ZString/ZStringInterpolatedStringHandler.cs b/src/ZString/ZStringInterpolatedStringHandler.cs new file mode 100644 index 0000000..6bad822 --- /dev/null +++ b/src/ZString/ZStringInterpolatedStringHandler.cs @@ -0,0 +1,514 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.Globalization; +using System.Runtime.CompilerServices; + +// ReSharper disable MergeCastWithTypeCheck + +#if NET6_0_OR_GREATER + +namespace Cysharp.Text +{ + [DebuggerDisplay("{_buffer[.._index]}")] + [InterpolatedStringHandler] + public ref struct ZStringInterpolatedStringHandler + { + private const int ThreadStaticBufferSize = 31111; + private const int DefaultBufferSize = 32768; // use 32K default buffer. + + [ThreadStatic] + private static char[]? _scratchBuffer; + + private readonly bool _disposeImmediately; + private static bool _scratchBufferUsed; + + private char[] _bufferArray; + private Span _buffer; + private int _index; + private readonly IFormatProvider? _provider; + private readonly bool _hasCustomFormatter; + + public int Length => _index; + + public ZStringInterpolatedStringHandler( + int literalLength, + int formattedCount, + IFormatProvider? provider = null, + bool disposeImmediately = true) + { + if (disposeImmediately && _scratchBufferUsed) { ThrowNestedException(); } + + _provider = provider; + _index = 0; + + if (disposeImmediately && literalLength <= ThreadStaticBufferSize) + { + _bufferArray = _scratchBuffer ??= new char[ThreadStaticBufferSize]; + _scratchBufferUsed = true; + } + else { _bufferArray = ArrayPool.Shared.Rent(DefaultBufferSize); } + + _buffer = formattedCount == 0 ? _bufferArray.AsSpan()[..literalLength] : _bufferArray.AsSpan(); + + _disposeImmediately = disposeImmediately; + _hasCustomFormatter = provider is not null && HasCustomFormatter(provider); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLiteral(string s) => AppendLiteral(s.AsSpan()); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AppendLiteral(ReadOnlySpan value) + { + if (value.Length == 0) { return; } + + if (_buffer.Length - _index < value.Length) { Grow(value.Length); } + + value.CopyTo(_buffer[_index..]); + _index += value.Length; + } + + #region AppendFormatted + + // 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. + + #region AppendFormatted T + + /// Writes the specified value to the handler. + /// The value to write. + /// The type of 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; + } + + if (value is 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(_buffer[_index..], + out charsWritten, + default, + _provider)) // constrained call avoiding boxing for value types + { + Grow(0); + } + + _index += charsWritten; + return; + } + + // constrained call avoiding boxing for value types + s = ((IFormattable)value).ToString(null, _provider); + } + else { s = value.ToString(); } + + if (s is not null) { AppendLiteral(s); } + } + + /// Writes the specified value to the handler. + /// The value to write. + /// The format string. + /// The type of the value to write. + public void AppendFormatted(T value, string? format) + { + // If there's a custom formatter, always use it. + if (_hasCustomFormatter) + { + AppendCustomFormatter(value, format); + return; + } + + if (value is 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(_buffer[_index..], + out charsWritten, + format, + _provider)) // constrained call avoiding boxing for value types + { + Grow(0); + } + + _index += charsWritten; + return; + } + + s = ((IFormattable)value).ToString(format, + _provider); // constrained call avoiding boxing for value types + } + else { s = value.ToString(); } + + if (s is not null) { AppendLiteral(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. + /// + /// The type of the value to write. + public void AppendFormatted(T value, int alignment) + { + var startingPos = _index; + 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. + /// + /// The type of the value to write. + public void AppendFormatted(T value, int alignment, string? format) + { + var startingPos = _index; + AppendFormatted(value, format); + if (alignment != 0) { AppendOrInsertAlignmentIfNeeded(startingPos, alignment); } + } + + #endregion + + #region AppendFormatted ReadOnlySpan + + /// 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(_buffer[_index..])) { _index += value.Length; } + else { AppendLiteral(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, 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(_buffer[_index..]); + _index += value.Length; + _buffer.Slice(_index, paddingRequired).Fill(' '); + _index += paddingRequired; + } + else + { + _buffer.Slice(_index, paddingRequired).Fill(' '); + _index += paddingRequired; + value.CopyTo(_buffer[_index..]); + _index += value.Length; + } + } + + #endregion + + #region AppendFormatted string + + /// 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 is not null && value.TryCopyTo(_buffer[_index..])) + { + _index += value.Length; + } + else { AppendFormattedSlow(value); } + } + + [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) + { + AppendLiteral(customFormatted); + } + } + + /// 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(_buffer[_index..]); + _index += 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, 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); + + #endregion + + #region AppendFormatted object + + /// 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); + + #endregion + + #endregion + + private void Grow(int sizeHint) + { + var nextSize = _buffer.Length * 2; + if (sizeHint != 0) { nextSize = Math.Max(nextSize, _index + sizeHint); } + + var newBufferArray = ArrayPool.Shared.Rent(nextSize); + var newBuffer = _bufferArray.AsSpan(); + + _buffer.CopyTo(newBuffer); + + if (!_disposeImmediately || _bufferArray.Length != ThreadStaticBufferSize) + { + _bufferArray.AsSpan().Clear(); + ArrayPool.Shared.Return(_bufferArray); + } + + _bufferArray = newBufferArray; + _buffer = _bufferArray.AsSpan(); + } + + internal void CopyTo(Span span) { _buffer[.._index].CopyTo(span); } + + internal void GetString(ref Utf8ValueStringBuilder builder) { builder.Append(_buffer[.._index]); } + + internal void GetString(ref Utf16ValueStringBuilder builder) { builder.Append(_buffer[.._index]); } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Dispose() + { + if (_disposeImmediately) { _scratchBufferUsed = false; } + else { ArrayPool.Shared.Return(_bufferArray); } + + _scratchBuffer?.AsSpan().Clear(); + } + + private static void ThrowNestedException() + { + throw new NestedStringBuilderCreationException(nameof(Utf16ValueStringBuilder)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] // only used in a few hot path call sites + private 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; + } + + private void AppendOrInsertAlignmentIfNeeded(int startingPos, int alignment) + { + Debug.Assert(startingPos >= 0 && startingPos <= _index); + Debug.Assert(alignment != 0); + + var charsWritten = _index - startingPos; + + var leftAlign = false; + if (alignment < 0) + { + leftAlign = true; + alignment = -alignment; + } + + var paddingNeeded = alignment - charsWritten; + if (paddingNeeded <= 0) { return; } + + EnsureCapacityForAdditionalChars(paddingNeeded); + + if (leftAlign) { _buffer.Slice(_index, paddingNeeded).Fill(' '); } + else + { + _buffer.Slice(startingPos, charsWritten).CopyTo(_buffer.Slice(startingPos + paddingNeeded)); + _buffer.Slice(startingPos, paddingNeeded).Fill(' '); + } + + _index += paddingNeeded; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void EnsureCapacityForAdditionalChars(int additionalChars) + { + if (_buffer.Length - _index < additionalChars) { Grow(additionalChars); } + } + + } +} + +#endif diff --git a/src/ZString/_InternalVisibleTo.cs b/src/ZString/_InternalVisibleTo.cs index 7c47ebd..cff4b1c 100644 --- a/src/ZString/_InternalVisibleTo.cs +++ b/src/ZString/_InternalVisibleTo.cs @@ -3,4 +3,4 @@ [assembly: InternalsVisibleTo("ConsoleApp, PublicKey=00240000048000009400000006020000002400005253413100040000010001000144ec28f1e9ef7b17dacc47425a7a153aea0a7baa590743a2d1a86f4b3e10a8a12712c6e647966bfd8bd6e830048b23bd42bbc56f179585c15b8c19cf86c0eed1b73c993dd7a93a30051dd50fdda0e4d6b65e6874e30f1c37cf8bcbc7fe02c7f2e6a0a3327c0ccc1631bf645f40732521fa0b41a30c178d08f7dd779d42a1ee")] [assembly: InternalsVisibleTo("ConsoleAppNet472, PublicKey=00240000048000009400000006020000002400005253413100040000010001000144ec28f1e9ef7b17dacc47425a7a153aea0a7baa590743a2d1a86f4b3e10a8a12712c6e647966bfd8bd6e830048b23bd42bbc56f179585c15b8c19cf86c0eed1b73c993dd7a93a30051dd50fdda0e4d6b65e6874e30f1c37cf8bcbc7fe02c7f2e6a0a3327c0ccc1631bf645f40732521fa0b41a30c178d08f7dd779d42a1ee")] [assembly: InternalsVisibleTo("ZString.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000144ec28f1e9ef7b17dacc47425a7a153aea0a7baa590743a2d1a86f4b3e10a8a12712c6e647966bfd8bd6e830048b23bd42bbc56f179585c15b8c19cf86c0eed1b73c993dd7a93a30051dd50fdda0e4d6b65e6874e30f1c37cf8bcbc7fe02c7f2e6a0a3327c0ccc1631bf645f40732521fa0b41a30c178d08f7dd779d42a1ee")] -[assembly: InternalsVisibleTo("ZString.NetCore2Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000144ec28f1e9ef7b17dacc47425a7a153aea0a7baa590743a2d1a86f4b3e10a8a12712c6e647966bfd8bd6e830048b23bd42bbc56f179585c15b8c19cf86c0eed1b73c993dd7a93a30051dd50fdda0e4d6b65e6874e30f1c37cf8bcbc7fe02c7f2e6a0a3327c0ccc1631bf645f40732521fa0b41a30c178d08f7dd779d42a1ee")] \ No newline at end of file +[assembly: InternalsVisibleTo("ZString.NetTests, PublicKey=00240000048000009400000006020000002400005253413100040000010001000144ec28f1e9ef7b17dacc47425a7a153aea0a7baa590743a2d1a86f4b3e10a8a12712c6e647966bfd8bd6e830048b23bd42bbc56f179585c15b8c19cf86c0eed1b73c993dd7a93a30051dd50fdda0e4d6b65e6874e30f1c37cf8bcbc7fe02c7f2e6a0a3327c0ccc1631bf645f40732521fa0b41a30c178d08f7dd779d42a1ee")] diff --git a/tests/ZString.NetTests/AppendInterpolatedStringTest.cs b/tests/ZString.NetTests/AppendInterpolatedStringTest.cs new file mode 100644 index 0000000..3e86729 --- /dev/null +++ b/tests/ZString.NetTests/AppendInterpolatedStringTest.cs @@ -0,0 +1,61 @@ +using System; +using Cysharp.Text; +using FluentAssertions; +using System.Text; +using Xunit; + +namespace ZStringTests +{ + public class AppendInterpolatedStringTest + { + [Fact] + public void InterpolatedStringHandlerTest() + { + var zsb = ZString.CreateStringBuilder(); + var bcl = new StringBuilder(); + + zsb.Append($"Const int{1}"); + bcl.Append($"Const int{1}"); + zsb.ToString().Should().Be(bcl.ToString()); + + zsb.Append($"Const int with format{1:0000}"); + bcl.Append($"Const int with format{1:0000}"); + zsb.ToString().Should().Be(bcl.ToString()); + + zsb.Append($"Const float with format{1f:0000}"); + bcl.Append($"Const float with format{1f:0000}"); + zsb.ToString().Should().Be(bcl.ToString()); + + zsb.Append($"Const double with format{1d:0000}"); + bcl.Append($"Const double with format{1d:0000}"); + zsb.ToString().Should().Be(bcl.ToString()); + + zsb.Append($"Const decimal with format{1m:0000}"); + bcl.Append($"Const decimal with format{1m:0000}"); + zsb.ToString().Should().Be(bcl.ToString()); + + zsb.Append($"Const long with format{1L:0000}"); + bcl.Append($"Const long with format{1L:0000}"); + zsb.ToString().Should().Be(bcl.ToString()); + + var str = "str"; + zsb.Append($"Const string with pd10{str,10}"); + bcl.Append($"Const string with pd10{str,10}"); + zsb.ToString().Should().Be(bcl.ToString()); + + zsb.Append($"Const string with pd-10{str,-10}"); + bcl.Append($"Const string with pd-10{str,-10}"); + zsb.ToString().Should().Be(bcl.ToString()); + + var guid = Guid.NewGuid(); + zsb.Insert(12, $"Some inserted {guid:N}"); + bcl.Insert(12, $"Some inserted {guid:N}"); + zsb.ToString().Should().Be(bcl.ToString()); + + var datetime = DateTime.Now; + zsb.Replace("long", $"Some inserted {datetime:yyyyMMddHHmmssfff}"); + bcl.Replace("long", $"Some inserted {datetime:yyyyMMddHHmmssfff}"); + zsb.ToString().Should().Be(bcl.ToString()); + } + } +} diff --git a/tests/ZString.NetCore2Tests/ZString.NetCore2Tests.csproj b/tests/ZString.NetTests/ZString.NetTests.csproj similarity index 96% rename from tests/ZString.NetCore2Tests/ZString.NetCore2Tests.csproj rename to tests/ZString.NetTests/ZString.NetTests.csproj index 2ed0668..d176a5f 100644 --- a/tests/ZString.NetCore2Tests/ZString.NetCore2Tests.csproj +++ b/tests/ZString.NetTests/ZString.NetTests.csproj @@ -1,7 +1,7 @@  - netcoreapp2.1 + net9.0 false ZStringTests true