Skip to content

Commit 21d99d2

Browse files
authored
Merge pull request #238 from Joy-less/simplify-replace
Simplify & optimize Replace
1 parent 58a2345 commit 21d99d2

File tree

6 files changed

+41
-228
lines changed

6 files changed

+41
-228
lines changed

CHANGELOG.md

+9
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ All notable changes to **ValueStringBuilder** will be documented in this file. T
66

77
## [Unreleased]
88

9+
### Changed
10+
11+
- Optimized and simplified `Replace` (by @Joy-less in #238)
12+
- Simplified `IndexOf` and `LastIndexOf` (by @Joy-less in #238)
13+
14+
### Fixed
15+
16+
- Fixed `IndexOf` and `LastIndexOf` allowing out-of-bounds index when the string to find is empty (by @Joy-less in #238)
17+
918
## [2.3.1] - 2025-02-20
1019

1120
### Changed

src/LinkDotNet.StringBuilder/IntegerSpanList.cs

-50
This file was deleted.

src/LinkDotNet.StringBuilder/NaiveSearch.cs

-127
This file was deleted.

src/LinkDotNet.StringBuilder/ValueStringBuilder.Replace.cs

+28-37
Original file line numberDiff line numberDiff line change
@@ -92,53 +92,45 @@ public void Replace(scoped ReadOnlySpan<char> oldValue, scoped ReadOnlySpan<char
9292
ArgumentOutOfRangeException.ThrowIfLessThan(startIndex, 0);
9393
ArgumentOutOfRangeException.ThrowIfGreaterThan(startIndex + count, Length);
9494

95-
var length = startIndex + count;
96-
var slice = buffer[startIndex..length];
97-
98-
if (oldValue.SequenceEqual(newValue))
95+
if (oldValue.IsEmpty || oldValue.Equals(newValue, StringComparison.Ordinal))
9996
{
10097
return;
10198
}
10299

103-
// We might want to check whether or not we want to introduce different
104-
// string search algorithms for longer strings.
105-
// I had checked initially with Boyer-Moore but it didn't make that much sense as we
106-
// don't expect very long strings and then the performance is literally the same. So I went with the easier solution.
107-
var hits = NaiveSearch.FindAll(slice, oldValue);
100+
var index = startIndex;
101+
var remainingChars = count;
108102

109-
if (hits.IsEmpty)
103+
while (remainingChars > 0)
110104
{
111-
return;
112-
}
113-
114-
var delta = newValue.Length - oldValue.Length;
105+
var foundSubIndex = buffer.Slice(index, remainingChars).IndexOf(oldValue, StringComparison.Ordinal);
106+
if (foundSubIndex < 0)
107+
{
108+
break;
109+
}
115110

116-
for (var i = 0; i < hits.Length; i++)
117-
{
118-
var index = startIndex + hits[i] + (delta * i);
111+
index += foundSubIndex;
112+
remainingChars -= foundSubIndex;
119113

120-
// newValue is smaller than old value
121-
// We can insert the slice and remove the overhead
122-
if (delta < 0)
114+
if (newValue.Length == oldValue.Length)
123115
{
116+
// Just replace the old slice
124117
newValue.CopyTo(buffer[index..]);
125-
Remove(index + newValue.Length, -delta);
126118
}
127-
128-
// Same length -> We can just replace the memory slice
129-
else if (delta == 0)
119+
else if (newValue.Length < oldValue.Length)
130120
{
121+
// Replace the old slice and trim the unused slice
131122
newValue.CopyTo(buffer[index..]);
123+
Remove(index + newValue.Length, oldValue.Length - newValue.Length);
132124
}
133-
134-
// newValue is larger than the old value
135-
// First add until the old memory region
136-
// and insert afterwards the rest
137125
else
138126
{
127+
// Replace the old slice and append the extra slice
139128
newValue[..oldValue.Length].CopyTo(buffer[index..]);
140129
Insert(index + oldValue.Length, newValue[oldValue.Length..]);
141130
}
131+
132+
index += newValue.Length;
133+
remainingChars -= oldValue.Length;
142134
}
143135
}
144136

@@ -153,7 +145,7 @@ public void Replace(scoped ReadOnlySpan<char> oldValue, scoped ReadOnlySpan<char
153145
/// </remarks>
154146
/// /// <typeparam name="T">Any type.</typeparam>
155147
[MethodImpl(MethodImplOptions.AggressiveInlining)]
156-
public void ReplaceGeneric<T>(ReadOnlySpan<char> oldValue, T newValue)
148+
public void ReplaceGeneric<T>(scoped ReadOnlySpan<char> oldValue, T newValue)
157149
=> ReplaceGeneric(oldValue, newValue, 0, Length);
158150

159151
/// <summary>
@@ -164,24 +156,23 @@ public void ReplaceGeneric<T>(ReadOnlySpan<char> oldValue, T newValue)
164156
/// <param name="startIndex">The index to start in this builder.</param>
165157
/// <param name="count">The number of characters to read in this builder.</param>
166158
/// <remarks>
167-
/// If <paramref name="newValue"/> is from type <see cref="ISpanFormattable"/> an optimized version is taken.
168-
/// Otherwise the ToString method is called.
159+
/// If <paramref name="newValue"/> is <see cref="ISpanFormattable"/>, <c>TryFormat</c> is used.
160+
/// Otherwise, <c>ToString</c> is used.
169161
/// </remarks>
170162
/// /// <typeparam name="T">Any type.</typeparam>
171163
[MethodImpl(MethodImplOptions.AggressiveInlining)]
172-
public void ReplaceGeneric<T>(ReadOnlySpan<char> oldValue, T newValue, int startIndex, int count)
164+
public void ReplaceGeneric<T>(scoped ReadOnlySpan<char> oldValue, T newValue, int startIndex, int count)
173165
{
174166
if (newValue is ISpanFormattable spanFormattable)
175167
{
176-
Span<char> tempBuffer = stackalloc char[24];
168+
Span<char> tempBuffer = stackalloc char[128];
177169
if (spanFormattable.TryFormat(tempBuffer, out var written, default, null))
178170
{
179171
Replace(oldValue, tempBuffer[..written], startIndex, count);
172+
return;
180173
}
181174
}
182-
else
183-
{
184-
Replace(oldValue, (newValue?.ToString() ?? string.Empty).AsSpan(), startIndex, count);
185-
}
175+
176+
Replace(oldValue, newValue?.ToString() ?? string.Empty, startIndex, count);
186177
}
187178
}

src/LinkDotNet.StringBuilder/ValueStringBuilder.cs

+2-12
Original file line numberDiff line numberDiff line change
@@ -209,12 +209,7 @@ public void Remove(int startIndex, int length)
209209
[MethodImpl(MethodImplOptions.AggressiveInlining)]
210210
public readonly int IndexOf(ReadOnlySpan<char> word, int startIndex)
211211
{
212-
if (startIndex < 0)
213-
{
214-
throw new ArgumentOutOfRangeException(nameof(startIndex), "Start index can't be smaller than 0.");
215-
}
216-
217-
return word.IsEmpty ? 0 : NaiveSearch.FindFirst(buffer[startIndex..bufferPosition], word);
212+
return buffer[startIndex..bufferPosition].IndexOf(word);
218213
}
219214

220215
/// <summary>
@@ -234,12 +229,7 @@ public readonly int IndexOf(ReadOnlySpan<char> word, int startIndex)
234229
[MethodImpl(MethodImplOptions.AggressiveInlining)]
235230
public readonly int LastIndexOf(ReadOnlySpan<char> word, int startIndex)
236231
{
237-
if (startIndex < 0)
238-
{
239-
throw new ArgumentOutOfRangeException(nameof(startIndex), "Start index can't be smaller than 0.");
240-
}
241-
242-
return word.IsEmpty ? 0 : NaiveSearch.FindLast(buffer[startIndex..bufferPosition], word);
232+
return buffer[startIndex..bufferPosition].LastIndexOf(word);
243233
}
244234

245235
/// <summary>

tests/LinkDotNet.StringBuilder.UnitTests/ValueStringBuilderTests.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ public void ShouldReturnZeroWhenEmptyStringInIndexOf()
295295
using var builder = new ValueStringBuilder();
296296
builder.Append("Hello");
297297

298-
var index = builder.IndexOf(string.Empty, 6);
298+
var index = builder.IndexOf(string.Empty, 5);
299299

300300
index.ShouldBe(0);
301301
}
@@ -316,7 +316,7 @@ public void ShouldReturnZeroWhenEmptyStringInLastIndexOf()
316316
using var builder = new ValueStringBuilder();
317317
builder.Append("Hello");
318318

319-
var index = builder.LastIndexOf(string.Empty, 6);
319+
var index = builder.LastIndexOf(string.Empty, 5);
320320

321321
index.ShouldBe(0);
322322
}

0 commit comments

Comments
 (0)