From a4cf27273a0ef14fc5e6d36d176596e27a935d6b Mon Sep 17 00:00:00 2001 From: Sam Pullara Date: Thu, 23 Jul 2026 13:21:29 -0700 Subject: [PATCH 1/8] Push intermediate scopes for dotted-name sections Agent-Id: agent-afae2211-1cf8-460a-8aea-733b035005fa Linked-Note-Id: 804110b6-0ff6-4285-ad35-33931c601c67 --- .../mustachejava/codes/IterableCode.java | 72 +++++++++++++++++- test_debug$1.class | Bin 0 -> 1216 bytes test_debug.class | Bin 0 -> 2220 bytes 3 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 test_debug$1.class create mode 100644 test_debug.class diff --git a/compiler/src/main/java/com/github/mustachejava/codes/IterableCode.java b/compiler/src/main/java/com/github/mustachejava/codes/IterableCode.java index bfc11a00..d092dd12 100644 --- a/compiler/src/main/java/com/github/mustachejava/codes/IterableCode.java +++ b/compiler/src/main/java/com/github/mustachejava/codes/IterableCode.java @@ -1,5 +1,6 @@ package com.github.mustachejava.codes; +import com.github.mustachejava.Binding; import com.github.mustachejava.DefaultMustacheFactory; import com.github.mustachejava.FragmentKey; import com.github.mustachejava.Iteration; @@ -25,11 +26,27 @@ public class IterableCode extends DefaultCode implements Iteration { + private static final Binding[] EMPTY_BINDINGS = new Binding[0]; + private final ExecutorService les; + private final Binding[] intermediateBindings; public IterableCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String variable, String type) { super(tc, df, mustache, variable, type); les = df.getExecutorService(); + if ("#".equals(type) && !dynamic && !returnThis && variable != null) { + List bindings = new ArrayList<>(); + int dotIndex = variable.indexOf('.'); + while (dotIndex != -1) { + if (dotIndex > 0) { + bindings.add(oh.createBinding(variable.substring(0, dotIndex), tc, this)); + } + dotIndex = variable.indexOf('.', dotIndex + 1); + } + intermediateBindings = bindings.toArray(EMPTY_BINDINGS); + } else { + intermediateBindings = EMPTY_BINDINGS; + } } public IterableCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String variable) { @@ -95,8 +112,18 @@ protected Writer handleCallable(Writer writer, final Callable callable, final Li return writer; } - @SuppressWarnings("unchecked") protected Writer handleFunction(Writer writer, Function function, List scopes) { + int scopeSize = scopes.size(); + try { + addIntermediateScopes(scopes); + return executeFunction(writer, function, scopes); + } finally { + removeScopes(scopes, scopeSize); + } + } + + @SuppressWarnings("unchecked") + private Writer executeFunction(Writer writer, Function function, List scopes) { StringWriter sw = new StringWriter(); runIdentity(sw); if (function instanceof TemplateFunction) { @@ -135,7 +162,48 @@ protected Writer writeTemplate(Writer writer, String templateText, List } protected Writer execute(Writer writer, Object resolve, List scopes) { - return oh.iterate(this, writer, resolve, scopes); + if (intermediateBindings.length == 0) { + return oh.iterate(this, writer, resolve, scopes); + } + int scopeSize = scopes.size(); + boolean[] added = {false}; + try { + return oh.iterate((currentWriter, next, currentScopes) -> { + if (!added[0]) { + addIntermediateScopes(currentScopes); + added[0] = true; + } + return IterableCode.this.next(currentWriter, next, currentScopes); + }, writer, resolve, scopes); + } finally { + removeScopes(scopes, scopeSize); + } + } + + private void addIntermediateScopes(List scopes) { + Object[] intermediateScopes = new Object[intermediateBindings.length]; + for (int i = 0; i < intermediateBindings.length; i++) { + Object scope = intermediateBindings[i].get(scopes); + while (scope instanceof Callable) { + try { + scope = oh.coerce(((Callable) scope).call()); + } catch (Exception e) { + throw new MustacheException("Failed to invoke intermediate callable", e, tc); + } + } + intermediateScopes[i] = scope; + } + for (Object scope : intermediateScopes) { + if (scope != null) { + addScope(scopes, scope); + } + } + } + + private void removeScopes(List scopes, int scopeSize) { + while (scopes.size() > scopeSize) { + scopes.remove(scopes.size() - 1); + } } public Writer next(Writer writer, Object next, List scopes) { diff --git a/test_debug$1.class b/test_debug$1.class new file mode 100644 index 0000000000000000000000000000000000000000..ff11e6de8cc7fe8d09282405caf39c4c1d82abc0 GIT binary patch literal 1216 zcma)6?M@Rx6g|V&LS1bI`4Z8p1zJE>{6a0^hYbWxMPmv4Fwso6LmAkeX?ABxcoQE( z{UHfId;lNHc&AH34fSK2&fMNT_ug~Q-06>>r{4jr<5>nh=ylNNq8|ecE4~iBrZoFW z-3yc0aDQJM@FVU;VlNQB@!qAo)ap(BfE=4#H3AVa46I5e%^E{*v9vRUK@2&_xVQwD zA=ge3aMko`$FUJ1p>$#xMyu^NQhRSBsZ7m8f`{c{qISha7CDCTkROVQRz5eoM2jpU zrA*8)TTFw~w1(oF<+P9e|a%vYC4I0u_0~c>^Z|Ft0H;Ul^^Jslz%PE zzHT^}p_p@+i#Zgjmyk=6cC7fmgAAQ@`^0t_2Dhm7O&!xfZqGqk~dqoZSnPo2`8 z0n?J{M8^eNGijACQtt5l7Px)f5?$b(O`n}W=a`F(OaK4? literal 0 HcmV?d00001 diff --git a/test_debug.class b/test_debug.class new file mode 100644 index 0000000000000000000000000000000000000000..c71678714038d571ee2985631052a78365848e43 GIT binary patch literal 2220 zcmah~&sW@36#iakAR!Y99q5lhf25X{fwqi5)yj{mrNvf(B4Dvrt&hwD0-0n?-e5T# z-1-M}(}jP4+q$5q!7OsqKP+fiSUTHn(GOM$rO+3OahJ`f&kce{HC$#m5hqxeu2;Ut zqt$6%Gth&ajuJy&Wu~K_vRMp5uTsD@T-PvQqKr2gTKBfnHtIo5_26cfj6??6^ioS- zCgaqkV~F&UaH8SH0qzzG0^Y{gQxxSdGSC>S%pi-rU zDtBr0PL&=Xq@#}#GwwchL`@BLq+zKs!|{avyFy;`K|OTDWIR@Fd%y##!3>2Ns(o50 zSZYT5?I4hm40&x@$km_{=~yM}Ob}MNXFEI+BX%$A)v-oBUtA@%Mm6q;?21P*=R`!u zT}p($nZQXjMOp7D(OSiGZh5u^;%Zk zsaP_6?c2DpSrdo2XZby3c+(IQ-1FwCVwy(tzVC~0!sAgSi2FLj@@2&Gxt!T7qR7Y8kE zKR}DsY_(W=7NI3Zn2r%t88ZpeEG0CzgZZSQ7<5sA8oNkxEm($C*dMA8hk6u;+9Plo z77<7{I1IF-6*gVPCA86r*N!oqAn|t4+0aR3pG1|evhT?MdvxI$y72?f;zyjv3tYf2 zwDUJy!tc0(KQMql=`Q(;ZW9B8tP4Z Date: Thu, 23 Jul 2026 13:42:00 -0700 Subject: [PATCH 2/8] Push intermediate scopes for dotted-name sections Agent-Id: agent-afae2211-1cf8-460a-8aea-733b035005fa Linked-Note-Id: 804110b6-0ff6-4285-ad35-33931c601c67 --- .../github/mustachejava/codes/IterableCode.java | 10 ++++++++++ test_debug$1.class | Bin 1216 -> 0 bytes test_debug.class | Bin 2220 -> 0 bytes 3 files changed, 10 insertions(+) delete mode 100644 test_debug$1.class delete mode 100644 test_debug.class diff --git a/compiler/src/main/java/com/github/mustachejava/codes/IterableCode.java b/compiler/src/main/java/com/github/mustachejava/codes/IterableCode.java index d092dd12..92f06eb1 100644 --- a/compiler/src/main/java/com/github/mustachejava/codes/IterableCode.java +++ b/compiler/src/main/java/com/github/mustachejava/codes/IterableCode.java @@ -8,9 +8,11 @@ import com.github.mustachejava.MustacheException; import com.github.mustachejava.TemplateContext; import com.github.mustachejava.TemplateFunction; +import com.github.mustachejava.reflect.ReflectionWrapper; import com.github.mustachejava.util.InternalArrayList; import com.github.mustachejava.util.LatchedWriter; import com.github.mustachejava.util.Node; +import com.github.mustachejava.util.Wrapper; import java.io.IOException; import java.io.StringWriter; @@ -181,6 +183,14 @@ protected Writer execute(Writer writer, Object resolve, List scopes) { } private void addIntermediateScopes(List scopes) { + Wrapper resolvedWrapper = oh.find(name, scopes); + if (!(resolvedWrapper instanceof ReflectionWrapper)) { + return; + } + Wrapper[] dottedWrappers = ((ReflectionWrapper) resolvedWrapper).getWrappers(); + if (dottedWrappers == null || dottedWrappers.length == 0) { + return; + } Object[] intermediateScopes = new Object[intermediateBindings.length]; for (int i = 0; i < intermediateBindings.length; i++) { Object scope = intermediateBindings[i].get(scopes); diff --git a/test_debug$1.class b/test_debug$1.class deleted file mode 100644 index ff11e6de8cc7fe8d09282405caf39c4c1d82abc0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1216 zcma)6?M@Rx6g|V&LS1bI`4Z8p1zJE>{6a0^hYbWxMPmv4Fwso6LmAkeX?ABxcoQE( z{UHfId;lNHc&AH34fSK2&fMNT_ug~Q-06>>r{4jr<5>nh=ylNNq8|ecE4~iBrZoFW z-3yc0aDQJM@FVU;VlNQB@!qAo)ap(BfE=4#H3AVa46I5e%^E{*v9vRUK@2&_xVQwD zA=ge3aMko`$FUJ1p>$#xMyu^NQhRSBsZ7m8f`{c{qISha7CDCTkROVQRz5eoM2jpU zrA*8)TTFw~w1(oF<+P9e|a%vYC4I0u_0~c>^Z|Ft0H;Ul^^Jslz%PE zzHT^}p_p@+i#Zgjmyk=6cC7fmgAAQ@`^0t_2Dhm7O&!xfZqGqk~dqoZSnPo2`8 z0n?J{M8^eNGijACQtt5l7Px)f5?$b(O`n}W=a`F(OaK4? diff --git a/test_debug.class b/test_debug.class deleted file mode 100644 index c71678714038d571ee2985631052a78365848e43..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2220 zcmah~&sW@36#iakAR!Y99q5lhf25X{fwqi5)yj{mrNvf(B4Dvrt&hwD0-0n?-e5T# z-1-M}(}jP4+q$5q!7OsqKP+fiSUTHn(GOM$rO+3OahJ`f&kce{HC$#m5hqxeu2;Ut zqt$6%Gth&ajuJy&Wu~K_vRMp5uTsD@T-PvQqKr2gTKBfnHtIo5_26cfj6??6^ioS- zCgaqkV~F&UaH8SH0qzzG0^Y{gQxxSdGSC>S%pi-rU zDtBr0PL&=Xq@#}#GwwchL`@BLq+zKs!|{avyFy;`K|OTDWIR@Fd%y##!3>2Ns(o50 zSZYT5?I4hm40&x@$km_{=~yM}Ob}MNXFEI+BX%$A)v-oBUtA@%Mm6q;?21P*=R`!u zT}p($nZQXjMOp7D(OSiGZh5u^;%Zk zsaP_6?c2DpSrdo2XZby3c+(IQ-1FwCVwy(tzVC~0!sAgSi2FLj@@2&Gxt!T7qR7Y8kE zKR}DsY_(W=7NI3Zn2r%t88ZpeEG0CzgZZSQ7<5sA8oNkxEm($C*dMA8hk6u;+9Plo z77<7{I1IF-6*gVPCA86r*N!oqAn|t4+0aR3pG1|evhT?MdvxI$y72?f;zyjv3tYf2 zwDUJy!tc0(KQMql=`Q(;ZW9B8tP4Z Date: Thu, 23 Jul 2026 13:42:21 -0700 Subject: [PATCH 3/8] Invoke intermediate callables in dotted names Agent-Id: agent-afae2211-1cf8-460a-8aea-733b035005fa --- .../reflect/ReflectionObjectHandler.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/compiler/src/main/java/com/github/mustachejava/reflect/ReflectionObjectHandler.java b/compiler/src/main/java/com/github/mustachejava/reflect/ReflectionObjectHandler.java index 84902a53..c7781147 100644 --- a/compiler/src/main/java/com/github/mustachejava/reflect/ReflectionObjectHandler.java +++ b/compiler/src/main/java/com/github/mustachejava/reflect/ReflectionObjectHandler.java @@ -2,6 +2,7 @@ import com.github.mustachejava.Binding; import com.github.mustachejava.Code; +import com.github.mustachejava.MustacheException; import com.github.mustachejava.ObjectHandler; import com.github.mustachejava.TemplateContext; import com.github.mustachejava.reflect.guards.ClassGuard; @@ -18,6 +19,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.Callable; import static java.util.Collections.singletonList; @@ -31,6 +33,17 @@ public class ReflectionObjectHandler extends BaseObjectHandler { private static final Wrapper[] EMPTY_WRAPPERS = new Wrapper[0]; private static final Guard[] EMPTY_GUARDS = new Guard[0]; + private static final Wrapper CALLABLE_WRAPPER = scopes -> { + Object scope = scopes.get(0); + if (!(scope instanceof Callable)) { + throw new GuardException(); + } + try { + return ((Callable) scope).call(); + } catch (Exception e) { + throw new MustacheException("Failed to invoke callable while resolving dotted name", e); + } + }; protected static final Method MAP_METHOD; @@ -92,6 +105,10 @@ public Wrapper find(String name, final List scopes) { try { // Pull out the next level from the coerced scope scope = coerce(wrapper.call(ObjectHandler.makeList(coerce(scope)))); + while (scope instanceof Callable) { + wrappers.add(CALLABLE_WRAPPER); + scope = coerce(CALLABLE_WRAPPER.call(ObjectHandler.makeList(scope))); + } } catch (GuardException e) { throw new AssertionError(e); } From efe93d752aae57d48bf6e60f8f706901aef9dfcd Mon Sep 17 00:00:00 2001 From: Sam Pullara Date: Thu, 23 Jul 2026 13:50:56 -0700 Subject: [PATCH 4/8] Add regression tests for dotted section scoping Agent-Id: agent-af8bcf7c-4595-4b36-a217-2b132bb9a1eb Linked-Note-Id: 507df405-bb15-40b9-bef8-4ad8222e7ae4 --- .../github/mustachejava/DotNotationTest.java | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/compiler/src/test/java/com/github/mustachejava/DotNotationTest.java b/compiler/src/test/java/com/github/mustachejava/DotNotationTest.java index a99b4287..841ed161 100644 --- a/compiler/src/test/java/com/github/mustachejava/DotNotationTest.java +++ b/compiler/src/test/java/com/github/mustachejava/DotNotationTest.java @@ -8,6 +8,8 @@ import java.io.StringWriter; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.Callable; +import java.util.function.Function; import static org.junit.Assert.assertEquals; @@ -59,6 +61,121 @@ public void testIncompleteObjectPath() { public void testAlmostCompleteObjectPath() { testMiss(objectModel, LAST_ELEMENT_MISS_TEMPLATE); } + + @Test + public void testDottedSectionPushesIntermediateScope() throws Exception { + Map bar = map(); + Map foo = map("bar", bar, "baz", "from foo"); + Map model = map("foo", foo); + + String dotted = render(compile("{{#foo.bar}}{{baz}}{{/foo.bar}}"), model); + String nested = render(compile("{{#foo}}{{#bar}}{{baz}}{{/bar}}{{/foo}}"), model); + + assertEquals("from foo", dotted); + assertEquals(nested, dotted); + } + + @Test + public void testDottedSectionPrefersFinalScope() throws Exception { + Map bar = map("baz", "from bar"); + Map foo = map("bar", bar, "baz", "from foo"); + + assertEquals("from bar", render(compile("{{#foo.bar}}{{baz}}{{/foo.bar}}"), map("foo", foo))); + } + + @Test + public void testThreePartDottedSectionPushesAllScopes() throws Exception { + Map c = map("fromC", "c"); + Map b = map("c", c, "fromB", "b"); + Map a = map("b", b, "fromA", "a"); + + assertEquals("a/b/c", render(compile( + "{{#a.b.c}}{{fromA}}/{{fromB}}/{{fromC}}{{/a.b.c}}"), map("a", a))); + } + + @Test + public void testDottedSectionPreservesContextPrecedence() throws Exception { + Map a = map("b", map()); + Map model = map("a", a, "c", true); + + assertEquals("", render(compile("{{#a.b.c}}ERROR{{/a.b.c}}"), model)); + } + + @Test + public void testBrokenDottedSectionChainRendersNothing() throws Exception { + assertEquals("", render(compile("{{#a.b.c}}ERROR{{/a.b.c}}"), map("a", map()))); + } + + @Test + public void testLiteralDottedKeyDoesNotPushIntermediateScope() throws Exception { + Mustache mustache = compile("{{#foo.bar}}{{baz}}{{/foo.bar}}"); + Map literalOnly = map("foo.bar", map("value", true), "baz", "root"); + Map collision = map( + "foo.bar", map("value", true), + "foo", map("baz", "wrong intermediate"), + "baz", "root"); + + assertEquals("root", render(mustache, literalOnly)); + assertEquals("root", render(mustache, collision)); + } + + @Test + public void testInvertedDottedSectionBehaviorIsUnchanged() throws Exception { + Mustache mustache = compile("{{^foo.bar}}missing{{/foo.bar}}"); + + assertEquals("", render(mustache, map("foo", map("bar", true)))); + assertEquals("missing", render(mustache, map("foo", map()))); + assertEquals("", render(mustache, map("foo.bar", true))); + } + + @Test + public void testCallableIntermediateMatchesNestedSections() throws Exception { + Callable foo = () -> map( + "bar", (Function) text -> "baz"); + Map model = map("foo", foo); + + String dotted = render(compile("{{#foo.bar}}quux{{/foo.bar}}"), model); + String nested = render(compile("{{#foo}}{{#bar}}quux{{/bar}}{{/foo}}"), model); + + assertEquals("baz", dotted); + assertEquals(nested, dotted); + } + + @Test + public void testNullCallableBreaksDottedSectionChain() throws Exception { + Callable foo = () -> null; + + assertEquals("", render(compile("{{#foo.bar}}ERROR{{/foo.bar}}"), map("foo", foo))); + } + + @Test + public void testCallableInMiddleOfThreePartName() throws Exception { + Callable b = () -> map("c", map("fromC", "c"), "fromB", "b"); + Map a = map("b", b, "fromA", "a"); + + assertEquals("a/b/c", render(compile( + "{{#a.b.c}}{{fromA}}/{{fromB}}/{{fromC}}{{/a.b.c}}"), map("a", a))); + } + + @Test + public void testAlternatingDottedSectionShapesReuseCachedWrappers() throws Exception { + Mustache mustache = compile("{{#foo.bar}}{{baz}}{{/foo.bar}}"); + Map literal = map( + "foo.bar", map("value", true), + "foo", map("baz", "wrong intermediate"), + "baz", "literal"); + Map nested = map( + "foo", map("bar", map(), "baz", "nested"), + "baz", "wrong root"); + Callable foo = () -> map("bar", map(), "baz", "callable"); + Map callable = map("foo", foo, "baz", "wrong root"); + + for (int i = 0; i < 2; i++) { + assertEquals("literal", render(mustache, literal)); + assertEquals("nested", render(mustache, nested)); + assertEquals("callable", render(mustache, callable)); + } + } private void testMiss(Object model, String template) { Mustache mustache = compile(template); @@ -72,6 +189,20 @@ private Mustache compile(String template) { Reader reader = new StringReader(template); return factory.compile(reader, "template"); } + + private String render(Mustache mustache, Object model) throws Exception { + StringWriter writer = new StringWriter(); + mustache.execute(writer, model).close(); + return writer.toString(); + } + + private static Map map(Object... entries) { + Map map = new HashMap<>(); + for (int i = 0; i < entries.length; i += 2) { + map.put((String) entries[i], entries[i + 1]); + } + return map; + } } From 3116f76cb5ad6bccd730c765bf6ed249b1d56a11 Mon Sep 17 00:00:00 2001 From: Sam Pullara Date: Thu, 23 Jul 2026 14:47:08 -0700 Subject: [PATCH 5/8] Add AGENTS.md repository guidelines --- AGENTS.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..f8d70adb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +- Multi‑module Maven build. Key modules: `compiler` (core), `javascript`, `handlebar`, `scala-extensions/*`, `mustache-maven-plugin`, `example`, `benchmarks`. +- Standard layout per module: `src/main/java|resources`, `src/test/java|resources` (Scala tests in `scala-extensions-*/src/test/scala`). +- Examples live under `example/src/main`, benchmarks under `benchmarks/src/main` and `benchmarks/src/main/resources`. + +## Build, Test, and Development Commands + +- Build all modules: `mvn -q package` (use `-DskipTests` to skip tests). +- Run all tests: `mvn -q test`. +- Build/test a single module (with deps): `mvn -q -pl compiler -am test`. +- Clean: `mvn -q clean`. +- Optional coverage (if configured): `mvn -q test jacoco:report`. + +## Coding Style & Naming Conventions + +- Language: Java (and some Scala in `scala-extensions`). Follow the existing two‑space indent and K&R braces in Java sources. +- Packages: lowercase; Classes: `PascalCase`; methods/fields: `lowerCamelCase`. +- Tests end with `*Test` and mirror source package structure. +- Keep imports organized; avoid unused code; prefer immutability (`final`) where sensible. No strict linter is enforced—match nearby style. + +## Testing Guidelines + +- Frameworks: JUnit 4 for Java tests; Scala tests use JUnit annotations under `scala-extensions`. +- Add tests for new features and bug fixes—focus on `compiler` behavior and edge cases (whitespace, partials, recursion limits). +- Run a single test: `mvn -q -Dtest=JavascriptObjectHandlerTest test` (adjust class name as needed). + +## Commit & Pull Request Guidelines + +- Commits: concise, imperative subject; reference issues when relevant (e.g., `ISSUE #257: clarify dynamic partials`). +- PRs must include: clear description, linked issues, tests or rationale for test impact, and any doc/example updates. +- CI hygiene: ensure `mvn -q package` and `mvn -q test` pass locally. Avoid breaking public APIs in `com.github.mustachejava` without discussion. +- If rendering output changes, include sample template and before/after output in the PR. + +## Agent-Specific Instructions + +- Keep changes scoped to the smallest relevant module. Do not alter public APIs lightly—preserve backwards compatibility and spec‑compliant whitespace behavior. +- Update README/examples only when behavior changes; otherwise keep diffs minimal. \ No newline at end of file From cc69244a683f40d31544d8bcfb003fb1750f883a Mon Sep 17 00:00:00 2001 From: Sam Pullara Date: Thu, 23 Jul 2026 15:27:51 -0700 Subject: [PATCH 6/8] Reduce redundant dotted section invocations Agent-Id: agent-7d67dd5b-4ec4-4525-aaa5-f1f08c624495 --- .../reflect/ReflectionWrapper.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/compiler/src/main/java/com/github/mustachejava/reflect/ReflectionWrapper.java b/compiler/src/main/java/com/github/mustachejava/reflect/ReflectionWrapper.java index bd59f90c..968f8dc6 100644 --- a/compiler/src/main/java/com/github/mustachejava/reflect/ReflectionWrapper.java +++ b/compiler/src/main/java/com/github/mustachejava/reflect/ReflectionWrapper.java @@ -12,6 +12,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.concurrent.Callable; /** * Used for evaluating values at a callsite @@ -54,6 +55,26 @@ protected Object unwrap(List scopes) { public Object call(List scopes) throws GuardException { guardCall(scopes); Object scope = oh.coerce(unwrap(scopes)); + return call(scope, scopes); + } + + /** + * Resolve a freshly found dotted wrapper while collecting the intermediate scopes. + */ + public Object callDotted(List scopes, List intermediateScopes) throws GuardException { + Object scope = oh.coerce(scopes.get(scopeIndex)); + if (wrappers != null) { + for (Wrapper wrapper : wrappers) { + scope = oh.coerce(wrapper.call(ObjectHandler.makeList(scope))); + if (!(scope instanceof Callable)) { + intermediateScopes.add(scope); + } + } + } + return call(scope, scopes); + } + + private Object call(Object scope, List scopes) { try { if (scope == null) return null; if (method == null) { From 8f4f4dfb34c1e1aedcf8729b856319788b2cb52e Mon Sep 17 00:00:00 2001 From: Sam Pullara Date: Thu, 23 Jul 2026 15:28:07 -0700 Subject: [PATCH 7/8] Use resolved dotted scopes during section rendering Agent-Id: agent-7d67dd5b-4ec4-4525-aaa5-f1f08c624495 --- .../mustachejava/codes/IterableCode.java | 98 +++++++++++-------- .../github/mustachejava/DotNotationTest.java | 20 ++++ 2 files changed, 78 insertions(+), 40 deletions(-) diff --git a/compiler/src/main/java/com/github/mustachejava/codes/IterableCode.java b/compiler/src/main/java/com/github/mustachejava/codes/IterableCode.java index 92f06eb1..006a7ce1 100644 --- a/compiler/src/main/java/com/github/mustachejava/codes/IterableCode.java +++ b/compiler/src/main/java/com/github/mustachejava/codes/IterableCode.java @@ -1,6 +1,5 @@ package com.github.mustachejava.codes; -import com.github.mustachejava.Binding; import com.github.mustachejava.DefaultMustacheFactory; import com.github.mustachejava.FragmentKey; import com.github.mustachejava.Iteration; @@ -8,6 +7,7 @@ import com.github.mustachejava.MustacheException; import com.github.mustachejava.TemplateContext; import com.github.mustachejava.TemplateFunction; +import com.github.mustachejava.reflect.ReflectionObjectHandler; import com.github.mustachejava.reflect.ReflectionWrapper; import com.github.mustachejava.util.InternalArrayList; import com.github.mustachejava.util.LatchedWriter; @@ -28,27 +28,16 @@ public class IterableCode extends DefaultCode implements Iteration { - private static final Binding[] EMPTY_BINDINGS = new Binding[0]; + private static final Object[] EMPTY_INTERMEDIATE_SCOPES = new Object[0]; private final ExecutorService les; - private final Binding[] intermediateBindings; + private final boolean dottedSection; + private final ThreadLocal intermediateScopes = new ThreadLocal<>(); public IterableCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String variable, String type) { super(tc, df, mustache, variable, type); les = df.getExecutorService(); - if ("#".equals(type) && !dynamic && !returnThis && variable != null) { - List bindings = new ArrayList<>(); - int dotIndex = variable.indexOf('.'); - while (dotIndex != -1) { - if (dotIndex > 0) { - bindings.add(oh.createBinding(variable.substring(0, dotIndex), tc, this)); - } - dotIndex = variable.indexOf('.', dotIndex + 1); - } - intermediateBindings = bindings.toArray(EMPTY_BINDINGS); - } else { - intermediateBindings = EMPTY_BINDINGS; - } + dottedSection = "#".equals(type) && !dynamic && !returnThis && hasIntermediateName(variable); } public IterableCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String variable) { @@ -58,11 +47,38 @@ public IterableCode(TemplateContext tc, DefaultMustacheFactory df, Mustache must @Override public Writer execute(Writer writer, final List scopes) { Object resolved = get(scopes); - writer = handle(writer, resolved, scopes); + try { + writer = handle(writer, resolved, scopes); + } finally { + intermediateScopes.remove(); + } appendText(writer); return writer; } + @Override + public Object get(List scopes) { + if (!dottedSection || !(oh instanceof ReflectionObjectHandler)) { + return super.get(scopes); + } + try { + Wrapper wrapper = oh.find(name, scopes); + if (wrapper instanceof ReflectionWrapper) { + List resolvedScopes = new ArrayList<>(); + Object resolved = ((ReflectionWrapper) wrapper).callDotted(scopes, resolvedScopes); + intermediateScopes.set(resolvedScopes.toArray(EMPTY_INTERMEDIATE_SCOPES)); + return oh.coerce(resolved); + } + intermediateScopes.remove(); + return oh.coerce(wrapper.call(scopes)); + } catch (MustacheException e) { + e.setContext(tc); + throw e; + } catch (Throwable e) { + throw new MustacheException(e.getMessage(), e, tc); + } + } + protected Writer handle(Writer writer, Object resolved, List scopes) { if (resolved != null) { if (resolved instanceof Function) { @@ -92,11 +108,13 @@ protected Writer handleCallable(Writer writer, final Callable callable, final Li } final Writer originalWriter = writer; final LatchedWriter latchedWriter = new LatchedWriter(writer); + final Object[] resolvedScopes = intermediateScopes.get(); writer = latchedWriter; // Scopes must not cross thread boundaries as they // are thread locally reused final List newScopes = new InternalArrayList<>(scopes); les.execute(() -> { + intermediateScopes.set(resolvedScopes); try { Object call = callable.call(); Writer subWriter = handle(originalWriter, call, newScopes); @@ -108,6 +126,8 @@ protected Writer handleCallable(Writer writer, final Callable callable, final Li latchedWriter.done(); } catch (Throwable e) { latchedWriter.failed(e); + } finally { + intermediateScopes.remove(); } }); } @@ -117,7 +137,7 @@ protected Writer handleCallable(Writer writer, final Callable callable, final Li protected Writer handleFunction(Writer writer, Function function, List scopes) { int scopeSize = scopes.size(); try { - addIntermediateScopes(scopes); + addIntermediateScopes(scopes, intermediateScopes.get()); return executeFunction(writer, function, scopes); } finally { removeScopes(scopes, scopeSize); @@ -164,7 +184,8 @@ protected Writer writeTemplate(Writer writer, String templateText, List } protected Writer execute(Writer writer, Object resolve, List scopes) { - if (intermediateBindings.length == 0) { + Object[] resolvedScopes = intermediateScopes.get(); + if (resolvedScopes == null || resolvedScopes.length == 0) { return oh.iterate(this, writer, resolve, scopes); } int scopeSize = scopes.size(); @@ -172,7 +193,7 @@ protected Writer execute(Writer writer, Object resolve, List scopes) { try { return oh.iterate((currentWriter, next, currentScopes) -> { if (!added[0]) { - addIntermediateScopes(currentScopes); + addIntermediateScopes(currentScopes, resolvedScopes); added[0] = true; } return IterableCode.this.next(currentWriter, next, currentScopes); @@ -182,34 +203,31 @@ protected Writer execute(Writer writer, Object resolve, List scopes) { } } - private void addIntermediateScopes(List scopes) { - Wrapper resolvedWrapper = oh.find(name, scopes); - if (!(resolvedWrapper instanceof ReflectionWrapper)) { - return; - } - Wrapper[] dottedWrappers = ((ReflectionWrapper) resolvedWrapper).getWrappers(); - if (dottedWrappers == null || dottedWrappers.length == 0) { + private void addIntermediateScopes(List scopes, Object[] resolvedScopes) { + if (resolvedScopes == null) { return; } - Object[] intermediateScopes = new Object[intermediateBindings.length]; - for (int i = 0; i < intermediateBindings.length; i++) { - Object scope = intermediateBindings[i].get(scopes); - while (scope instanceof Callable) { - try { - scope = oh.coerce(((Callable) scope).call()); - } catch (Exception e) { - throw new MustacheException("Failed to invoke intermediate callable", e, tc); - } - } - intermediateScopes[i] = scope; - } - for (Object scope : intermediateScopes) { + for (Object scope : resolvedScopes) { if (scope != null) { addScope(scopes, scope); } } } + private static boolean hasIntermediateName(String variable) { + if (variable == null) { + return false; + } + int dotIndex = variable.indexOf('.'); + while (dotIndex != -1) { + if (dotIndex > 0) { + return true; + } + dotIndex = variable.indexOf('.', dotIndex + 1); + } + return false; + } + private void removeScopes(List scopes, int scopeSize) { while (scopes.size() > scopeSize) { scopes.remove(scopes.size() - 1); diff --git a/compiler/src/test/java/com/github/mustachejava/DotNotationTest.java b/compiler/src/test/java/com/github/mustachejava/DotNotationTest.java index 841ed161..556ea81f 100644 --- a/compiler/src/test/java/com/github/mustachejava/DotNotationTest.java +++ b/compiler/src/test/java/com/github/mustachejava/DotNotationTest.java @@ -9,6 +9,7 @@ import java.util.HashMap; import java.util.Map; import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import static org.junit.Assert.assertEquals; @@ -141,6 +142,25 @@ public void testCallableIntermediateMatchesNestedSections() throws Exception { assertEquals(nested, dotted); } + @Test + public void testCallableIntermediateInvocationCount() throws Exception { + AtomicInteger dottedInvocations = new AtomicInteger(); + Callable dottedFoo = () -> { + dottedInvocations.incrementAndGet(); + return map("bar", true); + }; + AtomicInteger nestedInvocations = new AtomicInteger(); + Callable nestedFoo = () -> { + nestedInvocations.incrementAndGet(); + return map("bar", true); + }; + + assertEquals("dotted", render(compile("{{#foo.bar}}dotted{{/foo.bar}}"), map("foo", dottedFoo))); + assertEquals("nested", render(compile("{{#foo}}{{#bar}}nested{{/bar}}{{/foo}}"), map("foo", nestedFoo))); + assertEquals("Dotted lookup and resolution each invoke the intermediate once", 2, dottedInvocations.get()); + assertEquals(1, nestedInvocations.get()); + } + @Test public void testNullCallableBreaksDottedSectionChain() throws Exception { Callable foo = () -> null; From 60df784980415b9b8c0c24c5077ad9dbb53ea739 Mon Sep 17 00:00:00 2001 From: Sam Pullara Date: Thu, 23 Jul 2026 16:09:12 -0700 Subject: [PATCH 8/8] Keep only Callable dotted-name traversal Agent-Id: agent-8def19d3-9b6b-4877-bbce-d3164fc53f9e --- .../mustachejava/codes/IterableCode.java | 102 +----------------- .../reflect/ReflectionWrapper.java | 21 ---- .../github/mustachejava/DotNotationTest.java | 67 ++---------- 3 files changed, 9 insertions(+), 181 deletions(-) diff --git a/compiler/src/main/java/com/github/mustachejava/codes/IterableCode.java b/compiler/src/main/java/com/github/mustachejava/codes/IterableCode.java index 006a7ce1..bfc11a00 100644 --- a/compiler/src/main/java/com/github/mustachejava/codes/IterableCode.java +++ b/compiler/src/main/java/com/github/mustachejava/codes/IterableCode.java @@ -7,12 +7,9 @@ import com.github.mustachejava.MustacheException; import com.github.mustachejava.TemplateContext; import com.github.mustachejava.TemplateFunction; -import com.github.mustachejava.reflect.ReflectionObjectHandler; -import com.github.mustachejava.reflect.ReflectionWrapper; import com.github.mustachejava.util.InternalArrayList; import com.github.mustachejava.util.LatchedWriter; import com.github.mustachejava.util.Node; -import com.github.mustachejava.util.Wrapper; import java.io.IOException; import java.io.StringWriter; @@ -28,16 +25,11 @@ public class IterableCode extends DefaultCode implements Iteration { - private static final Object[] EMPTY_INTERMEDIATE_SCOPES = new Object[0]; - private final ExecutorService les; - private final boolean dottedSection; - private final ThreadLocal intermediateScopes = new ThreadLocal<>(); public IterableCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String variable, String type) { super(tc, df, mustache, variable, type); les = df.getExecutorService(); - dottedSection = "#".equals(type) && !dynamic && !returnThis && hasIntermediateName(variable); } public IterableCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String variable) { @@ -47,38 +39,11 @@ public IterableCode(TemplateContext tc, DefaultMustacheFactory df, Mustache must @Override public Writer execute(Writer writer, final List scopes) { Object resolved = get(scopes); - try { - writer = handle(writer, resolved, scopes); - } finally { - intermediateScopes.remove(); - } + writer = handle(writer, resolved, scopes); appendText(writer); return writer; } - @Override - public Object get(List scopes) { - if (!dottedSection || !(oh instanceof ReflectionObjectHandler)) { - return super.get(scopes); - } - try { - Wrapper wrapper = oh.find(name, scopes); - if (wrapper instanceof ReflectionWrapper) { - List resolvedScopes = new ArrayList<>(); - Object resolved = ((ReflectionWrapper) wrapper).callDotted(scopes, resolvedScopes); - intermediateScopes.set(resolvedScopes.toArray(EMPTY_INTERMEDIATE_SCOPES)); - return oh.coerce(resolved); - } - intermediateScopes.remove(); - return oh.coerce(wrapper.call(scopes)); - } catch (MustacheException e) { - e.setContext(tc); - throw e; - } catch (Throwable e) { - throw new MustacheException(e.getMessage(), e, tc); - } - } - protected Writer handle(Writer writer, Object resolved, List scopes) { if (resolved != null) { if (resolved instanceof Function) { @@ -108,13 +73,11 @@ protected Writer handleCallable(Writer writer, final Callable callable, final Li } final Writer originalWriter = writer; final LatchedWriter latchedWriter = new LatchedWriter(writer); - final Object[] resolvedScopes = intermediateScopes.get(); writer = latchedWriter; // Scopes must not cross thread boundaries as they // are thread locally reused final List newScopes = new InternalArrayList<>(scopes); les.execute(() -> { - intermediateScopes.set(resolvedScopes); try { Object call = callable.call(); Writer subWriter = handle(originalWriter, call, newScopes); @@ -126,26 +89,14 @@ protected Writer handleCallable(Writer writer, final Callable callable, final Li latchedWriter.done(); } catch (Throwable e) { latchedWriter.failed(e); - } finally { - intermediateScopes.remove(); } }); } return writer; } - protected Writer handleFunction(Writer writer, Function function, List scopes) { - int scopeSize = scopes.size(); - try { - addIntermediateScopes(scopes, intermediateScopes.get()); - return executeFunction(writer, function, scopes); - } finally { - removeScopes(scopes, scopeSize); - } - } - @SuppressWarnings("unchecked") - private Writer executeFunction(Writer writer, Function function, List scopes) { + protected Writer handleFunction(Writer writer, Function function, List scopes) { StringWriter sw = new StringWriter(); runIdentity(sw); if (function instanceof TemplateFunction) { @@ -184,54 +135,7 @@ protected Writer writeTemplate(Writer writer, String templateText, List } protected Writer execute(Writer writer, Object resolve, List scopes) { - Object[] resolvedScopes = intermediateScopes.get(); - if (resolvedScopes == null || resolvedScopes.length == 0) { - return oh.iterate(this, writer, resolve, scopes); - } - int scopeSize = scopes.size(); - boolean[] added = {false}; - try { - return oh.iterate((currentWriter, next, currentScopes) -> { - if (!added[0]) { - addIntermediateScopes(currentScopes, resolvedScopes); - added[0] = true; - } - return IterableCode.this.next(currentWriter, next, currentScopes); - }, writer, resolve, scopes); - } finally { - removeScopes(scopes, scopeSize); - } - } - - private void addIntermediateScopes(List scopes, Object[] resolvedScopes) { - if (resolvedScopes == null) { - return; - } - for (Object scope : resolvedScopes) { - if (scope != null) { - addScope(scopes, scope); - } - } - } - - private static boolean hasIntermediateName(String variable) { - if (variable == null) { - return false; - } - int dotIndex = variable.indexOf('.'); - while (dotIndex != -1) { - if (dotIndex > 0) { - return true; - } - dotIndex = variable.indexOf('.', dotIndex + 1); - } - return false; - } - - private void removeScopes(List scopes, int scopeSize) { - while (scopes.size() > scopeSize) { - scopes.remove(scopes.size() - 1); - } + return oh.iterate(this, writer, resolve, scopes); } public Writer next(Writer writer, Object next, List scopes) { diff --git a/compiler/src/main/java/com/github/mustachejava/reflect/ReflectionWrapper.java b/compiler/src/main/java/com/github/mustachejava/reflect/ReflectionWrapper.java index 968f8dc6..bd59f90c 100644 --- a/compiler/src/main/java/com/github/mustachejava/reflect/ReflectionWrapper.java +++ b/compiler/src/main/java/com/github/mustachejava/reflect/ReflectionWrapper.java @@ -12,7 +12,6 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.concurrent.Callable; /** * Used for evaluating values at a callsite @@ -55,26 +54,6 @@ protected Object unwrap(List scopes) { public Object call(List scopes) throws GuardException { guardCall(scopes); Object scope = oh.coerce(unwrap(scopes)); - return call(scope, scopes); - } - - /** - * Resolve a freshly found dotted wrapper while collecting the intermediate scopes. - */ - public Object callDotted(List scopes, List intermediateScopes) throws GuardException { - Object scope = oh.coerce(scopes.get(scopeIndex)); - if (wrappers != null) { - for (Wrapper wrapper : wrappers) { - scope = oh.coerce(wrapper.call(ObjectHandler.makeList(scope))); - if (!(scope instanceof Callable)) { - intermediateScopes.add(scope); - } - } - } - return call(scope, scopes); - } - - private Object call(Object scope, List scopes) { try { if (scope == null) return null; if (method == null) { diff --git a/compiler/src/test/java/com/github/mustachejava/DotNotationTest.java b/compiler/src/test/java/com/github/mustachejava/DotNotationTest.java index 556ea81f..e2d868bd 100644 --- a/compiler/src/test/java/com/github/mustachejava/DotNotationTest.java +++ b/compiler/src/test/java/com/github/mustachejava/DotNotationTest.java @@ -9,7 +9,6 @@ import java.util.HashMap; import java.util.Map; import java.util.concurrent.Callable; -import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import static org.junit.Assert.assertEquals; @@ -64,16 +63,11 @@ public void testAlmostCompleteObjectPath() { } @Test - public void testDottedSectionPushesIntermediateScope() throws Exception { - Map bar = map(); - Map foo = map("bar", bar, "baz", "from foo"); - Map model = map("foo", foo); - - String dotted = render(compile("{{#foo.bar}}{{baz}}{{/foo.bar}}"), model); - String nested = render(compile("{{#foo}}{{#bar}}{{baz}}{{/bar}}{{/foo}}"), model); + public void testDottedSectionDoesNotPushIntermediateScope() throws Exception { + Map a = map("x", "A", "b", map()); + Map model = map("a", a, "x", "ROOT"); - assertEquals("from foo", dotted); - assertEquals(nested, dotted); + assertEquals("ROOT", render(compile("{{#a.b}}{{x}}{{/a.b}}"), model)); } @Test @@ -84,16 +78,6 @@ public void testDottedSectionPrefersFinalScope() throws Exception { assertEquals("from bar", render(compile("{{#foo.bar}}{{baz}}{{/foo.bar}}"), map("foo", foo))); } - @Test - public void testThreePartDottedSectionPushesAllScopes() throws Exception { - Map c = map("fromC", "c"); - Map b = map("c", c, "fromB", "b"); - Map a = map("b", b, "fromA", "a"); - - assertEquals("a/b/c", render(compile( - "{{#a.b.c}}{{fromA}}/{{fromB}}/{{fromC}}{{/a.b.c}}"), map("a", a))); - } - @Test public void testDottedSectionPreservesContextPrecedence() throws Exception { Map a = map("b", map()); @@ -142,25 +126,6 @@ public void testCallableIntermediateMatchesNestedSections() throws Exception { assertEquals(nested, dotted); } - @Test - public void testCallableIntermediateInvocationCount() throws Exception { - AtomicInteger dottedInvocations = new AtomicInteger(); - Callable dottedFoo = () -> { - dottedInvocations.incrementAndGet(); - return map("bar", true); - }; - AtomicInteger nestedInvocations = new AtomicInteger(); - Callable nestedFoo = () -> { - nestedInvocations.incrementAndGet(); - return map("bar", true); - }; - - assertEquals("dotted", render(compile("{{#foo.bar}}dotted{{/foo.bar}}"), map("foo", dottedFoo))); - assertEquals("nested", render(compile("{{#foo}}{{#bar}}nested{{/bar}}{{/foo}}"), map("foo", nestedFoo))); - assertEquals("Dotted lookup and resolution each invoke the intermediate once", 2, dottedInvocations.get()); - assertEquals(1, nestedInvocations.get()); - } - @Test public void testNullCallableBreaksDottedSectionChain() throws Exception { Callable foo = () -> null; @@ -173,28 +138,8 @@ public void testCallableInMiddleOfThreePartName() throws Exception { Callable b = () -> map("c", map("fromC", "c"), "fromB", "b"); Map a = map("b", b, "fromA", "a"); - assertEquals("a/b/c", render(compile( - "{{#a.b.c}}{{fromA}}/{{fromB}}/{{fromC}}{{/a.b.c}}"), map("a", a))); - } - - @Test - public void testAlternatingDottedSectionShapesReuseCachedWrappers() throws Exception { - Mustache mustache = compile("{{#foo.bar}}{{baz}}{{/foo.bar}}"); - Map literal = map( - "foo.bar", map("value", true), - "foo", map("baz", "wrong intermediate"), - "baz", "literal"); - Map nested = map( - "foo", map("bar", map(), "baz", "nested"), - "baz", "wrong root"); - Callable foo = () -> map("bar", map(), "baz", "callable"); - Map callable = map("foo", foo, "baz", "wrong root"); - - for (int i = 0; i < 2; i++) { - assertEquals("literal", render(mustache, literal)); - assertEquals("nested", render(mustache, nested)); - assertEquals("callable", render(mustache, callable)); - } + assertEquals("c", render(compile( + "{{#a.b.c}}{{fromC}}{{/a.b.c}}"), map("a", a))); } private void testMiss(Object model, String template) {