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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,24 @@ The animal, Chewy, is not a dog.
*/
```

Block helpers can be chained with `{{else}}` clauses that themselves invoke another helper, similar to `{{else if}}`. This lets you avoid nesting an extra block and its extra closing tag - the chained helper shares the outer block's closing tag:

```c#
var template = "{{#StringEqualityBlockHelper value 'dog'}}is a dog{{else StringEqualityBlockHelper value 'cat'}}is a cat{{else}}is something else{{/StringEqualityBlockHelper}}";
```

This works the same way `{{#if}}`/`{{else if}}` chaining does, and can be chained as many times as needed:

```handlebars
{{#if isDog}}
is a dog
{{else if isCat}}
is a cat
{{else}}
is something else
{{/if}}
```

### Registering Decorators

```c#
Expand Down
97 changes: 97 additions & 0 deletions source/Handlebars.Test/IssueTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1137,5 +1137,102 @@ public void SingleBackslashNotBeforeMustachePassesThroughVerbatim()
var result = template(new { Name = "World" });
Assert.Equal(@"\*.World", result);
}

// Issue https://github.com/Handlebars-Net/Handlebars.Net/issues/263
[Fact]
public void ElseIfChainsForIfHelper()
{
var handlebars = Handlebars.Create();
var template = handlebars.Compile("{{#if isDog}}is a dog{{else if isCat}}is a cat{{else}}is something else{{/if}}");

Assert.Equal("is a dog", template(new { isDog = true, isCat = false }));
Assert.Equal("is a cat", template(new { isDog = false, isCat = true }));
Assert.Equal("is something else", template(new { isDog = false, isCat = false }));
}

// Issue https://github.com/Handlebars-Net/Handlebars.Net/issues/263
[Fact]
public void ElseWithCustomBlockHelperInvocationChainsToNestedHelper()
{
var handlebars = Handlebars.Create();
RegisterStringEqualityBlockHelper(handlebars);

var template = handlebars.Compile(
"{{#StringEqualityBlockHelper value 'dog'}}is a dog{{else StringEqualityBlockHelper value 'cat'}}is a cat{{else}}is something else{{/StringEqualityBlockHelper}}");

Assert.Equal("is a dog", template(new { value = "dog" }));
Assert.Equal("is a cat", template(new { value = "cat" }));
Assert.Equal("is something else", template(new { value = "fish" }));
}

// Issue https://github.com/Handlebars-Net/Handlebars.Net/issues/263
[Fact]
public void ElseIfSupportsMultipleChainedClauses()
{
var handlebars = Handlebars.Create();
var template = handlebars.Compile("{{#if a}}A{{else if b}}B{{else if c}}C{{else}}D{{/if}}");

Assert.Equal("A", template(new { a = true, b = false, c = false }));
Assert.Equal("B", template(new { a = false, b = true, c = false }));
Assert.Equal("C", template(new { a = false, b = false, c = true }));
Assert.Equal("D", template(new { a = false, b = false, c = false }));
}

// Issue https://github.com/Handlebars-Net/Handlebars.Net/issues/263
[Fact]
public void PlainElseStillWorksWithoutChainedHelperInvocation()
{
var handlebars = Handlebars.Create();
var template = handlebars.Compile("{{#if isDog}}is a dog{{else}}is not a dog{{/if}}");

Assert.Equal("is a dog", template(new { isDog = true }));
Assert.Equal("is not a dog", template(new { isDog = false }));
}

// Issue https://github.com/Handlebars-Net/Handlebars.Net/issues/263
[Fact]
public void ElseIfCanChainUnderNonConditionalBlockHelper()
{
var handlebars = Handlebars.Create();
RegisterStringEqualityBlockHelper(handlebars);

var template = handlebars.Compile(
"{{#StringEqualityBlockHelper value 'dog'}}A{{else if isCat}}B{{else}}C{{/StringEqualityBlockHelper}}");

Assert.Equal("A", template(new { value = "dog", isCat = false }));
Assert.Equal("B", template(new { value = "fish", isCat = true }));
Assert.Equal("C", template(new { value = "fish", isCat = false }));
}

// Issue https://github.com/Handlebars-Net/Handlebars.Net/issues/263
[Fact]
public void ElseWithCustomBlockHelperInvocationChainsThreeDeep()
{
var handlebars = Handlebars.Create();
RegisterStringEqualityBlockHelper(handlebars);

var template = handlebars.Compile(
"{{#StringEqualityBlockHelper value 'a'}}A{{else StringEqualityBlockHelper value 'b'}}B{{else StringEqualityBlockHelper value 'c'}}C{{else}}D{{/StringEqualityBlockHelper}}");

Assert.Equal("A", template(new { value = "a" }));
Assert.Equal("B", template(new { value = "b" }));
Assert.Equal("C", template(new { value = "c" }));
Assert.Equal("D", template(new { value = "z" }));
}

private static void RegisterStringEqualityBlockHelper(IHandlebars handlebars)
{
handlebars.RegisterHelper("StringEqualityBlockHelper", (output, options, context, arguments) =>
{
if (arguments.At<string>(0) == arguments.At<string>(1))
{
options.Template(output, context);
}
else
{
options.Inverse(output, context);
}
});
}
}
}
37 changes: 37 additions & 0 deletions source/Handlebars.Test/ReadmeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,43 @@ public void RegisterBlockHelper()
);
}

[Fact]
public void ElseChainingWithBlockHelper()
{
var handlebars = Handlebars.Create();
handlebars.RegisterHelper("StringEqualityBlockHelper", (output, options, context, arguments) =>
{
if (arguments.Length != 2)
{
throw new HandlebarsException("{{#StringEqualityBlockHelper}} helper must have exactly two arguments");
}

var left = arguments.At<string>(0);
var right = arguments[1] as string;
if (left == right) options.Template(output, context);
else options.Inverse(output, context);
});

var template = "{{#StringEqualityBlockHelper value 'dog'}}is a dog{{else StringEqualityBlockHelper value 'cat'}}is a cat{{else}}is something else{{/StringEqualityBlockHelper}}";
var compiledTemplate = handlebars.Compile(template);

Assert.Equal("is a dog", compiledTemplate(new { value = "dog" }));
Assert.Equal("is a cat", compiledTemplate(new { value = "cat" }));
Assert.Equal("is something else", compiledTemplate(new { value = "hamster" }));
}

[Fact]
public void ElseIfChaining()
{
var handlebars = Handlebars.Create();
var template = handlebars.Compile(
"{{#if isDog}}is a dog{{else if isCat}}is a cat{{else}}is something else{{/if}}");

Assert.Equal("is a dog", template(new { isDog = true, isCat = false }));
Assert.Equal("is a cat", template(new { isDog = false, isCat = true }));
Assert.Equal("is something else", template(new { isDog = false, isCat = false }));
}

[Fact]
public void RegisterHelper()
{
Expand Down
39 changes: 38 additions & 1 deletion source/Handlebars/Compiler/Lexer/Converter/BlockAccumulator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,19 @@ public override IEnumerable<object> ConvertTokens(IEnumerable<object> sequence)

private Expression AccumulateBlock(
Expression parentItem,
IEnumerator<object> enumerator,
IEnumerator<object> enumerator,
BlockAccumulatorContext context)
{
while (enumerator.MoveNext())
{
var item = (Expression)enumerator.Current;

if (!context.HandlesChainedElseInternally
&& BlockAccumulatorContext.TryGetChainedElseInvocation(item, out var invocation))
{
return AccumulateChainedElse(item, context, invocation, enumerator);
}

var innerContext = BlockAccumulatorContext.Create(item, parentItem, _configuration);
if (innerContext != null)
{
Expand All @@ -62,6 +69,36 @@ private Expression AccumulateBlock(
}
throw new HandlebarsCompilerException($"Reached end of template before block expression '{context.BlockName}' was closed");
}

/// <summary>
/// Desugars "{{else name arg1 arg2}}body{{/outer}}" into the equivalent of
/// "{{else}}{{#name arg1 arg2}}body{{/name}}{{/outer}}", except the nested "{{#name}}"
/// block shares the outer block's closing tag instead of requiring its own - the outer
/// closing tag is what ultimately terminates the recursive accumulation below, so this
/// method always returns the outer block, never loops back into the caller.
/// </summary>
private Expression AccumulateChainedElse(
Expression elseItem,
BlockAccumulatorContext context,
HelperExpression invocation,
IEnumerator<object> enumerator)
{
context.HandleElement(elseItem);

var nestedContext = BlockAccumulatorContext.Create(invocation, elseItem, _configuration)
?? throw new HandlebarsCompilerException($"'{invocation.HelperName.Substring(1)}' cannot be used as a chained else block", invocation.Context);

nestedContext.SetClosingNameOverride(context.ResolvedClosingName);

// The nested block has no literal closing tag - it is closed by the outer block's
// closing tag - so the parent reference used for detached-closing-tag detection has
// to describe that shared closing tag rather than the nested helper's own name.
var closingTagMarker = HandlebarsExpression.Helper("#" + nestedContext.ResolvedClosingName, true);
var nestedBlock = AccumulateBlock(closingTagMarker, enumerator, nestedContext);

context.HandleElement(nestedBlock);
return context.AccumulatedBlock!;
}
}
}

Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Linq.Expressions;
using HandlebarsDotNet.PathStructure;
using HandlebarsDotNet.StringUtils;
Expand Down Expand Up @@ -41,6 +42,52 @@ internal abstract class BlockAccumulatorContext

public abstract string BlockName { get; protected set; }

/// <summary>
/// True when this context resolves its own "{{else name ...}}" chaining internally
/// (e.g. the if/unless flattened else-if chain) instead of relying on the generic
/// nested-block desugaring performed by <see cref="BlockAccumulator"/>.
/// </summary>
internal virtual bool HandlesChainedElseInternally => false;

private string? _closingNameOverride;

/// <summary>
/// Used when this context represents the implicit nested block produced by desugaring
/// "{{else name ...}}" - it has no closing tag of its own, so it must be closed by
/// whichever closing tag actually closes the outermost block in the else-chain.
/// </summary>
internal void SetClosingNameOverride(string closingName)
{
_closingNameOverride = closingName;
}

internal string ResolvedClosingName => _closingNameOverride ?? OwnClosingName;

protected virtual string OwnClosingName => BlockName;

/// <summary>
/// Recognizes "{{else name arg1 arg2 hash=val}}" and, if found, builds the synthetic
/// "{{#name arg1 arg2 hash=val}}" opening node it desugars to.
/// </summary>
internal static bool TryGetChainedElseInvocation(Expression item, [NotNullWhen(true)] out HelperExpression? invocation)
{
item = UnwrapStatement(item);
if (item is HelperExpression { HelperName: "else" } helperExpression
&& helperExpression.Arguments.FirstOrDefault() is PathExpression nameExpression)
{
invocation = new HelperExpression(
"#" + nameExpression.Path,
isBlock: true,
arguments: helperExpression.Arguments.Skip(1),
isRaw: helperExpression.IsRaw,
context: helperExpression.Context);
return true;
}

invocation = null;
return false;
}

private static bool IsConditionalBlock(Expression item)
{
item = UnwrapStatement(item);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ public sealed override string BlockName
protected set => throw new NotSupportedException();
}

protected override string OwnClosingName => _startingNode.HelperName
.Replace("#", string.Empty)
.Replace("^", string.Empty)
.Replace("*", string.Empty);

public override void HandleElement(Expression item)
{
if (IsInversionBlock(item))
Expand Down Expand Up @@ -61,11 +66,7 @@ public override bool IsClosingElement(Expression item)

private bool IsClosingNode(Expression item)
{
var helperName = _startingNode.HelperName
.Replace("#", string.Empty)
.Replace("^", string.Empty)
.Replace("*", string.Empty);
return item is PathExpression expression && expression.Path == "/" + helperName;
return item is PathExpression expression && expression.Path == "/" + ResolvedClosingName;
}

public override Expression AccumulatedBlock
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@

public sealed override string BlockName { get; protected set; }

// "else if"/"else unless" is already resolved into a flat chain of conditions below;

Check warning on line 20 in source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/ConditionalBlockAccumulatorContext.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this commented out code.

See more on https://sonarcloud.io/project/issues?id=Handlebars-Net_Handlebars.Net&issues=AZ_Pxj6XrHnyh7rHhSe8&open=AZ_Pxj6XrHnyh7rHhSe8&pullRequest=648
// it must not also be desugared into a nested block by BlockAccumulator.
internal override bool HandlesChainedElseInternally => true;

public ConditionalBlockAccumulatorContext(Expression startingNode)
: base(startingNode)
{
Expand Down Expand Up @@ -127,7 +131,7 @@
private bool IsClosingNode(Expression item)
{
item = UnwrapStatement(item);
return item is PathExpression expression && expression.Path == "/" + BlockName;
return item is PathExpression expression && expression.Path == "/" + ResolvedClosingName;
}

private static Expression SinglifyExpressions(IEnumerable<Expression> expressions)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public sealed override string BlockName
protected set => throw new NotSupportedException();
}

protected override string OwnClosingName => "each";

public override void HandleElement(Expression item)
{
if (IsElseBlock(item))
Expand All @@ -39,30 +41,27 @@ public override void HandleElement(Expression item)
}
}

public override bool IsClosingElement(Expression item)
public override bool IsClosingElement(Expression item) => IsClosingNode(item);

public override Expression? AccumulatedBlock
{
if (!IsClosingNode(item)) return false;

// If the template has no content within the block, e.g. `{{#each ...}}{{/each}`, then the block body is a no-op.
var bodyStatements = _body.Count != 0 ? _body : new List<Expression>{ Expression.Empty() };
if (_accumulatedExpression == null)
get
{
_accumulatedExpression = HandlebarsExpression.Iterator(BlockName, _startingNode.Arguments.Single(o => o.NodeType != (ExpressionType)HandlebarsExpressionType.BlockParamsExpression), _startingNode.Arguments.OfType<BlockParamsExpression>().SingleOrDefault() ?? BlockParamsExpression.Empty(), Expression.Block(bodyStatements));
}
else
{
_accumulatedExpression = HandlebarsExpression.Iterator(BlockName, ((IteratorExpression)_accumulatedExpression).Sequence, ((IteratorExpression)_accumulatedExpression).BlockParams, ((IteratorExpression)_accumulatedExpression).Template, Expression.Block(bodyStatements));
// If the template has no content within the block, e.g. `{{#each ...}}{{/each}`, then the block body is a no-op.
var bodyStatements = _body.Count != 0 ? _body : new List<Expression>{ Expression.Empty() };
if (_accumulatedExpression == null)
{
return HandlebarsExpression.Iterator(BlockName, _startingNode.Arguments.Single(o => o.NodeType != (ExpressionType)HandlebarsExpressionType.BlockParamsExpression), _startingNode.Arguments.OfType<BlockParamsExpression>().SingleOrDefault() ?? BlockParamsExpression.Empty(), Expression.Block(bodyStatements));
}

return HandlebarsExpression.Iterator(BlockName, ((IteratorExpression)_accumulatedExpression).Sequence, ((IteratorExpression)_accumulatedExpression).BlockParams, ((IteratorExpression)_accumulatedExpression).Template, Expression.Block(bodyStatements));
}

return true;
}

public override Expression? AccumulatedBlock => _accumulatedExpression;

private static bool IsClosingNode(Expression item)
private bool IsClosingNode(Expression item)
{
item = UnwrapStatement(item);
return item is PathExpression pathExpression && pathExpression.Path.Replace("#", "").Replace("^", "") == "/each";
return item is PathExpression pathExpression && pathExpression.Path.Replace("#", "").Replace("^", "") == "/" + ResolvedClosingName;
}

private static bool IsElseBlock(Expression item)
Expand Down
Loading