diff --git a/README.md b/README.md index d271b74c..dddcd227 100644 --- a/README.md +++ b/README.md @@ -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# diff --git a/source/Handlebars.Test/IssueTests.cs b/source/Handlebars.Test/IssueTests.cs index ef171e93..f8eb5f88 100644 --- a/source/Handlebars.Test/IssueTests.cs +++ b/source/Handlebars.Test/IssueTests.cs @@ -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(0) == arguments.At(1)) + { + options.Template(output, context); + } + else + { + options.Inverse(output, context); + } + }); + } } } \ No newline at end of file diff --git a/source/Handlebars.Test/ReadmeTests.cs b/source/Handlebars.Test/ReadmeTests.cs index c41d9a3c..8966be8e 100644 --- a/source/Handlebars.Test/ReadmeTests.cs +++ b/source/Handlebars.Test/ReadmeTests.cs @@ -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(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() { diff --git a/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulator.cs b/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulator.cs index 757a1c27..c88c927d 100644 --- a/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulator.cs +++ b/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulator.cs @@ -40,12 +40,19 @@ public override IEnumerable ConvertTokens(IEnumerable sequence) private Expression AccumulateBlock( Expression parentItem, - IEnumerator enumerator, + IEnumerator 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) { @@ -62,6 +69,36 @@ private Expression AccumulateBlock( } throw new HandlebarsCompilerException($"Reached end of template before block expression '{context.BlockName}' was closed"); } + + /// + /// 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. + /// + private Expression AccumulateChainedElse( + Expression elseItem, + BlockAccumulatorContext context, + HelperExpression invocation, + IEnumerator 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!; + } } } diff --git a/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/BlockAccumulatorContext.cs b/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/BlockAccumulatorContext.cs index b5a0a857..cd525d6f 100644 --- a/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/BlockAccumulatorContext.cs +++ b/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/BlockAccumulatorContext.cs @@ -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; @@ -41,6 +42,52 @@ internal abstract class BlockAccumulatorContext public abstract string BlockName { get; protected set; } + /// + /// 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 . + /// + internal virtual bool HandlesChainedElseInternally => false; + + private string? _closingNameOverride; + + /// + /// 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. + /// + internal void SetClosingNameOverride(string closingName) + { + _closingNameOverride = closingName; + } + + internal string ResolvedClosingName => _closingNameOverride ?? OwnClosingName; + + protected virtual string OwnClosingName => BlockName; + + /// + /// Recognizes "{{else name arg1 arg2 hash=val}}" and, if found, builds the synthetic + /// "{{#name arg1 arg2 hash=val}}" opening node it desugars to. + /// + 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); diff --git a/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/BlockHelperAccumulatorContext.cs b/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/BlockHelperAccumulatorContext.cs index 5976721d..3f19d923 100644 --- a/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/BlockHelperAccumulatorContext.cs +++ b/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/BlockHelperAccumulatorContext.cs @@ -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)) @@ -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 diff --git a/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/ConditionalBlockAccumulatorContext.cs b/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/ConditionalBlockAccumulatorContext.cs index 11794421..80abeeed 100644 --- a/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/ConditionalBlockAccumulatorContext.cs +++ b/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/ConditionalBlockAccumulatorContext.cs @@ -17,6 +17,10 @@ private enum TestType { Direct, Reverse } public sealed override string BlockName { get; protected set; } + // "else if"/"else unless" is already resolved into a flat chain of conditions below; + // it must not also be desugared into a nested block by BlockAccumulator. + internal override bool HandlesChainedElseInternally => true; + public ConditionalBlockAccumulatorContext(Expression startingNode) : base(startingNode) { @@ -127,7 +131,7 @@ private Expression GetElseIfTestExpression(Expression item) 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 expressions) diff --git a/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/IteratorBlockAccumulatorContext.cs b/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/IteratorBlockAccumulatorContext.cs index ed21bce1..72ebbb35 100644 --- a/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/IteratorBlockAccumulatorContext.cs +++ b/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/IteratorBlockAccumulatorContext.cs @@ -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)) @@ -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.Empty() }; - if (_accumulatedExpression == null) + get { - _accumulatedExpression = HandlebarsExpression.Iterator(BlockName, _startingNode.Arguments.Single(o => o.NodeType != (ExpressionType)HandlebarsExpressionType.BlockParamsExpression), _startingNode.Arguments.OfType().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.Empty() }; + if (_accumulatedExpression == null) + { + return HandlebarsExpression.Iterator(BlockName, _startingNode.Arguments.Single(o => o.NodeType != (ExpressionType)HandlebarsExpressionType.BlockParamsExpression), _startingNode.Arguments.OfType().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)