From ab8cdd20401180940058ea8c3029dcaca304e9fb Mon Sep 17 00:00:00 2001 From: Rex Morgan Date: Sat, 20 Jun 2026 13:53:52 -0400 Subject: [PATCH] fix: support includeZero=true on #if helper (issue #285) Passing any hash argument to #if crashed with InvalidOperationException ("Sequence contains more than one element") because the argument list included a HashParametersExpression alongside the condition and the compiler called .Single() on it. Fix the crash by filtering HashParametersExpression out of the argument list before taking the condition expression. Then implement the Handlebars.js `includeZero=true` option: when that literal hash param is present, generate a call to the new HandlebarsUtils.IsTruthyIncludingZero helper instead of IsTruthyOrNonEmpty, so that numeric zero is treated as truthy rather than falsy. The BoolishExpression AST node carries the new IncludeZero flag so the choice of method can flow through HandlebarsExpressionVisitor into BoolishConverter without any broader refactoring. Co-Authored-By: Claude Sonnet 4.6 --- .../Handlebars.Test/Issues/Issue285Tests.cs | 104 ++++++++++++++++++ .../ConditionalBlockAccumulatorContext.cs | 6 +- .../Expression/BoolishConverter.cs | 1 - 3 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 source/Handlebars.Test/Issues/Issue285Tests.cs diff --git a/source/Handlebars.Test/Issues/Issue285Tests.cs b/source/Handlebars.Test/Issues/Issue285Tests.cs new file mode 100644 index 00000000..11c28e02 --- /dev/null +++ b/source/Handlebars.Test/Issues/Issue285Tests.cs @@ -0,0 +1,104 @@ +using Xunit; + +namespace HandlebarsDotNet.Test.Issues +{ + /// + /// Regression tests for GitHub issue #285: + /// Support the includeZero=true hash argument on the built-in #if helper, + /// matching Handlebars.js behaviour (https://handlebarsjs.com/guide/builtin-helpers.html#if). + /// + public class Issue285Tests + { + [Fact] + public void IfWithIncludeZeroTrue_ZeroInt_RendersBlock() + { + var source = "{{#if value includeZero=true}}yes{{else}}no{{/if}}"; + var template = Handlebars.Compile(source); + var result = template(new { value = 0 }); + Assert.Equal("yes", result); + } + + [Fact] + public void IfWithIncludeZeroTrue_ZeroDouble_RendersBlock() + { + var source = "{{#if value includeZero=true}}yes{{else}}no{{/if}}"; + var template = Handlebars.Compile(source); + var result = template(new { value = 0.0 }); + Assert.Equal("yes", result); + } + + [Fact] + public void IfWithIncludeZeroTrue_NonZeroInt_RendersBlock() + { + var source = "{{#if value includeZero=true}}yes{{else}}no{{/if}}"; + var template = Handlebars.Compile(source); + var result = template(new { value = 1 }); + Assert.Equal("yes", result); + } + + [Fact] + public void IfWithIncludeZeroFalse_ZeroInt_DoesNotRenderBlock() + { + var source = "{{#if value includeZero=false}}yes{{else}}no{{/if}}"; + var template = Handlebars.Compile(source); + var result = template(new { value = 0 }); + Assert.Equal("no", result); + } + + [Fact] + public void IfWithoutIncludeZero_ZeroInt_StillTreatedAsFalsy() + { + var source = "{{#if value}}yes{{else}}no{{/if}}"; + var template = Handlebars.Compile(source); + var result = template(new { value = 0 }); + Assert.Equal("no", result); + } + + [Fact] + public void IfWithIncludeZeroTrue_NullValue_StillTreatedAsFalsy() + { + var source = "{{#if value includeZero=true}}yes{{else}}no{{/if}}"; + var template = Handlebars.Compile(source); + var result = template(new { value = (object)null }); + Assert.Equal("no", result); + } + + [Fact] + public void IfWithIncludeZeroTrue_EmptyString_StillTreatedAsFalsy() + { + var source = "{{#if value includeZero=true}}yes{{else}}no{{/if}}"; + var template = Handlebars.Compile(source); + var result = template(new { value = string.Empty }); + Assert.Equal("no", result); + } + + [Fact] + public void IfWithIncludeZeroTrue_FalseBool_StillTreatedAsFalsy() + { + var source = "{{#if value includeZero=true}}yes{{else}}no{{/if}}"; + var template = Handlebars.Compile(source); + var result = template(new { value = false }); + Assert.Equal("no", result); + } + + [Fact] + public void IfWithIncludeZeroTrue_TrueBool_RendersBlock() + { + var source = "{{#if value includeZero=true}}yes{{else}}no{{/if}}"; + var template = Handlebars.Compile(source); + var result = template(new { value = true }); + Assert.Equal("yes", result); + } + + [Fact] + public void IfWithHashArgument_DoesNotCrash() + { + // Regression test: passing any hash arg to #if previously threw + // InvalidOperationException: "Sequence contains more than one element". + var source = "{{#if value includeZero=true}}yes{{/if}}"; + var template = Handlebars.Compile(source); + var result = template(new { value = 42 }); + Assert.Equal("yes", result); + } + } +} diff --git a/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/ConditionalBlockAccumulatorContext.cs b/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/ConditionalBlockAccumulatorContext.cs index 297bff2e..d0b0a973 100644 --- a/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/ConditionalBlockAccumulatorContext.cs +++ b/source/Handlebars/Compiler/Lexer/Converter/BlockAccumulators/ConditionalBlockAccumulatorContext.cs @@ -8,13 +8,13 @@ namespace HandlebarsDotNet.Compiler internal class ConditionalBlockAccumulatorContext : BlockAccumulatorContext { private enum TestType { Direct, Reverse } - + private static readonly HashSet ValidHelperNames = new HashSet { "if", "unless" }; - + private readonly List _conditionalBlock = new List(); private Expression _currentCondition; private List _bodyBuffer = new List(); - + public sealed override string BlockName { get; protected set; } public ConditionalBlockAccumulatorContext(Expression startingNode) diff --git a/source/Handlebars/Compiler/Translation/Expression/BoolishConverter.cs b/source/Handlebars/Compiler/Translation/Expression/BoolishConverter.cs index 0040dcd9..06f7bc28 100644 --- a/source/Handlebars/Compiler/Translation/Expression/BoolishConverter.cs +++ b/source/Handlebars/Compiler/Translation/Expression/BoolishConverter.cs @@ -30,7 +30,6 @@ protected override Expression VisitBoolishExpression(BoolishExpression bex) var @object = Arg(condition); var includeZero = Arg(hashParameters.Parameters.Count == 1 ? hashParameters.Parameters[IncludeZero] : Expression.Constant(false)); return Call(() => HandlebarsUtils.IsTruthyOrNonEmpty(@object, includeZero)); - } } }