diff --git a/Fluid.Benchmarks/AstSyncPathBenchmarks.cs b/Fluid.Benchmarks/AstSyncPathBenchmarks.cs new file mode 100644 index 00000000..df0ff126 --- /dev/null +++ b/Fluid.Benchmarks/AstSyncPathBenchmarks.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; +using Fluid.Ast; +using Fluid.Values; + +namespace Fluid.Benchmarks +{ + [MemoryDiagnoser] + public class AstSyncPathBenchmarks + { + private const int ItemCount = 100; + + private readonly CountingFluidOutput _output = new(); + private readonly TemplateContext _forContext; + private readonly TemplateContext _renderContext; + private readonly TemplateContext _includeContext; + private readonly ForStatement _for; + private readonly ForStatement _forAsync; + private readonly RenderStatement _render; + private readonly RenderStatement _renderAsync; + private readonly IncludeStatement _include; + private readonly IncludeStatement _includeAsync; + + public AstSyncPathBenchmarks() + { + var values = new FluidValue[ItemCount]; + for (var i = 0; i < values.Length; i++) + { + values[i] = NumberValue.Create(i); + } + + var source = new LiteralExpression(new ArrayValue(values)); + _forContext = new TemplateContext(); + _for = new ForStatement( + [new TextSpanStatement("x")], + "item", + source, + limit: null, + offset: null, + reversed: false); + _forAsync = new ForStatement( + [new SuspendingStatement()], + "item", + source, + limit: null, + offset: null, + reversed: false); + + var parser = new FluidParser(); + _renderContext = CreateTemplateContext(parser, out var renderPath); + _includeContext = CreateTemplateContext(parser, out var includePath); + + _render = new RenderStatement(parser, renderPath); + _renderAsync = new RenderStatement(parser, renderPath + "-async"); + _include = new IncludeStatement(parser, new LiteralExpression(new StringValue(includePath))); + _includeAsync = new IncludeStatement(parser, new LiteralExpression(new StringValue(includePath + "-async"))); + + WarmTemplateCaches(); + } + + [Benchmark] + public ValueTask For() => Run(_for, _forContext); + + [Benchmark] + public ValueTask ForAsync() => Run(_forAsync, _forContext); + + [Benchmark] + public ValueTask Render() => Run(_render, _renderContext); + + [Benchmark] + public ValueTask RenderAsync() => Run(_renderAsync, _renderContext); + + [Benchmark] + public ValueTask Include() => Run(_include, _includeContext); + + [Benchmark] + public ValueTask IncludeAsync() => Run(_includeAsync, _includeContext); + + private ValueTask Run(Statement statement, TemplateContext context) + { + _output.Reset(); + return statement.WriteToAsync(_output, NullEncoder.Default, context); + } + + private static TemplateContext CreateTemplateContext(FluidParser parser, out string path) + { + path = Guid.NewGuid().ToString("N"); + var provider = new BenchmarkTemplateFileProvider() + .Add(path, "x") + .Add(path + "-async", ""); + var options = new TemplateOptions + { + FileProvider = provider, + TemplateParsed = (templatePath, template) => + templatePath.EndsWith("-async", StringComparison.Ordinal) + ? new SuspendingTemplate() + : template + }; + + return new TemplateContext(options); + } + + private void WarmTemplateCaches() + { + Run(_render, _renderContext).GetAwaiter().GetResult(); + Run(_renderAsync, _renderContext).GetAwaiter().GetResult(); + Run(_include, _includeContext).GetAwaiter().GetResult(); + Run(_includeAsync, _includeContext).GetAwaiter().GetResult(); + } + + private sealed class SuspendingStatement : Statement + { + public override async ValueTask WriteToAsync( + IFluidOutput output, + TextEncoder encoder, + TemplateContext context) + { + await Task.Yield(); + output.Write("x"); + return Completion.Normal; + } + + protected override Statement Accept(AstVisitor visitor) => this; + } + + private sealed class SuspendingTemplate : IFluidTemplate + { + public async ValueTask RenderAsync( + IFluidOutput output, + TextEncoder encoder, + TemplateContext context) + { + await Task.Yield(); + output.Write("x"); + } + } + + private sealed class BenchmarkTemplateFileProvider : ITemplateFileProvider + { + private readonly Dictionary _files = new(StringComparer.Ordinal); + + public BenchmarkTemplateFileProvider Add(string path, string content) + { + _files[path] = Encoding.UTF8.GetBytes(content); + return this; + } + + public ValueTask GetFileInfoAsync( + string subpath, + TemplateContext context, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!_files.TryGetValue(subpath, out var content)) + { + return default; + } + + return new ValueTask( + new TemplateSourceInfo( + DateTimeOffset.UnixEpoch, + _ => new ValueTask(new MemoryStream(content, writable: false)))); + } + } + + private sealed class CountingFluidOutput : IFluidOutput + { + private char[] _buffer = new char[128]; + + public int Written { get; private set; } + + public void Advance(int count) => Written += count; + + public Memory GetMemory(int sizeHint = 0) + { + EnsureCapacity(sizeHint); + return _buffer.AsMemory(Written); + } + + public Span GetSpan(int sizeHint = 0) + { + EnsureCapacity(sizeHint); + return _buffer.AsSpan(Written); + } + + public void Write(string value) + { + EnsureCapacity(value.Length); + value.CopyTo(0, _buffer, Written, value.Length); + Written += value.Length; + } + + public void Write(char[] buffer, int index, int count) + { + EnsureCapacity(count); + buffer.AsSpan(index, count).CopyTo(_buffer.AsSpan(Written)); + Written += count; + } + + public ValueTask FlushAsync() => default; + + public void Reset() => Written = 0; + + private void EnsureCapacity(int sizeHint) + { + var required = Written + Math.Max(sizeHint, 1); + if (required > _buffer.Length) + { + Array.Resize(ref _buffer, Math.Max(required, _buffer.Length * 2)); + } + } + } + } +} diff --git a/Fluid.Benchmarks/IncludeScopeBenchmarks.cs b/Fluid.Benchmarks/IncludeScopeBenchmarks.cs new file mode 100644 index 00000000..8d979fdc --- /dev/null +++ b/Fluid.Benchmarks/IncludeScopeBenchmarks.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; + +namespace Fluid.Benchmarks +{ + [MemoryDiagnoser] + public class IncludeScopeBenchmarks + { + private readonly CountingFluidOutput _output = new(); + private readonly TemplateContext _context; + private readonly IFluidTemplate _oneKeywordArgument; + private readonly IFluidTemplate _multipleKeywordArguments; + private readonly IFluidTemplate _nestedIncludes; + private readonly IFluidTemplate _repeatedIncludes; + + public IncludeScopeBenchmarks() + { + var parser = new FluidParser(); + var options = new TemplateOptions + { + FileProvider = new InMemoryTemplateFileProvider( + ("value.liquid", "{{ value }}"), + ("arguments.liquid", "{{ first }}{{ second }}{{ third }}"), + ("outer.liquid", "{% include 'value', value: value %}")) + }; + + _context = new TemplateContext(options) + .SetValue("value", "v"); + + _oneKeywordArgument = parser.Parse("{% include 'value', value: value %}"); + _multipleKeywordArguments = parser.Parse( + "{% include 'arguments', first: value, second: value, third: value %}"); + _nestedIncludes = parser.Parse("{% include 'outer', value: value %}"); + _repeatedIncludes = parser.Parse( + "{% include 'value', value: value %}" + + "{% include 'value', value: value %}" + + "{% include 'value', value: value %}" + + "{% include 'value', value: value %}"); + + WarmUp(); + } + + [Benchmark] + public ValueTask OneKeywordArgument() + { + _output.Reset(); + return _oneKeywordArgument.RenderAsync(_output, NullEncoder.Default, _context); + } + + [Benchmark] + public ValueTask MultipleKeywordArguments() + { + _output.Reset(); + return _multipleKeywordArguments.RenderAsync(_output, NullEncoder.Default, _context); + } + + [Benchmark] + public ValueTask NestedIncludes() + { + _output.Reset(); + return _nestedIncludes.RenderAsync(_output, NullEncoder.Default, _context); + } + + [Benchmark] + public ValueTask RepeatedIncludes() + { + _output.Reset(); + return _repeatedIncludes.RenderAsync(_output, NullEncoder.Default, _context); + } + + private void WarmUp() + { + OneKeywordArgument().GetAwaiter().GetResult(); + MultipleKeywordArguments().GetAwaiter().GetResult(); + NestedIncludes().GetAwaiter().GetResult(); + RepeatedIncludes().GetAwaiter().GetResult(); + } + + private sealed class CountingFluidOutput : IFluidOutput + { + private char[] _buffer = new char[64]; + + public int Written { get; private set; } + + public void Advance(int count) => Written += count; + + public Memory GetMemory(int sizeHint = 0) + { + EnsureCapacity(sizeHint); + return _buffer.AsMemory(Written); + } + + public Span GetSpan(int sizeHint = 0) + { + EnsureCapacity(sizeHint); + return _buffer.AsSpan(Written); + } + + public void Write(string value) + { + EnsureCapacity(value.Length); + value.CopyTo(0, _buffer, Written, value.Length); + Written += value.Length; + } + + public void Write(char[] buffer, int index, int count) + { + EnsureCapacity(count); + buffer.AsSpan(index, count).CopyTo(_buffer.AsSpan(Written)); + Written += count; + } + + public ValueTask FlushAsync() => default; + + public void Reset() => Written = 0; + + private void EnsureCapacity(int sizeHint) + { + var required = Written + Math.Max(sizeHint, 1); + if (required > _buffer.Length) + { + Array.Resize(ref _buffer, Math.Max(required, _buffer.Length * 2)); + } + } + } + + private sealed class InMemoryTemplateFileProvider : ITemplateFileProvider + { + private readonly Dictionary _templates = new(StringComparer.Ordinal); + + public InMemoryTemplateFileProvider(params (string Path, string Content)[] templates) + { + foreach (var template in templates) + { + _templates[template.Path] = Encoding.UTF8.GetBytes(template.Content); + } + } + + public ValueTask GetFileInfoAsync( + string subpath, + TemplateContext context, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!_templates.TryGetValue(subpath, out var content)) + { + return default; + } + + return new ValueTask( + new TemplateSourceInfo( + DateTimeOffset.UnixEpoch, + _ => new ValueTask(new MemoryStream(content, writable: false)))); + } + } + } +} diff --git a/Fluid.Benchmarks/Program.cs b/Fluid.Benchmarks/Program.cs index d2fd98bb..661bd62a 100644 --- a/Fluid.Benchmarks/Program.cs +++ b/Fluid.Benchmarks/Program.cs @@ -1,13 +1,20 @@ using System; using System.Diagnostics; +using System.Threading.Tasks; using BenchmarkDotNet.Running; namespace Fluid.Benchmarks { class Program { - static void Main(string[] args) + static async Task Main(string[] args) { + if (args.Length > 0 && args[0].Equals("utf8-metrics", StringComparison.OrdinalIgnoreCase)) + { + await Utf8OutputBenchmarks.PrintMeasurementsAsync(); + return; + } + // Steady-state loop for sampling profilers (for instance `ultra profile -- Fluid.Benchmarks.exe profile render 25`). // BenchmarkDotNet spawns short-lived child processes, which a profiler can't follow. if (args.Length > 0 && args[0].Equals("profile", StringComparison.OrdinalIgnoreCase)) diff --git a/Fluid.Benchmarks/Utf8OutputBenchmarks.cs b/Fluid.Benchmarks/Utf8OutputBenchmarks.cs new file mode 100644 index 00000000..7449b372 --- /dev/null +++ b/Fluid.Benchmarks/Utf8OutputBenchmarks.cs @@ -0,0 +1,287 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using Fluid.Utils; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading; +using System.Threading.Tasks; + +namespace Fluid.Benchmarks +{ + [MemoryDiagnoser] + [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] + [ShortRunJob] + public class Utf8OutputBenchmarks + { + private static readonly UTF8Encoding Utf8 = new UTF8Encoding(false); + private static readonly Scenario[] Scenarios = + [ + new( + "SmallAscii", + "Hello {{ name }}!", + () => new TemplateContext().SetValue("name", "Fluid"), + NullEncoder.Default), + new( + "ProductTemplate", + """ +
+ {% for product in products %} +
+

{{ product.name }}

+

{{ product.description }}

+ {{ product.price }} +
+ {% endfor %} +
+ """, + CreateProductContext, + HtmlEncoder.Default), + new( + "UnicodeEncoded", + "

{{ title }}

{{ description }}

", + () => new TemplateContext() + .SetValue("title", "\u65b0\u5546\u54c1 \ud83d\ude80") + .SetValue("description", "Cr\u00e8me & caf\u00e9"), + HtmlEncoder.Default), + new( + "LargeOutput", + "{{ content }}", + () => new TemplateContext().SetValue("content", new string('x', 256 * 1024)), + NullEncoder.Default) + ]; + + private readonly CountingStream _stream = new(); + private readonly CountingByteWriter _byteWriter = new(512 * 1024); + private IFluidTemplate _template; + + [ParamsSource(nameof(ScenarioValues))] + public Scenario BenchmarkScenario { get; set; } + + public IEnumerable ScenarioValues => Scenarios; + + [GlobalSetup] + public async Task Setup() + { + _template = new FluidParser().Parse(BenchmarkScenario.Template); + + var baseline = await TextWriterThenUtf8(); + var candidate = await DirectUtf8(); + if (baseline.BytesWritten != candidate.BytesWritten || + baseline.FlushCount == 0 || + candidate.FlushCount == 0) + { + throw new InvalidOperationException( + $"Invalid output metrics for {BenchmarkScenario}: {baseline} vs {candidate}."); + } + } + + [Benchmark(Baseline = true)] + [BenchmarkCategory("UTF8")] + public async ValueTask TextWriterThenUtf8() + { + _stream.Reset(); + + await using (var writer = new StreamWriter( + _stream, + Utf8, + bufferSize: 1024, + leaveOpen: true)) + { + await using (var output = new TextWriterFluidOutput( + writer, + bufferSize: 16 * 1024, + leaveOpen: true, + allowSynchronousIO: false)) + { + await _template.RenderAsync( + output, + BenchmarkScenario.Encoder, + BenchmarkScenario.CreateContext()); + } + + } + + return new RenderMeasurement(_stream.BytesWritten, _stream.FlushCount); + } + + [Benchmark] + [BenchmarkCategory("UTF8")] + public async ValueTask DirectUtf8() + { + _byteWriter.Reset(); + + await _template.RenderAsync( + _byteWriter, + BenchmarkScenario.Encoder, + BenchmarkScenario.CreateContext()); + + await _byteWriter.FlushAsync(); + return new RenderMeasurement(_byteWriter.BytesWritten, _byteWriter.FlushCount); + } + + public static async Task PrintMeasurementsAsync() + { + Console.WriteLine("| Scenario | Path | Bytes written | Flushes |"); + Console.WriteLine("| --- | --- | ---: | ---: |"); + + foreach (var scenario in Scenarios) + { + var benchmark = new Utf8OutputBenchmarks { BenchmarkScenario = scenario }; + await benchmark.Setup(); + + var baseline = await benchmark.TextWriterThenUtf8(); + var candidate = await benchmark.DirectUtf8(); + Console.WriteLine($"| {scenario} | TextWriter + UTF-8 | {baseline.BytesWritten} | {baseline.FlushCount} |"); + Console.WriteLine($"| {scenario} | Direct UTF-8 | {candidate.BytesWritten} | {candidate.FlushCount} |"); + } + } + + private static TemplateContext CreateProductContext() + { + var products = new List>(100); + for (var i = 0; i < 100; i++) + { + products.Add(new Dictionary + { + ["id"] = i, + ["name"] = "Product " + i, + ["description"] = "A practical for home & office.", + ["price"] = 19.95m + i + }); + } + + return new TemplateContext().SetValue("products", products); + } + + public sealed class Scenario + { + public Scenario( + string name, + string template, + Func createContext, + TextEncoder encoder) + { + Name = name; + Template = template; + CreateContext = createContext; + Encoder = encoder; + } + + public string Name { get; } + + public string Template { get; } + + public Func CreateContext { get; } + + public TextEncoder Encoder { get; } + + public override string ToString() => Name; + } + + public readonly record struct RenderMeasurement(long BytesWritten, int FlushCount); + + private sealed class CountingByteWriter : IBufferWriter + { + private readonly byte[] _buffer; + private int _index; + + public CountingByteWriter(int capacity) + { + _buffer = new byte[capacity]; + } + + public long BytesWritten => _index; + + public int FlushCount { get; private set; } + + public void Advance(int count) => _index += count; + + public Memory GetMemory(int sizeHint = 0) => _buffer.AsMemory(_index); + + public Span GetSpan(int sizeHint = 0) => _buffer.AsSpan(_index); + + public ValueTask FlushAsync() + { + FlushCount++; + return default; + } + + public void Reset() + { + _index = 0; + FlushCount = 0; + } + } + + private sealed class CountingStream : Stream + { + public long BytesWritten { get; private set; } + + public int FlushCount { get; private set; } + + public override bool CanRead => false; + + public override bool CanSeek => false; + + public override bool CanWrite => true; + + public override long Length => BytesWritten; + + public override long Position + { + get => BytesWritten; + set => throw new NotSupportedException(); + } + + public override void Flush() => FlushCount++; + + public override Task FlushAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + FlushCount++; + return Task.CompletedTask; + } + + public override void Write(byte[] buffer, int offset, int count) => BytesWritten += count; + + public override Task WriteAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + BytesWritten += count; + return Task.CompletedTask; + } + + public override ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + BytesWritten += buffer.Length; + return default; + } + + public void Reset() + { + BytesWritten = 0; + FlushCount = 0; + } + + public override int Read(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + public override void SetLength(long value) => + throw new NotSupportedException(); + } + } +} diff --git a/Fluid.Tests/IncludeStatementAllocationTests.cs b/Fluid.Tests/IncludeStatementAllocationTests.cs new file mode 100644 index 00000000..32adca5f --- /dev/null +++ b/Fluid.Tests/IncludeStatementAllocationTests.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Encodings.Web; +using System.Threading; +using System.Threading.Tasks; +using Fluid.Ast; +using Fluid.Tests.Mocks; +using Fluid.Values; +using Xunit; + +namespace Fluid.Tests +{ + public class IncludeStatementAllocationTests + { +#if COMPILED + private static readonly FluidParser _parser = new FluidParser().Compile(); +#else + private static readonly FluidParser _parser = new FluidParser(); +#endif + + [Fact] + public void IncludeArguments_AreRemovedWhileAssignmentsPersist() + { + var provider = new MockFileProvider() + .Add("snippet.liquid", "{{ argument }}{% assign persisted = argument %}"); + var context = new TemplateContext(new TemplateOptions { FileProvider = provider }); + var template = _parser.Parse( + "{% include 'snippet', argument: 'inner' %}|{{ argument }}|{{ persisted }}"); + + Assert.Equal("inner||inner", template.Render(context)); + Assert.IsType(context.GetValue("argument")); + } + + [Fact] + public void IncludeDuplicateArguments_PreserveEvaluationAndCleanupOrder() + { + var provider = new MockFileProvider() + .Add("snippet.liquid", "{{ value }}"); + var context = new TemplateContext(new TemplateOptions { FileProvider = provider }); + var template = _parser.Parse( + "{% include 'snippet', value: 'first', value: value %}|{{ value }}"); + + Assert.Equal("first|", template.Render(context)); + Assert.IsType(context.GetValue("value")); + } + + [Fact] + public void NestedIncludeArguments_RestoreTheOuterIncludeValue() + { + var provider = new MockFileProvider() + .Add("outer.liquid", "{{ value }}|{% include 'inner', value: 'inner' %}|{{ value }}") + .Add("inner.liquid", "{{ value }}"); + var context = new TemplateContext(new TemplateOptions { FileProvider = provider }); + var template = _parser.Parse( + "{% include 'outer', value: 'outer' %}|{{ value }}"); + + Assert.Equal("outer|inner|outer|", template.Render(context)); + Assert.IsType(context.GetValue("value")); + } + + [Fact] + public async Task IncludeBindingsAndArguments_RestoreExistingValues() + { + var provider = new MockFileProvider() + .Add("snippet.liquid", "{{ item }}|{{ value }}"); + var context = new TemplateContext(new TemplateOptions { FileProvider = provider }) + .SetValue("item", "outer-item") + .SetValue("value", "outer-value"); + var include = new IncludeStatement( + _parser, + new LiteralExpression(new StringValue("snippet")), + with: new LiteralExpression(new StringValue("inner-item")), + alias: "item", + assignStatements: new List + { + new("value", new LiteralExpression(new StringValue("inner-value"))) + }); + var writer = new StringWriter(); + + await include.WriteToAsync(writer, NullEncoder.Default, context); + + Assert.Equal("inner-item|inner-value", writer.ToString()); + Assert.Equal("outer-item", context.GetValue("item").ToStringValue()); + Assert.Equal("outer-value", context.GetValue("value").ToStringValue()); + } + + [Theory] + [InlineData("break", "before|")] + [InlineData("continue", "beforebefore|")] + public void IncludeArguments_AreRemovedOnLoopCompletion(string completion, string expected) + { + var provider = new MockFileProvider() + .Add("flow.liquid", $"{{% {completion} %}}"); + var context = new TemplateContext(new TemplateOptions { FileProvider = provider }) + .SetValue("items", new[] { 1, 2 }); + var template = _parser.Parse( + "{% for item in items %}before{% include 'flow', argument: 'inner' %}after{% endfor %}|{{ argument }}"); + + Assert.Equal(expected, template.Render(context)); + Assert.IsType(context.GetValue("argument")); + } + + [Fact] + public async Task IncludeArguments_AreRemovedWhenFallbackTemplateThrows() + { + var provider = new MockFileProvider() + .Add("snippet.liquid", ""); + var options = new TemplateOptions + { + FileProvider = provider, + TemplateParsed = (_, _) => new ThrowingTemplate() + }; + var context = new TemplateContext(options) + .SetValue("existing", "outer"); + var rootScope = context.LocalScope; + var template = _parser.Parse( + "{% include 'snippet', argument: 'inner', existing: 'inner' %}"); + + var exception = await Assert.ThrowsAsync( + () => template.RenderAsync(new TestFluidOutput(), HtmlEncoder.Default, context).AsTask()); + + Assert.Equal("render failed", exception.Message); + Assert.Same(rootScope, context.LocalScope); + Assert.IsType(context.GetValue("argument")); + Assert.Equal("outer", context.GetValue("existing").ToStringValue()); + } + + [Fact] + public async Task IncludeArguments_AreRemovedWhenArgumentEvaluationThrows() + { + var provider = new MockFileProvider() + .Add("snippet.liquid", ""); + var context = new TemplateContext(new TemplateOptions { FileProvider = provider }) + .SetValue("existing", "outer"); + var rootScope = context.LocalScope; + var include = new IncludeStatement( + _parser, + new LiteralExpression(new StringValue("snippet")), + assignStatements: new List + { + new("argument", new LiteralExpression(new StringValue("inner"))), + new("existing", new ThrowingExpression()) + }); + + var exception = await Assert.ThrowsAsync( + () => include.WriteToAsync(new TestFluidOutput(), HtmlEncoder.Default, context).AsTask()); + + Assert.Equal("evaluation failed", exception.Message); + Assert.Same(rootScope, context.LocalScope); + Assert.IsType(context.GetValue("argument")); + Assert.Equal("outer", context.GetValue("existing").ToStringValue()); + } + + [Fact] + public async Task IncludeArguments_AreRemovedWhenFallbackTemplateIsCanceled() + { + var provider = new MockFileProvider() + .Add("snippet.liquid", ""); + var options = new TemplateOptions + { + FileProvider = provider, + TemplateParsed = (_, _) => new CanceledTemplate() + }; + var context = new TemplateContext(options); + var rootScope = context.LocalScope; + var template = _parser.Parse( + "{% include 'snippet', argument: 'inner' %}"); + + await Assert.ThrowsAnyAsync( + () => template.RenderAsync(new TestFluidOutput(), HtmlEncoder.Default, context).AsTask()); + + Assert.Same(rootScope, context.LocalScope); + Assert.IsType(context.GetValue("argument")); + } + + private sealed class ThrowingTemplate : IFluidTemplate + { + public ValueTask RenderAsync(IFluidOutput output, TextEncoder encoder, TemplateContext context) => + new(Task.FromException(new InvalidOperationException("render failed"))); + } + + private sealed class CanceledTemplate : IFluidTemplate + { + public ValueTask RenderAsync(IFluidOutput output, TextEncoder encoder, TemplateContext context) => + new(Task.FromCanceled(new CancellationToken(canceled: true))); + } + + private sealed class ThrowingExpression : Expression + { + public override ValueTask EvaluateAsync(TemplateContext context) => + new(Task.FromException(new InvalidOperationException("evaluation failed"))); + } + + private sealed class TestFluidOutput : IFluidOutput + { + public void Advance(int count) + { + } + + public Memory GetMemory(int sizeHint = 0) => Memory.Empty; + + public Span GetSpan(int sizeHint = 0) => Span.Empty; + + public void Write(string value) + { + } + + public void Write(char[] buffer, int index, int count) + { + } + + public ValueTask FlushAsync() => default; + } + } +} diff --git a/Fluid.Tests/RenderStatementSyncTests.cs b/Fluid.Tests/RenderStatementSyncTests.cs new file mode 100644 index 00000000..c74f0add --- /dev/null +++ b/Fluid.Tests/RenderStatementSyncTests.cs @@ -0,0 +1,231 @@ +using System; +using System.IO; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading; +using System.Threading.Tasks; +using Fluid.Ast; +using Fluid.Tests.Mocks; +using Fluid.Values; +using Xunit; + +namespace Fluid.Tests +{ + public class RenderStatementSyncTests + { +#if COMPILED + private static readonly FluidParser _parser = new FluidParser().Compile(); +#else + private static readonly FluidParser _parser = new FluidParser(); +#endif + + [Fact] + public void SynchronousRender_CompletesSynchronouslyAndRestoresScope() + { + var nested = new SynchronousTemplate(); + var context = CreateContext(nested); + var rootScope = context.LocalScope; + var statement = new RenderStatement(_parser, "snippet"); + + var task = statement.WriteToAsync(new TestFluidOutput(), HtmlEncoder.Default, context); + + Assert.True(task.IsCompletedSuccessfully); + Assert.Equal(Completion.Normal, task.Result); + Assert.NotNull(nested.ObservedScope); + Assert.NotSame(rootScope, nested.ObservedScope); + Assert.Same(rootScope, context.LocalScope); + } + + [Fact] + public async Task SuspendedFileLoad_DoesNotEnterScopeEarly() + { + var provider = new ControlledFileProvider(); + var context = new TemplateContext(new TemplateOptions { FileProvider = provider }); + var rootScope = context.LocalScope; + var statement = new RenderStatement(_parser, "snippet"); + + var task = statement.WriteToAsync(new TestFluidOutput(), HtmlEncoder.Default, context); + + Assert.False(task.IsCompletedSuccessfully); + Assert.Same(rootScope, context.LocalScope); + + provider.SetResult("x"); + + Assert.Equal(Completion.Normal, await task); + Assert.Same(rootScope, context.LocalScope); + } + + [Fact] + public async Task SuspendedRender_RestoresScopeAfterCompletion() + { + var nested = new ControlledTemplate(); + var context = CreateContext(nested); + var rootScope = context.LocalScope; + var statement = new RenderStatement(_parser, "snippet"); + + var task = statement.WriteToAsync(new TestFluidOutput(), HtmlEncoder.Default, context); + + Assert.False(task.IsCompletedSuccessfully); + Assert.NotSame(rootScope, context.LocalScope); + + nested.SetResult(); + + Assert.Equal(Completion.Normal, await task); + Assert.Same(rootScope, context.LocalScope); + } + + [Fact] + public void SynchronousRenderException_RestoresScope() + { + var context = CreateContext(new SynchronousThrowingTemplate()); + var rootScope = context.LocalScope; + var statement = new RenderStatement(_parser, "snippet"); + + var exception = Assert.Throws( + () => statement.WriteToAsync(new TestFluidOutput(), HtmlEncoder.Default, context)); + + Assert.Equal("render failed", exception.Message); + Assert.Same(rootScope, context.LocalScope); + } + + [Fact] + public async Task SuspendedRenderException_RestoresScope() + { + var nested = new ControlledTemplate(); + var context = CreateContext(nested); + var rootScope = context.LocalScope; + var statement = new RenderStatement(_parser, "snippet"); + + var task = statement.WriteToAsync(new TestFluidOutput(), HtmlEncoder.Default, context); + nested.SetException(new InvalidOperationException("render failed")); + + var exception = await Assert.ThrowsAsync(() => task.AsTask()); + + Assert.Equal("render failed", exception.Message); + Assert.Same(rootScope, context.LocalScope); + } + + [Fact] + public async Task SuspendedRenderCancellation_RestoresScope() + { + var nested = new ControlledTemplate(); + var context = CreateContext(nested); + var rootScope = context.LocalScope; + var statement = new RenderStatement(_parser, "snippet"); + + var task = statement.WriteToAsync(new TestFluidOutput(), HtmlEncoder.Default, context); + nested.SetCanceled(); + + await Assert.ThrowsAnyAsync(() => task.AsTask()); + Assert.Same(rootScope, context.LocalScope); + } + + [Theory] + [InlineData("break")] + [InlineData("continue")] + public void NestedCompletion_DoesNotEscapeRender(string completion) + { + var provider = new MockFileProvider() + .Add("flow.liquid", $"{{% {completion} %}}"); + var context = new TemplateContext(new TemplateOptions { FileProvider = provider }) + .SetValue("items", new[] { 1, 2 }); + var template = _parser.Parse( + "{% for item in items %}a{% render 'flow' %}b{% endfor %}"); + + Assert.Equal("abab", template.Render(context)); + } + + private static TemplateContext CreateContext(IFluidTemplate nested) + { + var provider = new MockFileProvider().Add("snippet.liquid", ""); + return new TemplateContext(new TemplateOptions + { + FileProvider = provider, + TemplateParsed = (_, _) => nested + }); + } + + private sealed class SynchronousTemplate : IFluidTemplate + { + public Scope ObservedScope { get; private set; } + + public ValueTask RenderAsync( + IFluidOutput output, + TextEncoder encoder, + TemplateContext context) + { + ObservedScope = context.LocalScope; + return default; + } + } + + private sealed class SynchronousThrowingTemplate : IFluidTemplate + { + public ValueTask RenderAsync( + IFluidOutput output, + TextEncoder encoder, + TemplateContext context) => + throw new InvalidOperationException("render failed"); + } + + private sealed class ControlledTemplate : IFluidTemplate + { + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ValueTask RenderAsync( + IFluidOutput output, + TextEncoder encoder, + TemplateContext context) => + new(_completion.Task); + + public void SetResult() => _completion.SetResult(); + + public void SetException(Exception exception) => _completion.SetException(exception); + + public void SetCanceled() => _completion.SetCanceled(); + } + + private sealed class ControlledFileProvider : ITemplateFileProvider + { + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ValueTask GetFileInfoAsync( + string subpath, + TemplateContext context, + CancellationToken cancellationToken) => + new(_completion.Task); + + public void SetResult(string content) + { + var bytes = Encoding.UTF8.GetBytes(content); + _completion.SetResult( + new TemplateSourceInfo( + DateTimeOffset.UnixEpoch, + _ => new ValueTask(new MemoryStream(bytes, writable: false)))); + } + } + + private sealed class TestFluidOutput : IFluidOutput + { + public void Advance(int count) + { + } + + public Memory GetMemory(int sizeHint = 0) => Memory.Empty; + + public Span GetSpan(int sizeHint = 0) => Span.Empty; + + public void Write(string value) + { + } + + public void Write(char[] buffer, int index, int count) + { + } + + public ValueTask FlushAsync() => default; + } + } +} diff --git a/Fluid.Tests/Utf8FluidOutputTests.cs b/Fluid.Tests/Utf8FluidOutputTests.cs new file mode 100644 index 00000000..7904ecf7 --- /dev/null +++ b/Fluid.Tests/Utf8FluidOutputTests.cs @@ -0,0 +1,321 @@ +using Fluid.Tests.Mocks; +using Fluid.Utils; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO.Pipelines; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Fluid.Tests +{ + public class Utf8FluidOutputTests + { +#if COMPILED + private static readonly FluidParser _parser = new FluidParser().Compile(); +#else + private static readonly FluidParser _parser = new FluidParser(); +#endif + + [Fact] + public async Task WritesAsciiBmpAndNonBmpUnicode() + { + var writer = new ArrayBufferWriter(); + await using var output = new Utf8FluidOutput(writer); + + output.Write("ASCII | \u4e16\u754c | \ud83d\ude80"); + await output.FlushAsync(); + + Assert.Equal("ASCII | \u4e16\u754c | \ud83d\ude80", Decode(writer)); + } + + [Fact] + public async Task PreservesSurrogatePairsAcrossWrites() + { + var writer = new ArrayBufferWriter(); + await using var output = new Utf8FluidOutput(writer); + + output.Write("\ud83d"); + await output.FlushAsync(); + output.Write("\ude80"); + await output.FlushAsync(); + + Assert.Equal("\ud83d\ude80", Decode(writer)); + } + + [Fact] + public async Task ContinuesWritingAfterIntermediateFlush() + { + var writer = new ArrayBufferWriter(); + await using var output = new Utf8FluidOutput(writer); + + output.Write("\u4e16\u754c"); + await output.FlushAsync(); + output.Write("\ud83d\ude80"); + await output.FlushAsync(); + + Assert.Equal("\u4e16\u754c\ud83d\ude80", Decode(writer)); + } + + [Fact] + public async Task ReplacesMalformedSurrogatesLikeStandardUtf8() + { + var writer = new ArrayBufferWriter(); + var output = new Utf8FluidOutput(writer); + + output.Write("\ud83dX\ude80"); + await output.DisposeAsync(); + + Assert.Equal("\ufffdX\ufffd", Decode(writer)); + } + + [Fact] + public async Task EncodesCharactersWrittenThroughBufferWriter() + { + var writer = new ArrayBufferWriter(); + await using var output = new Utf8FluidOutput(writer, minimumCharBufferSize: 2); + + "Fluid \ud83d\ude80".AsSpan().CopyTo(output.GetSpan(8)); + output.Advance(8); + await output.FlushAsync(); + + Assert.Equal("Fluid \ud83d\ude80", Decode(writer)); + } + + [Fact] + public async Task RendersLiteralsEncodedValuesAndRawValuesWithCorrectSemantics() + { + var template = _parser.Parse("

{{ value }}|{{ value | raw }}

"); + var context = new TemplateContext().SetValue("value", "<\ud83d\ude80>"); + var writer = new ArrayBufferWriter(); + + await template.RenderAsync(writer, HtmlEncoder.Default, context); + + Assert.Equal("

<🚀>|<\ud83d\ude80>

", Decode(writer)); + } + + [Fact] + public async Task RenderAsyncFinalizesEncodingWithoutOwningDestination() + { + var template = _parser.Parse("{{ value | raw }}"); + var writer = new DisposableByteWriter(); + + await template.RenderAsync( + writer, + NullEncoder.Default, + new TemplateContext().SetValue("value", "\ud83d")); + + Assert.Equal("\ufffd", writer.ToString()); + Assert.False(writer.IsDisposed); + } + + [Fact] + public async Task RenderAsyncReportsCancellationRaisedDuringFinalization() + { + using var cancellation = new CancellationTokenSource(); + var writer = new CancellationOnSecondGetSpanWriter(cancellation); + + await Assert.ThrowsAsync(() => + _parser.Parse("value").RenderAsync( + writer, + NullEncoder.Default, + new TemplateContext { CancellationToken = cancellation.Token }).AsTask()); + } + + [Fact] + public async Task TranscodesAcrossTinyDestinationSegments() + { + var writer = new SegmentedByteWriter(segmentSize: 4); + await using var output = new Utf8FluidOutput(writer); + + output.Write("a\u00e9\u4e16\ud83d\ude80z"); + await output.FlushAsync(); + + Assert.Equal("a\u00e9\u4e16\ud83d\ude80z", writer.ToString()); + Assert.True(writer.AdvanceCount > 1); + } + + [Fact] + public async Task FlushHonorsCancellation() + { + var writer = new ArrayBufferWriter(); + using var cancellation = new CancellationTokenSource(); + var output = new Utf8FluidOutput(writer, cancellationToken: cancellation.Token); + output.Write("value"); + cancellation.Cancel(); + + await Assert.ThrowsAsync(() => output.FlushAsync().AsTask()); + await output.DisposeAsync(); + } + + [Fact] + public async Task DisposalSkipsBufferedEncodingAfterCancellation() + { + var writer = new ArrayBufferWriter(); + using var cancellation = new CancellationTokenSource(); + var output = new Utf8FluidOutput( + writer, + minimumCharBufferSize: 4, + cancellationToken: cancellation.Token); + "data".AsSpan().CopyTo(output.GetSpan(4)); + output.Advance(4); + + cancellation.Cancel(); + await output.DisposeAsync(); + + Assert.Equal(0, writer.WrittenCount); + } + + [Fact] + public async Task PipeBackpressureIsAwaitedOnlyAtTheTransportBoundary() + { + var pipe = new Pipe(new PipeOptions( + pauseWriterThreshold: 1, + resumeWriterThreshold: 1, + useSynchronizationContext: false)); + await using var output = new Utf8FluidOutput(pipe.Writer); + output.Write(new string('x', 32)); + + await output.FlushAsync(); + var transportFlush = pipe.Writer.FlushAsync(); + Assert.False(transportFlush.IsCompletedSuccessfully); + + var read = await pipe.Reader.ReadAsync(); + Assert.Equal(32, read.Buffer.Length); + pipe.Reader.AdvanceTo(read.Buffer.End); + Assert.False((await transportFlush).IsCanceled); + } + + [Fact] + public async Task NestedRenderUsesOneContinuousUtf8Output() + { + var fileProvider = new MockFileProvider().Add("item.liquid", "[{{ item }}]"); + var context = new TemplateContext(new TemplateOptions { FileProvider = fileProvider }); + context.SetValue("items", new[] { "\ud83d\ude80", "\u4e16\u754c" }); + var template = _parser.Parse("{% render 'item' for items as item %}"); + var writer = new ArrayBufferWriter(); + + await template.RenderAsync(writer, NullEncoder.Default, context); + + Assert.Equal("[\ud83d\ude80][\u4e16\u754c]", Decode(writer)); + } + + [Fact] + public async Task MaxOutputSizeRemainsAUtf16CharacterLimit() + { + var template = _parser.Parse("{{ value }}"); + var writer = new ArrayBufferWriter(); + + await template.RenderAsync( + writer, + NullEncoder.Default, + new TemplateContext { MaxOutputSize = 2 }.SetValue("value", "\ud83d\ude80")); + + Assert.Equal(4, writer.WrittenCount); + await Assert.ThrowsAsync(() => + template.RenderAsync( + new ArrayBufferWriter(), + NullEncoder.Default, + new TemplateContext { MaxOutputSize = 1 }.SetValue("value", "\ud83d\ude80")).AsTask()); + } + + [Fact] + public async Task DisposalFinalizesEncodingWithoutOwningDestination() + { + var writer = new DisposableByteWriter(); + var output = new Utf8FluidOutput(writer); + output.Write("\ud83d"); + + await output.DisposeAsync(); + + Assert.Equal("\ufffd", writer.ToString()); + Assert.False(writer.IsDisposed); + Assert.Throws(() => output.Write("x")); + } + + private static string Decode(ArrayBufferWriter writer) => + Encoding.UTF8.GetString(writer.WrittenSpan); + + private class SegmentedByteWriter : IBufferWriter + { + private readonly int _segmentSize; + private readonly List _bytes = new(); + private byte[] _current; + + public SegmentedByteWriter(int segmentSize) + { + _segmentSize = segmentSize; + } + + public int AdvanceCount { get; private set; } + + public void Advance(int count) + { + AdvanceCount++; + for (var i = 0; i < count; i++) + { + _bytes.Add(_current[i]); + } + } + + public Memory GetMemory(int sizeHint = 0) + { + _current = new byte[Math.Max(_segmentSize, sizeHint)]; + return _current; + } + + public Span GetSpan(int sizeHint = 0) => GetMemory(sizeHint).Span; + + public override string ToString() => Encoding.UTF8.GetString(_bytes.ToArray()); + } + + private sealed class DisposableByteWriter : SegmentedByteWriter, IDisposable + { + public DisposableByteWriter() + : base(4) + { + } + + public bool IsDisposed { get; private set; } + + public void Dispose() => IsDisposed = true; + } + + private sealed class CancellationOnSecondGetSpanWriter : IBufferWriter + { + private readonly ArrayBufferWriter _inner = new(); + private readonly CancellationTokenSource _cancellation; + private int _getSpanCount; + + public CancellationOnSecondGetSpanWriter(CancellationTokenSource cancellation) + { + _cancellation = cancellation; + } + + public void Advance(int count) => _inner.Advance(count); + + public Memory GetMemory(int sizeHint = 0) + { + BeforeGetBuffer(); + return _inner.GetMemory(sizeHint); + } + + public Span GetSpan(int sizeHint = 0) + { + BeforeGetBuffer(); + return _inner.GetSpan(sizeHint); + } + + private void BeforeGetBuffer() + { + if (++_getSpanCount == 2) + { + _cancellation.Cancel(); + } + } + } + } +} diff --git a/Fluid/Ast/RenderStatement.cs b/Fluid/Ast/RenderStatement.cs index fce2ce39..a084348d 100644 --- a/Fluid/Ast/RenderStatement.cs +++ b/Fluid/Ast/RenderStatement.cs @@ -30,7 +30,81 @@ public RenderStatement(FluidParser parser, string path, Expression with = null, public Expression For { get; } public string Alias { get; } - public override async ValueTask WriteToAsync(IFluidOutput output, TextEncoder encoder, TemplateContext context) + public override ValueTask WriteToAsync(IFluidOutput output, TextEncoder encoder, TemplateContext context) + { + if (With != null || For != null || AssignStatements.Count > 0) + { + return WriteToAsyncCore(output, encoder, context); + } + + context.IncrementSteps(); + + var task = TemplateLoader.LoadAsync( + Parser, + Path, + context, + context.Options.DefaultFileExtension); + + if (task.IsCompletedSuccessfully) + { + return RenderLoadedTemplate(task.Result.Template, output, encoder, context); + } + + return AwaitedLoad(task, output, encoder, context); + + static async ValueTask AwaitedLoad( + ValueTask task, + IFluidOutput output, + TextEncoder encoder, + TemplateContext context) + { + var loadedTemplate = await task; + return await RenderLoadedTemplate(loadedTemplate.Template, output, encoder, context); + } + } + + private static ValueTask RenderLoadedTemplate( + IFluidTemplate template, + IFluidOutput output, + TextEncoder encoder, + TemplateContext context) + { + var scope = context.EnterScope(ScopeBehavior.Isolated); + + try + { + var task = template.RenderAsync(output, encoder, context); + if (task.IsCompletedSuccessfully) + { + scope.Dispose(); + return NormalCompletion; + } + + return AwaitedRender(task, scope); + } + catch + { + scope.Dispose(); + throw; + } + + static async ValueTask AwaitedRender( + ValueTask task, + TemplateContext.ScopeLease scope) + { + try + { + await task; + return Completion.Normal; + } + finally + { + scope.Dispose(); + } + } + } + + private async ValueTask WriteToAsyncCore(IFluidOutput output, TextEncoder encoder, TemplateContext context) { context.IncrementSteps(); diff --git a/Fluid/FluidOutputExtensions.cs b/Fluid/FluidOutputExtensions.cs index bae35241..d6193634 100644 --- a/Fluid/FluidOutputExtensions.cs +++ b/Fluid/FluidOutputExtensions.cs @@ -61,6 +61,39 @@ public static async ValueTask RenderAsync(this IFluidTemplate template, TextWrit await output.FlushAsync(); } +#if NET8_0_OR_GREATER + /// + /// Renders a template as UTF-8 directly to a byte buffer writer. + /// + /// + /// The destination remains owned by the caller. This method does not flush or complete + /// asynchronous transport destinations such as pipe writers. + /// + public static async ValueTask RenderAsync( + this IFluidTemplate template, + IBufferWriter writer, + TextEncoder encoder, + TemplateContext context) + { + ArgumentNullException.ThrowIfNull(template); + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(encoder); + ArgumentNullException.ThrowIfNull(context); + + context.CancellationToken.ThrowIfCancellationRequested(); + + await using (var output = new Utf8FluidOutput( + writer, + cancellationToken: context.CancellationToken)) + { + var limitedOutput = LimitedFluidOutput.Create(output, context.MaxOutputSize); + await template.RenderAsync(limitedOutput, encoder, context); + } + + context.CancellationToken.ThrowIfCancellationRequested(); + } +#endif + public static async ValueTask WriteToAsync(this FluidValue value, TextWriter writer, TextEncoder encoder, CultureInfo cultureInfo) { ArgumentNullException.ThrowIfNull(value); diff --git a/Fluid/Utils/Utf8FluidOutput.cs b/Fluid/Utils/Utf8FluidOutput.cs new file mode 100644 index 00000000..754041df --- /dev/null +++ b/Fluid/Utils/Utf8FluidOutput.cs @@ -0,0 +1,263 @@ +#if NET8_0_OR_GREATER +using System.Buffers; +using System.Text; + +namespace Fluid.Utils +{ + /// + /// Transcodes Fluid's character output directly to UTF-8 in an . + /// + /// + /// The destination remains owned by the caller. In particular, this type does not flush or + /// complete a pipe; callers should flush the destination after the outer render completes. + /// + public sealed class Utf8FluidOutput : IFluidOutput, IDisposable, IAsyncDisposable + { + private const int MinimumUtf8BufferSize = 4; + private static readonly UTF8Encoding Utf8 = new UTF8Encoding( + encoderShouldEmitUTF8Identifier: false, + throwOnInvalidBytes: false); + + private readonly IBufferWriter _writer; + private readonly Encoder _encoder; + private readonly ArrayPool _pool; + private readonly int _minimumCharBufferSize; + private readonly CancellationToken _cancellationToken; + private char[] _charBuffer; + private int _charIndex; + private int _availableChars; + private bool _hasWrittenChars; + private bool _disposed; + + public Utf8FluidOutput( + IBufferWriter writer, + int minimumCharBufferSize = 1024, + ArrayPool pool = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(minimumCharBufferSize); + + _writer = writer; + _encoder = Utf8.GetEncoder(); + _pool = pool ?? ArrayPool.Shared; + _minimumCharBufferSize = minimumCharBufferSize; + _cancellationToken = cancellationToken; + } + + public void Advance(int count) + { + ThrowIfDisposed(); + + if ((uint)count > (uint)_availableChars) + { + throw new ArgumentOutOfRangeException(nameof(count)); + } + + _availableChars = 0; + if (count != 0) + { + _charIndex += count; + } + } + + public Memory GetMemory(int sizeHint = 0) + { + EnsureCharBuffer(sizeHint); + return _charBuffer.AsMemory(_charIndex); + } + + public Span GetSpan(int sizeHint = 0) + { + EnsureCharBuffer(sizeHint); + return _charBuffer.AsSpan(_charIndex); + } + + public void Write(string value) + { + ThrowIfDisposed(); + + if (!string.IsNullOrEmpty(value)) + { + Write(value.AsSpan()); + } + } + + public void Write(char[] buffer, int index, int count) + { + ArgumentNullException.ThrowIfNull(buffer); + ThrowIfDisposed(); + + if (count != 0) + { + Write(buffer.AsSpan(index, count)); + } + } + + public ValueTask FlushAsync() + { + ThrowIfDisposed(); + _cancellationToken.ThrowIfCancellationRequested(); + FlushCharBuffer(); + return default; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + try + { + if (!_cancellationToken.IsCancellationRequested) + { + FlushCharBuffer(); + FinishEncoding(); + } + } + finally + { + DisposeCore(); + } + } + + public ValueTask DisposeAsync() + { + Dispose(); + return default; + } + + private void Encode(ReadOnlySpan source, bool flush) + { + _cancellationToken.ThrowIfCancellationRequested(); + + if (!flush) + { + _hasWrittenChars = true; + } + + bool completed; + do + { + var destination = _writer.GetSpan(MinimumUtf8BufferSize); + _encoder.Convert( + source, + destination, + flush, + out var charsUsed, + out var bytesUsed, + out completed); + + if (bytesUsed != 0) + { + _writer.Advance(bytesUsed); + } + + source = source.Slice(charsUsed); + + if (!completed && charsUsed == 0 && bytesUsed == 0) + { + throw new InvalidOperationException("The UTF-8 destination did not provide enough writable memory."); + } + } + while (!completed); + } + + private void FinishEncoding() + { + if (_hasWrittenChars) + { + Encode(ReadOnlySpan.Empty, flush: true); + _encoder.Reset(); + _hasWrittenChars = false; + } + } + + private void EnsureCharBuffer(int sizeHint) + { + ThrowIfDisposed(); + ArgumentOutOfRangeException.ThrowIfNegative(sizeHint); + + if (sizeHint == 0) + { + sizeHint = 1; + } + + if (_charBuffer != null && _charBuffer.Length - _charIndex >= sizeHint) + { + _availableChars = _charBuffer.Length - _charIndex; + return; + } + + FlushCharBuffer(); + + var required = Math.Max(sizeHint, _minimumCharBufferSize); + if (_charBuffer == null || _charBuffer.Length < required) + { + var replacement = _pool.Rent(required); + if (_charBuffer != null) + { + _pool.Return(_charBuffer); + } + + _charBuffer = replacement; + } + + _availableChars = _charBuffer.Length - _charIndex; + } + + private void Write(ReadOnlySpan value) + { + if (_charBuffer == null) + { + Encode(value, flush: false); + return; + } + + if (value.Length >= _charBuffer.Length) + { + FlushCharBuffer(); + Encode(value, flush: false); + return; + } + + if (_charBuffer.Length - _charIndex < value.Length) + { + FlushCharBuffer(); + } + + value.CopyTo(_charBuffer.AsSpan(_charIndex)); + _charIndex += value.Length; + } + + private void FlushCharBuffer() + { + if (_charIndex != 0) + { + Encode(_charBuffer.AsSpan(0, _charIndex), flush: false); + _charIndex = 0; + } + } + + private void DisposeCore() + { + _disposed = true; + _charIndex = 0; + _availableChars = 0; + + if (_charBuffer != null) + { + _pool.Return(_charBuffer); + _charBuffer = null; + } + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + } +} +#endif diff --git a/README.md b/README.md index dbcb71e9..09783b2b 100644 --- a/README.md +++ b/README.md @@ -1700,6 +1700,19 @@ These instances are meant to be reused. This is why there is a separation betwee Instantiating a `FluidParser` instance is expensive, do it once and reuse the instance. This can be registered as a singleton if you use dependency injection, but in most cases a `static` instance makes sense since it's rare to customize these. +### Render directly to UTF-8 + +On .NET 8 and later, `Utf8FluidOutput` writes directly to an `IBufferWriter` without accumulating the rendered response as UTF-16 or adding an ASP.NET Core dependency to Fluid: + +```csharp +await template.RenderAsync(response.BodyWriter, HtmlEncoder.Default, context); + +// The destination remains caller-owned. +await response.BodyWriter.FlushAsync(requestAborted); +``` + +The `RenderAsync` overload creates and disposes `Utf8FluidOutput`, ensuring its UTF-8 encoder state is finalized. The destination remains caller-owned and is not flushed or completed. Create `Utf8FluidOutput` directly when multiple templates need to share one character stream. Its `FlushAsync` method transcodes buffered characters while preserving encoder state so surrogate pairs remain valid across intermediate template flushes. Because `IFluidOutput` writes synchronously, transport flushing is not attempted in the middle of a render. `MaxOutputSize` continues to count UTF-16 characters, not encoded bytes. + ### Benchmarks A benchmark application is provided in the source code to compare Fluid, [Scriban](https://github.com/scriban/scriban), [DotLiquid](https://github.com/dotliquid/dotliquid), [Liquid.NET](https://github.com/mikebridge/Liquid.NET), and [Handlebars.NET](https://github.com/Handlebars-Net/Handlebars.Net).