diff --git a/source/Handlebars.Benchmark/RenderToString.cs b/source/Handlebars.Benchmark/RenderToString.cs new file mode 100644 index 00000000..9b4e902d --- /dev/null +++ b/source/Handlebars.Benchmark/RenderToString.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using System.IO; +using BenchmarkDotNet.Attributes; +using HandlebarsDotNet; + +namespace HandlebarsNet.Benchmark +{ + // Render time for the string-returning template API — the most common way the + // library is consumed. Unlike the other Render* benchmarks (which write to + // TextWriter.Null and therefore only measure resolution overhead), this suite + // pays the real output cost: HTML encoding and StringBuilder-backed writes. + // + // Content=clean: values contain nothing that needs HTML escaping (typical data). + // Content=html: values are dense with characters that must be escaped (worst case). + [MemoryDiagnoser] + public class RenderToString + { + private HandlebarsTemplate _template; + private object _data; + + private const int ItemCount = 50; + + private const string Source = + ""; + + [Params("clean", "html")] + public string Content { get; set; } + + [GlobalSetup] + public void Setup() + { + var handlebars = Handlebars.Create(); + _template = handlebars.Compile(Source); + + var description = Content == "clean" + ? "Reliable everyday item for home and office use" + : "Fast & reliable item \"for\" home & office use"; + + var items = new List(ItemCount); + for (var i = 0; i < ItemCount; i++) + { + items.Add(new + { + name = $"Product {i:D4}", + description, + qty = i * 7 + 1, + onSale = i % 3 == 0 + }); + } + + _data = new { items }; + } + + [Benchmark] + public string Render() => _template(_data); + } +} diff --git a/source/Handlebars/IO/HtmlEncoder.cs b/source/Handlebars/IO/HtmlEncoder.cs index 7a5459bc..311bbd5b 100644 --- a/source/Handlebars/IO/HtmlEncoder.cs +++ b/source/Handlebars/IO/HtmlEncoder.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Runtime.CompilerServices; using System.Text; @@ -12,30 +13,97 @@ namespace HandlebarsDotNet /// public class HtmlEncoder : ITextEncoder { + /* + * Escape set based on: https://github.com/handlebars-lang/handlebars.js/blob/master/lib/handlebars/utils.js + * As of 2021-12-20 / commit https://github.com/handlebars-lang/handlebars.js/commit/3fb331ef40ee1a8308dd83b8e5adbcd798d0adc9 + */ +#if NET8_0_OR_GREATER + private static readonly System.Buffers.SearchValues EscapeChars = System.Buffers.SearchValues.Create("&<>\"'`="); +#endif + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Encode(StringBuilder? text, TextWriter target) { if(text == null || text.Length == 0) return; - + EncodeImpl(new StringBuilderEnumerator(text), target); } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Encode(string? text, TextWriter target) { if (text is not {Length: > 0}) return; - - EncodeImpl(new StringEnumerator(text), target); + + var index = IndexOfEscapeChar(text, 0); + if (index == -1) + { + // Fast path: nothing to escape, write the whole string at once + target.Write(text); + return; + } + + // Bulk-write the clean prefix, then fall back to per-character encoding. + // Escape-dense content makes per-segment bulk writes counter-productive: + // the fixed cost of a span write outweighs a few Write(char) calls. + if (index != 0) WriteRun(text, 0, index, target); + EncodeImpl(new StringEnumerator(text, index), target); } - + [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Encode(T? text, TextWriter target) where T : IEnumerator { if (text is null) return; - + EncodeImpl(text, target); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int IndexOfEscapeChar(string text, int start) + { +#if NET8_0_OR_GREATER + // SearchValues pre-computes the lookup structure once; string.IndexOfAny(char[]) + // with more than 5 needles would rebuild a probabilistic map on every call. + var index = text.AsSpan(start).IndexOfAny(EscapeChars); + return index < 0 ? -1 : index + start; +#else + for (var i = start; i < text.Length; i++) + { + switch (text[i]) + { + case '&': + case '<': + case '>': + case '"': + case '\'': + case '`': + case '=': + return i; + } + } + + return -1; +#endif + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void WriteRun(string text, int start, int length, TextWriter target) + { +#if !NETSTANDARD2_0 + // StringWriter and StreamWriter override Write(ReadOnlySpan) with efficient + // implementations; for other writers TextWriter's base implementation rents and + // copies through ArrayPool, so they keep the original per-character writes. + if (target is StringWriter || target is StreamWriter) + { + target.Write(text.AsSpan(start, length)); + return; + } +#endif + var end = start + length; + for (var i = start; i < end; i++) + { + target.Write(text[i]); + } + } + private static void EncodeImpl(T text, TextWriter target) where T : IEnumerator { /* diff --git a/source/Handlebars/IO/HtmlEncoderLegacy.cs b/source/Handlebars/IO/HtmlEncoderLegacy.cs index 354d446d..d05dbae4 100644 --- a/source/Handlebars/IO/HtmlEncoderLegacy.cs +++ b/source/Handlebars/IO/HtmlEncoderLegacy.cs @@ -1,4 +1,5 @@ -using HandlebarsDotNet.StringUtils; +using HandlebarsDotNet.StringUtils; +using System; using System.Collections.Generic; using System.IO; using System.Runtime.CompilerServices; @@ -24,12 +25,24 @@ public void Encode(StringBuilder? text, TextWriter target) EncodeImpl(new StringBuilderEnumerator(text), target); } - [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Encode(string? text, TextWriter target) { if (text is not {Length: > 0}) return; - EncodeImpl(new StringEnumerator(text), target); + var length = text.Length; + var index = 0; + while (index < length && !RequiresEscaping(text[index])) index++; + + if (index == length) + { + // Fast path: nothing to escape, write the whole string at once + target.Write(text); + return; + } + + // Bulk-write the clean prefix, then fall back to per-character encoding. + if (index != 0) WriteRun(text, 0, index, target); + EncodeImpl(new StringEnumerator(text, index), target); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -40,6 +53,41 @@ public void Encode(T? text, TextWriter target) where T : IEnumerator EncodeImpl(text, target); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool RequiresEscaping(char value) + { + switch (value) + { + case '"': + case '&': + case '<': + case '>': + return true; + default: + return value > 159; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void WriteRun(string text, int start, int length, TextWriter target) + { +#if !NETSTANDARD2_0 + // StringWriter and StreamWriter override Write(ReadOnlySpan) with efficient + // implementations; for other writers TextWriter's base implementation rents and + // copies through ArrayPool, so they keep the original per-character writes. + if (target is StringWriter || target is StreamWriter) + { + target.Write(text.AsSpan(start, length)); + return; + } +#endif + var end = start + length; + for (var i = start; i < end; i++) + { + target.Write(text[i]); + } + } + private static void EncodeImpl(T text, TextWriter target) where T : IEnumerator { while (text.MoveNext()) diff --git a/source/Handlebars/StringUtils/StringEnumerator.cs b/source/Handlebars/StringUtils/StringEnumerator.cs index deb4d1df..3c305b59 100644 --- a/source/Handlebars/StringUtils/StringEnumerator.cs +++ b/source/Handlebars/StringUtils/StringEnumerator.cs @@ -18,6 +18,14 @@ public StringEnumerator(string text) _length = _text.Length; _index = -1; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public StringEnumerator(string text, int start) + { + _text = text; + _length = _text.Length; + _index = start - 1; + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool MoveNext() => ++_index < _length;