diff --git a/README.md b/README.md
index 5050fe6..41ffa3e 100644
--- a/README.md
+++ b/README.md
@@ -105,6 +105,7 @@ Sections behave as you would expect:
* `Boolean` values enable or disable the section.
* Array, `Iterator`, or `Iterable` values repeatedly execute the section with each element used as the context for each iteration. Empty collections result in zero instances of the section being included in the template.
+ * An unresolvable or null value is treated as false (by default, see //Default Values// for more details).
* Any other object results in a single execution of the section with that object as a context.
See the code in
@@ -134,9 +135,9 @@ the template.
Default Values
--------------
-By default, an exception will be thrown any time a variable cannot be found, or
-resolves to null. A value to use in such circumstances can be provided when
-creating your compiler:
+By default, an exception will be thrown any time a variable cannot be resolved,
+or resolves to null. You can change this behavior in two ways. If you want to
+provide a value for use in all such circumstances, use `defaultValue()`:
String tmpl = "{{exists}} {{nullValued}} {{doesNotExist}}?";
Mustache.compiler().defaultValue("what").compile(tmpl).execute(new Object() {
@@ -147,6 +148,33 @@ creating your compiler:
// result:
Say what what?
+If you only wish to provide a default value for variables that resolve to null,
+and wish to preserve exceptions in cases where variables cannot be resolved,
+use `nullValue()`:
+
+ String tmpl = "{{exists}} {{nullValued}} {{doesNotExist}}?";
+ Mustache.compiler().nullValue("what").compile(tmpl).execute(new Object() {
+ String exists = "Say";
+ String nullValued = null;
+ // String doesNotExist
+ });
+ // throws MustacheException when executing the template because
+ // doesNotExist cannot be resolved
+
+Note that any variable resolved against a `Map` context will be resolvable, but
+will be treated as having the value null if the map contains no mapping for the
+variable. Only variables resolved against Java object fields or methods risk
+being unresolvable.
+
+Note that section behavior deviates from the above specification (for
+historical reasons and because it's kind of useful). By default, a section that
+is not resolvable or resolves to null will be omitted (and conversely, an
+inverse section that is not resolvable or resolves to null will be included).
+If you use `defaultValue()`, this behavior is preserved. If you use
+`nullValue()`, sections that refer to an unresolvable variable will now throw
+an exception (sections that refer to a resolvable, but null-valued variable,
+will behave as before).
+
Extensions
==========
diff --git a/src/main/java/com/samskivert/mustache/Mustache.java b/src/main/java/com/samskivert/mustache/Mustache.java
index 2624e76..a7cd8b1 100644
--- a/src/main/java/com/samskivert/mustache/Mustache.java
+++ b/src/main/java/com/samskivert/mustache/Mustache.java
@@ -38,9 +38,15 @@ public class Mustache
/** Whether or not standards mode is enabled. */
public final boolean standardsMode;
- /** A value to use when a variable cannot be resolved, or resolves to null. If the default
- * value is null (which is the default default value), an exception will be thrown. */
- public final String defaultValue;
+ /** A value to use when a variable resolves to null. If this value is null (which is the
+ * default null value), an exception will be thrown. If {@link #missingIsNull} is also
+ * true, this value will be used when a variable cannot be resolved. */
+ public final String nullValue;
+
+ /** If this value is true, missing variables will be treated like variables that return
+ * null. {@link #nullValue} will be used in their place, assuming {@link #nullValue} is
+ * configured to a non-null value. */
+ public final boolean missingIsNull;
/** The template loader in use during this compilation. */
public final TemplateLoader loader;
@@ -57,32 +63,59 @@ public class Mustache
/** Returns a compiler that either does or does not escape HTML by default. */
public Compiler escapeHTML (boolean escapeHTML) {
- return new Compiler(escapeHTML, this.standardsMode, this.defaultValue, this.loader);
+ return new Compiler(escapeHTML, this.standardsMode,
+ this.nullValue, this.missingIsNull, this.loader);
}
/** Returns a compiler that either does or does not use standards mode. Standards mode
* disables the non-standard JMustache extensions like looking up missing names in a parent
* context. */
public Compiler standardsMode (boolean standardsMode) {
- return new Compiler(this.escapeHTML, standardsMode, this.defaultValue, this.loader);
+ return new Compiler(this.escapeHTML, standardsMode,
+ this.nullValue, this.missingIsNull, this.loader);
}
/** Returns a compiler that will use the given value for any variable that is missing, or
- * otherwise resolves to null. */
+ * otherwise resolves to null. This is like {@link #nullValue} except that it returns the
+ * supplied default for missing keys and existing keys that return null values. Note that
+ * regardless of whether a null or default value is configured, if the resolution of part
+ * of a compound key results in a missing or null value, an exception will be raised. */
public Compiler defaultValue (String defaultValue) {
- return new Compiler(this.escapeHTML, this.standardsMode, defaultValue, this.loader);
+ return new Compiler(this.escapeHTML, this.standardsMode,
+ defaultValue, true, this.loader);
+ }
+
+ /** Returns a compiler that will use the given value for any variable that resolves to
+ * null, but will still raise an exception for variables for which an accessor cannot be
+ * found. This is like {@link #defaultValue} except that it differentiates between missing
+ * accessors, and accessors that exist but return null.
+ *
+ * - In the case of a Java object being used as a context, if no field or method can be
+ * found for a variable, an exception will be raised.
+ * - In the case of a {@link Map} being used as a context, all possible accessors are
+ * assumed to exist (but potentially return null), and no exception will ever be
+ * raised.
+ *
+ * Note that regardless of whether a null or default value is configured, if the resolution
+ * of part of a compound key results in a missing or null value, an exception will be
+ * raised. */
+ public Compiler nullValue (String nullValue) {
+ return new Compiler(this.escapeHTML, this.standardsMode,
+ nullValue, false, this.loader);
}
/** Returns a compiler configured to use the supplied template loader to handle partials. */
public Compiler withLoader (TemplateLoader loader) {
- return new Compiler(this.escapeHTML, this.standardsMode, this.defaultValue, loader);
+ return new Compiler(this.escapeHTML, this.standardsMode,
+ this.nullValue, this.missingIsNull, loader);
}
- protected Compiler (boolean escapeHTML, boolean standardsMode, String defaultValue,
- TemplateLoader loader) {
+ protected Compiler (boolean escapeHTML, boolean standardsMode,
+ String nullValue, boolean missingIsNull, TemplateLoader loader) {
this.escapeHTML = escapeHTML;
this.standardsMode = standardsMode;
- this.defaultValue = defaultValue;
+ this.nullValue = nullValue;
+ this.missingIsNull = missingIsNull;
this.loader = loader;
}
}
@@ -100,7 +133,7 @@ public class Mustache
*/
public static Compiler compiler ()
{
- return new Compiler(true, false, null, FAILING_LOADER);
+ return new Compiler(true, false, null, true, FAILING_LOADER);
}
/**
@@ -513,10 +546,7 @@ public class Mustache
super(name, segs, line);
}
@Override public void execute (Template tmpl, Template.Context ctx, Writer out) {
- Object value = tmpl.getValue(ctx, _name, _line);
- if (value == null) {
- return; // TODO: configurable behavior on missing values
- }
+ Object value = tmpl.getSectionValue(ctx, _name, _line); // won't return null
if (value instanceof Iterable>) {
value = ((Iterable>)value).iterator();
}
@@ -551,10 +581,8 @@ public class Mustache
super(name, segs, line);
}
@Override public void execute (Template tmpl, Template.Context ctx, Writer out) {
- Object value = tmpl.getValue(ctx, _name, _line);
- if (value == null) {
- executeSegs(tmpl, ctx, out); // TODO: configurable behavior on missing values
- } else if (value instanceof Iterable>) {
+ Object value = tmpl.getSectionValue(ctx, _name, _line); // won't return null
+ if (value instanceof Iterable>) {
Iterable> iable = (Iterable>)value;
if (!iable.iterator().hasNext()) {
executeSegs(tmpl, ctx, out);
diff --git a/src/main/java/com/samskivert/mustache/Template.java b/src/main/java/com/samskivert/mustache/Template.java
index b201f56..7ef22cd 100644
--- a/src/main/java/com/samskivert/mustache/Template.java
+++ b/src/main/java/com/samskivert/mustache/Template.java
@@ -9,6 +9,7 @@ import java.io.StringWriter;
import java.io.Writer;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
+import java.util.Collections;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -76,10 +77,12 @@ public class Template
*
* @param ctx the context in which to look up the variable.
* @param name the name of the variable to be resolved, which must be an interned string.
+ * @param missingIsNull whether to fail if a variable cannot be resolved, or to return null in
+ * that case.
*
* @return the value associated with the supplied name or null if no value could be resolved.
*/
- protected Object getValue (Context ctx, String name, int line)
+ protected Object getValue (Context ctx, String name, int line, boolean missingIsNull)
{
if (!_compiler.standardsMode) {
// if we're dealing with a compound key, resolve each component and use the result to
@@ -89,10 +92,13 @@ public class Template
// we want to allow the first component of a compound key to be located in a parent
// context, but once we're selecting sub-components, they must only be resolved in the
// object that represents that component
- Object data = getValue(ctx, comps[0].intern(), line);
+ Object data = getValue(ctx, comps[0].intern(), line, missingIsNull);
for (int ii = 1; ii < comps.length; ii++) {
- // generate more helpful error message
- if (data == null) {
+ if (data == NO_FETCHER_FOUND) {
+ throw new NullPointerException(
+ "Missing context for compound variable '" + name + "' on line " + line +
+ ". '" + comps[ii - 1] + "' was not found.");
+ } else if (data == null) {
throw new NullPointerException(
"Null context for compound variable '" + name + "' on line " + line +
". '" + comps[ii - 1] + "' resolved to null.");
@@ -101,7 +107,7 @@ public class Template
// contexts; that would be weird and confusing
data = getValueIn(data, comps[ii].intern(), line);
}
- return data;
+ return checkForMissing(name, line, missingIsNull, data);
}
}
@@ -116,18 +122,40 @@ public class Template
// if we're in standards mode, we don't search our parent contexts
if (_compiler.standardsMode) {
- return getValueIn(ctx.data, name, line);
+ return checkForMissing(name, line, missingIsNull, getValueIn(ctx.data, name, line));
}
+ boolean variableMissing = true;
while (ctx != null) {
Object value = getValueIn(ctx.data, name, line);
- if (value != null) {
+ if (value == NO_FETCHER_FOUND) {
+ // preserve variableMissing
+ } else if (value == null) {
+ // we found a fetcher, and it returned null; so we keep searching our parents, but
+ // we won't freak out about a missing variable if we have a nullValue configured
+ variableMissing = false;
+ } else {
return value;
}
ctx = ctx.parent;
}
- // we've popped off the top of our stack of contexts, so return null
- return null;
+ // we've popped off the top of our stack of contexts, if we never actually found a fetcher
+ // for our variable, we need to let checkForMissing() know
+ return checkForMissing(name, line, missingIsNull, variableMissing ? NO_FETCHER_FOUND : null);
+ }
+
+ /**
+ * Returns the value of the specified variable, noting that it is intended to be used as the
+ * contents for a segment. Presently this does not do anything special, but eventually this
+ * will be the means by which we enact configured behavior for sections that reference null or
+ * missing variables. Right now, all such variables result in a length 0 section.
+ */
+ protected Object getSectionValue (Context ctx, String name, int line)
+ {
+ // TODO: configurable behavior on missing values
+ Object value = getValue(ctx, name, line, _compiler.missingIsNull);
+ // TODO: configurable behavior on null values
+ return (value == null) ? Collections.emptyList() : value;
}
/**
@@ -136,8 +164,11 @@ public class Template
*/
protected Object getValueOrDefault (Context ctx, String name, int line)
{
- Object value = getValue(ctx, name, line);
- return (value == null) ? _compiler.defaultValue : value;
+ Object value = getValue(ctx, name, line, _compiler.missingIsNull);
+ // getValue will raise MustacheException if a variable cannot be resolved and missingIsNull
+ // is not configured; so we're safe to assume that any null that makes it up to this point
+ // can be converted to nullValue
+ return (value == null) ? _compiler.nullValue : value;
}
protected Object getValueIn (Object data, String name, int line)
@@ -163,7 +194,7 @@ public class Template
// if we were unable to create a fetcher, just return null and our caller can either try
// the parent context, or do le freak out
if (fetcher == null) {
- return null;
+ return NO_FETCHER_FOUND;
}
try {
@@ -176,6 +207,20 @@ public class Template
}
}
+ protected Object checkForMissing (String name, int line, boolean missingIsNull, Object value)
+ {
+ if (value == NO_FETCHER_FOUND) {
+ if (missingIsNull) {
+ return null;
+ } else {
+ throw new MustacheException(
+ "No method or field with name '" + name + "' on line " + line);
+ }
+ } else {
+ return value;
+ }
+ }
+
protected final Segment[] _segs;
protected final Mustache.Compiler _compiler;
protected final Map _fcache =
@@ -339,6 +384,8 @@ public class Template
}
};
+ protected static final Object NO_FETCHER_FOUND = new Object();
+
protected static final String DOT_NAME = ".".intern();
protected static final String THIS_NAME = "this".intern();
protected static final String FIRST_NAME = "-first".intern();
diff --git a/src/test/java/com/samskivert/mustache/MustacheTest.java b/src/test/java/com/samskivert/mustache/MustacheTest.java
index 816572e..06bf4e7 100644
--- a/src/test/java/com/samskivert/mustache/MustacheTest.java
+++ b/src/test/java/com/samskivert/mustache/MustacheTest.java
@@ -113,6 +113,43 @@ public class MustacheTest
"d", new Object[] { context("e", "3"), context("e", "4") })));
}
+ @Test public void testNullSection () {
+ test("", "{{#foo}}{{bar}}{{/foo}}", new Object() {
+ Object foo = null;
+ });
+ }
+
+ @Test public void testNullSectionWithDefaultValue () {
+ test(Mustache.compiler().defaultValue(""), "", "{{#foo}}{{bar}}{{/foo}}", new Object() {
+ Object foo = null;
+ });
+ }
+
+ @Test public void testNullSectionWithNullValue () {
+ test(Mustache.compiler().nullValue(""), "", "{{#foo}}{{bar}}{{/foo}}", new Object() {
+ Object foo = null;
+ });
+ }
+
+ @Test public void testMissingSection () {
+ test("", "{{#foo}}{{bar}}{{/foo}}", new Object() {
+ // no foo
+ });
+ }
+
+ @Test public void testMissingSectionWithDefaultValue () {
+ test(Mustache.compiler().defaultValue(""), "", "{{#foo}}{{bar}}{{/foo}}", new Object() {
+ // no foo
+ });
+ }
+
+ @Test(expected=MustacheException.class)
+ public void testMissingSectionWithNullValue () {
+ test(Mustache.compiler().nullValue(""), "", "{{#foo}}{{bar}}{{/foo}}", new Object() {
+ // no foo
+ });
+ }
+
@Test public void testComment () {
test("foobar", "foo{{! nothing to see here}}bar", new Object());
}
@@ -215,6 +252,28 @@ public class MustacheTest
});
}
+ @Test(expected=NullPointerException.class)
+ public void testNullComponentInCompoundVariable () {
+ // make sure that even if we have a null value configured, we freak out
+ test(Mustache.compiler().nullValue("foo"), "unused", "{{foo.bar.baz}}", new Object() {
+ Object foo () {
+ return new Object() {
+ Object bar = null;
+ };
+ }
+ });
+ }
+
+ @Test(expected=NullPointerException.class)
+ public void testMissingComponentInCompoundVariable () {
+ // make sure that even if we have a default value configured, we freak out
+ test(Mustache.compiler().defaultValue("foo"), "unused", "{{foo.bar.baz}}", new Object() {
+ Object foo () {
+ return new Object(); // no bar
+ }
+ });
+ }
+
@Test public void testNewlineSkipping () {
String tmpl = "list:\n" +
"{{#items}}\n" +
@@ -322,6 +381,23 @@ public class MustacheTest
});
}
+ @Test(expected=MustacheException.class)
+ public void testMissingValueWithNullDefault () {
+ test(Mustache.compiler().nullValue(""),
+ "bar", "{{missing}}{{notmissing}}", new Object() {
+ String notmissing = "bar";
+ // no field or method for 'missing'
+ });
+ }
+
+ @Test public void testNullValueGetsNullDefault () {
+ test(Mustache.compiler().nullValue("foo"),
+ "foobar", "{{nullvar}}{{nonnullvar}}", new Object() {
+ String nonnullvar = "bar";
+ String nullvar = null;
+ });
+ }
+
protected void test (Mustache.Compiler compiler, String expected, String template, Object ctx)
{
assertEquals(expected, compiler.compile(template).execute(ctx));