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
110 changes: 110 additions & 0 deletions Fluid.Tests/IncludeStatementTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -614,5 +614,115 @@ public void IncludeTag_Caches_HandleFileSystemCasing()
// Ignore any exceptions
}
}

[Fact]
public void RenderTag_With_And_NamedArguments()
{
var fileProvider = new MockFileProvider();
fileProvider.Add("icon.liquid", "Icon: {{ icon }}, Class: {{ class }}");

var options = new TemplateOptions() { FileProvider = fileProvider, MemberAccessStrategy = UnsafeMemberAccessStrategy.Instance };
var context = new TemplateContext(options);
_parser.TryParse("{% render 'icon' with 'rating-star', class: 'rating__star' %}", out var template);
var result = template.Render(context);

Assert.Equal("Icon: rating-star, Class: rating__star", result);
}

[Fact]
public void RenderTag_With_As_And_NamedArguments()
{
var fileProvider = new MockFileProvider();
fileProvider.Add("product.liquid", "Product: {{ p.title }}, Price: {{ price }}");

var options = new TemplateOptions() { FileProvider = fileProvider, MemberAccessStrategy = UnsafeMemberAccessStrategy.Instance };
var context = new TemplateContext(options);
context.SetValue("my_product", new { title = "Draft 151cm" });
_parser.TryParse("{% render 'product' with my_product as p, price: '$99' %}", out var template);
var result = template.Render(context);

Assert.Equal("Product: Draft 151cm, Price: $99", result);
}

[Fact]
public void RenderTag_With_MultipleNamedArguments()
{
var fileProvider = new MockFileProvider();
fileProvider.Add("button.liquid", "Text: {{ button }}, Size: {{ size }}, Color: {{ color }}");

var options = new TemplateOptions() { FileProvider = fileProvider, MemberAccessStrategy = UnsafeMemberAccessStrategy.Instance };
var context = new TemplateContext(options);
_parser.TryParse("{% render 'button' with 'Click Me', size: 'large', color: 'blue' %}", out var template);
var result = template.Render(context);

Assert.Equal("Text: Click Me, Size: large, Color: blue", result);
}

[Fact]
public void RenderTag_For_And_NamedArguments()
{
var fileProvider = new MockFileProvider();
fileProvider.Add("product.liquid", "Product: {{ product.title }}, Tag: {{ tag }} ");

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" } });

var parseResult = _parser.TryParse("{% render 'product' for products, tag: 'sale' %}", out var template, out var error);
Assert.True(parseResult, $"Parse failed: {error}");

// Check the parsed statement
var statements = (template as Fluid.Parser.FluidTemplate).Statements;
var renderStmt = statements.FirstOrDefault() as RenderStatement;
Assert.NotNull(renderStmt);
Assert.NotNull(renderStmt.For);
Assert.Single(renderStmt.AssignStatements);
Assert.Equal("tag", renderStmt.AssignStatements[0].Identifier);

// Check that the For expression evaluates correctly
Assert.IsType<MemberExpression>(renderStmt.For);
var forValue = renderStmt.For.EvaluateAsync(context).GetAwaiter().GetResult();
var items = forValue.Enumerate(context).ToList();
Assert.Equal(2, items.Count); // Should have 2 items

// Also check that For is really the "products" variable
var memberExpr = renderStmt.For as MemberExpression;
Assert.Single(memberExpr.Segments);
Assert.IsType<IdentifierSegment>(memberExpr.Segments[0]);
Assert.Equal("products", ((IdentifierSegment)memberExpr.Segments[0]).Identifier);

var result = template.Render(context);

Assert.Equal("Product: Draft 151cm, Tag: sale Product: Element 155cm, Tag: sale ", result);
}

[Fact]
public void RenderTag_For_As_And_NamedArguments()
{
var fileProvider = new MockFileProvider();
fileProvider.Add("item.liquid", "Item: {{ i.name }}, Status: {{ status }} ");

var options = new TemplateOptions() { FileProvider = fileProvider, MemberAccessStrategy = UnsafeMemberAccessStrategy.Instance };
var context = new TemplateContext(options);
context.SetValue("items", new[] { new { name = "First" }, new { name = "Second" } });
_parser.TryParse("{% render 'item' for items as i, status: 'active' %}", out var template);
var result = template.Render(context);

Assert.Equal("Item: First, Status: active Item: Second, Status: active ", result);
}

[Fact]
public void RenderTag_NamedArguments_DoNotLeakToParentScope()
{
var fileProvider = new MockFileProvider();
fileProvider.Add("snippet.liquid", "{{ class }}");

var options = new TemplateOptions() { FileProvider = fileProvider, MemberAccessStrategy = UnsafeMemberAccessStrategy.Instance };
var context = new TemplateContext(options);
_parser.TryParse("{% render 'snippet', class: 'test' %}{{ class }}", out var template);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
_parser.TryParse("{% render 'snippet', class: 'test' %}{{ class }}", out var template);
_parser.TryParse("{% render 'snippet', class: 'test' %}", out var template);

I suppose we don't want {{ class }} here as it's not what we're checking for and leads to doubling output

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this test is actually checking that this is not available in the calling template, check the name of the test. And also the result (no dupliation)

var result = template.Render(context);

Assert.Equal("test", result);
}
}
}
41 changes: 31 additions & 10 deletions Fluid/Ast/RenderStatement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,19 +82,17 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
previousScope.CopyTo(context.LocalScope);

context.SetValue(Alias ?? identifier, with);
await template.RenderAsync(writer, encoder, context);
}
else if (AssignStatements.Count > 0)
{
var length = AssignStatements.Count;
for (var i = 0; i < length; i++)

// Evaluate assign statements in the new scope if present
if (AssignStatements.Count > 0)
{
await AssignStatements[i].WriteToAsync(writer, encoder, context);
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)
Expand All @@ -108,6 +106,16 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
context.LocalScope = new Scope(context.RootScope);
previousScope.CopyTo(context.LocalScope);

// Evaluate assign statements in the new scope before the loop if present
if (AssignStatements.Count > 0)
{
var assignLength = AssignStatements.Count;
for (var j = 0; j < assignLength; j++)
{
await AssignStatements[j].WriteToAsync(writer, encoder, context);
}
}

var length = forloop.Length = list.Count;

context.SetValue("forloop", forloop);
Expand Down Expand Up @@ -140,6 +148,19 @@ public override async ValueTask<Completion> WriteToAsync(TextWriter writer, Text
context.LocalScope.Delete("forloop");
}
}
else if (AssignStatements.Count > 0)
{
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
{
context.LocalScope = new Scope(context.RootScope);
Expand Down
4 changes: 2 additions & 2 deletions Fluid/FluidParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -366,9 +366,9 @@ public FluidParser(FluidParserOptions parserOptions)
FromTag.Name = "FromTag";

var RenderTag = OneOf(
String.AndSkip(Terms.Text("with")).And(Primary).And(ZeroOrOne(Terms.Text("as").SkipAnd(Identifier))).And(ZeroOrOne(Comma.SkipAnd(Separated(Comma, Identifier.AndSkip(Colon).And(Primary).Then(static x => new AssignStatement(x.Item1, x.Item2)))))).Then(x => new RenderStatement(this, x.Item1.ToString(), with: x.Item2, alias: x.Item3, assignStatements: x.Item4 ?? [])),
String.AndSkip(Terms.Text("for")).And(Primary).And(ZeroOrOne(Terms.Text("as").SkipAnd(Identifier))).And(ZeroOrOne(Comma.SkipAnd(Separated(Comma, Identifier.AndSkip(Colon).And(Primary).Then(static x => new AssignStatement(x.Item1, x.Item2)))))).Then(x => new RenderStatement(this, x.Item1.ToString(), @for: x.Item2, alias: x.Item3, assignStatements: x.Item4 ?? [])),
String.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.ToString(), null, null, null, x.Item2)),
String.AndSkip(Terms.Text("with")).And(Primary).And(ZeroOrOne(Terms.Text("as").SkipAnd(Identifier))).Then(x => new RenderStatement(this, x.Item1.ToString(), with: x.Item2, alias: x.Item3)),
String.AndSkip(Terms.Text("for")).And(Primary).And(ZeroOrOne(Terms.Text("as").SkipAnd(Identifier))).Then(x => new RenderStatement(this, x.Item1.ToString(), @for: x.Item2, alias: x.Item3)),
String.Then(x => new RenderStatement(this, x.ToString()))
).ElseError(ErrorMessages.ExpectedStringRender).AndSkip(TagEnd)
.Then<Statement>(x => x)
Expand Down