diff --git a/src/Umbraco.Core/Extensions/StringExtensions.cs b/src/Umbraco.Core/Extensions/StringExtensions.cs index 6a76650523..c1abeb8650 100644 --- a/src/Umbraco.Core/Extensions/StringExtensions.cs +++ b/src/Umbraco.Core/Extensions/StringExtensions.cs @@ -1040,14 +1040,15 @@ public static class StringExtensions throw new ArgumentNullException(nameof(text)); } - var pos = text.IndexOf(search, StringComparison.InvariantCulture); + ReadOnlySpan spanText = text.AsSpan(); + var pos = spanText.IndexOf(search, StringComparison.InvariantCulture); if (pos < 0) { return text; } - return text.Substring(0, pos) + replace + text.Substring(pos + search.Length); + return string.Concat(spanText[..pos], replace.AsSpan(), spanText[(pos + search.Length)..]); } /// diff --git a/tests/Umbraco.Tests.Benchmarks/StringReplaceFirstBenchmarks.cs b/tests/Umbraco.Tests.Benchmarks/StringReplaceFirstBenchmarks.cs new file mode 100644 index 0000000000..e5c6c6b2b5 --- /dev/null +++ b/tests/Umbraco.Tests.Benchmarks/StringReplaceFirstBenchmarks.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; +using Umbraco.Tests.Benchmarks.Config; + +namespace Umbraco.Tests.Benchmarks; + +[QuickRunWithMemoryDiagnoserConfig] +public class StringReplaceFirstBenchmarks +{ + [Params("Test string", + "This is a test string that contains multiple test entries", + "This is a string where the searched value is very far back. The system needs to go through all of this code before it reaches the test")] + public string Text { get; set; } + public string Search { get; set; } + public string Replace { get; set; } + + [GlobalSetup] + public void Setup() + { + Search = "test"; + Replace = "release"; + } + + [Benchmark(Baseline = true, Description = "Replace first w/ substring")] + public string SubstringReplaceFirst() + { + var pos = Text.IndexOf(Search, StringComparison.InvariantCulture); + + if (pos < 0) + { + return Text; + } + + return Text.Substring(0, pos) + Replace + Text.Substring(pos + Search.Length); + } + + [Benchmark(Description = "Replace first w/ span")] + public string SpanReplaceFirst() + { + var spanText = Text.AsSpan(); + var pos = spanText.IndexOf(Search, StringComparison.InvariantCulture); + + if (pos < 0) + { + return Text; + } + + return string.Concat(spanText[..pos], Replace.AsSpan(), spanText[(pos + Search.Length)..]); + } + + //| Method | Text | Mean | Error | StdDev | Ratio | RatioSD | Gen 0 | Allocated | + //|----------------------------- |--------------------- |----------:|---------:|---------:|------:|--------:|-------:|----------:| + //| 'Replace first w/ substring' | Test string | 46.08 ns | 25.83 ns | 1.416 ns | 1.00 | 0.00 | - | - | + //| 'Replace first w/ span' | Test string | 38.59 ns | 19.46 ns | 1.067 ns | 0.84 | 0.05 | - | - | + //| | | | | | | | | | + //| 'Replace first w/ substring' | This(...)test[134] | 407.89 ns | 52.08 ns | 2.855 ns | 1.00 | 0.00 | 0.1833 | 584 B | + //| 'Replace first w/ span' | This(...)test[134] | 372.99 ns | 58.38 ns | 3.200 ns | 0.91 | 0.01 | 0.0941 | 296 B | + //| | | | | | | | | | + //| 'Replace first w/ substring' | This(...)tries[57] | 113.16 ns | 27.95 ns | 1.532 ns | 1.00 | 0.00 | 0.0961 | 304 B | + //| 'Replace first w/ span' | This(...)tries[57] | 76.57 ns | 17.86 ns | 0.979 ns | 0.68 | 0.01 | 0.0455 | 144 B | +} diff --git a/tests/Umbraco.Tests.Benchmarks/StringReplaceManyBenchmarks.cs b/tests/Umbraco.Tests.Benchmarks/StringReplaceManyBenchmarks.cs index 096d591463..8c4914d0df 100644 --- a/tests/Umbraco.Tests.Benchmarks/StringReplaceManyBenchmarks.cs +++ b/tests/Umbraco.Tests.Benchmarks/StringReplaceManyBenchmarks.cs @@ -74,6 +74,7 @@ public class StringReplaceManyBenchmarks return result; } + /* short text, short replacement: diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/StringExtensionsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/StringExtensionsTests.cs index 01fc57c1d8..bd02bead1c 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/StringExtensionsTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/ShortStringHelper/StringExtensionsTests.cs @@ -326,6 +326,14 @@ public class StringExtensionsTests Assert.AreEqual(expected, output); } + [TestCase("test to test", "test", "release", "release to test")] + [TestCase("nothing to do", "test", "release", "nothing to do")] + public void ReplaceFirst(string input, string search, string replacement, string expected) + { + var output = input.ReplaceFirst(search, replacement); + Assert.AreEqual(expected, output); + } + [Test] public void IsFullPath() {