diff --git a/src/libraries/System.Private.CoreLib/src/System/Text/StringBuilder.cs b/src/libraries/System.Private.CoreLib/src/System/Text/StringBuilder.cs index 83f9306b16ee71..28a6c1e32125b3 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Text/StringBuilder.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Text/StringBuilder.cs @@ -381,6 +381,44 @@ public StringBuilder Clear() return this; } + /// + /// Creates a new instance initialized to the same state as + /// , and resets to an empty, usable state + /// with no allocated buffers. + /// + /// The whose chunks should be moved to the + /// returned instance. + /// A new instance that owns the chunks previously held + /// by . + /// is . + /// + /// + /// In contrast to , which retains the existing internal buffer, + /// this method releases all internal buffers from . Ownership of + /// the chunks is transferred in O(1) to the returned ; the + /// underlying character data is not copied. + /// + /// + /// After the call, has and + /// of zero but retains its original . + /// It remains fully usable; subsequent append or insert operations will allocate new + /// buffers as needed. + /// + /// + public static StringBuilder MoveChunks(StringBuilder source) + { + ArgumentNullException.ThrowIfNull(source); + + StringBuilder destination = new StringBuilder(source); + + source.m_ChunkChars = []; + source.m_ChunkPrevious = null; + source.m_ChunkLength = 0; + source.m_ChunkOffset = 0; + + return destination; + } + /// /// Gets or sets the length of this builder. /// diff --git a/src/libraries/System.Runtime/ref/System.Runtime.cs b/src/libraries/System.Runtime/ref/System.Runtime.cs index e5cb2b3b05da45..9041d080579cd4 100644 --- a/src/libraries/System.Runtime/ref/System.Runtime.cs +++ b/src/libraries/System.Runtime/ref/System.Runtime.cs @@ -16225,6 +16225,7 @@ public StringBuilder(string? value, int startIndex, int length, int capacity) { public System.Text.StringBuilder AppendLine(string? value) { throw null; } public System.Text.StringBuilder AppendLine([System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute("")] ref System.Text.StringBuilder.AppendInterpolatedStringHandler handler) { throw null; } public System.Text.StringBuilder Clear() { throw null; } + public static System.Text.StringBuilder MoveChunks(System.Text.StringBuilder source) { throw null; } public void CopyTo(int sourceIndex, char[] destination, int destinationIndex, int count) { } public void CopyTo(int sourceIndex, System.Span destination, int count) { } public int EnsureCapacity(int capacity) { throw null; } diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Text/StringBuilderTests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Text/StringBuilderTests.cs index e9a9741a4b1fc1..5adc464a126507 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Text/StringBuilderTests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Text/StringBuilderTests.cs @@ -5,6 +5,8 @@ using System.Diagnostics; using System.Globalization; using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; using System.Tests; using Microsoft.DotNet.RemoteExecutor; using Xunit; @@ -2129,6 +2131,123 @@ public static void Clear_StringBuilderHasTwoChunks_OneChunkIsEmpty_ClearReducesC Assert.Equal(initialCapacity, sb.Capacity); } + [Fact] + public static void MoveChunks_NullSource_ThrowsArgumentNullException() + { + AssertExtensions.Throws("source", () => StringBuilder.MoveChunks(null)); + } + + [Fact] + public static void MoveChunks_Empty_ProducesEmptyDestinationAndDrainsSource() + { + var source = new StringBuilder(32, 64); + char[] originalChars = GetChunkCharsField(source); + + StringBuilder destination = StringBuilder.MoveChunks(source); + + Assert.NotNull(destination); + Assert.NotSame(source, destination); + Assert.Equal(0, destination.Length); + Assert.Equal(32, destination.Capacity); + Assert.Equal(64, destination.MaxCapacity); + Assert.Same(originalChars, GetChunkCharsField(destination)); + + AssertSourceIsDrained(source); + } + + [Fact] + public static void MoveChunks_SingleChunk_TransfersContentsAndDrainsSource() + { + var source = new StringBuilder(16, 100); + source.Append("Hello"); + char[] originalChars = GetChunkCharsField(source); + + StringBuilder destination = StringBuilder.MoveChunks(source); + + Assert.Equal("Hello", destination.ToString()); + Assert.Equal(100, destination.MaxCapacity); + Assert.Same(originalChars, GetChunkCharsField(destination)); + + AssertSourceIsDrained(source); + } + + [Fact] + public static void MoveChunks_MultipleChunks_TransfersChainAndDrainsSource() + { + StringBuilder source = StringBuilderWithMultipleChunks(); + string expected = source.ToString(); + + // Capture the backing char[] arrays by identity to verify no-copy move semantics. + List<(char[] Array, int Offset, int Count)> originalChunks = new List<(char[], int, int)>(); + foreach (ReadOnlyMemory chunk in source.GetChunks()) + { + Assert.True(MemoryMarshal.TryGetArray(chunk, out ArraySegment segment)); + originalChunks.Add((segment.Array!, segment.Offset, segment.Count)); + } + + StringBuilder destination = StringBuilder.MoveChunks(source); + + Assert.Equal(expected, destination.ToString()); + + int i = 0; + foreach (ReadOnlyMemory chunk in destination.GetChunks()) + { + Assert.True(MemoryMarshal.TryGetArray(chunk, out ArraySegment segment)); + Assert.Same(originalChunks[i].Array, segment.Array); + Assert.Equal(originalChunks[i].Offset, segment.Offset); + Assert.Equal(originalChunks[i].Count, segment.Count); + i++; + } + Assert.Equal(originalChunks.Count, i); + + AssertSourceIsDrained(source); + } + + [Fact] + public static void MoveChunks_DrainedSource_RemainsUsable() + { + var source = new StringBuilder("abc"); + int originalMaxCapacity = source.MaxCapacity; + StringBuilder destination = StringBuilder.MoveChunks(source); + + Assert.Equal("abc", destination.ToString()); + Assert.Equal(originalMaxCapacity, source.MaxCapacity); + + // source is empty but fully usable; subsequent appends allocate new buffers. + source.Append('x'); + Assert.Equal("x", source.ToString()); + } + + [Fact] + public static void MoveChunks_AlreadyDrainedSource_ProducesEmptyDestination() + { + var source = new StringBuilder("abc"); + int originalMaxCapacity = source.MaxCapacity; + _ = StringBuilder.MoveChunks(source); + + // MoveChunks on an already-drained (empty) source produces an empty destination. + StringBuilder destination = StringBuilder.MoveChunks(source); + + Assert.Equal(0, destination.Length); + Assert.Equal(0, destination.Capacity); + Assert.Equal(originalMaxCapacity, destination.MaxCapacity); + AssertSourceIsDrained(source); + } + + private static readonly FieldInfo s_chunkCharsField = typeof(StringBuilder).GetField("m_ChunkChars", BindingFlags.Instance | BindingFlags.NonPublic)!; + + private static char[] GetChunkCharsField(StringBuilder builder) + { + return (char[])s_chunkCharsField.GetValue(builder)!; + } + + private static void AssertSourceIsDrained(StringBuilder source) + { + Assert.Equal(0, source.Length); + Assert.Equal(0, source.Capacity); + Assert.Same(Array.Empty(), GetChunkCharsField(source)); + } + [Theory] [InlineData("Hello", 0, new char[] { '\0', '\0', '\0', '\0', '\0' }, 5, new char[] { 'H', 'e', 'l', 'l', 'o' })] [InlineData("Hello", 0, new char[] { '\0', '\0', '\0', '\0' }, 4, new char[] { 'H', 'e', 'l', 'l' })]