diff --git a/Fluid.Benchmarks/NestedRenderBenchmarks.cs b/Fluid.Benchmarks/NestedRenderBenchmarks.cs new file mode 100644 index 00000000..faae50a0 --- /dev/null +++ b/Fluid.Benchmarks/NestedRenderBenchmarks.cs @@ -0,0 +1,140 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; + +namespace Fluid.Benchmarks +{ + [MemoryDiagnoser] + public class NestedRenderBenchmarks + { + private const int ItemCount = 100; + + private readonly CountingFluidOutput _output = new(); + private readonly TemplateContext _context; + private readonly IFluidTemplate _renderTemplate; + private readonly IFluidTemplate _renderForTemplate; + + public NestedRenderBenchmarks() + { + var parser = new FluidParser(); + var options = new TemplateOptions + { + FileProvider = new InMemoryTemplateFileProvider("product.liquid", "{{ product }};") + }; + + _context = new TemplateContext(options); + _context.SetValue("products", Enumerable.Range(1, ItemCount).ToArray()); + + _renderTemplate = parser.Parse("{% render 'product', product: products[0] %}"); + _renderForTemplate = parser.Parse("{% render 'product' for products as product %}"); + } + + [Benchmark] + public async ValueTask Render() + { + _output.Reset(); + await _renderTemplate.RenderAsync(_output, NullEncoder.Default, _context); + return _output.Written; + } + + [Benchmark] + public async ValueTask RenderFor() + { + _output.Reset(); + await _renderForTemplate.RenderAsync(_output, NullEncoder.Default, _context); + return _output.Written; + } + + private sealed class CountingFluidOutput : IFluidOutput + { + private char[] _buffer = new char[1024]; + + public int Written { get; private set; } + + public int FlushCount { 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() + { + FlushCount++; + return default; + } + + public void Reset() + { + Written = 0; + FlushCount = 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 byte[] _content; + private readonly string _path; + + public InMemoryTemplateFileProvider(string path, string content) + { + _path = path; + _content = Encoding.UTF8.GetBytes(content); + } + + public ValueTask GetFileInfoAsync( + string subpath, + TemplateContext context, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!string.Equals(subpath, _path, StringComparison.Ordinal)) + { + return default; + } + + return new ValueTask( + new TemplateSourceInfo( + DateTimeOffset.UnixEpoch, + _ => new ValueTask(new MemoryStream(_content, writable: false)))); + } + } + } +} diff --git a/Fluid.Tests/RenderFlushTests.cs b/Fluid.Tests/RenderFlushTests.cs new file mode 100644 index 00000000..ac442fe7 --- /dev/null +++ b/Fluid.Tests/RenderFlushTests.cs @@ -0,0 +1,111 @@ +using Fluid.Tests.Mocks; +using System; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Xunit; + +namespace Fluid.Tests +{ + public class RenderFlushTests + { +#if COMPILED + private static readonly FluidParser _parser = new FluidParser().Compile(); +#else + private static readonly FluidParser _parser = new FluidParser(); +#endif + + [Fact] + public async Task PublicOutputRender_FlushesOnce() + { + var template = _parser.Parse("Hello"); + var output = new FlushTrackingOutput(); + + await template.RenderAsync(output, NullEncoder.Default, new TemplateContext()); + + Assert.Equal("Hello", output.ToString()); + Assert.Equal(1, output.FlushCount); + } + + [Fact] + public async Task RenderFor_FlushesOnlyAtOuterBoundary() + { + var fileProvider = new MockFileProvider() + .Add("product.liquid", "{{ product }};"); + var context = new TemplateContext(new TemplateOptions { FileProvider = fileProvider }); + context.SetValue("products", new[] { 1, 2, 3 }); + var template = _parser.Parse("{% render 'product' for products as product %}"); + var output = new FlushTrackingOutput(); + + await template.RenderAsync(output, NullEncoder.Default, context); + + Assert.Equal("1;2;3;", output.ToString()); + Assert.Equal(1, output.FlushCount); + } + + [Fact] + public async Task IncludeFor_FlushesOnlyAtOuterBoundary() + { + var fileProvider = new MockFileProvider() + .Add("product.liquid", "{{ product }};"); + var context = new TemplateContext(new TemplateOptions { FileProvider = fileProvider }); + context.SetValue("products", new[] { 1, 2, 3 }); + var template = _parser.Parse("{% include 'product' for products as product %}"); + var output = new FlushTrackingOutput(); + + await template.RenderAsync(output, NullEncoder.Default, context); + + Assert.Equal("1;2;3;", output.ToString()); + Assert.Equal(1, output.FlushCount); + } + + [Fact] + public async Task RenderFor_MaxOutputSizeIsCumulativeAcrossChildren() + { + var fileProvider = new MockFileProvider() + .Add("product.liquid", "{{ product }}"); + var context = new TemplateContext(new TemplateOptions { FileProvider = fileProvider }) + { + MaxOutputSize = 5 + }; + context.SetValue("products", new[] { 12, 34, 56 }); + var template = _parser.Parse("{% render 'product' for products as product %}"); + + await Assert.ThrowsAsync( + () => template.RenderAsync(new FlushTrackingOutput(), NullEncoder.Default, context).AsTask()); + } + + private sealed class FlushTrackingOutput : IFluidOutput + { + private char[] _buffer = new char[256]; + private int _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 void Write(string value) + { + value.CopyTo(0, _buffer, _index, value.Length); + _index += value.Length; + } + + public void Write(char[] buffer, int index, int count) + { + buffer.AsSpan(index, count).CopyTo(_buffer.AsSpan(_index)); + _index += count; + } + + public ValueTask FlushAsync() + { + FlushCount++; + return default; + } + + public override string ToString() => new string(_buffer, 0, _index); + } + } +} diff --git a/Fluid.Tests/SourceGeneration/SourceGenerationTests.cs b/Fluid.Tests/SourceGeneration/SourceGenerationTests.cs index e0cf30bb..4d24f649 100644 --- a/Fluid.Tests/SourceGeneration/SourceGenerationTests.cs +++ b/Fluid.Tests/SourceGeneration/SourceGenerationTests.cs @@ -101,7 +101,33 @@ public async Task GeneratedTemplate_FlushesOutput() await instance.RenderAsync(output, HtmlEncoder.Default, new TemplateContext()); - Assert.True(output.Flushed); + Assert.Equal(1, output.FlushCount); + } + + [Fact] + public async Task GeneratedNestedRender_FlushesOnlyAtOuterBoundary() + { + var provider = new MockFileProvider() + .Add("partial", "{{ item }}"); + + var parser = new FluidParser(); + var template = parser.Parse("{% render 'partial' for items as item %}"); + var source = template.Compile(new SourceGenerationOptions + { + Namespace = "Fluid.Tests.Generated", + ClassName = "T" + Guid.NewGuid().ToString("N"), + FileProvider = provider + }); + + var generated = CompileToAssembly(source.SourceCode); + var type = generated.GetType(source.FullTypeName, throwOnError: true); + var instance = (IFluidTemplate)Activator.CreateInstance(type, nonPublic: true); + var output = new FlushTrackingOutput(); + var context = new TemplateContext().SetValue("items", new[] { 1, 2, 3 }); + + await instance.RenderAsync(output, HtmlEncoder.Default, context); + + Assert.Equal(1, output.FlushCount); } [Fact] @@ -240,7 +266,7 @@ private sealed class FlushTrackingOutput : IFluidOutput private char[] _buffer = new char[256]; private int _index; - public bool Flushed { get; private set; } + public int FlushCount { get; private set; } public void Advance(int count) => _index += count; @@ -262,7 +288,7 @@ public void Write(char[] buffer, int index, int count) public ValueTask FlushAsync() { - Flushed = true; + FlushCount++; return default; } } diff --git a/Fluid/Ast/IncludeStatement.cs b/Fluid/Ast/IncludeStatement.cs index 992a8cf5..3ee25aee 100644 --- a/Fluid/Ast/IncludeStatement.cs +++ b/Fluid/Ast/IncludeStatement.cs @@ -70,7 +70,7 @@ public override async ValueTask WriteToAsync(IFluidOutput output, Te } } - return await RenderStatementsAsync(template, output, encoder, context); + return await FluidTemplateRenderer.RenderWithCompletionAsync(template, output, encoder, context); } else if (AssignStatements.Count > 0) { @@ -83,7 +83,7 @@ public override async ValueTask WriteToAsync(IFluidOutput output, Te context.LocalScope.SetOwnValue(stmt.Identifier, await stmt.Value.EvaluateAsync(context)); } - return await RenderStatementsAsync(template, output, encoder, context); + return await FluidTemplateRenderer.RenderWithCompletionAsync(template, output, encoder, context); } else if (For != null) { @@ -118,7 +118,7 @@ public override async ValueTask WriteToAsync(IFluidOutput output, Te forloop.First = i == 0; forloop.Last = i == length - 1; - var completion = await RenderStatementsAsync(template, output, encoder, context); + var completion = await FluidTemplateRenderer.RenderWithCompletionAsync(template, output, encoder, context); if (completion == Completion.Break) { @@ -140,7 +140,7 @@ public override async ValueTask WriteToAsync(IFluidOutput output, Te else { // no with, for or assignments, e.g. {% include 'products' %} - return await RenderStatementsAsync(template, output, encoder, context); + return await FluidTemplateRenderer.RenderWithCompletionAsync(template, output, encoder, context); } } finally @@ -158,37 +158,6 @@ public override async ValueTask WriteToAsync(IFluidOutput output, Te } } - /// - /// Renders template statements and returns the completion status. - /// This allows break/continue signals to propagate from included templates. - /// - private static async ValueTask RenderStatementsAsync(IFluidTemplate template, IFluidOutput output, TextEncoder encoder, TemplateContext context) - { - if (template is IStatementList statementList) - { - var statements = statementList.Statements; - var count = statements.Count; - for (var i = 0; i < count; i++) - { - var completion = await statements[i].WriteToAsync(output, encoder, context); - - if (completion != Completion.Normal) - { - return completion; - } - } - } - else - { - // Fallback for non-standard template implementations - await template.RenderAsync(output, encoder, context); - } - - context.CancellationToken.ThrowIfCancellationRequested(); - await output.FlushAsync(); - return Completion.Normal; - } - protected internal override Statement Accept(AstVisitor visitor) => visitor.VisitIncludeStatement(this); private sealed record CachedTemplate(IFluidTemplate Template, string Name); diff --git a/Fluid/Ast/RenderStatement.cs b/Fluid/Ast/RenderStatement.cs index 71a6fb11..b4e78d95 100644 --- a/Fluid/Ast/RenderStatement.cs +++ b/Fluid/Ast/RenderStatement.cs @@ -65,7 +65,7 @@ public override async ValueTask WriteToAsync(IFluidOutput output, Te await EvaluateAssignStatementsAsync(AssignStatements, context); } - await template.RenderAsync(output, encoder, context); + await FluidTemplateRenderer.RenderAsync(template, output, encoder, context); } else if (For != null) { @@ -109,7 +109,7 @@ public override async ValueTask WriteToAsync(IFluidOutput output, Te forloop.First = i == 0; forloop.Last = i == length - 1; - await template.RenderAsync(output, encoder, context); + await FluidTemplateRenderer.RenderAsync(template, output, encoder, context); // Restore the forloop property after every statement in case it replaced it, // for instance if it contains a nested for loop @@ -128,14 +128,14 @@ public override async ValueTask WriteToAsync(IFluidOutput output, Te context.LocalScope = new Scope(context.RootScope); previousScope.CopyTo(context.LocalScope); - await template.RenderAsync(output, encoder, context); + await FluidTemplateRenderer.RenderAsync(template, output, encoder, context); } else { context.LocalScope = new Scope(context.RootScope); previousScope.CopyTo(context.LocalScope); - await template.RenderAsync(output, encoder, context); + await FluidTemplateRenderer.RenderAsync(template, output, encoder, context); } } finally @@ -226,7 +226,7 @@ void EmitEvaluateAssignStatements() EmitEvaluateAssignStatements(); } - context.WriteLine($"await template.RenderAsync({context.WriterName}, {context.EncoderName}, {context.ContextName});"); + context.WriteLine($"await template.RenderInternalAsync({context.WriterName}, {context.EncoderName}, {context.ContextName});"); } else if (For != null) { @@ -279,7 +279,7 @@ void EmitEvaluateAssignStatements() context.WriteLine("forloop.First = i == 0;"); context.WriteLine("forloop.Last = i == length - 1;"); - context.WriteLine($"await template.RenderAsync({context.WriterName}, {context.EncoderName}, {context.ContextName});"); + context.WriteLine($"await template.RenderInternalAsync({context.WriterName}, {context.EncoderName}, {context.ContextName});"); context.WriteLine($"{context.ContextName}.SetValue(\"forloop\", forloop);"); } context.WriteLine("}"); @@ -299,13 +299,13 @@ void EmitEvaluateAssignStatements() context.WriteLine($"{context.ContextName}.LocalScope = new Scope(rootScope);"); context.WriteLine($"previousScope.CopyTo({context.ContextName}.LocalScope);"); - context.WriteLine($"await template.RenderAsync({context.WriterName}, {context.EncoderName}, {context.ContextName});"); + context.WriteLine($"await template.RenderInternalAsync({context.WriterName}, {context.EncoderName}, {context.ContextName});"); } else { context.WriteLine($"{context.ContextName}.LocalScope = new Scope(rootScope);"); context.WriteLine($"previousScope.CopyTo({context.ContextName}.LocalScope);"); - context.WriteLine($"await template.RenderAsync({context.WriterName}, {context.EncoderName}, {context.ContextName});"); + context.WriteLine($"await template.RenderInternalAsync({context.WriterName}, {context.EncoderName}, {context.ContextName});"); } } context.WriteLine("}"); diff --git a/Fluid/FluidOutputExtensions.cs b/Fluid/FluidOutputExtensions.cs index bae35241..e89cd361 100644 --- a/Fluid/FluidOutputExtensions.cs +++ b/Fluid/FluidOutputExtensions.cs @@ -56,7 +56,8 @@ public static async ValueTask RenderAsync(this IFluidTemplate template, TextWrit bufferSize, leaveOpen: true, cancellationToken: context.CancellationToken); - await template.RenderAsync(output, encoder, context); + var limitedOutput = LimitedFluidOutput.Create(output, context.MaxOutputSize); + await FluidTemplateRenderer.RenderAsync(template, limitedOutput, encoder, context); context.CancellationToken.ThrowIfCancellationRequested(); await output.FlushAsync(); } diff --git a/Fluid/FluidTemplateExtensions.String.cs b/Fluid/FluidTemplateExtensions.String.cs index a124d075..aa8c5a24 100644 --- a/Fluid/FluidTemplateExtensions.String.cs +++ b/Fluid/FluidTemplateExtensions.String.cs @@ -61,7 +61,8 @@ public static async ValueTask RenderAsync(this IFluidTemplate template, } using var output = new BufferFluidOutput(initialCapacity); - await template.RenderAsync(output, encoder, context); + var limitedOutput = LimitedFluidOutput.Create(output, context.MaxOutputSize); + await FluidTemplateRenderer.RenderAsync(template, limitedOutput, encoder, context); context.CancellationToken.ThrowIfCancellationRequested(); await output.FlushAsync(); return output.ToString(); diff --git a/Fluid/FluidTemplateExtensions.cs b/Fluid/FluidTemplateExtensions.cs index 60b501b7..34948633 100644 --- a/Fluid/FluidTemplateExtensions.cs +++ b/Fluid/FluidTemplateExtensions.cs @@ -47,6 +47,7 @@ public static async ValueTask RenderAsync(this IFluidTemplate template, TextWrit ArgumentNullException.ThrowIfNull(textWriter); ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(template); + ArgumentNullException.ThrowIfNull(encoder); context.CancellationToken.ThrowIfCancellationRequested(); @@ -70,7 +71,8 @@ public static async ValueTask RenderAsync(this IFluidTemplate template, TextWrit try { - await template.RenderAsync(output, encoder, context); + var limitedOutput = LimitedFluidOutput.Create(output, context.MaxOutputSize); + await FluidTemplateRenderer.RenderAsync(template, limitedOutput, encoder, context); context.CancellationToken.ThrowIfCancellationRequested(); await output.FlushAsync(); await textWriter.FlushAsync(); diff --git a/Fluid/FluidTemplateRenderer.cs b/Fluid/FluidTemplateRenderer.cs new file mode 100644 index 00000000..b5c5536c --- /dev/null +++ b/Fluid/FluidTemplateRenderer.cs @@ -0,0 +1,50 @@ +using Fluid.Ast; +using Fluid.Parser; +using System.Runtime.CompilerServices; +using System.Text.Encodings.Web; + +namespace Fluid +{ + internal static class FluidTemplateRenderer + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static ValueTask RenderAsync( + IFluidTemplate template, + IFluidOutput output, + TextEncoder encoder, + TemplateContext context) + { + if (template is FluidTemplate fluidTemplate) + { + return fluidTemplate.RenderInternalAsync(output, encoder, context); + } + + if (template is CompositeFluidTemplate compositeTemplate) + { + return compositeTemplate.RenderInternalAsync(output, encoder, context); + } + + return template.RenderAsync(output, encoder, context); + } + + public static ValueTask RenderWithCompletionAsync( + IFluidTemplate template, + IFluidOutput output, + TextEncoder encoder, + TemplateContext context) + { + if (template is IStatementList statementList) + { + return statementList.Statements.RenderStatementsAsync(output, encoder, context); + } + + return Awaited(template.RenderAsync(output, encoder, context)); + + static async ValueTask Awaited(ValueTask task) + { + await task; + return Completion.Normal; + } + } + } +} diff --git a/Fluid/Parser/CompositeFluidTemplate.cs b/Fluid/Parser/CompositeFluidTemplate.cs index a0f70cd7..faf3ccc6 100644 --- a/Fluid/Parser/CompositeFluidTemplate.cs +++ b/Fluid/Parser/CompositeFluidTemplate.cs @@ -1,5 +1,6 @@ using Fluid.Ast; using Fluid.Utils; +using System.Runtime.CompilerServices; using System.Text.Encodings.Web; namespace Fluid.Parser @@ -51,6 +52,45 @@ public ValueTask RenderAsync(IFluidOutput output, TextEncoder encoder, TemplateC return output.FlushAsync(); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal ValueTask RenderInternalAsync(IFluidOutput output, TextEncoder encoder, TemplateContext context) + { + var count = Statements.Count; + for (var i = 0; i < count; i++) + { + var task = Statements[i].WriteToAsync(output, encoder, context); + if (!task.IsCompletedSuccessfully) + { + return AwaitedInternal( + task, + output, + encoder, + context, + Statements, + startIndex: i + 1); + } + } + + return default; + } + + private static async ValueTask AwaitedInternal( + ValueTask task, + IFluidOutput output, + TextEncoder encoder, + TemplateContext context, + IReadOnlyList statements, + int startIndex) + { + await task; + for (var i = startIndex; i < statements.Count; i++) + { + await statements[i].WriteToAsync(output, encoder, context); + } + + context.CancellationToken.ThrowIfCancellationRequested(); + } + private static async ValueTask Awaited( ValueTask task, IFluidOutput output, diff --git a/Fluid/Parser/FluidTemplate.cs b/Fluid/Parser/FluidTemplate.cs index 10a60c6f..214aaf06 100644 --- a/Fluid/Parser/FluidTemplate.cs +++ b/Fluid/Parser/FluidTemplate.cs @@ -1,6 +1,7 @@ using System.Text.Encodings.Web; using Fluid.Ast; using Fluid.Utils; +using System.Runtime.CompilerServices; namespace Fluid.Parser { @@ -47,6 +48,45 @@ public ValueTask RenderAsync(IFluidOutput output, TextEncoder encoder, TemplateC return output.FlushAsync(); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal ValueTask RenderInternalAsync(IFluidOutput output, TextEncoder encoder, TemplateContext context) + { + var count = Statements.Count; + for (var i = 0; i < count; i++) + { + var task = Statements[i].WriteToAsync(output, encoder, context); + if (!task.IsCompletedSuccessfully) + { + return AwaitedInternal( + task, + output, + encoder, + context, + Statements, + startIndex: i + 1); + } + } + + return default; + } + + private static async ValueTask AwaitedInternal( + ValueTask task, + IFluidOutput output, + TextEncoder encoder, + TemplateContext context, + IReadOnlyList statements, + int startIndex) + { + await task; + for (var i = startIndex; i < statements.Count; i++) + { + await statements[i].WriteToAsync(output, encoder, context); + } + + context.CancellationToken.ThrowIfCancellationRequested(); + } + private static async ValueTask Awaited( ValueTask task, IFluidOutput output, diff --git a/Fluid/SourceGeneration/TemplateSourceGenerator.cs b/Fluid/SourceGeneration/TemplateSourceGenerator.cs index 6ab957e9..5210caa2 100644 --- a/Fluid/SourceGeneration/TemplateSourceGenerator.cs +++ b/Fluid/SourceGeneration/TemplateSourceGenerator.cs @@ -93,6 +93,16 @@ private static void WriteTemplateClass( ctx.WriteLine("if (context.Options.Trimming != TrimmingFlags.None) throw new NotSupportedException(\"Source-generated templates do not support TemplateOptions.Trimming.\");"); ctx.WriteLine("context.CancellationToken.ThrowIfCancellationRequested();"); ctx.WriteLine("writer = LimitedFluidOutput.Create(writer, context.MaxOutputSize);"); + ctx.WriteLine("await RenderInternalAsync(writer, encoder, context);"); + ctx.WriteLine("context.CancellationToken.ThrowIfCancellationRequested();"); + ctx.WriteLine("await writer.FlushAsync();"); + } + ctx.WriteLine("}"); + ctx.WriteLine(); + ctx.WriteLine("internal async ValueTask RenderInternalAsync(IFluidOutput writer, TextEncoder encoder, TemplateContext context)"); + ctx.WriteLine("{"); + using (ctx.Indent()) + { ctx.WriteLine(); foreach (var statement in statementList.Statements) @@ -117,7 +127,6 @@ private static void WriteTemplateClass( } ctx.WriteLine("context.CancellationToken.ThrowIfCancellationRequested();"); - ctx.WriteLine("await writer.FlushAsync();"); } ctx.WriteLine("}");