From 3eea40d1d4916d46af351ed4e2499e77f2fc6bce Mon Sep 17 00:00:00 2001 From: Michael Bayne Date: Thu, 21 Oct 2010 18:08:07 +0000 Subject: [PATCH] Basic implementation and some tests. --- .../com/samskivert/mustache/Mustache.java | 302 ++++++++++++++++-- .../com/samskivert/mustache/MustacheTest.java | 81 +++++ 2 files changed, 364 insertions(+), 19 deletions(-) create mode 100644 src/test/java/com/samskivert/mustache/MustacheTest.java 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: + *

*/ 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; + } +}