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.