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 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); } diff --git a/compiler/src/test/java/com/github/mustachejava/DotNotationTest.java b/compiler/src/test/java/com/github/mustachejava/DotNotationTest.java index a99b4287..e2d868bd 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,86 @@ public void testIncompleteObjectPath() { public void testAlmostCompleteObjectPath() { testMiss(objectModel, LAST_ELEMENT_MISS_TEMPLATE); } + + @Test + public void testDottedSectionDoesNotPushIntermediateScope() throws Exception { + Map a = map("x", "A", "b", map()); + Map model = map("a", a, "x", "ROOT"); + + assertEquals("ROOT", render(compile("{{#a.b}}{{x}}{{/a.b}}"), model)); + } + + @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 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("c", render(compile( + "{{#a.b.c}}{{fromC}}{{/a.b.c}}"), map("a", a))); + } private void testMiss(Object model, String template) { Mustache mustache = compile(template); @@ -72,6 +154,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; + } }