diff --git a/src/main/java/com/samskivert/mustache/Mustache.java b/src/main/java/com/samskivert/mustache/Mustache.java
index c133ddf..b9a9e39 100644
--- a/src/main/java/com/samskivert/mustache/Mustache.java
+++ b/src/main/java/com/samskivert/mustache/Mustache.java
@@ -3,15 +3,23 @@
package com.samskivert.mustache;
+import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.io.Writer;
import java.lang.reflect.Array;
+import java.util.ArrayList;
import java.util.Iterator;
+import java.util.List;
import java.util.Map;
/**
* Provides Mustache templating services.
+ *
Limitations:
+ *
- Only one or two character delimiters are supported when using {{=ab cd=}} to change
+ * delimiters.
+ *
- {{< include}} is not supported. We specifically do not want the complexity of handling the
+ * automatic loading of dependent templates.
*/
public class Mustache
{
@@ -26,50 +34,273 @@ public class Mustache
/**
* Compiles the supplied template into a repeatedly executable intermediate form.
*/
- public static Template compile (Reader template)
+ public static Template compile (Reader source)
{
- return null; // TODO
+ // a hand-rolled parser; whee!
+ Accumulator accum = new Accumulator();
+ char start1 = '{', start2 = '{', end1 = '}', end2 = '}';
+ int state = TEXT, startPos = 0, endPos = 0;
+ StringBuilder text = new StringBuilder();
+ int line = 0;
+
+ while (true) {
+ char c;
+ try {
+ int v = source.read();
+ if (v == -1) {
+ break;
+ }
+ c = (char)v;
+ } catch (IOException e) {
+ throw new MustacheException(e);
+ }
+
+ if (c == '\n') {
+ line++;
+ }
+
+ switch (state) {
+ case TEXT:
+ if (c == start1) {
+ if (start2 == -1) {
+ accum.addTextSegment(text);
+ state = TAG;
+ } else {
+ state = MATCHING_START;
+ }
+ } else {
+ text.append(c);
+ }
+ break;
+
+ case MATCHING_START:
+ if (c == start2) {
+ accum.addTextSegment(text);
+ state = TAG;
+ } else {
+ text.append(start1);
+ if (c != start1) {
+ state = TEXT;
+ }
+ }
+ break;
+
+ case TAG:
+ if (c == end1) {
+ if (end2 == -1) {
+ if (text.charAt(0) == '=') {
+ // TODO: change delimiters
+ } else {
+ accum = accum.addTagSegment(text, line);
+ }
+ state = TEXT;
+ } else {
+ state = MATCHING_END;
+ }
+ } else {
+ text.append(c);
+ }
+ break;
+
+ case MATCHING_END:
+ if (c == end2) {
+ if (text.charAt(0) == '=') {
+ // TODO: change delimiters
+ } else {
+ accum = accum.addTagSegment(text, line);
+ }
+ state = TEXT;
+ } else {
+ text.append(end1);
+ if (c != end1) {
+ state = TAG;
+ }
+ }
+ break;
+ }
+ }
+
+ // accumulate any trailing text
+ switch (state) {
+ case TEXT:
+ accum.addTextSegment(text);
+ break;
+ case MATCHING_START:
+ text.append(start1);
+ accum.addTextSegment(text);
+ break;
+ case MATCHING_END:
+ text.append(end1);
+ accum.addTextSegment(text);
+ break;
+ case TAG:
+ throw new MustacheException("Template ended while parsing a tag TODO");
+ }
+
+ return new Template(accum.finish());
}
private Mustache () {} // no instantiateski
+ protected static String escapeHTML (String text)
+ {
+ for (int ii = 0; ii < ATTR_ESCAPES.length; ii++) {
+ text = text.replace(ATTR_ESCAPES[ii][0], ATTR_ESCAPES[ii][1]);
+ }
+ return text;
+ }
+
+ protected static final int TEXT = 0;
+ protected static final int MATCHING_START = 1;
+ protected static final int MATCHING_END = 2;
+ protected static final int TAG = 3;
+
+ protected static class Accumulator {
+ public void addTextSegment (StringBuilder text) {
+ if (text.length() > 0) {
+ _segs.add(new StringSegment(text.toString()));
+ text.setLength(0);
+ }
+ }
+
+ public Accumulator addTagSegment (StringBuilder accum, final int line) {
+ final Accumulator outer = this;
+ String tag = accum.toString().trim();
+ final String tag1 = tag.substring(1).trim();
+ accum.setLength(0);
+
+ switch (tag.charAt(0)) {
+ case '#':
+ requireNoNewlines(tag, line);
+ return new Accumulator() {
+ public Template.Segment[] finish () {
+ throw new MustacheException("Section missing close tag " +
+ "[line=" + line + ", name=" + tag1 + "]");
+ }
+ protected Accumulator addCloseSectionSegment (String itag, int line) {
+ requireSameName(tag1, itag, line);
+ outer._segs.add(new SectionSegment(itag, super.finish()));
+ return outer;
+ }
+ };
+
+ case '^':
+ requireNoNewlines(tag, line);
+ return new Accumulator() {
+ public Template.Segment[] finish () {
+ throw new MustacheException("Inverted section missing close tag " +
+ "[line=" + line + ", name=" + tag1 + "]");
+ }
+ protected Accumulator addCloseSectionSegment (String itag, int line) {
+ requireSameName(tag1, itag, line);
+ outer._segs.add(new InvertedSectionSegment(itag, super.finish()));
+ return outer;
+ }
+ };
+
+ case '/':
+ requireNoNewlines(tag, line);
+ return addCloseSectionSegment(tag1, line);
+
+ case '!':
+ // comment!, ignore
+ return this;
+
+ case '&':
+ requireNoNewlines(tag, line);
+ _segs.add(new VariableSegment(tag1, false));
+ return this;
+
+ default:
+ requireNoNewlines(tag, line);
+ _segs.add(new VariableSegment(tag, true));
+ return this;
+ }
+ }
+
+ public Template.Segment[] finish () {
+ return _segs.toArray(new Template.Segment[_segs.size()]);
+ }
+
+ protected Accumulator addCloseSectionSegment (String tag, int line) {
+ throw new MustacheException("Section close tag with no open tag " +
+ "[line=" + line + ", name=" + tag + "]");
+ }
+
+ protected static void requireNoNewlines (String tag, int line) {
+ if (tag.indexOf("\n") != -1 || tag.indexOf("\r") != -1) {
+ throw new MustacheException("Invalid tag name: contains newlne " +
+ "[line=" + line + ", name=" + tag + "]");
+ }
+ }
+
+ protected static void requireSameName (String name1, String name2, int line)
+ {
+ if (!name1.equals(name2)) {
+ throw new MustacheException(
+ "Section close tag with mismatched open tag " +
+ "[line=" + line + ", expected=" + name1 + ", got=" + name2 + "]");
+ }
+ }
+
+ protected final List _segs = new ArrayList();
+ }
+
/** A simple segment that reproduces a string. */
protected static class StringSegment extends Template.Segment {
public StringSegment (String text) {
_text = text;
}
-
@Override public void execute (Object ctx, Writer out) {
write(out, _text);
}
-
protected final String _text;
}
- /** A segment that substitutes the contents of a variable. */
- protected static class VariableSegment extends Template.Segment {
- public VariableSegment (String name) {
+ /** A helper class for named segments. */
+ protected static abstract class NamedSegment extends Template.Segment {
+ protected NamedSegment (String name) {
_name = name;
}
+ protected final String _name;
+ }
+ /** A segment that substitutes the contents of a variable. */
+ protected static class VariableSegment extends NamedSegment {
+ public VariableSegment (String name, boolean escapeHTML) {
+ super(name);
+ _escapeHTML = escapeHTML;
+ }
@Override public void execute (Object ctx, Writer out) {
Object value = getValue(ctx, _name);
// TODO: configurable behavior on missing values
if (value != null) {
- write(out, String.valueOf(value));
+ String text = String.valueOf(value);
+ write(out, _escapeHTML ? escapeHTML(text) : text);
}
}
+ protected boolean _escapeHTML;
+ }
- protected final String _name;
+ /** A helper class for compound segments. */
+ protected static abstract class CompoundSegment extends NamedSegment {
+ protected CompoundSegment (String name, Template.Segment[] segs) {
+ super(name);
+ _segs = segs;
+ }
+ protected void executeSegs (Object ctx, Writer out) {
+ for (Template.Segment seg : _segs) {
+ seg.execute(ctx, out);
+ }
+ }
+ protected final Template.Segment[] _segs;
}
/** A segment that represents a section. */
- protected static class SectionSegment extends Template.Segment {
+ protected static class SectionSegment extends CompoundSegment {
public SectionSegment (String name, Template.Segment[] segs) {
- _name = name;
- _segs = segs;
+ super(name, segs);
}
-
@Override public void execute (Object ctx, Writer out) {
Object value = getValue(ctx, _name);
if (value == null) {
@@ -97,14 +328,47 @@ public class Mustache
executeSegs(value, out);
}
}
+ }
- protected void executeSegs (Object ctx, Writer out) {
- for (Template.Segment seg : _segs) {
- seg.execute(ctx, out);
+ /** A segment that represents an inverted section. */
+ protected static class InvertedSectionSegment extends CompoundSegment {
+ public InvertedSectionSegment (String name, Template.Segment[] segs) {
+ super(name, segs);
+ }
+ @Override public void execute (Object ctx, Writer out) {
+ Object value = getValue(ctx, _name);
+ if (value == null) {
+ executeSegs(ctx, out); // TODO: configurable behavior on missing values
+ }
+ if (value instanceof Iterable>) {
+ Iterable> iable = (Iterable>)value;
+ if (!iable.iterator().hasNext()) {
+ executeSegs(ctx, out);
+ }
+ } else if (value instanceof Boolean) {
+ if (!(Boolean)value) {
+ executeSegs(ctx, out);
+ }
+ } else if (value.getClass().isArray()) {
+ if (Array.getLength(value) == 0) {
+ executeSegs(ctx, out);
+ }
+ } else if (value instanceof Iterator>) {
+ Iterator> iter = (Iterator>)value;
+ if (!iter.hasNext()) {
+ executeSegs(ctx, out);
+ }
}
}
-
- protected String _name;
- protected Template.Segment[] _segs;
}
+
+ /** Map of strings that must be replaced inside html attributes and their replacements. (They
+ * need to be applied in order so amps are not double escaped.) */
+ protected static final String[][] ATTR_ESCAPES = {
+ { "&", "&" },
+ { "'", "'" },
+ { "\"", """ },
+ { "<", "<" },
+ { ">", ">" },
+ };
}
diff --git a/src/test/java/com/samskivert/mustache/MustacheTest.java b/src/test/java/com/samskivert/mustache/MustacheTest.java
new file mode 100644
index 0000000..85cfb6b
--- /dev/null
+++ b/src/test/java/com/samskivert/mustache/MustacheTest.java
@@ -0,0 +1,81 @@
+//
+// $Id$
+
+package com.samskivert.mustache;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.*;
+import static org.junit.Assert.*;
+
+/**
+ * Various unit tests.
+ */
+public class MustacheTest
+{
+ @Test public void testSimpleVariable () {
+ test("bar", "{{foo}}", "foo", "bar");
+ }
+
+ @Test public void testOneShotSection () {
+ test("baz", "{{#foo}}{{bar}}{{/foo}}", "foo", context("bar", "baz"));
+ }
+
+ @Test public void testListSection () {
+ test("bazbif", "{{#foo}}{{bar}}{{/foo}}", "foo",
+ Arrays.asList(context("bar", "baz"), context("bar", "bif")));
+ }
+
+ @Test public void testArraySection () {
+ test("bazbif", "{{#foo}}{{bar}}{{/foo}}", "foo",
+ new Object[] { context("bar", "baz"), context("bar", "bif") });
+ }
+
+ @Test public void testIteratorSection () {
+ test("bazbif", "{{#foo}}{{bar}}{{/foo}}", "foo",
+ Arrays.asList(context("bar", "baz"), context("bar", "bif")).iterator());
+ }
+
+ @Test public void testEmptyListSection () {
+ test("", "{{#foo}}{{bar}}{{/foo}}", "foo", Collections.emptyList());
+ }
+
+ @Test public void testEmptyArraySection () {
+ test("", "{{#foo}}{{bar}}{{/foo}}", "foo", new Object[0]);
+ }
+
+ @Test public void testEmptyIteratorSection () {
+ test("", "{{#foo}}{{bar}}{{/foo}}", "foo", Collections.emptyList().iterator());
+ }
+
+ @Test public void testFalseSection () {
+ test("", "{{#foo}}{{bar}}{{/foo}}", "foo", false);
+ }
+
+ @Test public void testNestedListSection () {
+ test("1234", "{{#a}}{{#b}}{{c}}{{/b}}{{#d}}{{e}}{{/d}}{{/a}}", "a",
+ new Object[] { context("b", new Object[] { context("c", "1"), context("c", "2") }),
+ context("d", new Object[] { context("e", "3"), context("e", "4") }) });
+ }
+
+ @Test public void testComment () {
+ test("foobar", "foo{{! nothing to see here}}bar");
+ }
+
+ protected void test (String expected, String template, Object... data)
+ {
+ assertEquals(expected, Mustache.compile(template).execute(context(data)));
+ }
+
+ protected static Object context (Object... data)
+ {
+ Map ctx = new HashMap();
+ for (int ii = 0; ii < data.length; ii += 2) {
+ ctx.put(data[ii].toString(), data[ii+1]);
+ }
+ return ctx;
+ }
+}