From 272315a676dad87c8c917e688bab52a8f04746b4 Mon Sep 17 00:00:00 2001 From: Shashank Shailabh Date: Tue, 2 Dec 2025 05:07:27 +0530 Subject: [PATCH] add support for child first resolution --- .../com/github/jknack/handlebars/Context.java | 37 ++++- .../github/jknack/handlebars/Handlebars.java | 41 ++++++ .../com/github/jknack/handlebars/Options.java | 2 +- .../handlebars/internal/BaseTemplate.java | 14 +- .../internal/ForwardingTemplate.java | 18 ++- .../handlebars/ChildFirstResolutionTest.java | 129 ++++++++++++++++++ 6 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 handlebars/src/test/java/com/github/jknack/handlebars/ChildFirstResolutionTest.java diff --git a/handlebars/src/main/java/com/github/jknack/handlebars/Context.java b/handlebars/src/main/java/com/github/jknack/handlebars/Context.java index 09736422..f2ef95e8 100644 --- a/handlebars/src/main/java/com/github/jknack/handlebars/Context.java +++ b/handlebars/src/main/java/com/github/jknack/handlebars/Context.java @@ -66,6 +66,7 @@ protected BlockParam(final Context parent, final Map hash) { this.parent = parent; this.data = parent.data; this.resolver = parent.resolver; + this.childFirstResolution = parent.childFirstResolution; } @Override @@ -84,7 +85,13 @@ public Object get(final List path) { @Override protected Context newChildContext(final Object model) { - return new ParentFirst(model); + if (childFirstResolution) { + // Child-first + return new Context(model); + } else { + // Parent-first: default behavior + return new ParentFirst(model); + } } } @@ -116,7 +123,13 @@ public Object get(final List path) { @Override protected Context newChildContext(final Object model) { - return new ParentFirst(model); + if (childFirstResolution) { + // Child-first + return new Context(model); + } else { + // Parent-first: default behavior + return new ParentFirst(model); + } } } @@ -143,6 +156,7 @@ protected PartialCtx(final Context parent, final Object model, final Map void sneakyThrow0(final Throwable x) throws /** True, if we want to extend lookup to parent scope. */ private boolean parentScopeResolution = true; + /** + * If true, child context fields will be preferred over parent context fields when resolving + * variables. This affects contexts created by helpers like {{#with}}, {{#each}}, etc. + * + *

When false (default), contexts use parent-first resolution where parent fields can shadow + * child fields. This is the behavior of handlebars.js with block parameters. + * + *

When true, contexts use child-first resolution where child fields are checked before parent + * fields. This is more intuitive for Mustache-style templates. + * + *

Default is: false for backward compatibility. + */ + private boolean childFirstResolution = false; + /** * If true partial blocks will be evaluated to allow side effects by defining inline blocks within * the partials blocks. Attention: This feature slows down the performance severly if your @@ -1241,6 +1255,33 @@ public Handlebars parentScopeResolution(final boolean parentScopeResolution) { return this; } + /** + * @return True if child context is preferred over parent context when resolving variables. + */ + public boolean childFirstResolution() { + return childFirstResolution; + } + + /** + * If true, child context fields will be preferred over parent context fields. + * + * @param childFirstResolution True for child-first resolution, false for parent-first. + */ + public void setChildFirstResolution(final boolean childFirstResolution) { + this.childFirstResolution = childFirstResolution; + } + + /** + * If true, child context fields will be preferred over parent context fields. + * + * @param childFirstResolution True for child-first resolution, false for parent-first. + * @return This handlebars object. + */ + public Handlebars childFirstResolution(final boolean childFirstResolution) { + setChildFirstResolution(childFirstResolution); + return this; + } + /** * If true, partial blocks will implicitly be evaluated before the partials will actually be * executed. If false, you need to explicitly evaluate and render partial blocks with diff --git a/handlebars/src/main/java/com/github/jknack/handlebars/Options.java b/handlebars/src/main/java/com/github/jknack/handlebars/Options.java index a97c765e..50cc7fc6 100644 --- a/handlebars/src/main/java/com/github/jknack/handlebars/Options.java +++ b/handlebars/src/main/java/com/github/jknack/handlebars/Options.java @@ -701,7 +701,7 @@ private Context wrap(final Context context) { if (context != null) { return context; } - return Context.newContext(null); + return Context.newBuilder(null).childFirstResolution(handlebars.childFirstResolution()).build(); } /** diff --git a/handlebars/src/main/java/com/github/jknack/handlebars/internal/BaseTemplate.java b/handlebars/src/main/java/com/github/jknack/handlebars/internal/BaseTemplate.java index 2909e066..7cb14093 100644 --- a/handlebars/src/main/java/com/github/jknack/handlebars/internal/BaseTemplate.java +++ b/handlebars/src/main/java/com/github/jknack/handlebars/internal/BaseTemplate.java @@ -121,11 +121,13 @@ public void apply(final Context context, final Writer writer) throws IOException * @param candidate The candidate object. * @return A context. */ - private static Context wrap(final Object candidate) { + private Context wrap(final Object candidate) { if (candidate instanceof Context) { return (Context) candidate; } - return Context.newContext(candidate); + return Context.newBuilder(candidate) + .childFirstResolution(handlebars.childFirstResolution()) + .build(); } /** @@ -218,7 +220,7 @@ public TypeSafeTemplate as() { * @param template The target template. * @return A new {@link TypeSafeTemplate}. */ - private static Object newTypeSafeTemplate(final Class rootType, final Template template) { + private Object newTypeSafeTemplate(final Class rootType, final Template template) { return Proxy.newProxyInstance( rootType.getClassLoader(), new Class[] {rootType}, @@ -275,7 +277,11 @@ public Object invoke(final Object proxy, final Method method, final Object[] met } if ("apply".equals(methodName)) { - Context context = Context.newBuilder(args[0]).combine(attributes).build(); + Context context = + Context.newBuilder(args[0]) + .combine(attributes) + .childFirstResolution(handlebars.childFirstResolution()) + .build(); attributes.clear(); if (args.length == 2) { template.apply(context, (Writer) args[1]); diff --git a/handlebars/src/main/java/com/github/jknack/handlebars/internal/ForwardingTemplate.java b/handlebars/src/main/java/com/github/jknack/handlebars/internal/ForwardingTemplate.java index d2ed86b3..768d5278 100644 --- a/handlebars/src/main/java/com/github/jknack/handlebars/internal/ForwardingTemplate.java +++ b/handlebars/src/main/java/com/github/jknack/handlebars/internal/ForwardingTemplate.java @@ -147,10 +147,17 @@ public String toString() { * @param candidate The candidate object. * @return A context. */ - private static Context wrap(final Object candidate) { + private Context wrap(final Object candidate) { if (candidate instanceof Context) { return (Context) candidate; } + // Try to get childFirstResolution from the wrapped template if it's a BaseTemplate + if (template instanceof BaseTemplate) { + BaseTemplate baseTemplate = (BaseTemplate) template; + return Context.newBuilder(candidate) + .childFirstResolution(baseTemplate.handlebars.childFirstResolution()) + .build(); + } return Context.newContext(candidate); } @@ -160,10 +167,17 @@ private static Context wrap(final Object candidate) { * @param candidate The candidate object. * @return A context. */ - private static Context wrap(final Context candidate) { + private Context wrap(final Context candidate) { if (candidate != null) { return candidate; } + // Try to get childFirstResolution from the wrapped template if it's a BaseTemplate + if (template instanceof BaseTemplate) { + BaseTemplate baseTemplate = (BaseTemplate) template; + return Context.newBuilder(null) + .childFirstResolution(baseTemplate.handlebars.childFirstResolution()) + .build(); + } return Context.newContext(null); } diff --git a/handlebars/src/test/java/com/github/jknack/handlebars/ChildFirstResolutionTest.java b/handlebars/src/test/java/com/github/jknack/handlebars/ChildFirstResolutionTest.java new file mode 100644 index 00000000..ff500b07 --- /dev/null +++ b/handlebars/src/test/java/com/github/jknack/handlebars/ChildFirstResolutionTest.java @@ -0,0 +1,129 @@ +/* + * Handlebars.java: https://github.com/jknack/handlebars.java + * Apache License Version 2.0 http://www.apache.org/licenses/LICENSE-2.0 + * Copyright (c) 2012 Edgar Espina + */ +package com.github.jknack.handlebars; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; + +public class ChildFirstResolutionTest extends AbstractTest { + + @Override + protected void configure(final Handlebars handlebars) { + handlebars.childFirstResolution(true); + } + + @Test + public void testNestedWithChildFirst() throws IOException { + shouldCompileTo( + "{{x}} {{#with a}}{{x}} {{#with b}}{{x}}{{/with}}{{/with}}", + $("x", "1", "a", $("x", "2", "b", $("x", "3"))), + "1 2 3"); + } + + @Test + public void testBlockParamsChildFirst() throws IOException { + shouldCompileTo( + "{{#each items as |item|}}{{name}}{{/each}}", + $("name", "Global", "items", new Object[] {$("name", "Item1"), $("name", "Item2")}), + "Item1Item2"); + } + + @Test + public void testNestedContextsChildFirst() throws IOException { + shouldCompileTo( + "{{#each outer}}{{#with inner}}{{field}}{{/with}}{{/each}}", + $( + "field", + "Root", + "outer", + new Object[] {$("field", "Outer1", "inner", $("field", "Inner1"))}), + "Inner1"); + } + + @Test + public void testMixedContexts() throws IOException { + shouldCompileTo( + "{{#with person}}{{#if active}}{{name}}{{/if}}{{/with}}", + $("name", "Outer", "person", $("name", "Inner", "active", true)), + "Inner"); + } + + @Test + public void testChildFirstConfiguration() { + Handlebars handlebars = new Handlebars(); + assertFalse(handlebars.childFirstResolution()); + + handlebars.setChildFirstResolution(true); + assertTrue(handlebars.childFirstResolution()); + + Handlebars handlebars2 = new Handlebars().childFirstResolution(true); + assertTrue(handlebars2.childFirstResolution()); + } + + @Test + public void testEachWithIndexChildFirst() throws IOException { + shouldCompileTo( + "{{#each items as |item index|}}{{index}}:{{value}} {{/each}}", + $("value", "Global", "items", new Object[] {$("value", "A"), $("value", "B")}), + "0:A 1:B "); + } + + @Test + public void testPartialWithChildFirst() throws IOException { + shouldCompileToWithPartials( + "{{> partial name=\"Override\"}}", + $("name", "Original"), + $("partial", "{{name}}"), + "Override"); + } + + @Test + public void testWithHelperMapData() throws IOException { + shouldCompileTo( + "Name: {{name}}\n{{#with person}}Name: {{name}}\nAge: {{age}}{{/with}}", + $("name", "Outer", "person", $("name", "Inner", "age", 30)), + "Name: Outer\nName: Inner\nAge: 30"); + } + + @Test + public void testEachItemsWithGlobalField() throws IOException { + shouldCompileTo( + "{{#each items}}Global: {{globalField}}\nItem: {{name}}\n{{/each}}", + $( + "globalField", + "Global Value", + "items", + new Object[] { + $("name", "Item 1", "globalField", "Item 1 Global"), + $("name", "Item 2", "globalField", "Item 2 Global") + }), + "Global: Item 1 Global\nItem: Item 1\nGlobal: Item 2 Global\nItem: Item 2\n"); + } + + @Test + public void testDeepNestingChildFirst() throws IOException { + shouldCompileTo( + "{{#with a}}{{#with b}}{{#with c}}{{value}}{{/with}}{{/with}}{{/with}}", + $( + "value", + "root", + "a", + $("value", "a-value", "b", $("value", "b-value", "c", $("value", "c-value")))), + "c-value"); + } + + @Test + public void testLocalPathBehavior() throws IOException { + shouldCompileTo( + "{{#with child}}{{./name}}{{/with}}", + $("name", "Parent", "child", $("name", "Child")), + "Child"); + } +}