From e0d510f10f94332c5b694824355414f3d4f24552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:22:34 -0700 Subject: [PATCH 1/5] Reduce include argument allocations Reuse parsed assignment identifiers during include cleanup instead of allocating a temporary list. Add scope-semantic coverage and focused include benchmarks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Fluid.Benchmarks/IncludeScopeBenchmarks.cs | 164 +++++++++++++ .../IncludeStatementAllocationTests.cs | 216 ++++++++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 Fluid.Benchmarks/IncludeScopeBenchmarks.cs create mode 100644 Fluid.Tests/IncludeStatementAllocationTests.cs 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.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; + } + } +} From f3834bb27cb173a13be54dadba1b4f78c3d4190c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:41:24 -0700 Subject: [PATCH 2/5] Optimize synchronous render statements Use a sync-first no-argument render path and defer async state machines until template loading or nested rendering suspends. Add focused cleanup tests and benchmark coverage for for, render, and include candidates. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Fluid.Benchmarks/AstSyncPathBenchmarks.cs | 220 +++++++++++++++++++++ Fluid.Tests/RenderStatementSyncTests.cs | 231 ++++++++++++++++++++++ Fluid/Ast/RenderStatement.cs | 83 +++++++- 3 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 Fluid.Benchmarks/AstSyncPathBenchmarks.cs create mode 100644 Fluid.Tests/RenderStatementSyncTests.cs 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.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/Ast/RenderStatement.cs b/Fluid/Ast/RenderStatement.cs index fce2ce39..48f80884 100644 --- a/Fluid/Ast/RenderStatement.cs +++ b/Fluid/Ast/RenderStatement.cs @@ -30,7 +30,88 @@ 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) + { + context.EnterChildScope(); + var previousScope = context.LocalScope; + + try + { + context.IsolateCurrentScope(); + + var task = FluidTemplateRenderer.RenderAsync(template, output, encoder, context); + if (task.IsCompletedSuccessfully) + { + context.LocalScope = previousScope; + context.ReleaseScope(); + return NormalCompletion; + } + + return AwaitedRender(task, previousScope, context); + } + catch + { + context.LocalScope = previousScope; + context.ReleaseScope(); + throw; + } + + static async ValueTask AwaitedRender( + ValueTask task, + Scope previousScope, + TemplateContext context) + { + try + { + await task; + return Completion.Normal; + } + finally + { + context.LocalScope = previousScope; + context.ReleaseScope(); + } + } + } + + private async ValueTask WriteToAsyncCore(IFluidOutput output, TextEncoder encoder, TemplateContext context) { context.IncrementSteps(); From e75696fa6d0473588129f7a0b2fc3339d4e92559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:58:58 -0700 Subject: [PATCH 3/5] Add direct UTF-8 output Introduce a modern-TFM IBufferWriter output that preserves Fluid's character-based rendering contract while transcoding directly to UTF-8. Add correctness coverage and measured TextWriter comparisons without changing MinimalApis.LiquidViews behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Fluid.Benchmarks/Program.cs | 9 +- Fluid.Benchmarks/Utf8OutputBenchmarks.cs | 290 +++++++++++++++++++++++ Fluid.Tests/Utf8FluidOutputTests.cs | 262 ++++++++++++++++++++ Fluid/Utils/Utf8FluidOutput.cs | 263 ++++++++++++++++++++ README.md | 18 ++ 5 files changed, 841 insertions(+), 1 deletion(-) create mode 100644 Fluid.Benchmarks/Utf8OutputBenchmarks.cs create mode 100644 Fluid.Tests/Utf8FluidOutputTests.cs create mode 100644 Fluid/Utils/Utf8FluidOutput.cs 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..947a134f --- /dev/null +++ b/Fluid.Benchmarks/Utf8OutputBenchmarks.cs @@ -0,0 +1,290 @@ +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 using (var output = new Utf8FluidOutput(_byteWriter)) + { + await _template.RenderAsync( + output, + 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/Utf8FluidOutputTests.cs b/Fluid.Tests/Utf8FluidOutputTests.cs new file mode 100644 index 00000000..6f7ddf47 --- /dev/null +++ b/Fluid.Tests/Utf8FluidOutputTests.cs @@ -0,0 +1,262 @@ +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 using var output = new Utf8FluidOutput(writer, minimumCharBufferSize: 4); + + await template.RenderAsync(output, HtmlEncoder.Default, context); + + Assert.Equal("

<🚀>|<\ud83d\ude80>

", Decode(writer)); + } + + [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 using var output = new Utf8FluidOutput(writer); + + await template.RenderAsync(output, 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 using var output = new Utf8FluidOutput(writer); + + await template.RenderAsync( + output, + NullEncoder.Default, + new TemplateContext { MaxOutputSize = 2 }.SetValue("value", "\ud83d\ude80")); + + Assert.Equal(4, writer.WrittenCount); + await Assert.ThrowsAsync(() => + template.RenderAsync( + new Utf8FluidOutput(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; + } + } +} 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..f6e2c461 100644 --- a/README.md +++ b/README.md @@ -1700,6 +1700,24 @@ 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 using (var output = new Utf8FluidOutput( + response.BodyWriter, + cancellationToken: requestAborted)) +{ + await template.RenderAsync(output, HtmlEncoder.Default, context); +} + +// Disposal finalizes UTF-8 encoder state. The destination remains caller-owned. +await response.BodyWriter.FlushAsync(requestAborted); +``` + +`Utf8FluidOutput.FlushAsync` transcodes buffered characters but preserves encoder state so surrogate pairs remain valid across intermediate template flushes. Disposal finalizes that state, including an unmatched surrogate, but does not flush or complete the destination. This keeps pipe backpressure asynchronous and under the transport owner's control. 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). From 57ca5acff2be30102d90b12eb8bf248c79e4ff71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:53:17 -0700 Subject: [PATCH 4/5] Add UTF-8 render overload Expose a .NET 8+ IBufferWriter rendering overload that manages Utf8FluidOutput finalization while leaving destination ownership and transport flushing to the caller. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23c4e9ec-be5d-4730-8c36-c301c20d02e1 --- Fluid.Benchmarks/Utf8OutputBenchmarks.cs | 11 ++-- Fluid.Tests/Utf8FluidOutputTests.cs | 73 +++++++++++++++++++++--- Fluid/FluidOutputExtensions.cs | 33 +++++++++++ README.md | 11 +--- 4 files changed, 106 insertions(+), 22 deletions(-) diff --git a/Fluid.Benchmarks/Utf8OutputBenchmarks.cs b/Fluid.Benchmarks/Utf8OutputBenchmarks.cs index 947a134f..7449b372 100644 --- a/Fluid.Benchmarks/Utf8OutputBenchmarks.cs +++ b/Fluid.Benchmarks/Utf8OutputBenchmarks.cs @@ -114,13 +114,10 @@ public async ValueTask DirectUtf8() { _byteWriter.Reset(); - await using (var output = new Utf8FluidOutput(_byteWriter)) - { - await _template.RenderAsync( - output, - BenchmarkScenario.Encoder, - BenchmarkScenario.CreateContext()); - } + await _template.RenderAsync( + _byteWriter, + BenchmarkScenario.Encoder, + BenchmarkScenario.CreateContext()); await _byteWriter.FlushAsync(); return new RenderMeasurement(_byteWriter.BytesWritten, _byteWriter.FlushCount); diff --git a/Fluid.Tests/Utf8FluidOutputTests.cs b/Fluid.Tests/Utf8FluidOutputTests.cs index 6f7ddf47..7904ecf7 100644 --- a/Fluid.Tests/Utf8FluidOutputTests.cs +++ b/Fluid.Tests/Utf8FluidOutputTests.cs @@ -91,13 +91,40 @@ public async Task RendersLiteralsEncodedValuesAndRawValuesWithCorrectSemantics() var template = _parser.Parse("

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

"); var context = new TemplateContext().SetValue("value", "<\ud83d\ude80>"); var writer = new ArrayBufferWriter(); - await using var output = new Utf8FluidOutput(writer, minimumCharBufferSize: 4); - await template.RenderAsync(output, HtmlEncoder.Default, context); + 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() { @@ -170,9 +197,8 @@ public async Task NestedRenderUsesOneContinuousUtf8Output() context.SetValue("items", new[] { "\ud83d\ude80", "\u4e16\u754c" }); var template = _parser.Parse("{% render 'item' for items as item %}"); var writer = new ArrayBufferWriter(); - await using var output = new Utf8FluidOutput(writer); - await template.RenderAsync(output, NullEncoder.Default, context); + await template.RenderAsync(writer, NullEncoder.Default, context); Assert.Equal("[\ud83d\ude80][\u4e16\u754c]", Decode(writer)); } @@ -182,17 +208,16 @@ public async Task MaxOutputSizeRemainsAUtf16CharacterLimit() { var template = _parser.Parse("{{ value }}"); var writer = new ArrayBufferWriter(); - await using var output = new Utf8FluidOutput(writer); await template.RenderAsync( - output, + writer, NullEncoder.Default, new TemplateContext { MaxOutputSize = 2 }.SetValue("value", "\ud83d\ude80")); Assert.Equal(4, writer.WrittenCount); await Assert.ThrowsAsync(() => template.RenderAsync( - new Utf8FluidOutput(new ArrayBufferWriter()), + new ArrayBufferWriter(), NullEncoder.Default, new TemplateContext { MaxOutputSize = 1 }.SetValue("value", "\ud83d\ude80")).AsTask()); } @@ -258,5 +283,39 @@ public DisposableByteWriter() 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/FluidOutputExtensions.cs b/Fluid/FluidOutputExtensions.cs index bae35241..72726894 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 FluidTemplateRenderer.RenderAsync(template, 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/README.md b/README.md index f6e2c461..09783b2b 100644 --- a/README.md +++ b/README.md @@ -1705,18 +1705,13 @@ Instantiating a `FluidParser` instance is expensive, do it once and reuse the in 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 using (var output = new Utf8FluidOutput( - response.BodyWriter, - cancellationToken: requestAborted)) -{ - await template.RenderAsync(output, HtmlEncoder.Default, context); -} +await template.RenderAsync(response.BodyWriter, HtmlEncoder.Default, context); -// Disposal finalizes UTF-8 encoder state. The destination remains caller-owned. +// The destination remains caller-owned. await response.BodyWriter.FlushAsync(requestAborted); ``` -`Utf8FluidOutput.FlushAsync` transcodes buffered characters but preserves encoder state so surrogate pairs remain valid across intermediate template flushes. Disposal finalizes that state, including an unmatched surrogate, but does not flush or complete the destination. This keeps pipe backpressure asynchronous and under the transport owner's control. 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. +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 From 1778d55f0e1ecc790437347ce7441e221d4ce769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Ros?= <1165805+sebastienros@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:19:18 -0700 Subject: [PATCH 5/5] Adapt restored PRs to main Translate dependencies on stacked base helpers to the scope and rendering APIs already present on main. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 36106012-3f79-4363-934f-7baa9f0b7402 --- Fluid/Ast/RenderStatement.cs | 21 +++++++-------------- Fluid/FluidOutputExtensions.cs | 2 +- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/Fluid/Ast/RenderStatement.cs b/Fluid/Ast/RenderStatement.cs index 48f80884..a084348d 100644 --- a/Fluid/Ast/RenderStatement.cs +++ b/Fluid/Ast/RenderStatement.cs @@ -69,34 +69,28 @@ private static ValueTask RenderLoadedTemplate( TextEncoder encoder, TemplateContext context) { - context.EnterChildScope(); - var previousScope = context.LocalScope; + var scope = context.EnterScope(ScopeBehavior.Isolated); try { - context.IsolateCurrentScope(); - - var task = FluidTemplateRenderer.RenderAsync(template, output, encoder, context); + var task = template.RenderAsync(output, encoder, context); if (task.IsCompletedSuccessfully) { - context.LocalScope = previousScope; - context.ReleaseScope(); + scope.Dispose(); return NormalCompletion; } - return AwaitedRender(task, previousScope, context); + return AwaitedRender(task, scope); } catch { - context.LocalScope = previousScope; - context.ReleaseScope(); + scope.Dispose(); throw; } static async ValueTask AwaitedRender( ValueTask task, - Scope previousScope, - TemplateContext context) + TemplateContext.ScopeLease scope) { try { @@ -105,8 +99,7 @@ static async ValueTask AwaitedRender( } finally { - context.LocalScope = previousScope; - context.ReleaseScope(); + scope.Dispose(); } } } diff --git a/Fluid/FluidOutputExtensions.cs b/Fluid/FluidOutputExtensions.cs index 72726894..d6193634 100644 --- a/Fluid/FluidOutputExtensions.cs +++ b/Fluid/FluidOutputExtensions.cs @@ -87,7 +87,7 @@ public static async ValueTask RenderAsync( cancellationToken: context.CancellationToken)) { var limitedOutput = LimitedFluidOutput.Create(output, context.MaxOutputSize); - await FluidTemplateRenderer.RenderAsync(template, limitedOutput, encoder, context); + await template.RenderAsync(limitedOutput, encoder, context); } context.CancellationToken.ThrowIfCancellationRequested();