From 5c1bb1383ae4b14e721f50ab2e344c073b2e564e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:56:14 +0000 Subject: [PATCH] Split and join text with no markup without the run builder and interleaved copies Substring of a run-free text skips the run builder, Split counts its delimiters to size the result exactly, Join with one separator writes the parts straight into the result, and Concat no longer copies its parts. Results are unchanged; property tests compare each against the forms they replaced on plain and marked text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DaoLzbDbE9CZBRWWkKM4Es --- CHANGELOG.md | 12 + MarkupString.Tests/AllocationTests.cs | 27 +++ .../Properties/SplitJoinProperties.cs | 210 ++++++++++++++++++ MarkupString/MarkupText.Operations.cs | 24 +- MarkupString/MarkupText.cs | 103 ++++++++- 5 files changed, 351 insertions(+), 25 deletions(-) create mode 100644 MarkupString.Tests/Properties/SplitJoinProperties.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cd8430..2572bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ and `MarkupString.Pueblo`. The packages share one version and are released toget The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 2.24.3 — Unreleased + +### Changed + +- **Splitting and joining text with no markup allocate less.** `Substring` of a text with no runs no longer + builds a run list to clip, `Split` counts its delimiters first and sizes the result exactly, and + `Join` with one separator writes the parts and separators straight into the result instead of + collecting them into a list, copying it to an array and copying that again. `Concat` no longer copies + its parts, and skips the run builder when none of them has runs. Splitting a 100-item list on spaces + went from 8.7 µs and 28,336 B to 4.2 µs and 8,752 B; joining 100 items with a space from 2.7 µs and + 8,232 B to 1.6 µs and 656 B. Results are unchanged, runs and all. + ## 2.24.2 — Unreleased ### Fixed diff --git a/MarkupString.Tests/AllocationTests.cs b/MarkupString.Tests/AllocationTests.cs index 3d1198d..321742c 100644 --- a/MarkupString.Tests/AllocationTests.cs +++ b/MarkupString.Tests/AllocationTests.cs @@ -25,6 +25,33 @@ public async Task Plain_DoesNotAllocateRenderCaches() // measurement ever reads zero, which would let a broken probe pass vacuously. await Assert.That(perInstance).IsBetween(32, 300); } + + /// + /// A softcode list is split into its items and joined back after every map(). For text with no + /// markup that costs the segments themselves and the joined result, not a run builder per segment or + /// an interleaved copy of the parts. + /// + [Test] + public async Task SplittingAndJoiningPlainText_AllocatesOnlyTheResults() + { + const int iterations = 2_000; + var list = MarkupText.Plain(string.Join(" ", Enumerable.Range(1, 100))); + var items = list.Split(" "); + for (var i = 0; i < 100; i++) GC.KeepAlive(MarkupText.Join(MarkupText.Space, list.Split(" "))); + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < iterations; i++) GC.KeepAlive(list.Split(" ")); + var perSplit = (GC.GetAllocatedBytesForCurrentThread() - before) / iterations; + + before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < iterations; i++) GC.KeepAlive(MarkupText.Join(MarkupText.Space, items)); + var perJoin = (GC.GetAllocatedBytesForCurrentThread() - before) / iterations; + + // 100 segments: the array, and a string and a MarkupText each (about 8.7 KB); a run builder per + // segment came to more than three times that. The join is its string and its MarkupText. + await Assert.That(perSplit).IsBetween(4_000, 12_000); + await Assert.That(perJoin).IsBetween(300, 1_200); + } } public class AnsiAllocationTests diff --git a/MarkupString.Tests/Properties/SplitJoinProperties.cs b/MarkupString.Tests/Properties/SplitJoinProperties.cs new file mode 100644 index 0000000..41cac2e --- /dev/null +++ b/MarkupString.Tests/Properties/SplitJoinProperties.cs @@ -0,0 +1,210 @@ +using System.Collections.Immutable; +using CsCheck; +namespace MarkupString.Tests.Properties; + +/// +/// , +/// and take shorter ways for text with no runs. +/// These check them against the straightforward forms they replaced, over plain and marked texts, empty +/// items, delimiters at either end, multi-character delimiters and delimiters inside a cluster. +/// +public class SplitJoinProperties +{ + private const string Seed = "000000000000"; + + private sealed record Tag(string Name) : IMarkup; + + private static readonly MarkupSet Red = MarkupSet.Of(new Tag("red")); + private static readonly MarkupSet Blue = MarkupSet.Of(new Tag("blue")); + + /// + /// Pieces chosen to put delimiters next to each other, at the ends, and inside clusters: a + /// combining mark after a delimiter, \r\n (one cluster) against a \n delimiter. + /// + private static readonly string[] Alphabet = + [ + "a", "bc", ",", ", ", " ", "\n", "\r\n", "\r", "日", "́", "\U0001F600", ",,", + ]; + + private static readonly Gen GenPiece = Gen.OneOf( + Gen.Int[0, Alphabet.Length - 1].Select(i => MarkupText.Plain(Alphabet[i])), + Gen.Int[0, Alphabet.Length - 1].Select(i => MarkupText.Plain(Alphabet[i])), + Gen.Int[0, Alphabet.Length - 1].Select(i => MarkupText.Wrap(Red, Alphabet[i])), + Gen.Int[0, Alphabet.Length - 1].Select(i => MarkupText.Wrap(Blue, Alphabet[i])), + Gen.Const(MarkupText.Bell())); + + private static readonly Gen GenText = + GenPiece.Array[0, 10].Select(parts => ReferenceConcat(parts)); + + private static readonly Gen GenDelimiter = + Gen.OneOf(Gen.Const(""), Gen.Const(","), Gen.Const(", "), Gen.Const(" "), Gen.Const("\n"), + Gen.Const("\r\n"), Gen.Const("́"), Gen.Const("a"), Gen.Const(",,"), Gen.Const("bc,")); + + [Test] + public void Split_MatchesTheReference_OnPlainAndMarkedText() + { + Action<(MarkupText Text, string Delimiter)> property = t => + { + AssertSame(ReferenceSplit(t.Text, t.Delimiter), t.Text.Split(t.Delimiter), t.Text, t.Delimiter); + + // The same characters with no runs split at the same places, and every segment is plain. + var plain = MarkupText.Plain(t.Text.Text); + var plainSegments = plain.Split(t.Delimiter); + AssertSame(ReferenceSplit(plain, t.Delimiter), plainSegments, plain, t.Delimiter); + var markedSegments = t.Text.Split(t.Delimiter); + if (plainSegments.Length != markedSegments.Length) + throw new MarkupPropertyException($"plain split gave {plainSegments.Length} segments, marked {markedSegments.Length}"); + for (var i = 0; i < plainSegments.Length; i++) + { + if (plainSegments[i].Text != markedSegments[i].Text || !plainSegments[i].Runs.IsEmpty) + throw new MarkupPropertyException($"segment {i} of a plain split differs from the marked one"); + } + }; + + Check.Sample(Gen.Select(GenText, GenDelimiter), property, seed: Seed, iter: 5000); + } + + [Test] + public void Join_MatchesTheReference() + { + Action<(MarkupText[] Parts, MarkupText Separator)> property = t => + { + var expected = ReferenceJoin(t.Separator, t.Parts); + var context = $"join of {t.Parts.Length} parts"; + AssertSame(expected, MarkupText.Join(t.Separator, t.Parts), context); + AssertSame(expected, MarkupText.Join(t.Separator, t.Parts.ToList()), context + " (list)"); + AssertSame(expected, MarkupText.Join(t.Separator, t.Parts.Select(p => p)), context + " (lazy)"); + AssertSame(expected, MarkupText.Join(_ => t.Separator, t.Parts), context + " (separator function)"); + AssertSame(expected, MarkupText.Join(_ => t.Separator, t.Parts.Where(_ => true)), context + " (lazy, separator function)"); + }; + + Check.Sample(Gen.Select(GenText.Array[0, 8], GenText), property, seed: Seed, iter: 5000); + } + + [Test] + public void Concat_MatchesTheReference() + { + Action property = parts => + { + var expected = ReferenceConcat(parts); + AssertSame(expected, MarkupText.Concat(parts.AsSpan()), "concat"); + AssertSame(expected, MarkupText.Concat(parts.Select(p => p)), "concat (lazy)"); + AssertSame(expected, MarkupText.Concat(parts.ToList()), "concat (list)"); + }; + + Check.Sample(GenText.Array[0, 8], property, seed: Seed, iter: 5000); + } + + [Test] + public void Join_CallsTheSeparatorFunctionOncePerGapInOrder() + { + var calls = new List(); + var joined = MarkupText.Join(i => + { + calls.Add(i); + return MarkupText.Plain($"<{i}>"); + }, [MarkupText.Plain("a"), MarkupText.Plain("b"), MarkupText.Plain("c")]); + + if (joined.Text != "a<1>b<2>c" || !calls.SequenceEqual([1, 2])) + throw new MarkupPropertyException($"{joined.Text}, calls {string.Join(",", calls)}"); + } + + // ── References: the forms the shorter ways replaced ───────────────────────── + + private static MarkupText[] ReferenceSplit(MarkupText text, string delimiter) + { + if (text.Length == 0) return []; + if (delimiter.Length == 0) return [text]; + + List? positions = null; + var position = 0; + while (position <= text.Length - delimiter.Length) + { + var found = text.Text.IndexOf(delimiter, position, StringComparison.Ordinal); + if (found < 0) break; + (positions ??= []).Add(found); + position = found + delimiter.Length; + } + if (positions is null) return [text]; + + var segments = new MarkupText[positions.Count + 1]; + var cursor = 0; + for (var i = 0; i < positions.Count; i++) + { + segments[i] = ReferenceSubstring(text, cursor, positions[i] - cursor); + cursor = positions[i] + delimiter.Length; + } + segments[^1] = ReferenceSubstring(text, cursor, text.Length); + return segments; + } + + /// as it was, clipping runs one by one. + private static MarkupText ReferenceSubstring(MarkupText text, int start, int length) + { + if (length <= 0 || start >= text.Length) return MarkupText.Empty; + var clampedStart = Math.Max(0, start); + var from = Graphemes.SnapStart(text.Text, clampedStart); + var to = length >= text.Length - clampedStart ? text.Length : Graphemes.SnapStart(text.Text, from + length); + if (to <= from) return MarkupText.Empty; + if (from == 0 && to == text.Length) return text; + + var runs = ImmutableArray.CreateBuilder(); + foreach (var run in text.Runs) + { + var s = Math.Max(run.Start, from); + var e = Math.Min(run.End, to); + if (e > s) runs.Add(new Run(s - from, e - s, run.Markups)); + } + return new MarkupText(text.Text[from..to], runs.ToImmutable()); + } + + private static MarkupText ReferenceJoin(MarkupText separator, MarkupText[] parts) + { + var list = new List(); + for (var i = 0; i < parts.Length; i++) + { + if (i > 0) list.Add(separator); + list.Add(parts[i]); + } + return ReferenceConcat([.. list]); + } + + private static MarkupText ReferenceConcat(MarkupText[] parts) + { + var nonEmpty = parts.Where(p => p.Length > 0).ToArray(); + if (nonEmpty.Length == 0) return MarkupText.Empty; + if (nonEmpty.Length == 1) return nonEmpty[0]; + var runs = ImmutableArray.CreateBuilder(); + var offset = 0; + foreach (var p in nonEmpty) + { + foreach (var run in p.Runs) runs.Add(run with { Start = run.Start + offset }); + offset += p.Length; + } + return new MarkupText(string.Concat(nonEmpty.Select(p => p.Text)), runs.ToImmutable()); + } + + // ── Comparison ─────────────────────────────────────────────────────────────── + + private static void AssertSame(MarkupText[] expected, MarkupText[] actual, MarkupText text, string delimiter) + { + var context = $"split of {Escape(text.Text)} ({text.Runs.Length} runs) on {Escape(delimiter)}"; + if (expected.Length != actual.Length) + throw new MarkupPropertyException($"{context}: {actual.Length} segments, expected {expected.Length}"); + for (var i = 0; i < expected.Length; i++) AssertSame(expected[i], actual[i], $"{context}, segment {i}"); + } + + private static void AssertSame(MarkupText expected, MarkupText actual, string context) + { + if (!string.Equals(expected.Text, actual.Text, StringComparison.Ordinal)) + throw new MarkupPropertyException($"{context}: text {Escape(actual.Text)}, expected {Escape(expected.Text)}"); + if (!expected.Runs.SequenceEqual(actual.Runs)) + { + throw new MarkupPropertyException( + $"{context}: runs [{string.Join(", ", actual.Runs)}], expected [{string.Join(", ", expected.Runs)}]"); + } + } + + private static string Escape(string text) => + "\"" + string.Concat(text.Select(c => c is >= ' ' and < '\u007f' ? c.ToString() : $"\\u{(int)c:x4}")) + "\""; +} diff --git a/MarkupString/MarkupText.Operations.cs b/MarkupString/MarkupText.Operations.cs index 8e95659..e5410a3 100644 --- a/MarkupString/MarkupText.Operations.cs +++ b/MarkupString/MarkupText.Operations.cs @@ -74,6 +74,8 @@ public MarkupText Substring(int start, int length) private MarkupText ExtractRange(int from, int to) { if (from == 0 && to == Length) return this; + // Text with no runs has none to clip: no builder to fill, and an empty array to share. + if (Runs.IsDefaultOrEmpty) return new MarkupText(Text[from..to], ImmutableArray.Empty); var runs = ImmutableArray.CreateBuilder(); ClipInto(from, to, -from, runs); @@ -183,23 +185,17 @@ public MarkupText[] Split(string delimiter) if (Length == 0) return []; if (delimiter.Length == 0) return [this]; - List? positions = null; - var position = 0; - while (position <= Length - delimiter.Length) - { - var found = Text.IndexOf(delimiter, position, StringComparison.Ordinal); - if (found < 0) break; - (positions ??= []).Add(found); - position = found + delimiter.Length; - } - if (positions is null) return [this]; + // Counting first sizes the result exactly, so there is no list of positions to grow and copy. + var count = Text.AsSpan().Count(delimiter.AsSpan()); + if (count == 0) return [this]; - var segments = new MarkupText[positions.Count + 1]; + var segments = new MarkupText[count + 1]; var cursor = 0; - for (var i = 0; i < positions.Count; i++) + for (var i = 0; i < count; i++) { - segments[i] = Substring(cursor, positions[i] - cursor); - cursor = positions[i] + delimiter.Length; + var found = Text.IndexOf(delimiter, cursor, StringComparison.Ordinal); + segments[i] = Substring(cursor, found - cursor); + cursor = found + delimiter.Length; } segments[^1] = Substring(cursor); return segments; diff --git a/MarkupString/MarkupText.cs b/MarkupString/MarkupText.cs index b7c62a1..70ef6c0 100644 --- a/MarkupString/MarkupText.cs +++ b/MarkupString/MarkupText.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.Runtime.InteropServices; namespace MarkupString; /// @@ -114,30 +115,37 @@ public static MarkupText Concat(ReadOnlySpan parts) } if (nonEmpty == 0) return Empty; if (nonEmpty == 1) return last!; - var runs = ImmutableArray.CreateBuilder(totalRuns); - var text = string.Create(totalLength, (parts: parts.ToArray(), runs), static (span, state) => + var text = string.Create(totalLength, parts, static (span, parts) => { var offset = 0; - foreach (var p in state.parts) + foreach (var p in parts) { - if (p.Length == 0) continue; p.Text.AsSpan().CopyTo(span[offset..]); - foreach (var run in p.Runs) state.runs.Add(run with { Start = run.Start + offset }); offset += p.Length; } }); - return new MarkupText(text, runs.ToImmutable()); + if (totalRuns == 0) return new MarkupText(text, ImmutableArray.Empty); + + var runs = ImmutableArray.CreateBuilder(totalRuns); + var start = 0; + foreach (var p in parts) + { + foreach (var run in p.Runs) runs.Add(run with { Start = run.Start + start }); + start += p.Length; + } + return new MarkupText(text, runs.MoveToImmutable()); } - public static MarkupText Concat(IEnumerable parts) => - parts is MarkupText[] array ? Concat(array.AsSpan()) : Concat(parts.ToArray().AsSpan()); + public static MarkupText Concat(IEnumerable parts) => Concat(AsSpan(parts)); public static MarkupText Join(MarkupText separator, IEnumerable parts) => - Join(_ => separator, parts); + Join(separator, AsSpan(parts)); public static MarkupText Join(Func separator, IEnumerable parts) { - var list = new List(); + var list = parts.TryGetNonEnumeratedCount(out var count) + ? new List(Math.Max(0, count * 2 - 1)) + : []; var i = 0; foreach (var part in parts) { @@ -145,9 +153,82 @@ public static MarkupText Join(Func separator, IEnumerable + /// with between each two, written straight into + /// the result: what of the interleaved parts makes, + /// without building them. + /// + private static MarkupText Join(MarkupText separator, ReadOnlySpan parts) + { + if (parts.Length == 0) return Empty; + if (separator.Length == 0) return Concat(parts); + + var separators = parts.Length - 1; + var totalLength = separator.Length * separators; + var totalRuns = separator.Runs.Length * separators; + var nonEmpty = 0; + MarkupText? last = null; + foreach (var p in parts) + { + if (p.Length == 0) continue; + totalLength += p.Length; + totalRuns += p.Runs.Length; + nonEmpty++; + last = p; + } + // The one non-empty piece is returned as it is, as Concat returns it. + if (nonEmpty + separators == 1) return last ?? separator; + if (nonEmpty + separators == 0) return Empty; + + var text = string.Create(totalLength, new JoinState(parts, separator.Text), static (span, state) => + { + var offset = 0; + for (var i = 0; i < state.Parts.Length; i++) + { + if (i > 0) + { + state.Separator.AsSpan().CopyTo(span[offset..]); + offset += state.Separator.Length; + } + var part = state.Parts[i].Text; + part.AsSpan().CopyTo(span[offset..]); + offset += part.Length; + } + }); + if (totalRuns == 0) return new MarkupText(text, ImmutableArray.Empty); + + var runs = ImmutableArray.CreateBuilder(totalRuns); + var start = 0; + for (var i = 0; i < parts.Length; i++) + { + if (i > 0) + { + foreach (var run in separator.Runs) runs.Add(run with { Start = run.Start + start }); + start += separator.Length; + } + foreach (var run in parts[i].Runs) runs.Add(run with { Start = run.Start + start }); + start += parts[i].Length; + } + return new MarkupText(text, runs.MoveToImmutable()); + } + + private readonly ref struct JoinState(ReadOnlySpan parts, string separator) + { + public ReadOnlySpan Parts { get; } = parts; + public string Separator { get; } = separator; } + /// The parts as a span: in place for an array or a list, copied once otherwise. + private static ReadOnlySpan AsSpan(IEnumerable parts) => parts switch + { + MarkupText[] array => array, + List list => CollectionsMarshal.AsSpan(list), + _ => parts.ToArray(), + }; + /// /// The text as a reader sees it: without the carriers points ride on /// (), which are positions rather than characters.