diff --git a/src/RazorSlices/BufferWriterHtmlExtensions.cs b/src/RazorSlices/BufferWriterHtmlExtensions.cs
new file mode 100644
index 0000000..b5ee4c2
--- /dev/null
+++ b/src/RazorSlices/BufferWriterHtmlExtensions.cs
@@ -0,0 +1,285 @@
+using System.Buffers.Text;
+using System.Diagnostics;
+using System.Text.Encodings.Web;
+using System.Text.Unicode;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+using RazorSlices;
+
+namespace System.Buffers;
+
+internal static class BufferWriterHtmlExtensions
+{
+ public static void HtmlEncodeAndWriteUtf8(this IBufferWriter bufferWriter, ReadOnlySpan utf8Text, HtmlEncoder htmlEncoder)
+ {
+ if (utf8Text.Length == 0)
+ {
+ return;
+ }
+
+ if (htmlEncoder == NullHtmlEncoder.Default)
+ {
+ // No HTML encoding required
+ bufferWriter.Write(utf8Text);
+ return;
+ }
+
+ Span writerSpan = default;
+ var encodeStatus = OperationStatus.Done;
+ var waitingToAdvance = 0;
+
+ while (utf8Text.Length > 0)
+ {
+ if (writerSpan.Length == 0)
+ {
+ if (waitingToAdvance > 0)
+ {
+ bufferWriter.Advance(waitingToAdvance);
+ waitingToAdvance = 0;
+ }
+ // Allow space for HTML encoding the string
+ var spanSizeHint = BufferSizes.GetHtmlEncodedSizeHint(utf8Text.Length);
+ writerSpan = bufferWriter.GetSpan(spanSizeHint);
+ }
+
+ // Encode to buffer
+ encodeStatus = htmlEncoder.EncodeUtf8(utf8Text, writerSpan, out var bytesConsumed, out var bytesWritten);
+ waitingToAdvance += bytesWritten;
+
+ if (utf8Text.Length - bytesConsumed == 0)
+ {
+ break;
+ }
+
+ utf8Text = utf8Text[bytesConsumed..];
+ writerSpan = writerSpan[bytesWritten..];
+ }
+
+ if (waitingToAdvance > 0)
+ {
+ bufferWriter.Advance(waitingToAdvance);
+ }
+
+ Debug.Assert(encodeStatus == OperationStatus.Done, "Bad math in IBufferWriter HTML writing extensions");
+ }
+
+ public static void HtmlEncodeAndWriteSpanFormattable(this IBufferWriter bufferWriter, T? formattable, HtmlEncoder htmlEncoder, ReadOnlySpan format = default, IFormatProvider? formatProvider = null)
+ where T : ISpanFormattable
+ {
+ if (formattable is null)
+ {
+ return;
+ }
+
+ if (TryHtmlEncodeAndWriteSpanFormattableSmall(bufferWriter, formattable, htmlEncoder, format, formatProvider))
+ {
+ return;
+ }
+
+ var bufferSize = BufferSizes.SmallFormattableWriteCharSize * 2;
+ var rentedBuffer = ArrayPool.Shared.Rent(bufferSize);
+ int charsWritten;
+
+ while (!formattable.TryFormat(rentedBuffer, out charsWritten, format, formatProvider))
+ {
+ // Buffer was too small, return the current buffer and rent a new buffer twice the size
+ bufferSize = rentedBuffer.Length * 2;
+ ArrayPool.Shared.Return(rentedBuffer);
+ rentedBuffer = ArrayPool.Shared.Rent(bufferSize);
+ }
+
+ HtmlEncodeAndWrite(bufferWriter, rentedBuffer.AsSpan()[..charsWritten], htmlEncoder);
+ ArrayPool.Shared.Return(rentedBuffer);
+ }
+
+ private static bool TryHtmlEncodeAndWriteSpanFormattableSmall(IBufferWriter bufferWriter, T formattable, HtmlEncoder htmlEncoder, ReadOnlySpan format = default, IFormatProvider? formatProvider = null)
+ where T : ISpanFormattable
+ {
+ Span buffer = stackalloc char[BufferSizes.SmallFormattableWriteCharSize];
+ if (formattable.TryFormat(buffer, out var charsWritten, format, formatProvider))
+ {
+ HtmlEncodeAndWrite(bufferWriter, buffer[..charsWritten], htmlEncoder);
+ return true;
+ }
+ return false;
+ }
+
+#if NET8_0_OR_GREATER
+ public static void HtmlEncodeAndWriteUtf8SpanFormattable(this IBufferWriter bufferWriter, T? formattable, HtmlEncoder htmlEncoder, ReadOnlySpan format = default, IFormatProvider? formatProvider = null)
+ where T : IUtf8SpanFormattable
+ {
+ if (formattable is null)
+ {
+ return;
+ }
+
+ if (TryHtmlEncodeAndWriteUtf8SpanFormattableSmall(bufferWriter, formattable, htmlEncoder, format, formatProvider))
+ {
+ return;
+ }
+
+ var bufferSize = BufferSizes.SmallFormattableWriteByteSize * 2;
+ var rentedBuffer = ArrayPool.Shared.Rent(bufferSize);
+ int bytesWritten;
+
+ while (!formattable.TryFormat(rentedBuffer, out bytesWritten, format, formatProvider))
+ {
+ // Buffer was too small, return the current buffer and rent a new buffer twice the size
+ bufferSize = rentedBuffer.Length * 2;
+ ArrayPool.Shared.Return(rentedBuffer);
+ rentedBuffer = ArrayPool.Shared.Rent(bufferSize);
+ }
+
+ HtmlEncodeAndWriteUtf8(bufferWriter, rentedBuffer.AsSpan()[..bytesWritten], htmlEncoder);
+ ArrayPool.Shared.Return(rentedBuffer);
+ }
+
+ private static bool TryHtmlEncodeAndWriteUtf8SpanFormattableSmall(IBufferWriter bufferWriter, T formattable, HtmlEncoder htmlEncoder, ReadOnlySpan format = default, IFormatProvider? formatProvider = null)
+ where T : IUtf8SpanFormattable
+ {
+ Span buffer = stackalloc byte[BufferSizes.SmallFormattableWriteByteSize];
+ if (formattable.TryFormat(buffer, out var bytesWritten, format, formatProvider))
+ {
+ HtmlEncodeAndWriteUtf8(bufferWriter, buffer[..bytesWritten], htmlEncoder);
+ return true;
+ }
+ return false;
+ }
+#endif
+
+ public static void HtmlEncodeAndWrite(this IBufferWriter bufferWriter, ReadOnlySpan textSpan, HtmlEncoder htmlEncoder)
+ {
+ if (textSpan.Length == 0)
+ {
+ return;
+ }
+
+ if (htmlEncoder == NullHtmlEncoder.Default)
+ {
+ // No HTML encoding required
+ WriteHtml(bufferWriter, textSpan);
+ return;
+ }
+
+ if (textSpan.Length <= BufferSizes.SmallTextWriteCharSize)
+ {
+ HtmlEncodeAndWriteSmall(bufferWriter, textSpan, htmlEncoder);
+ return;
+ }
+
+ var sizeHint = BufferSizes.GetHtmlEncodedSizeHint(textSpan.Length);
+ var rentedBuffer = ArrayPool.Shared.Rent(sizeHint);
+ Span bufferSpan = rentedBuffer;
+ var waitingToWrite = 0;
+ var encodeStatus = OperationStatus.Done;
+
+ while (textSpan.Length > 0)
+ {
+ if (bufferSpan.Length == 0)
+ {
+ if (waitingToWrite > 0)
+ {
+ WriteHtml(bufferWriter, rentedBuffer.AsSpan()[..waitingToWrite]);
+ waitingToWrite = 0;
+ bufferSpan = rentedBuffer;
+ }
+ }
+
+ // Encode to rented buffer
+ encodeStatus = htmlEncoder.Encode(textSpan, bufferSpan, out var charsConsumed, out var charsWritten);
+ waitingToWrite += charsWritten;
+
+ if (textSpan.Length - charsConsumed == 0)
+ {
+ break;
+ }
+
+ textSpan = textSpan[charsConsumed..];
+ bufferSpan = bufferSpan[charsWritten..];
+ }
+
+ if (waitingToWrite > 0)
+ {
+ WriteHtml(bufferWriter, rentedBuffer.AsSpan()[..waitingToWrite]);
+ }
+
+ ArrayPool.Shared.Return(rentedBuffer);
+
+ Debug.Assert(encodeStatus == OperationStatus.Done, "Bad math in IBufferWriter HTML writing extensions");
+ }
+
+ private static void HtmlEncodeAndWriteSmall(IBufferWriter bufferWriter, ReadOnlySpan textSpan, HtmlEncoder htmlEncoder)
+ {
+ Span encodedBuffer = stackalloc char[BufferSizes.SmallTextWriteCharSize];
+ var encodeStatus = OperationStatus.Done;
+
+ // It's possible for encoding to take multiple cycles if an unusually high number of chars need HTML encoding and/or the
+ // the total number of chars is close to the SmallWriteCharSize limit.
+ while (textSpan.Length > 0)
+ {
+ // Encode to buffer
+ encodeStatus = htmlEncoder.Encode(textSpan, encodedBuffer, out var charsConsumed, out var charsWritten);
+
+ Debug.Assert(encodeStatus != OperationStatus.NeedMoreData, "Not expecting to hit this case");
+
+ // Write encoded chars to the writer
+ var encoded = encodedBuffer[..charsWritten];
+ WriteHtml(bufferWriter, encoded);
+
+ textSpan = textSpan[charsConsumed..];
+ }
+
+ Debug.Assert(encodeStatus == OperationStatus.Done, "Bad math in IBufferWriter HTML writing extensions");
+ }
+
+ public static void WriteHtml(this IBufferWriter bufferWriter, ReadOnlySpan html)
+ {
+ Span writerSpan = default;
+
+ var status = OperationStatus.Done;
+ int waitingToAdvance = 0;
+
+ while (html.Length > 0)
+ {
+ if (writerSpan.Length == 0)
+ {
+ if (waitingToAdvance > 0)
+ {
+ bufferWriter.Advance(waitingToAdvance);
+ waitingToAdvance = 0;
+ }
+ var spanLengthHint = Math.Min(html.Length, BufferSizes.MaxBufferSize);
+ writerSpan = bufferWriter.GetSpan(spanLengthHint);
+ }
+
+ status = Utf8.FromUtf16(html, writerSpan, out var charsRead, out var bytesWritten);
+ waitingToAdvance += bytesWritten;
+
+ if (html.Length - charsRead == 0)
+ {
+ break;
+ }
+
+ html = html[charsRead..];
+ writerSpan = writerSpan[bytesWritten..];
+ }
+
+ if (waitingToAdvance > 0)
+ {
+ bufferWriter.Advance(waitingToAdvance);
+ }
+
+ Debug.Assert(status == OperationStatus.Done, "Bad math in IBufferWriter HTML writing extensions");
+ }
+
+ private static readonly int TrueStringLength = bool.TrueString.Length;
+ private static readonly int FalseStringLength = bool.FalseString.Length;
+
+ public static void Write(this IBufferWriter bufferWriter, bool value)
+ {
+ var buffer = bufferWriter.GetSpan(value ? TrueStringLength : FalseStringLength);
+ if (!Utf8Formatter.TryFormat(value, buffer, out var _))
+ {
+ throw new FormatException("Unexpectedly insufficient space in buffer to format bool value.");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/RazorSlices/RazorSlice.Partials.cs b/src/RazorSlices/RazorSlice.Partials.cs
index 15bcea9..538dd01 100644
--- a/src/RazorSlices/RazorSlice.Partials.cs
+++ b/src/RazorSlices/RazorSlice.Partials.cs
@@ -87,6 +87,10 @@ internal ValueTask RenderChildSliceAsync(RazorSlice child)
{
renderPartialTask = child.RenderToTextWriterAsync(_textWriter, _htmlEncoder, CancellationToken, renderLayout: false);
}
+ else if (_bufferWriter is not null)
+ {
+ renderPartialTask = child.RenderToBufferWriterAsync(_bufferWriter, _htmlEncoder, CancellationToken, renderLayout: false);
+ }
#pragma warning restore CA2012
else
{
diff --git a/src/RazorSlices/RazorSlice.Write.cs b/src/RazorSlices/RazorSlice.Write.cs
index 15f44aa..82d4b0f 100644
--- a/src/RazorSlices/RazorSlice.Write.cs
+++ b/src/RazorSlices/RazorSlice.Write.cs
@@ -25,6 +25,7 @@ protected void WriteLiteral(string? value)
_pipeWriter?.WriteHtml(value.AsSpan());
_textWriter?.Write(value);
+ _bufferWriter?.WriteHtml(value.AsSpan());
}
///
@@ -75,6 +76,7 @@ protected void WriteLiteral(ReadOnlySpan value)
_pipeWriter?.Write(value);
_textWriter?.WriteUtf8(value);
+ _bufferWriter?.Write(value);
}
///
@@ -119,6 +121,7 @@ protected void Write(ReadOnlySpan value)
_pipeWriter?.HtmlEncodeAndWriteUtf8(value, _htmlEncoder);
_textWriter?.HtmlEncodeAndWriteUtf8(value, _htmlEncoder);
+ _bufferWriter?.HtmlEncodeAndWriteUtf8(value, _htmlEncoder);
}
///
@@ -177,6 +180,7 @@ protected void Write(ReadOnlySpan value)
{
_pipeWriter?.HtmlEncodeAndWrite(value, _htmlEncoder);
_textWriter?.HtmlEncodeAndWrite(value, _htmlEncoder);
+ _bufferWriter?.HtmlEncodeAndWrite(value, _htmlEncoder);
}
}
@@ -204,6 +208,7 @@ protected HtmlString WriteBool(bool? value)
{
_pipeWriter?.Write(value.Value);
_textWriter?.Write(value.Value);
+ _bufferWriter?.Write(value.Value);
}
return HtmlString.Empty;
}
@@ -224,6 +229,7 @@ protected HtmlString WriteSpanFormattable(T? formattable, ReadOnlySpan
var htmlEncoder = htmlEncode ? _htmlEncoder : NullHtmlEncoder.Default;
_pipeWriter?.HtmlEncodeAndWriteSpanFormattable(formattable, htmlEncoder, format, formatProvider);
_textWriter?.HtmlEncodeAndWriteSpanFormattable(formattable, htmlEncoder, format, formatProvider);
+ _bufferWriter?.HtmlEncodeAndWriteSpanFormattable(formattable, htmlEncoder, format, formatProvider);
}
return HtmlString.Empty;
@@ -245,6 +251,7 @@ protected HtmlString WriteUtf8SpanFormattable(T? formattable, ReadOnlySpan(T htmlContent)
{
if (_pipeWriter is not null)
{
- _utf8BufferTextWriter ??= Utf8PipeTextWriter.Get(_pipeWriter);
- htmlContent.WriteTo(_utf8BufferTextWriter, _htmlEncoder);
+ _utf8PipeTextWriter ??= Utf8PipeWriterTextWriter.Get(_pipeWriter);
+ htmlContent.WriteTo(_utf8PipeTextWriter, _htmlEncoder);
}
- if (_textWriter is not null)
+ else if (_textWriter is not null)
{
htmlContent.WriteTo(_textWriter, _htmlEncoder);
}
+ else if (_bufferWriter is not null)
+ {
+ _utf8BufferTextWriter ??= Utf8BufferTextWriter.Get(_bufferWriter);
+ htmlContent.WriteTo(_utf8BufferTextWriter, _htmlEncoder);
+ }
}
return HtmlString.Empty;
@@ -285,6 +297,7 @@ protected HtmlString WriteHtml(HtmlString htmlString)
{
_pipeWriter?.WriteHtml(htmlString.Value);
_textWriter?.Write(htmlString.Value);
+ _bufferWriter?.WriteHtml(htmlString.Value);
}
return HtmlString.Empty;
@@ -305,6 +318,7 @@ protected HtmlString WriteHtml(string? htmlString)
{
_pipeWriter?.WriteHtml(htmlString.AsSpan());
_textWriter?.Write(htmlString);
+ _bufferWriter?.WriteHtml(htmlString.AsSpan());
}
return HtmlString.Empty;
diff --git a/src/RazorSlices/RazorSlice.cs b/src/RazorSlices/RazorSlice.cs
index ad3c804..15e15cc 100644
--- a/src/RazorSlices/RazorSlice.cs
+++ b/src/RazorSlices/RazorSlice.cs
@@ -1,4 +1,5 @@
-using System.ComponentModel;
+using System.Buffers;
+using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO.Pipelines;
@@ -24,7 +25,10 @@ public abstract partial class RazorSlice : IDisposable
private HtmlEncoder _htmlEncoder = HtmlEncoder.Default;
private PipeWriter? _pipeWriter;
private TextWriter? _textWriter;
- private Utf8PipeTextWriter? _utf8BufferTextWriter;
+ private IBufferWriter? _bufferWriter;
+ private Utf8PipeWriterTextWriter? _utf8PipeTextWriter;
+ private Utf8BufferTextWriter? _utf8BufferTextWriter;
+ private Func>? _outputFlush;
private bool _disposed;
///
@@ -63,6 +67,7 @@ public IServiceProvider? ServiceProvider
///
/// This method should not be called directly. Call
/// or
+ /// or
/// instead to render the template.
///
/// A representing the execution of the template.
@@ -141,6 +146,41 @@ public ValueTask RenderAsync(TextWriter textWriter, HtmlEncoder? htmlEncoder = n
return RenderToTextWriterAsync(textWriter, htmlEncoder, cancellationToken);
}
+
+ ///
+ /// Renders the template to the specified .
+ ///
+ /// The to render the template to.
+ /// An optional delegate that flushes the .
+ /// An optional instance to use when rendering the template. If none is specified, will be used.
+ /// A token to monitor for cancellation requests.
+ /// A representing the rendering of the template.
+ public ValueTask RenderAsync(IBufferWriter bufferWriter, Func? flushAsync = null, HtmlEncoder? htmlEncoder = null, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(bufferWriter);
+
+ if (flushAsync != null)
+ {
+ _outputFlush = (ct) =>
+ {
+ var flushTask = flushAsync(ct);
+
+ if (flushTask.IsCompletedSuccessfully)
+ {
+ return ValueTask.FromResult(_noFlushResult);
+ }
+
+ return AwaitOutputFlushTask(flushTask);
+ };
+ }
+ else
+ {
+ _outputFlush = null;
+ }
+
+ return RenderToBufferWriterAsync(bufferWriter, htmlEncoder, cancellationToken);
+ }
+
[MemberNotNull(nameof(_pipeWriter))]
private ValueTask RenderToPipeWriterAsync(PipeWriter pipeWriter, HtmlEncoder? htmlEncoder, CancellationToken cancellationToken, bool renderLayout = true)
{
@@ -148,6 +188,7 @@ private ValueTask RenderToPipeWriterAsync(PipeWriter pipeWriter, HtmlEncoder? ht
_pipeWriter = pipeWriter;
_textWriter = null;
+ _bufferWriter = null;
_htmlEncoder = htmlEncoder ?? _htmlEncoder;
CancellationToken = cancellationToken;
@@ -176,6 +217,7 @@ private ValueTask RenderToTextWriterAsync(TextWriter textWriter, HtmlEncoder? ht
_pipeWriter = null;
_textWriter = textWriter;
+ _bufferWriter = null;
_htmlEncoder = htmlEncoder ?? _htmlEncoder;
CancellationToken = cancellationToken;
@@ -199,12 +241,42 @@ private ValueTask RenderToTextWriterAsync(TextWriter textWriter, HtmlEncoder? ht
return ValueTask.CompletedTask;
}
+ [MemberNotNull(nameof(_bufferWriter))]
+ private ValueTask RenderToBufferWriterAsync(IBufferWriter bufferWriter, HtmlEncoder? htmlEncoder, CancellationToken cancellationToken, bool renderLayout = true)
+ {
+ _pipeWriter = null;
+ _textWriter = null;
+ _bufferWriter = bufferWriter;
+ _htmlEncoder = htmlEncoder ?? _htmlEncoder;
+ CancellationToken = cancellationToken;
+
+ if (renderLayout && this is IUsesLayout useLayout)
+ {
+ return RenderViaLayout(RenderToBufferWriterAsync, useLayout, bufferWriter, htmlEncoder, cancellationToken);
+ }
+
+ var executeTask = ExecuteAsyncImpl();
+
+ if (!executeTask.HandleSynchronousCompletion())
+ {
+ // Go async
+ return AwaitExecuteTaskFlushAndDispose(this, executeTask);
+ }
+
+ Dispose();
+
+ return AutoFlush().GetAsValueTask();
+ }
+
private static ValueTask RenderToPipeWriterAsync(RazorSlice razorSlice, PipeWriter pipeWriter, HtmlEncoder? htmlEncoder, CancellationToken cancellationToken)
=> razorSlice.RenderToPipeWriterAsync(pipeWriter, htmlEncoder, cancellationToken);
private static ValueTask RenderToTextWriterAsync(RazorSlice razorSlice, TextWriter textWriter, HtmlEncoder? htmlEncoder, CancellationToken cancellationToken)
=> razorSlice.RenderToTextWriterAsync(textWriter, htmlEncoder, cancellationToken);
+ private static ValueTask RenderToBufferWriterAsync(RazorSlice razorSlice, IBufferWriter bufferWriter, HtmlEncoder? htmlEncoder, CancellationToken cancellationToken)
+ => razorSlice.RenderToBufferWriterAsync(bufferWriter, htmlEncoder, cancellationToken);
+
private ValueTask RenderViaLayout(Func render, IUsesLayout usesLayout, TWriter writer, HtmlEncoder? htmlEncoder, CancellationToken cancellationToken)
{
var layoutSlice = usesLayout.CreateLayoutImpl();
@@ -244,12 +316,19 @@ private ValueTask AutoFlush()
return _pipeWriter.FlushAsync(CancellationToken);
}
+ if (_outputFlush is not null)
+ {
+ Debug.WriteLine($"Auto-flushing slice of type '{GetType().Name}' to a BufferWriter");
+ return _outputFlush(CancellationToken);
+ }
+
return ValueTask.FromResult(_noFlushResult);
}
- private static async ValueTask AwaitOutputFlushTask(Task flushTask)
+ private static async ValueTask AwaitOutputFlushTask(ValueTask flushTask)
{
await flushTask;
+ return _noFlushResult;
}
private static async ValueTask AwaitExecuteTaskFlushAndDispose(RazorSlice slice, Task executeTask)
@@ -287,6 +366,17 @@ protected ValueTask FlushAsync()
return ValueTask.FromResult(HtmlString.Empty);
}
+ else if (_bufferWriter is not null && _outputFlush is not null)
+ {
+ var bufferWriterFlushTask = _outputFlush(CancellationToken);
+ if (!bufferWriterFlushTask.IsCompletedSuccessfully)
+ {
+ // Go async
+ return AwaitBufferWriterFlushAsyncTask(bufferWriterFlushTask);
+ }
+
+ return ValueTask.FromResult(HtmlString.Empty);
+ }
throw new UnreachableException();
}
@@ -303,12 +393,18 @@ private static async ValueTask AwaitTextWriterFlushAsyncTask(Task fl
return HtmlString.Empty;
}
+ private static async ValueTask AwaitBufferWriterFlushAsyncTask(ValueTask flushTask)
+ {
+ await flushTask;
+ return HtmlString.Empty;
+ }
+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void ReturnPooledObjects()
{
- if (_utf8BufferTextWriter is not null)
+ if (_utf8PipeTextWriter is not null)
{
- Utf8PipeTextWriter.Return(_utf8BufferTextWriter);
+ Utf8PipeWriterTextWriter.Return(_utf8PipeTextWriter);
}
}
diff --git a/src/RazorSlices/RazorSlices.csproj b/src/RazorSlices/RazorSlices.csproj
index 5589cf6..34fe5ba 100644
--- a/src/RazorSlices/RazorSlices.csproj
+++ b/src/RazorSlices/RazorSlices.csproj
@@ -45,6 +45,11 @@
True
RazorSlice.Formattables.tt
+
+ True
+ True
+ Utf8TextWriterTemplate.tt
+
@@ -52,6 +57,10 @@
TextTemplatingFileGenerator
RazorSlice.Formattables.cs
+
+ TextTemplatingFileGenerator
+ Utf8TextWriterTemplate.cs
+
diff --git a/src/RazorSlices/Utf8TextWriterTemplate.cs b/src/RazorSlices/Utf8TextWriterTemplate.cs
new file mode 100644
index 0000000..928ec1e
--- /dev/null
+++ b/src/RazorSlices/Utf8TextWriterTemplate.cs
@@ -0,0 +1,363 @@
+// DO NOT EDIT
+// THIS FILE IS GENERATED BY Utf8TextWriterTemplate.tt
+
+using System.Buffers;
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Text.Unicode;
+
+namespace Microsoft.AspNetCore.Internal;
+
+
+// Adapted from https://github.com/dotnet/aspnetcore/blob/main/src/SignalR/common/Shared/Utf8BufferTextWriter.cs
+internal sealed class Utf8BufferTextWriter : TextWriter
+{
+ private static readonly UTF8Encoding _utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
+ private const int MaximumBytesPerUtf8Char = 4;
+
+ [ThreadStatic]
+ private static Utf8BufferTextWriter? _cachedInstance;
+
+ private IBufferWriter? _bufferWriter;
+ private Memory _memory;
+ private int _memoryUsed;
+
+#if DEBUG
+ private bool _inUse;
+#endif
+
+ public override Encoding Encoding => _utf8NoBom;
+
+ public static Utf8BufferTextWriter Get(IBufferWriter bufferWriter)
+ {
+ var writer = _cachedInstance;
+ writer ??= new Utf8BufferTextWriter();
+
+ // Taken off the thread static
+ _cachedInstance = null;
+#if DEBUG
+ if (writer._inUse)
+ {
+ throw new InvalidOperationException("The writer wasn't returned!");
+ }
+
+ writer._inUse = true;
+#endif
+ writer.SetWriter(bufferWriter);
+ return writer;
+ }
+
+ public static void Return(Utf8BufferTextWriter writer)
+ {
+ _cachedInstance = writer;
+
+ writer._memory = Memory.Empty;
+ writer._memoryUsed = 0;
+ writer._bufferWriter = null;
+
+#if DEBUG
+ writer._inUse = false;
+#endif
+ }
+
+ public void SetWriter(IBufferWriter bufferWriter)
+ {
+ _bufferWriter = bufferWriter;
+ }
+
+ public override void Write(char[] buffer, int index, int count)
+ {
+ WriteInternal(buffer.AsSpan(index, count));
+ }
+
+ public override void Write(char[]? buffer)
+ {
+ if (buffer is not null)
+ {
+ WriteInternal(buffer);
+ }
+ }
+
+ public override void Write(char value)
+ {
+ if (value <= 127)
+ {
+ EnsureBuffer();
+
+ // Only need to set one byte
+ // Avoid Memory.Slice overhead for perf
+ _memory.Span[_memoryUsed] = (byte)value;
+ _memoryUsed++;
+ }
+ else
+ {
+ WriteMultiByteChar(value);
+ }
+ }
+
+ private void WriteMultiByteChar(char value)
+ {
+ var destination = GetBuffer();
+
+ // Json.NET only writes ASCII characters by themselves, e.g. {}[], etc
+ // this should be an exceptional case
+
+#if NET7_0_OR_GREATER
+ Utf8.FromUtf16(new ReadOnlySpan(ref value), destination, out var charsUsed, out var bytesUsed);
+#else
+ ReadOnlySpan valueArray = stackalloc [] { value };
+ Utf8.FromUtf16(valueArray, destination, out var charsUsed, out var bytesUsed);
+#endif
+
+ Debug.Assert(charsUsed == 1);
+
+ _memoryUsed += bytesUsed;
+ }
+
+ public override void Write(string? value)
+ {
+ if (value is not null)
+ {
+ WriteInternal(value.AsSpan());
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private Span GetBuffer()
+ {
+ EnsureBuffer();
+
+ return _memory.Span[_memoryUsed.._memory.Length];
+ }
+
+ private void EnsureBuffer()
+ {
+ // We need at least enough bytes to encode a single UTF-8 character, or Encoder.Convert will throw.
+ // Normally, if there isn't enough space to write every character of a char buffer, Encoder.Convert just
+ // writes what it can. However, if it can't even write a single character, it throws. So if the buffer has only
+ // 2 bytes left and the next character to write is 3 bytes in UTF-8, an exception is thrown.
+ var remaining = _memory.Length - _memoryUsed;
+ if (remaining < MaximumBytesPerUtf8Char)
+ {
+ // Used up the memory from the buffer writer so advance and get more
+ if (_memoryUsed > 0)
+ {
+ _bufferWriter!.Advance(_memoryUsed);
+ }
+
+ _memory = _bufferWriter!.GetMemory(MaximumBytesPerUtf8Char);
+ _memoryUsed = 0;
+ }
+ }
+
+ private void WriteInternal(ReadOnlySpan buffer)
+ {
+ while (buffer.Length > 0)
+ {
+ // The destination byte array might not be large enough so multiple writes are sometimes required
+ var destination = GetBuffer();
+
+ Utf8.FromUtf16(buffer, destination, out var charsUsed, out var bytesUsed);
+
+ buffer = buffer[charsUsed..];
+ _memoryUsed += bytesUsed;
+ }
+ }
+
+ public override void Flush()
+ {
+ if (_memoryUsed > 0)
+ {
+ _bufferWriter!.Advance(_memoryUsed);
+ _memory = _memory[_memoryUsed..];
+ _memoryUsed = 0;
+ }
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+
+ if (disposing)
+ {
+ Flush();
+ }
+ }
+}
+
+// Adapted from https://github.com/dotnet/aspnetcore/blob/main/src/SignalR/common/Shared/Utf8BufferTextWriter.cs
+internal sealed class Utf8PipeWriterTextWriter : TextWriter
+{
+ private static readonly UTF8Encoding _utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
+ private const int MaximumBytesPerUtf8Char = 4;
+
+ [ThreadStatic]
+ private static Utf8PipeWriterTextWriter? _cachedInstance;
+
+ private IBufferWriter? _bufferWriter;
+ private Memory _memory;
+ private int _memoryUsed;
+
+#if DEBUG
+ private bool _inUse;
+#endif
+
+ public override Encoding Encoding => _utf8NoBom;
+
+ public static Utf8PipeWriterTextWriter Get(IBufferWriter bufferWriter)
+ {
+ var writer = _cachedInstance;
+ writer ??= new Utf8PipeWriterTextWriter();
+
+ // Taken off the thread static
+ _cachedInstance = null;
+#if DEBUG
+ if (writer._inUse)
+ {
+ throw new InvalidOperationException("The writer wasn't returned!");
+ }
+
+ writer._inUse = true;
+#endif
+ writer.SetWriter(bufferWriter);
+ return writer;
+ }
+
+ public static void Return(Utf8PipeWriterTextWriter writer)
+ {
+ _cachedInstance = writer;
+
+ writer._memory = Memory.Empty;
+ writer._memoryUsed = 0;
+ writer._bufferWriter = null;
+
+#if DEBUG
+ writer._inUse = false;
+#endif
+ }
+
+ public void SetWriter(IBufferWriter bufferWriter)
+ {
+ _bufferWriter = bufferWriter;
+ }
+
+ public override void Write(char[] buffer, int index, int count)
+ {
+ WriteInternal(buffer.AsSpan(index, count));
+ }
+
+ public override void Write(char[]? buffer)
+ {
+ if (buffer is not null)
+ {
+ WriteInternal(buffer);
+ }
+ }
+
+ public override void Write(char value)
+ {
+ if (value <= 127)
+ {
+ EnsureBuffer();
+
+ // Only need to set one byte
+ // Avoid Memory.Slice overhead for perf
+ _memory.Span[_memoryUsed] = (byte)value;
+ _memoryUsed++;
+ }
+ else
+ {
+ WriteMultiByteChar(value);
+ }
+ }
+
+ private void WriteMultiByteChar(char value)
+ {
+ var destination = GetBuffer();
+
+ // Json.NET only writes ASCII characters by themselves, e.g. {}[], etc
+ // this should be an exceptional case
+
+#if NET7_0_OR_GREATER
+ Utf8.FromUtf16(new ReadOnlySpan(ref value), destination, out var charsUsed, out var bytesUsed);
+#else
+ ReadOnlySpan valueArray = stackalloc [] { value };
+ Utf8.FromUtf16(valueArray, destination, out var charsUsed, out var bytesUsed);
+#endif
+
+ Debug.Assert(charsUsed == 1);
+
+ _memoryUsed += bytesUsed;
+ }
+
+ public override void Write(string? value)
+ {
+ if (value is not null)
+ {
+ WriteInternal(value.AsSpan());
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private Span GetBuffer()
+ {
+ EnsureBuffer();
+
+ return _memory.Span[_memoryUsed.._memory.Length];
+ }
+
+ private void EnsureBuffer()
+ {
+ // We need at least enough bytes to encode a single UTF-8 character, or Encoder.Convert will throw.
+ // Normally, if there isn't enough space to write every character of a char buffer, Encoder.Convert just
+ // writes what it can. However, if it can't even write a single character, it throws. So if the buffer has only
+ // 2 bytes left and the next character to write is 3 bytes in UTF-8, an exception is thrown.
+ var remaining = _memory.Length - _memoryUsed;
+ if (remaining < MaximumBytesPerUtf8Char)
+ {
+ // Used up the memory from the buffer writer so advance and get more
+ if (_memoryUsed > 0)
+ {
+ _bufferWriter!.Advance(_memoryUsed);
+ }
+
+ _memory = _bufferWriter!.GetMemory(MaximumBytesPerUtf8Char);
+ _memoryUsed = 0;
+ }
+ }
+
+ private void WriteInternal(ReadOnlySpan buffer)
+ {
+ while (buffer.Length > 0)
+ {
+ // The destination byte array might not be large enough so multiple writes are sometimes required
+ var destination = GetBuffer();
+
+ Utf8.FromUtf16(buffer, destination, out var charsUsed, out var bytesUsed);
+
+ buffer = buffer[charsUsed..];
+ _memoryUsed += bytesUsed;
+ }
+ }
+
+ public override void Flush()
+ {
+ if (_memoryUsed > 0)
+ {
+ _bufferWriter!.Advance(_memoryUsed);
+ _memory = _memory[_memoryUsed..];
+ _memoryUsed = 0;
+ }
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+
+ if (disposing)
+ {
+ Flush();
+ }
+ }
+}
diff --git a/src/RazorSlices/Utf8PipeTextWriter.cs b/src/RazorSlices/Utf8TextWriterTemplate.tt
similarity index 77%
rename from src/RazorSlices/Utf8PipeTextWriter.cs
rename to src/RazorSlices/Utf8TextWriterTemplate.tt
index 4280582..8d41749 100644
--- a/src/RazorSlices/Utf8PipeTextWriter.cs
+++ b/src/RazorSlices/Utf8TextWriterTemplate.tt
@@ -1,22 +1,29 @@
-using System.Diagnostics;
-using System.Diagnostics.CodeAnalysis;
-using System.IO.Pipelines;
+<#@ template debug="false" hostspecific="false" language="C#" #>
+<#@ assembly name="System.Core" #>
+<#@ output extension=".cs" #>
+// DO NOT EDIT
+// THIS FILE IS GENERATED BY Utf8TextWriterTemplate.tt
+
+using System.Buffers;
+using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Unicode;
namespace Microsoft.AspNetCore.Internal;
+<# foreach (var type in new string[] { "Utf8BufferTextWriter", "Utf8PipeWriterTextWriter" }) { #>
+
// Adapted from https://github.com/dotnet/aspnetcore/blob/main/src/SignalR/common/Shared/Utf8BufferTextWriter.cs
-internal sealed class Utf8PipeTextWriter : TextWriter
+internal sealed class <#= type #> : TextWriter
{
private static readonly UTF8Encoding _utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
private const int MaximumBytesPerUtf8Char = 4;
[ThreadStatic]
- private static Utf8PipeTextWriter? _cachedInstance;
+ private static <#= type #>? _cachedInstance;
- private PipeWriter? _pipeWriter;
+ private IBufferWriter? _bufferWriter;
private Memory _memory;
private int _memoryUsed;
@@ -26,10 +33,10 @@ internal sealed class Utf8PipeTextWriter : TextWriter
public override Encoding Encoding => _utf8NoBom;
- public static Utf8PipeTextWriter Get(PipeWriter pipeWriter)
+ public static <#= type #> Get(IBufferWriter bufferWriter)
{
var writer = _cachedInstance;
- writer ??= new Utf8PipeTextWriter();
+ writer ??= new <#= type #>();
// Taken off the thread static
_cachedInstance = null;
@@ -41,27 +48,26 @@ public static Utf8PipeTextWriter Get(PipeWriter pipeWriter)
writer._inUse = true;
#endif
- writer.SetWriter(pipeWriter);
+ writer.SetWriter(bufferWriter);
return writer;
}
- public static void Return(Utf8PipeTextWriter writer)
+ public static void Return(<#= type #> writer)
{
_cachedInstance = writer;
writer._memory = Memory.Empty;
writer._memoryUsed = 0;
- writer._pipeWriter = null;
+ writer._bufferWriter = null;
#if DEBUG
writer._inUse = false;
#endif
}
- [MemberNotNull(nameof(_pipeWriter))]
- public void SetWriter(PipeWriter pipeWriter)
+ public void SetWriter(IBufferWriter bufferWriter)
{
- _pipeWriter = pipeWriter;
+ _bufferWriter = bufferWriter;
}
public override void Write(char[] buffer, int index, int count)
@@ -100,7 +106,13 @@ private void WriteMultiByteChar(char value)
// Json.NET only writes ASCII characters by themselves, e.g. {}[], etc
// this should be an exceptional case
+
+#if NET7_0_OR_GREATER
Utf8.FromUtf16(new ReadOnlySpan(ref value), destination, out var charsUsed, out var bytesUsed);
+#else
+ ReadOnlySpan valueArray = stackalloc [] { value };
+ Utf8.FromUtf16(valueArray, destination, out var charsUsed, out var bytesUsed);
+#endif
Debug.Assert(charsUsed == 1);
@@ -135,10 +147,10 @@ private void EnsureBuffer()
// Used up the memory from the buffer writer so advance and get more
if (_memoryUsed > 0)
{
- _pipeWriter!.Advance(_memoryUsed);
+ _bufferWriter!.Advance(_memoryUsed);
}
- _memory = _pipeWriter!.GetMemory(MaximumBytesPerUtf8Char);
+ _memory = _bufferWriter!.GetMemory(MaximumBytesPerUtf8Char);
_memoryUsed = 0;
}
}
@@ -161,7 +173,7 @@ public override void Flush()
{
if (_memoryUsed > 0)
{
- _pipeWriter!.Advance(_memoryUsed);
+ _bufferWriter!.Advance(_memoryUsed);
_memory = _memory[_memoryUsed..];
_memoryUsed = 0;
}
@@ -177,3 +189,4 @@ protected override void Dispose(bool disposing)
}
}
}
+<# } #>
\ No newline at end of file
diff --git a/tests/RazorSlices.Tests/BufferWriterHtmlWritingTests.cs b/tests/RazorSlices.Tests/BufferWriterHtmlWritingTests.cs
index 11d31b4..6f7038b 100644
--- a/tests/RazorSlices.Tests/BufferWriterHtmlWritingTests.cs
+++ b/tests/RazorSlices.Tests/BufferWriterHtmlWritingTests.cs
@@ -1,6 +1,5 @@
using System.Buffers;
using System.Diagnostics;
-using System.IO.Pipelines;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Razor.TagHelpers;
@@ -12,46 +11,40 @@ public class BufferWriterHtmlWritingTests
private static readonly TimeSpan _timeout = Debugger.IsAttached ? Timeout.InfiniteTimeSpan : TimeSpan.FromMilliseconds(5000);
[Fact]
- public async ValueTask HtmlEncodeAndWrite_DoesNotEncodeTextWhenPassedNullEncoder()
+ public void HtmlEncodeAndWrite_DoesNotEncodeTextWhenPassedNullEncoder()
{
- var bufferStream = new MemoryStream(512);
- var pipeWriter = PipeWriter.Create(bufferStream);
+ var bufferWriter = new ArrayBufferWriter();
var text = "Hello, World!";
- pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), NullHtmlEncoder.Default);
- await pipeWriter.FlushAsync();
+ bufferWriter.HtmlEncodeAndWrite(text.AsSpan(), NullHtmlEncoder.Default);
- var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length);
+ var writtenText = Encoding.UTF8.GetString(bufferWriter.WrittenSpan);
Assert.Equal(text, writtenText);
}
[Fact]
- public async ValueTask HtmlEncodeAndWrite_DoesNotEncodeTextThatRequiresNoEncoding()
+ public void HtmlEncodeAndWrite_DoesNotEncodeTextThatRequiresNoEncoding()
{
- var bufferStream = new MemoryStream(512);
- var pipeWriter = PipeWriter.Create(bufferStream);
+ var bufferWriter = new ArrayBufferWriter();
var text = "Hello, World!";
- pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default);
- await pipeWriter.FlushAsync();
+ bufferWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default);
- var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length);
+ var writtenText = Encoding.UTF8.GetString(bufferWriter.WrittenSpan);
Assert.Equal(text, writtenText);
}
[Fact]
- public async ValueTask HtmlEncodeAndWrite_EncodesSimpleText()
+ public void HtmlEncodeAndWrite_EncodesSimpleText()
{
- var bufferStream = new MemoryStream(512);
- var pipeWriter = PipeWriter.Create(bufferStream);
+ var bufferWriter = new ArrayBufferWriter();
var text = "Hello, !";
- pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default);
- await pipeWriter.FlushAsync();
+ bufferWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default);
- var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length);
+ var writtenText = Encoding.UTF8.GetString(bufferWriter.WrittenSpan);
Assert.Equal("Hello, <World>!", writtenText);
}
@@ -59,22 +52,19 @@ public async ValueTask HtmlEncodeAndWrite_EncodesSimpleText()
[Fact]
public async Task HtmlEncodeAndWrite_EncodesTextThatIsLessThanSmallEncodeBuffer()
{
- var bufferStream = new MemoryStream(512);
- var pipeWriter = PipeWriter.Create(bufferStream);
+ var bufferWriter = new ArrayBufferWriter();
// Create a string with no encodable chars that's half the small threshold then add a small string that requires encoding
var text = new string('a', BufferSizes.SmallTextWriteCharSize / 2);
text += "";
- var encodeTask = Task.Run(() => pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default));
+ var encodeTask = Task.Run(() => bufferWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default));
var completedTask = await Task.WhenAny(encodeTask, Task.Delay(_timeout));
Assert.Equal(encodeTask, completedTask);
- await pipeWriter.FlushAsync();
-
- var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length);
+ var writtenText = Encoding.UTF8.GetString(bufferWriter.WrittenSpan);
var expected = text.Replace("<", "<").Replace(">", ">");
Assert.Equal(expected, writtenText);
@@ -84,8 +74,7 @@ public async Task HtmlEncodeAndWrite_EncodesTextThatIsLessThanSmallEncodeBuffer(
[Fact]
public async Task HtmlEncodeAndWrite_EncodesTextThatIsLessThanSmallEncodeBufferAndButRequiresMultiplePasses()
{
- var bufferStream = new MemoryStream(512);
- var pipeWriter = PipeWriter.Create(bufferStream);
+ var bufferWriter = new ArrayBufferWriter();
// Create a string with no encodable chars that's 5 chars smaller than the threshold then add a two chars that require encoding
// This should result in the first encode phase being successful as the encoded chars will fit in the small buffer but there will
@@ -93,15 +82,13 @@ public async Task HtmlEncodeAndWrite_EncodesTextThatIsLessThanSmallEncodeBufferA
var text = new string('a', BufferSizes.SmallTextWriteCharSize - 5);
text += "<>";
- var encodeTask = Task.Run(() => pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default));
+ var encodeTask = Task.Run(() => bufferWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default));
var completedTask = await Task.WhenAny(encodeTask, Task.Delay(_timeout));
Assert.Equal(encodeTask, completedTask);
- await pipeWriter.FlushAsync();
-
- var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length);
+ var writtenText = Encoding.UTF8.GetString(bufferWriter.WrittenSpan);
var expected = text.Replace("<", "<").Replace(">", ">");
Assert.Equal(expected, writtenText);
@@ -109,47 +96,20 @@ public async Task HtmlEncodeAndWrite_EncodesTextThatIsLessThanSmallEncodeBufferA
[Fact]
public async Task HtmlEncodeAndWrite_EncodesTextThatIsLargerThanEncodeBuffer()
{
- var bufferStream = new MemoryStream(512);
- var pipeWriter = PipeWriter.Create(bufferStream);
+ var bufferWriter = new ArrayBufferWriter();
// Every character in this text must be HTML encoded so it will definitely exceed the max buffer size
var text = new string('<', BufferSizes.MaxBufferSize);
- var encodeTask = Task.Run(() => pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default));
+ var encodeTask = Task.Run(() => bufferWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default));
var completedTask = await Task.WhenAny(encodeTask, Task.Delay(_timeout));
- Assert.Equal(encodeTask, completedTask);
+ Assert.Equal(encodeTask, completedTask);
- await pipeWriter.FlushAsync();
-
- var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length);
+ var writtenText = Encoding.UTF8.GetString(bufferWriter.WrittenSpan);
var expected = text.Replace("<", "<");
Assert.Equal(expected, writtenText);
}
-
- [Fact]
- public async Task HtmlEncodeAndWrite_EncodesTextWhenRemainingBufferIsTooSmallToEncodeAnyRemainingChars()
- {
- var bufferStream = new MemoryStream(512);
- var pipeWriter = PipeWriter.Create(bufferStream);
-
- // This text can't be fully encoded in one cycle and results in the buffer being too small to encode even one of the remaining chars into on the second cycle
- // so specifically exercises the case where the buffer is too small to encode any of the remaining chars into and thus must write out the chars encoded thus
- // far before resetting the buffer and encoding the remaining chars
- var text = "{'antiForgery':'CfDJ8PXqQxmMPkhOiAitfXYwz3Qx8ObgK9ND-yZcc0Wf5Vtyo8pylpAtos8zwLNhJ-T3gJ4m2iX96lbH15gV0LIdcEBHU5xf3U95L_rlojOvp-0XIvZ8HI3CWIr6xUIKGhDpKaasa9hMAzjUZuFjYVdDkmE','formFieldName':'__RequestVerificationToken','antiForgery':'CfDJ8PXqQxmMPkhOiAitfXYwz3Qx8ObgK9ND-yZcc0Wf5Vtyo8pylpAtos8zwLNhJ-T3gJ4m2iX96lbH15gV0LIdcEBHU5xf3U95L_rlojOvp-0XIvZ8HI3CWIr6xUIKGhDpKaasa9hMAzjUZuFjYVdDkmE','formFieldName':'__RequestVerificationToken'}";
-
- var encodeTask = Task.Run(() => pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default));
-
- var completedTask = await Task.WhenAny(encodeTask, Task.Delay(_timeout));
-
- Assert.Equal(encodeTask, completedTask);
-
- await pipeWriter.FlushAsync();
-
- var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length);
-
- Assert.Equal("{'antiForgery':'CfDJ8PXqQxmMPkhOiAitfXYwz3Qx8ObgK9ND-yZcc0Wf5Vtyo8pylpAtos8zwLNhJ-T3gJ4m2iX96lbH15gV0LIdcEBHU5xf3U95L_rlojOvp-0XIvZ8HI3CWIr6xUIKGhDpKaasa9hMAzjUZuFjYVdDkmE','formFieldName':'__RequestVerificationToken','antiForgery':'CfDJ8PXqQxmMPkhOiAitfXYwz3Qx8ObgK9ND-yZcc0Wf5Vtyo8pylpAtos8zwLNhJ-T3gJ4m2iX96lbH15gV0LIdcEBHU5xf3U95L_rlojOvp-0XIvZ8HI3CWIr6xUIKGhDpKaasa9hMAzjUZuFjYVdDkmE','formFieldName':'__RequestVerificationToken'}", writtenText);
- }
}
\ No newline at end of file
diff --git a/tests/RazorSlices.Tests/PipeWriterHtmlWritingTests.cs b/tests/RazorSlices.Tests/PipeWriterHtmlWritingTests.cs
new file mode 100644
index 0000000..d770d22
--- /dev/null
+++ b/tests/RazorSlices.Tests/PipeWriterHtmlWritingTests.cs
@@ -0,0 +1,155 @@
+using System.Buffers;
+using System.Diagnostics;
+using System.IO.Pipelines;
+using System.Text;
+using System.Text.Encodings.Web;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+
+namespace RazorSlices.Tests;
+
+public class PipeWriterHtmlWritingTests
+{
+ private static readonly TimeSpan _timeout = Debugger.IsAttached ? Timeout.InfiniteTimeSpan : TimeSpan.FromMilliseconds(5000);
+
+ [Fact]
+ public async ValueTask HtmlEncodeAndWrite_DoesNotEncodeTextWhenPassedNullEncoder()
+ {
+ var bufferStream = new MemoryStream(512);
+ var pipeWriter = PipeWriter.Create(bufferStream);
+
+ var text = "Hello, World!";
+ pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), NullHtmlEncoder.Default);
+ await pipeWriter.FlushAsync();
+
+ var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length);
+
+ Assert.Equal(text, writtenText);
+ }
+
+ [Fact]
+ public async ValueTask HtmlEncodeAndWrite_DoesNotEncodeTextThatRequiresNoEncoding()
+ {
+ var bufferStream = new MemoryStream(512);
+ var pipeWriter = PipeWriter.Create(bufferStream);
+
+ var text = "Hello, World!";
+ pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default);
+ await pipeWriter.FlushAsync();
+
+ var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length);
+
+ Assert.Equal(text, writtenText);
+ }
+
+ [Fact]
+ public async ValueTask HtmlEncodeAndWrite_EncodesSimpleText()
+ {
+ var bufferStream = new MemoryStream(512);
+ var pipeWriter = PipeWriter.Create(bufferStream);
+
+ var text = "Hello, !";
+ pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default);
+ await pipeWriter.FlushAsync();
+
+ var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length);
+
+ Assert.Equal("Hello, <World>!", writtenText);
+ }
+
+ [Fact]
+ public async Task HtmlEncodeAndWrite_EncodesTextThatIsLessThanSmallEncodeBuffer()
+ {
+ var bufferStream = new MemoryStream(512);
+ var pipeWriter = PipeWriter.Create(bufferStream);
+
+ // Create a string with no encodable chars that's half the small threshold then add a small string that requires encoding
+ var text = new string('a', BufferSizes.SmallTextWriteCharSize / 2);
+ text += "";
+
+ var encodeTask = Task.Run(() => pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default));
+
+ var completedTask = await Task.WhenAny(encodeTask, Task.Delay(_timeout));
+
+ Assert.Equal(encodeTask, completedTask);
+
+ await pipeWriter.FlushAsync();
+
+ var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length);
+
+ var expected = text.Replace("<", "<").Replace(">", ">");
+ Assert.Equal(expected, writtenText);
+ }
+
+
+ [Fact]
+ public async Task HtmlEncodeAndWrite_EncodesTextThatIsLessThanSmallEncodeBufferAndButRequiresMultiplePasses()
+ {
+ var bufferStream = new MemoryStream(512);
+ var pipeWriter = PipeWriter.Create(bufferStream);
+
+ // Create a string with no encodable chars that's 5 chars smaller than the threshold then add a two chars that require encoding
+ // This should result in the first encode phase being successful as the encoded chars will fit in the small buffer but there will
+ // not be enough space left in the buffer to encode the remaining char, forcing a buffer reset or growth.
+ var text = new string('a', BufferSizes.SmallTextWriteCharSize - 5);
+ text += "<>";
+
+ var encodeTask = Task.Run(() => pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default));
+
+ var completedTask = await Task.WhenAny(encodeTask, Task.Delay(_timeout));
+
+ Assert.Equal(encodeTask, completedTask);
+
+ await pipeWriter.FlushAsync();
+
+ var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length);
+
+ var expected = text.Replace("<", "<").Replace(">", ">");
+ Assert.Equal(expected, writtenText);
+ }
+ [Fact]
+ public async Task HtmlEncodeAndWrite_EncodesTextThatIsLargerThanEncodeBuffer()
+ {
+ var bufferStream = new MemoryStream(512);
+ var pipeWriter = PipeWriter.Create(bufferStream);
+
+ // Every character in this text must be HTML encoded so it will definitely exceed the max buffer size
+ var text = new string('<', BufferSizes.MaxBufferSize);
+
+ var encodeTask = Task.Run(() => pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default));
+
+ var completedTask = await Task.WhenAny(encodeTask, Task.Delay(_timeout));
+
+ Assert.Equal(encodeTask, completedTask);
+
+ await pipeWriter.FlushAsync();
+
+ var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length);
+
+ var expected = text.Replace("<", "<");
+ Assert.Equal(expected, writtenText);
+ }
+
+ [Fact]
+ public async Task HtmlEncodeAndWrite_EncodesTextWhenRemainingBufferIsTooSmallToEncodeAnyRemainingChars()
+ {
+ var bufferStream = new MemoryStream(512);
+ var pipeWriter = PipeWriter.Create(bufferStream);
+
+ // This text can't be fully encoded in one cycle and results in the buffer being too small to encode even one of the remaining chars into on the second cycle
+ // so specifically exercises the case where the buffer is too small to encode any of the remaining chars into and thus must write out the chars encoded thus
+ // far before resetting the buffer and encoding the remaining chars
+ var text = "{'antiForgery':'CfDJ8PXqQxmMPkhOiAitfXYwz3Qx8ObgK9ND-yZcc0Wf5Vtyo8pylpAtos8zwLNhJ-T3gJ4m2iX96lbH15gV0LIdcEBHU5xf3U95L_rlojOvp-0XIvZ8HI3CWIr6xUIKGhDpKaasa9hMAzjUZuFjYVdDkmE','formFieldName':'__RequestVerificationToken','antiForgery':'CfDJ8PXqQxmMPkhOiAitfXYwz3Qx8ObgK9ND-yZcc0Wf5Vtyo8pylpAtos8zwLNhJ-T3gJ4m2iX96lbH15gV0LIdcEBHU5xf3U95L_rlojOvp-0XIvZ8HI3CWIr6xUIKGhDpKaasa9hMAzjUZuFjYVdDkmE','formFieldName':'__RequestVerificationToken'}";
+
+ var encodeTask = Task.Run(() => pipeWriter.HtmlEncodeAndWrite(text.AsSpan(), HtmlEncoder.Default));
+
+ var completedTask = await Task.WhenAny(encodeTask, Task.Delay(_timeout));
+
+ Assert.Equal(encodeTask, completedTask);
+
+ await pipeWriter.FlushAsync();
+
+ var writtenText = Encoding.UTF8.GetString(bufferStream.ToArray(), 0, (int)bufferStream.Length);
+
+ Assert.Equal("{'antiForgery':'CfDJ8PXqQxmMPkhOiAitfXYwz3Qx8ObgK9ND-yZcc0Wf5Vtyo8pylpAtos8zwLNhJ-T3gJ4m2iX96lbH15gV0LIdcEBHU5xf3U95L_rlojOvp-0XIvZ8HI3CWIr6xUIKGhDpKaasa9hMAzjUZuFjYVdDkmE','formFieldName':'__RequestVerificationToken','antiForgery':'CfDJ8PXqQxmMPkhOiAitfXYwz3Qx8ObgK9ND-yZcc0Wf5Vtyo8pylpAtos8zwLNhJ-T3gJ4m2iX96lbH15gV0LIdcEBHU5xf3U95L_rlojOvp-0XIvZ8HI3CWIr6xUIKGhDpKaasa9hMAzjUZuFjYVdDkmE','formFieldName':'__RequestVerificationToken'}", writtenText);
+ }
+}
\ No newline at end of file