Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion Fluid.Tests/IncludeStatementTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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()
{
Expand All @@ -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);
Expand Down
9 changes: 5 additions & 4 deletions Fluid.Tests/TemplateContextTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
27 changes: 7 additions & 20 deletions Fluid/Ast/IncludeStatement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,16 @@ 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<AssignStatement> assignStatements = null, bool isolatedScope = false)
public IncludeStatement(FluidParser parser, Expression path, Expression with = null, Expression @for = null, string alias = null, IList<AssignStatement> assignStatements = null)
{
_parser = parser;
IsolatedScope = isolatedScope;
Path = path;
With = with;
For = @for;
Alias = alias;
AssignStatements = assignStatements;
}

public bool IsolatedScope { get; }
public Expression Path { get; }
public IList<AssignStatement> AssignStatements { get; }
public Expression With { get; }
Expand Down Expand Up @@ -73,29 +71,22 @@ public override async ValueTask<Completion> 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);
Expand Down Expand Up @@ -149,10 +140,6 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
finally
{
context.ReleaseScope();

// use this in render tag
// context.LocalScope = new Scope(context.Options.Scope);

}

return Completion.Normal;
Expand Down
163 changes: 163 additions & 0 deletions Fluid/Ast/RenderStatement.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<AssignStatement> assignStatements = null)
{
_parser = parser;
Path = path;
With = with;
For = @for;
Alias = alias;
AssignStatements = assignStatements;
}

public Expression Path { get; }
public IList<AssignStatement> AssignStatements { get; }
public Expression With { get; }
public Expression For { get; }
public string Alias { get; }

public override async ValueTask<Completion> 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;
}
}
}
16 changes: 8 additions & 8 deletions Fluid/FluidParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Statement>(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<Statement>(x => x)
.ElseError("Invalid 'render' tag")
Expand Down
22 changes: 17 additions & 5 deletions Fluid/FluidTemplateExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,29 @@ static async ValueTask<string> 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<string>(result);
}

Expand Down
Loading