Skip to content

Commit

Permalink
Implement functions (sebastienros#434)
Browse files Browse the repository at this point in the history
  • Loading branch information
sebastienros authored Jan 5, 2022
1 parent 91aa570 commit 7bd01cc
Show file tree
Hide file tree
Showing 14 changed files with 645 additions and 14 deletions.
120 changes: 120 additions & 0 deletions Fluid.Tests/FunctionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using Fluid.Values;
using System;
using System.Threading.Tasks;
using Xunit;

namespace Fluid.Tests
{
public class FunctionTests
{
#if COMPILED
private static FluidParser _parser = new FluidParser(new FluidParserOptions { AllowFunctions = true }).Compile();
#else
private static FluidParser _parser = new FluidParser(new FluidParserOptions { AllowFunctions = true });
#endif

[Fact]
public async Task FunctionCallsShouldDefaultToNil()
{
var source = @"
{{- a() -}}
";

_parser.TryParse(source, out var template, out var error);
var context = new TemplateContext();
var result = await template.RenderAsync(context);
Assert.Equal("", result);
}

[Fact]
public async Task FunctionCallsAreInvoked()
{
var source = @"{{ a() | append: b()}}";

_parser.TryParse(source, out var template, out var error);
Assert.True(template != null, error);
var context = new TemplateContext();
context.SetValue("a", new FunctionValue((args, c) => new StringValue("hello")));
context.SetValue("b", new FunctionValue((args, c) => new ValueTask<FluidValue>(new StringValue("world"))));

// Use a loop to exercise the arguments cache
for (var i = 0; i < 10; i++)
{
var result = await template.RenderAsync(context);
Assert.Equal("helloworld", result);
}
}

[Fact]
public async Task FunctionCallsUseArguments()
{
var source = @"{{ a('x', 2) }}";

_parser.TryParse(source, out var template, out var error);
Assert.True(template != null, error);
var context = new TemplateContext();
context.SetValue("a", new FunctionValue((args, c) => new StringValue(new String(args.At(0).ToStringValue()[0], (int) args.At(1).ToNumberValue()))));

// Use a loop to exercise the arguments cache
for (var i = 0; i < 10; i++)
{
var result = await template.RenderAsync(context);
Assert.Equal("xx", result);
}
}

[Fact]
public async Task FunctionCallsUseNamedArguments()
{
var source = @"{{ a(c = 'x', r = 2) }}";

_parser.TryParse(source, out var template, out var error);
Assert.True(template != null, error);
var context = new TemplateContext();
context.SetValue("a", new FunctionValue((args, c) => new StringValue(new String(args["c"].ToStringValue()[0], (int)args["r"].ToNumberValue()))));

// Use a loop to exercise the arguments cache
for (var i = 0; i < 10; i++)
{
var result = await template.RenderAsync(context);
Assert.Equal("xx", result);
}
}

[Fact]
public async Task FunctionCallsRecursively()
{
var source = @"{{ a(b(), r = 2) }}";

_parser.TryParse(source, out var template, out var error);
Assert.True(template != null, error);
var context = new TemplateContext();
context.SetValue("a", new FunctionValue((args, c) => new StringValue(new String(args.At(0).ToStringValue()[0], (int)args["r"].ToNumberValue()))));
context.SetValue("b", new FunctionValue((args, c) => new StringValue("hello")));

// Use a loop to exercise the arguments cache
for (var i = 0; i < 10; i++)
{
var result = await template.RenderAsync(context);
Assert.Equal("hh", result);
}
}

[Fact]
public async Task ShouldDefineMacro()
{
var source = @"
{%- macro hello(first, last='Smith') -%}
Hello {{first | capitalize }} {{last}}
{%- endmacro -%}
{{- hello('mike') }} {{ hello(last= 'ros', first='sebastien') -}}
";

_parser.TryParse(source, out var template, out var error);
var context = new TemplateContext();
var result = await template.RenderAsync(context);
Assert.Equal("Hello Mike Smith Hello Sebastien ros", result);
}
}
}
42 changes: 42 additions & 0 deletions Fluid.Tests/ParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -987,5 +987,47 @@ public void ShouldParseLiquidTagWithBlocks()
var rendered = template.Render();
Assert.Contains("WELCOME TO THE LIQUID TAG", rendered);
}

[Fact]
public void ShouldParseFunctionCall()
{

var options = new FluidParserOptions { AllowFunctions = true };

#if COMPILED
var _parser = new FluidParser(options).Compile();
#else
var _parser = new FluidParser(options);
#endif

_parser.TryParse("{{ a() }}", out var template, out var errors);
var statements = ((FluidTemplate)template).Statements;

Assert.Single(statements);

var outputStatement = statements[0] as OutputStatement;
Assert.NotNull(outputStatement);

var memberExpression = outputStatement.Expression as MemberExpression;
Assert.Equal(2, memberExpression.Segments.Count);
Assert.IsType<IdentifierSegment>(memberExpression.Segments[0]);
Assert.IsType<FunctionCallSegment>(memberExpression.Segments[1]);
}

[Fact]
public void ShouldNotParseFunctionCall()
{

var options = new FluidParserOptions { AllowFunctions = false };

#if COMPILED
var parser = new FluidParser(options).Compile();
#else
var parser = new FluidParser(options);
#endif

Assert.False(parser.TryParse("{{ a() }}", out var template, out var errors));
Assert.Contains(ErrorMessages.FunctionsNotAllowed, errors);
}
}
}
4 changes: 2 additions & 2 deletions Fluid/Ast/FilterExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ public FilterExpression(Expression input, string name, List<FilterArgument> para
public string Name { get; }
public List<FilterArgument> Parameters { get; }

private bool _canBeCached = true;
private FilterArguments _cachedArguments;
private volatile bool _canBeCached = true;
private volatile FilterArguments _cachedArguments;

public override async ValueTask<FluidValue> EvaluateAsync(TemplateContext context)
{
Expand Down
18 changes: 18 additions & 0 deletions Fluid/Ast/FunctionCallArgument.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Fluid.Ast
{
public readonly struct FunctionCallArgument
{
public FunctionCallArgument(string name, Expression expression)
{
Name = name;
Expression = expression;
}

/// <summary>
/// Gets the name of the argument, or <c>null</c> if not defined.
/// </summary>
public string Name { get; }

public Expression Expression { get; }
}
}
60 changes: 60 additions & 0 deletions Fluid/Ast/FunctionCallSegment.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Fluid.Values;

namespace Fluid.Ast
{
public class FunctionCallSegment : MemberSegment
{
private static readonly FunctionArguments NonCacheableArguments = new();
private volatile FunctionArguments _cachedArguments = null;

public FunctionCallSegment(IReadOnlyList<FunctionCallArgument> arguments)
{
Arguments = arguments;
}

public IReadOnlyList<FunctionCallArgument> Arguments { get; }

public override async ValueTask<FluidValue> ResolveAsync(FluidValue value, TemplateContext context)
{
var arguments = _cachedArguments;

// Do we need to evaluate arguments?

if (arguments == null || arguments == NonCacheableArguments)
{
if (Arguments.Count == 0)
{
arguments = FunctionArguments.Empty;
_cachedArguments = arguments;
}
else
{
var newArguments = new FunctionArguments();

foreach (var argument in Arguments)
{
newArguments.Add(argument.Name, await argument.Expression.EvaluateAsync(context));
}

// The arguments can be cached if all the parameters are LiteralExpression

if (arguments == null && Arguments.All(x => x.Expression is LiteralExpression))
{
_cachedArguments = newArguments;
}
else
{
_cachedArguments = NonCacheableArguments;
}

arguments = newArguments;
}
}

return await value.InvokeAsync(arguments, context);
}
}
}
96 changes: 96 additions & 0 deletions Fluid/Ast/MacroStatement.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using Fluid.Utils;
using Fluid.Values;
using System.Collections.Generic;
using System.IO;
using System.Text.Encodings.Web;
using System.Threading.Tasks;

namespace Fluid.Ast
{
public class MacroStatement : TagStatement
{
public MacroStatement(string identifier, IReadOnlyList<FunctionCallArgument> arguments, List<Statement> statements): base(statements)
{
Identifier = identifier;
Arguments = arguments;
}

public string Identifier { get; }
public IReadOnlyList<FunctionCallArgument> Arguments { get; }

public override async ValueTask<Completion> WriteToAsync(TextWriter writer, TextEncoder encoder, TemplateContext context)
{
// Evaluate all default values only once
var defaultValues = new Dictionary<string, FluidValue>();

for (var i = 0; i < Arguments.Count; i++)
{
var argument = Arguments[i];
defaultValues[argument.Name] = argument.Expression == null ? NilValue.Instance : await argument.Expression.EvaluateAsync(context);
}

var f = new FunctionValue(async (args, c) =>
{
using var sb = StringBuilderPool.GetInstance();
using var sw = new StringWriter(sb.Builder);

context.EnterChildScope();

try
{
// Initialize the local context with the default values
foreach (var a in defaultValues)
{
context.SetValue(a.Key, a.Value);
}

var namedArguments = false;

// Apply all arguments from the invocation.
// As soon as a named argument is used, all subsequent ones need a name too.

for (var i = 0; i < args.Count; i++)
{
var positionalName = Arguments[i].Name;

namedArguments |= args.HasNamed(positionalName);

if (!namedArguments)
{
context.SetValue(positionalName, args.At(i));
}
else
{
context.SetValue(positionalName, args[positionalName]);
}
}

for (var i = 0; i < _statements.Count; i++)
{
var completion = await _statements[i].WriteToAsync(sw, encoder, context);

if (completion != Completion.Normal)
{
// Stop processing the block statements
// We return the completion to flow it to the outer loop
break;
}
}

var result = sw.ToString();

// Don't encode the result
return new StringValue(result, false);
}
finally
{
context.ReleaseScope();
}
});

context.SetValue(Identifier, f);

return Completion.Normal;
}
}
}
2 changes: 1 addition & 1 deletion Fluid/Fluid.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Parlot" Version="0.0.18" />
<PackageReference Include="Parlot" Version="0.0.19" />
<PackageReference Include="Microsoft.Extensions.FileProviders.Abstractions" Version="1.1.1" />
<PackageReference Include="TimeZoneConverter" Version="3.5.0" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="all" />
Expand Down
Loading

0 comments on commit 7bd01cc

Please sign in to comment.