diff --git a/Fluid.Tests/IncludeStatementTests.cs b/Fluid.Tests/IncludeStatementTests.cs index 2246e599..7fc9d22c 100644 --- a/Fluid.Tests/IncludeStatementTests.cs +++ b/Fluid.Tests/IncludeStatementTests.cs @@ -206,6 +206,21 @@ public void IncludeTag_With_Alias() Assert.Equal("Product: Draft 151cm ", result); } + [Fact] + public void RenderTag_With_Alias() + { + var fileProvider = new MockFileProvider(); + fileProvider.Add("product_alias.liquid", "Product: {{ product.title }} "); + + var options = new TemplateOptions() { FileProvider = fileProvider, MemberAccessStrategy = UnsafeMemberAccessStrategy.Instance }; + var context = new TemplateContext(options); + context.SetValue("products", new[] { new { title = "Draft 151cm" }, new { title = "Element 155cm" } }); + _parser.TryParse("{% render 'product_alias' with products[0] as product %}", out var template); + var result = template.Render(context); + + Assert.Equal("Product: Draft 151cm ", result); + } + [Fact] public void IncludeTag_With_Default_Name() { @@ -221,6 +236,21 @@ public void IncludeTag_With_Default_Name() Assert.Equal("Product: Draft 151cm ", result); } + [Fact] + public void RenderTag_With_Default_Name() + { + var fileProvider = new MockFileProvider(); + fileProvider.Add("product.liquid", "Product: {{ product.title }} "); + + var options = new TemplateOptions() { FileProvider = fileProvider, MemberAccessStrategy = UnsafeMemberAccessStrategy.Instance }; + var context = new TemplateContext(options); + context.SetValue("product", new { title = "Draft 151cm" }); + _parser.TryParse("{% render 'product' %}", out var template); + var result = template.Render(context); + + Assert.Equal("Product: Draft 151cm ", result); + } + [Fact] public void IncludeTag_For_Loop() { @@ -231,7 +261,23 @@ public void IncludeTag_For_Loop() var context = new TemplateContext(options); context.SetValue("products", new[] { new { title = "Draft 151cm" }, new { title = "Element 155cm" } }); _parser.TryParse("{% include 'product' for products %}", out var template); - + + var result = template.Render(context); + + Assert.Equal("Product: Draft 151cm first index:1 Product: Element 155cm last index:2 ", result); + } + + [Fact] + public void RenderTag_For_Loop() + { + var fileProvider = new MockFileProvider(); + fileProvider.Add("product.liquid", "Product: {{ product.title }} {% if forloop.first %}first{% endif %} {% if forloop.last %}last{% endif %} index:{{ forloop.index }} "); + + var options = new TemplateOptions() { FileProvider = fileProvider, MemberAccessStrategy = UnsafeMemberAccessStrategy.Instance }; + var context = new TemplateContext(options); + context.SetValue("products", new[] { new { title = "Draft 151cm" }, new { title = "Element 155cm" } }); + _parser.TryParse("{% render 'product' for products %}", out var template); + var result = template.Render(context); Assert.Equal("Product: Draft 151cm first index:1 Product: Element 155cm last index:2 ", result); diff --git a/Fluid.Tests/TemplateContextTests.cs b/Fluid.Tests/TemplateContextTests.cs index b58bae4a..b4e56591 100644 --- a/Fluid.Tests/TemplateContextTests.cs +++ b/Fluid.Tests/TemplateContextTests.cs @@ -113,17 +113,18 @@ public void SegmentAccessorCacheShouldVaryByType() } [Fact] - public void CaptureShouldUpdateContext() + public void TemplateContextShouldBeImmutable() { - _parser.TryParse("{% capture greetings %}Hello {{text1}}{%endcapture%}", out var template, out var error); + _parser.TryParse("{% capture greetings %}Hello {{text1}}{%endcapture%} {% assign foo = 'bar' %}", out var template, out var error); var context = new TemplateContext(); context.SetValue("text1", "World"); template.Render(context); - Assert.Equal("Hello World", context.GetValue("greetings").ToStringValue()); - Assert.Contains("greetings", context.ValueNames); + Assert.Equal("World", context.GetValue("text1").ToStringValue()); + Assert.DoesNotContain("greetings", context.ValueNames); + Assert.DoesNotContain("foo", context.ValueNames); } [Fact] diff --git a/Fluid/Ast/IncludeStatement.cs b/Fluid/Ast/IncludeStatement.cs index a4b3edab..20227326 100644 --- a/Fluid/Ast/IncludeStatement.cs +++ b/Fluid/Ast/IncludeStatement.cs @@ -15,10 +15,9 @@ public class IncludeStatement : Statement private IFluidTemplate _template; private string _identifier; - public IncludeStatement(FluidParser parser, Expression path, Expression with = null, Expression @for = null, string alias = null, IList assignStatements = null, bool isolatedScope = false) + public IncludeStatement(FluidParser parser, Expression path, Expression with = null, Expression @for = null, string alias = null, IList assignStatements = null) { _parser = parser; - IsolatedScope = isolatedScope; Path = path; With = with; For = @for; @@ -26,7 +25,6 @@ public IncludeStatement(FluidParser parser, Expression path, Expression with = n AssignStatements = assignStatements; } - public bool IsolatedScope { get; } public Expression Path { get; } public IList AssignStatements { get; } public Expression With { get; } @@ -73,29 +71,22 @@ public override async ValueTask WriteToAsync(TextWriter writer, Text try { - if (IsolatedScope) - { - // render tag - context.EnterIsolatedScope(); - } - else - { - //include tag - context.EnterChildScope(); - } - + context.EnterChildScope(); + if (With != null) { var with = await With.EvaluateAsync(context); + context.SetValue(Alias ?? _identifier, with); await _template.RenderAsync(writer, encoder, context); } else if (AssignStatements != null) { - foreach (var assignStatement in AssignStatements) + var length = AssignStatements.Count; + for (var i = 0; i < length; i++) { - await assignStatement.WriteToAsync(writer, encoder, context); + await AssignStatements[i].WriteToAsync(writer, encoder, context); } await _template.RenderAsync(writer, encoder, context); @@ -149,10 +140,6 @@ public override async ValueTask WriteToAsync(TextWriter writer, Text finally { context.ReleaseScope(); - - // use this in render tag - // context.LocalScope = new Scope(context.Options.Scope); - } return Completion.Normal; diff --git a/Fluid/Ast/RenderStatement.cs b/Fluid/Ast/RenderStatement.cs new file mode 100644 index 00000000..84196814 --- /dev/null +++ b/Fluid/Ast/RenderStatement.cs @@ -0,0 +1,163 @@ +using Fluid.Values; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Encodings.Web; +using System.Threading.Tasks; + +namespace Fluid.Ast +{ + /// + /// The render tag can only access immutable environments, which means the scope of the context that was passed to the main template, the options' scope, and the model. + /// + public class RenderStatement : Statement + { + public const string ViewExtension = ".liquid"; + private readonly FluidParser _parser; + private IFluidTemplate _template; + private string _identifier; + + public RenderStatement(FluidParser parser, Expression path, Expression with = null, Expression @for = null, string alias = null, IList assignStatements = null) + { + _parser = parser; + Path = path; + With = with; + For = @for; + Alias = alias; + AssignStatements = assignStatements; + } + + public Expression Path { get; } + public IList AssignStatements { get; } + public Expression With { get; } + public Expression For { get; } + public string Alias { get; } + + public override async ValueTask WriteToAsync(TextWriter writer, TextEncoder encoder, TemplateContext context) + { + context.IncrementSteps(); + + var relativePath = (await Path.EvaluateAsync(context)).ToStringValue(); + + if (!relativePath.EndsWith(ViewExtension, StringComparison.OrdinalIgnoreCase)) + { + relativePath += ViewExtension; + } + + if (_template == null || !string.Equals(_identifier, System.IO.Path.GetFileNameWithoutExtension(relativePath), StringComparison.OrdinalIgnoreCase)) + { + var fileProvider = context.Options.FileProvider; + + var fileInfo = fileProvider.GetFileInfo(relativePath); + + if (fileInfo == null || !fileInfo.Exists) + { + throw new FileNotFoundException(relativePath); + } + + var content = ""; + + using (var stream = fileInfo.CreateReadStream()) + using (var streamReader = new StreamReader(stream)) + { + content = await streamReader.ReadToEndAsync(); + } + + if (!_parser.TryParse(content, out _template, out var errors)) + { + throw new ParseException(errors); + } + + _identifier = System.IO.Path.GetFileNameWithoutExtension(relativePath); + } + + context.EnterChildScope(); + var previousScope = context.LocalScope; + + try + { + if (With != null) + { + var with = await With.EvaluateAsync(context); + + context.LocalScope = new Scope(context.RootScope); + previousScope.CopyTo(context.LocalScope); + + context.SetValue(Alias ?? _identifier, with); + await _template.RenderAsync(writer, encoder, context); + } + else if (AssignStatements != null) + { + var length = AssignStatements.Count; + for (var i = 0; i < length; i++) + { + await AssignStatements[i].WriteToAsync(writer, encoder, context); + } + + context.LocalScope = new Scope(context.RootScope); + previousScope.CopyTo(context.LocalScope); + + await _template.RenderAsync(writer, encoder, context); + } + else if (For != null) + { + try + { + var forloop = new ForLoopValue(); + + var list = (await For.EvaluateAsync(context)).Enumerate(context).ToList(); + + context.LocalScope = new Scope(context.RootScope); + previousScope.CopyTo(context.LocalScope); + + var length = forloop.Length = list.Count; + + context.SetValue("forloop", forloop); + + for (var i = 0; i < length; i++) + { + context.IncrementSteps(); + + var item = list[i]; + + context.SetValue(Alias ?? _identifier, item); + + // Set helper variables + forloop.Index = i + 1; + forloop.Index0 = i; + forloop.RIndex = length - i - 1; + forloop.RIndex0 = length - i; + forloop.First = i == 0; + forloop.Last = i == length - 1; + + await _template.RenderAsync(writer, encoder, context); + + // Restore the forloop property after every statement in case it replaced it, + // for instance if it contains a nested for loop + context.SetValue("forloop", forloop); + } + } + finally + { + context.LocalScope.Delete("forloop"); + } + } + else + { + context.LocalScope = new Scope(context.RootScope); + previousScope.CopyTo(context.LocalScope); + + await _template.RenderAsync(writer, encoder, context); + } + } + finally + { + context.LocalScope = previousScope; + context.ReleaseScope(); + } + + return Completion.Normal; + } + } +} diff --git a/Fluid/FluidParser.cs b/Fluid/FluidParser.cs index 4f3e0ac5..2d943292 100644 --- a/Fluid/FluidParser.cs +++ b/Fluid/FluidParser.cs @@ -255,19 +255,19 @@ public FluidParser() .ElseError("Invalid 'decrement' tag") ; var IncludeTag = OneOf( - Primary.AndSkip(Comma).And(Separated(Comma, Identifier.AndSkip(Colon).And(Primary).Then(static x => new AssignStatement(x.Item1, x.Item2)))).Then(x => new IncludeStatement(this, x.Item1, null, null, null, x.Item2, isolatedScope: false)), - Primary.AndSkip(Terms.Text("with")).And(Primary).And(ZeroOrOne(Terms.Text("as").SkipAnd(Identifier))).Then(x => new IncludeStatement(this, x.Item1, with: x.Item2, alias: x.Item3, isolatedScope: false)), - Primary.AndSkip(Terms.Text("for")).And(Primary).And(ZeroOrOne(Terms.Text("as").SkipAnd(Identifier))).Then(x => new IncludeStatement(this, x.Item1, @for: x.Item2, alias: x.Item3, isolatedScope: false)), - Primary.Then(x => new IncludeStatement(this, x, isolatedScope: false)) + Primary.AndSkip(Comma).And(Separated(Comma, Identifier.AndSkip(Colon).And(Primary).Then(static x => new AssignStatement(x.Item1, x.Item2)))).Then(x => new IncludeStatement(this, x.Item1, null, null, null, x.Item2)), + Primary.AndSkip(Terms.Text("with")).And(Primary).And(ZeroOrOne(Terms.Text("as").SkipAnd(Identifier))).Then(x => new IncludeStatement(this, x.Item1, with: x.Item2, alias: x.Item3)), + Primary.AndSkip(Terms.Text("for")).And(Primary).And(ZeroOrOne(Terms.Text("as").SkipAnd(Identifier))).Then(x => new IncludeStatement(this, x.Item1, @for: x.Item2, alias: x.Item3)), + Primary.Then(x => new IncludeStatement(this, x)) ).AndSkip(TagEnd) .Then(x => x) .ElseError("Invalid 'include' tag") ; var RenderTag = OneOf( - Primary.AndSkip(Comma).And(Separated(Comma, Identifier.AndSkip(Colon).And(Primary).Then(static x => new AssignStatement(x.Item1, x.Item2)))).Then(x => new IncludeStatement(this, x.Item1, null, null, null, x.Item2, isolatedScope: true)), - Primary.AndSkip(Terms.Text("with")).And(Primary).And(ZeroOrOne(Terms.Text("as").SkipAnd(Identifier))).Then(x => new IncludeStatement(this, x.Item1, with: x.Item2, alias: x.Item3, isolatedScope: true)), - Primary.AndSkip(Terms.Text("for")).And(Primary).And(ZeroOrOne(Terms.Text("as").SkipAnd(Identifier))).Then(x => new IncludeStatement(this, x.Item1, @for: x.Item2, alias: x.Item3, isolatedScope: true)), - Primary.Then(x => new IncludeStatement(this, x, isolatedScope: true)) + Primary.AndSkip(Comma).And(Separated(Comma, Identifier.AndSkip(Colon).And(Primary).Then(static x => new AssignStatement(x.Item1, x.Item2)))).Then(x => new RenderStatement(this, x.Item1, null, null, null, x.Item2)), + Primary.AndSkip(Terms.Text("with")).And(Primary).And(ZeroOrOne(Terms.Text("as").SkipAnd(Identifier))).Then(x => new RenderStatement(this, x.Item1, with: x.Item2, alias: x.Item3)), + Primary.AndSkip(Terms.Text("for")).And(Primary).And(ZeroOrOne(Terms.Text("as").SkipAnd(Identifier))).Then(x => new RenderStatement(this, x.Item1, @for: x.Item2, alias: x.Item3)), + Primary.Then(x => new RenderStatement(this, x)) ).AndSkip(TagEnd) .Then(x => x) .ElseError("Invalid 'render' tag") diff --git a/Fluid/FluidTemplateExtensions.cs b/Fluid/FluidTemplateExtensions.cs index 221fe069..00d152a7 100644 --- a/Fluid/FluidTemplateExtensions.cs +++ b/Fluid/FluidTemplateExtensions.cs @@ -59,17 +59,29 @@ static async ValueTask Awaited( var sb = StringBuilderPool.GetInstance(); var writer = new StringWriter(sb.Builder); - var task = template.RenderAsync(writer, encoder, context); - if (!task.IsCompletedSuccessfully) + + try { - return Awaited(task, writer, sb); - } + // A template is evaluated in a child scope such that the provided TemplateContext is immutable + context.EnterChildScope(); - writer.Flush(); + var task = template.RenderAsync(writer, encoder, context); + if (!task.IsCompletedSuccessfully) + { + return Awaited(task, writer, sb); + } + + writer.Flush(); + } + finally + { + context.ReleaseScope(); + } var result = sb.ToString(); sb.Dispose(); writer.Dispose(); + return new ValueTask(result); } diff --git a/Fluid/Scope.cs b/Fluid/Scope.cs index 05fb3466..12ef14ab 100644 --- a/Fluid/Scope.cs +++ b/Fluid/Scope.cs @@ -75,5 +75,16 @@ public FluidValue GetIndex(FluidValue index) { return GetValue(index.ToString()); } + + public void CopyTo(Scope scope) + { + if (_properties != null) + { + foreach (var property in _properties) + { + scope.SetValue(property.Key, property.Value); + } + } + } } } diff --git a/Fluid/TemplateContext.cs b/Fluid/TemplateContext.cs index ec586576..757e5c8b 100644 --- a/Fluid/TemplateContext.cs +++ b/Fluid/TemplateContext.cs @@ -50,6 +50,7 @@ public TemplateContext(TemplateOptions options) { Options = options; LocalScope = new Scope(options.Scope); + RootScope = LocalScope; CultureInfo = options.CultureInfo; TimeZone = options.TimeZone; Captured = options.Captured; @@ -111,8 +112,16 @@ public void IncrementSteps() } } + /// + /// Gets or sets the current scope. + /// internal Scope LocalScope { get; set; } + /// + /// Gets or sets the root scope. + /// + internal Scope RootScope { get; set; } + private Dictionary _ambientValues; /// @@ -152,21 +161,6 @@ public void EnterChildScope() LocalScope = LocalScope.EnterChildScope(); } - /// - /// Creates a new isolated scope. After than any value added to this content object will be released once - /// is called. The global scope is linked such that its values are still available. - /// - public void EnterIsolatedScope() - { - if (Options.MaxRecursion > 0 && _recursion++ > Options.MaxRecursion) - { - ExceptionHelper.ThrowMaximumRecursionException(); - return; - } - - LocalScope = LocalScope.EnterChildScope(Options.Scope); - } - /// /// Exits the current scope that has been created by /// @@ -181,7 +175,7 @@ public void ReleaseScope() if (LocalScope == null) { - ExceptionHelper.ThrowInvalidOperationException("Release scoped invoked without corresponding EnterChildScope"); + ExceptionHelper.ThrowInvalidOperationException("ReleaseScope invoked without corresponding EnterChildScope"); return; } }