Skip to content
Open
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
37 changes: 35 additions & 2 deletions handlebars/src/main/java/com/github/jknack/handlebars/Context.java
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ protected BlockParam(final Context parent, final Map<String, Object> hash) {
this.parent = parent;
this.data = parent.data;
this.resolver = parent.resolver;
this.childFirstResolution = parent.childFirstResolution;
}

@Override
Expand All @@ -84,7 +85,13 @@ public Object get(final List<PathExpression> 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);
}
}
}

Expand Down Expand Up @@ -116,7 +123,13 @@ public Object get(final List<PathExpression> 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);
}
}
}

Expand All @@ -143,6 +156,7 @@ protected PartialCtx(final Context parent, final Object model, final Map<String,
this.parent = parent;
this.data = parent.data;
this.resolver = parent.resolver;
this.childFirstResolution = parent.childFirstResolution;
}

@Override
Expand Down Expand Up @@ -306,6 +320,17 @@ public Builder push(final ValueResolver... resolvers) {
return this;
}

/**
* Set whether child context is preferred over parent context when resolving variables.
*
* @param childFirstResolution True for child-first resolution, false for parent-first.
* @return This builder.
*/
public Builder childFirstResolution(final boolean childFirstResolution) {
context.childFirstResolution = childFirstResolution;
return this;
}

/**
* Build a context stack.
*
Expand Down Expand Up @@ -404,6 +429,12 @@ public Object eval(final ValueResolver resolver, final Context context, final Ob
/** The value resolver. */
protected ValueResolver resolver;

/**
* If true, child context is preferred over parent context when resolving variables. This flag
* controls whether BlockParam and ParentFirst create ParentFirst children.
*/
protected boolean childFirstResolution;

/**
* Creates a new context.
*
Expand Down Expand Up @@ -752,6 +783,7 @@ private Context newChild(final Object model) {
child.setResolver(this.resolver);
child.parent = this;
child.data = this.data;
child.childFirstResolution = this.childFirstResolution;
return child;
}

Expand All @@ -776,6 +808,7 @@ public static Context copy(final Context context, final Object model) {
Context ctx = Context.newContext(model);
ctx.data = context.data;
ctx.resolver = context.resolver;
ctx.childFirstResolution = context.childFirstResolution;
return ctx;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,20 @@ private static <E extends Throwable> 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.
*
* <p>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.
*
* <p>When true, contexts use child-first resolution where child fields are checked before parent
* fields. This is more intuitive for Mustache-style templates.
*
* <p>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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -218,7 +220,7 @@ public <T> TypeSafeTemplate<T> 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},
Expand Down Expand Up @@ -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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}