diff --git a/Semantics.Strings/SemanticString.cs b/Semantics.Strings/SemanticString.cs index f29de23..fcbb33a 100644 --- a/Semantics.Strings/SemanticString.cs +++ b/Semantics.Strings/SemanticString.cs @@ -975,6 +975,10 @@ public ref struct SpanSplitEnumerator private readonly char _separator; private readonly StringSplitOptions _options; + // StringSplitOptions.TrimEntries, which netstandard2.0/2.1 do not name. The value is the same on + // every target, so a caller on .NET 5+ passing the named member is honoured everywhere. + private const StringSplitOptions TrimEntries = (StringSplitOptions)2; + internal SpanSplitEnumerator(ReadOnlySpan span, char separator, StringSplitOptions options) { _remaining = span; @@ -1000,30 +1004,36 @@ internal SpanSplitEnumerator(ReadOnlySpan span, char separator, StringSpli /// true if there is a next segment; otherwise, false. public bool MoveNext() { - if (_remaining.IsEmpty) - { - return false; - } - - int separatorIndex = _remaining.IndexOf(_separator); - if (separatorIndex >= 0) - { - Current = _remaining[..separatorIndex]; - _remaining = _remaining[(separatorIndex + 1)..]; - } - else - { - Current = _remaining; - _remaining = default; - } - - // Handle StringSplitOptions.RemoveEmptyEntries - if (_options == StringSplitOptions.RemoveEmptyEntries && Current.IsEmpty) + // A loop, not recursion: a long run of separators with RemoveEmptyEntries would otherwise + // take one stack frame per empty entry and overflow the stack. + while (!_remaining.IsEmpty) { - return MoveNext(); // Recursively skip empty entries + int separatorIndex = _remaining.IndexOf(_separator); + if (separatorIndex >= 0) + { + Current = _remaining[..separatorIndex]; + _remaining = _remaining[(separatorIndex + 1)..]; + } + else + { + Current = _remaining; + _remaining = default; + } + + if ((_options & TrimEntries) != 0) + { + Current = Current.Trim(); + } + + if ((_options & StringSplitOptions.RemoveEmptyEntries) != 0 && Current.IsEmpty) + { + continue; + } + + return true; } - return true; + return false; } } } diff --git a/Semantics.Test/SemanticStringTests.cs b/Semantics.Test/SemanticStringTests.cs index 4fec2ed..db46aef 100644 --- a/Semantics.Test/SemanticStringTests.cs +++ b/Semantics.Test/SemanticStringTests.cs @@ -1416,6 +1416,54 @@ public void SpanSplitEnumerator_OnlySeparators_HandlesCorrectly() Assert.IsTrue(parts.All(string.IsNullOrEmpty), "All parts should be empty"); } + private static readonly string[] expectedOnlyX = ["x"]; + private static readonly string[] expectedAB = ["a", "b"]; + private static readonly string[] expectedTrimmed = ["a", "b", "", "c"]; + + private static List SplitToList(string value, StringSplitOptions options) + { + MySemanticString semantic = SemanticString.Create(value); + List parts = []; + foreach (ReadOnlySpan part in semantic.Split(',', options)) + { + parts.Add(part.ToString()); + } + + return parts; + } + + [TestMethod] + public void SpanSplitEnumerator_RemoveEmptyEntries_LongRunOfSeparators_DoesNotOverflowTheStack() + { + List parts = SplitToList(new string(',', 1_000_000) + "x", StringSplitOptions.RemoveEmptyEntries); + + Assert.AreSequenceEqual(expectedOnlyX, parts); + } + + [TestMethod] + public void SpanSplitEnumerator_RemoveEmptyEntriesWithTrimEntries_RemovesWhitespaceEntries() + { + List parts = SplitToList("a, ,b", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + Assert.AreSequenceEqual(expectedAB, parts); + } + + [TestMethod] + public void SpanSplitEnumerator_RemoveEmptyEntriesWithTrimEntries_RemovesEmptyEntries() + { + List parts = SplitToList("a,,b", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + Assert.AreSequenceEqual(expectedAB, parts); + } + + [TestMethod] + public void SpanSplitEnumerator_TrimEntries_TrimsEachEntry() + { + List parts = SplitToList(" a , b , ,c", StringSplitOptions.TrimEntries); + + Assert.AreSequenceEqual(expectedTrimmed, parts); + } + [TestMethod] public void ErrorMessages_ContainTypeInformation() {