Skip to content
62 changes: 62 additions & 0 deletions source/Handlebars.Benchmark/RenderToString.cs
Original file line number Diff line number Diff line change
@@ -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<object, object> _template;

Check warning on line 18 in source/Handlebars.Benchmark/RenderToString.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Non-nullable field '_template' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

See more on https://sonarcloud.io/project/issues?id=Handlebars-Net_Handlebars.Net&issues=AZ_QCPSDxlHCB9ZFNP0R&open=AZ_QCPSDxlHCB9ZFNP0R&pullRequest=651
private object _data;

Check warning on line 19 in source/Handlebars.Benchmark/RenderToString.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Non-nullable field '_data' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

See more on https://sonarcloud.io/project/issues?id=Handlebars-Net_Handlebars.Net&issues=AZ_QCPSDxlHCB9ZFNP0S&open=AZ_QCPSDxlHCB9ZFNP0S&pullRequest=651

private const int ItemCount = 50;

private const string Source =
"<ul>" +
"{{#each items}}" +
"<li id=\"item-{{@index}}\"><span>{{name}}</span> &mdash; {{description}} " +
"<em>x{{qty}}</em>{{#if onSale}} <strong>SALE</strong>{{/if}}</li>" +
"{{/each}}" +
"</ul>";

[Params("clean", "html")]
public string Content { get; set; }

Check warning on line 32 in source/Handlebars.Benchmark/RenderToString.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Non-nullable property 'Content' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

See more on https://sonarcloud.io/project/issues?id=Handlebars-Net_Handlebars.Net&issues=AZ_QCPSDxlHCB9ZFNP0T&open=AZ_QCPSDxlHCB9ZFNP0T&pullRequest=651

[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 <everyday> item \"for\" home & office use";

var items = new List<object>(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);
}
}
84 changes: 76 additions & 8 deletions source/Handlebars/IO/HtmlEncoder.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;
Expand All @@ -12,30 +13,97 @@ namespace HandlebarsDotNet
/// </summary>
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<char> 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>(T? text, TextWriter target) where T : IEnumerator<char>
{
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<char>) 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>(T text, TextWriter target) where T : IEnumerator<char>
{
/*
Expand Down
54 changes: 51 additions & 3 deletions source/Handlebars/IO/HtmlEncoderLegacy.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using HandlebarsDotNet.StringUtils;
using HandlebarsDotNet.StringUtils;
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
Expand All @@ -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)]
Expand All @@ -40,6 +53,41 @@ public void Encode<T>(T? text, TextWriter target) where T : IEnumerator<char>
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<char>) 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>(T text, TextWriter target) where T : IEnumerator<char>
{
while (text.MoveNext())
Expand Down
8 changes: 8 additions & 0 deletions source/Handlebars/StringUtils/StringEnumerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading