// // JMustache - A Java implementation of the Mustache templating language // http://github.com/samskivert/jmustache/blob/master/LICENSE 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; /** * Provides Mustache templating services. *

Basic usage:

{@code
 * String source = "Hello {{arg}}!";
 * Template tmpl = Mustache.compiler().compile(source);
 * Map context = new HashMap();
 * context.put("arg", "world");
 * tmpl.execute(context); // returns "Hello world!" }
*

Limitations: *

*/ public class Mustache { /** An interface to the Mustache compilation process. See {@link Mustache}. */ public static class Compiler { /** Whether or not HTML entities are escaped by default. */ public final boolean escapeHTML; /** Whether or not standards mode is enabled. */ public final boolean standardsMode; /** The template loader in use during this compilation. */ public final TemplateLoader loader; /** Compiles the supplied template into a repeatedly executable intermediate form. */ public Template compile (String template) { return compile(new StringReader(template)); } /** Compiles the supplied template into a repeatedly executable intermediate form. */ public Template compile (Reader source) { return Mustache.compile(source, this); } /** 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.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.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, loader); } protected Compiler (boolean escapeHTML, boolean standardsMode, TemplateLoader loader) { this.escapeHTML = escapeHTML; this.standardsMode = standardsMode; this.loader = loader; } } /** Used to handle partials. */ public interface TemplateLoader { /** Returns a reader for the template with the supplied name. * @throws Exception if the template could not be loaded for any reason. */ public Reader getTemplate (String name) throws Exception; } /** * Returns a compiler that escapes HTML by default and does not use standards mode. */ public static Compiler compiler () { return new Compiler(true, false, FAILING_LOADER); } /** * Compiles the supplied template into a repeatedly executable intermediate form. */ protected static Template compile (Reader source, Compiler compiler) { // a hand-rolled parser; whee! Accumulator accum = new Accumulator(compiler); char start1 = '{', start2 = '{', end1 = '}', end2 = '}'; int state = TEXT; StringBuilder text = new StringBuilder(); int line = 1; boolean skipNewline = false; 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++; // if we just parsed an open section or close section task, we'll skip the first // newline character following it, if desired; TODO: handle CR, sigh if (skipNewline) { skipNewline = false; continue; } } else { skipNewline = false; } 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) { text.append(c); state = TEXT; } } break; case TAG: if (c == end1) { if (end2 == -1) { if (text.charAt(0) == '=') { // TODO: change delimiters } else { sanityCheckTag(text, line, start1, start2); accum = accum.addTagSegment(text, line); skipNewline = accum.skipNewline(); } 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 { // if we haven't remapped the delimiters, and the tag starts with {{{ then // require that it end with }}} and disable HTML escaping if (start1 == '{' && start2 == '{' && text.charAt(0) == '{') { try { // we've only parsed }} at this point, so we have to slurp in // another character from the input stream and check it int end3 = (char)source.read(); if (end3 != '}') { throw new MustacheParseException( "Invalid triple-mustache tag: {{{" + text + "}}", line); } } catch (IOException e) { throw new MustacheException(e); } // convert it into (equivalent) {{&text}} which addTagSegment handles text.replace(0, 1, "&"); } // process the tag between the mustaches sanityCheckTag(text, line, start1, start2); accum = accum.addTagSegment(text, line); skipNewline = accum.skipNewline(); } state = TEXT; } else { text.append(end1); if (c != end1) { text.append(c); 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 MustacheParseException("Template ended while parsing a tag: " + text); } return new Template(accum.finish(), compiler); } private Mustache () {} // no instantiateski protected static void sanityCheckTag (StringBuilder accum, int line, char start1, char start2) { for (int ii = 0, ll = accum.length(); ii < ll; ii++) { if (accum.charAt(ii) == start1) { if (start2 == -1 || (ii < ll-1 && accum.charAt(ii+1) == start2)) { throw new MustacheParseException("Tag contains start tag delimiter, probably " + "missing close delimiter '" + accum + "'", line); } } } } protected static String escapeHTML (String text) { for (String[] escape : ATTR_ESCAPES) { text = text.replace(escape[0], escape[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 Accumulator (Compiler compiler) { _compiler = compiler; } public boolean skipNewline () { // return true if we just added a compound segment which means we're immediately // following the close section tag return (_segs.size() > 0 && _segs.get(_segs.size()-1) instanceof CompoundSegment); } public void addTextSegment (StringBuilder text) { if (text.length() > 0) { _segs.add(new StringSegment(text.toString())); text.setLength(0); } } public Accumulator addTagSegment (final StringBuilder accum, final int tagLine) { 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, tagLine); return new Accumulator(_compiler) { @Override public boolean skipNewline () { // if we just opened this section, we want to skip a newline return (_segs.size() == 0) || super.skipNewline(); } @Override public Template.Segment[] finish () { throw new MustacheParseException( "Section missing close tag '" + tag1 + "'", tagLine); } @Override protected Accumulator addCloseSectionSegment (String itag, int line) { requireSameName(tag1, itag, line); outer._segs.add(new SectionSegment(itag, super.finish(), tagLine)); return outer; } }; case '>': _segs.add(new IncludedTemplateSegment(tag1, _compiler)); return this; case '^': requireNoNewlines(tag, tagLine); return new Accumulator(_compiler) { @Override public boolean skipNewline () { // if we just opened this section, we want to skip a newline return (_segs.size() == 0) || super.skipNewline(); } @Override public Template.Segment[] finish () { throw new MustacheParseException( "Inverted section missing close tag '" + tag1 + "'", tagLine); } @Override protected Accumulator addCloseSectionSegment (String itag, int line) { requireSameName(tag1, itag, line); outer._segs.add(new InvertedSectionSegment(itag, super.finish(), tagLine)); return outer; } }; case '/': requireNoNewlines(tag, tagLine); return addCloseSectionSegment(tag1, tagLine); case '!': // comment!, ignore return this; case '&': requireNoNewlines(tag, tagLine); _segs.add(new VariableSegment(tag1, false, tagLine)); return this; default: requireNoNewlines(tag, tagLine); _segs.add(new VariableSegment(tag, _compiler.escapeHTML, tagLine)); return this; } } public Template.Segment[] finish () { return _segs.toArray(new Template.Segment[_segs.size()]); } protected Accumulator addCloseSectionSegment (String tag, int line) { throw new MustacheParseException( "Section close tag with no open tag '" + tag + "'", line); } protected static void requireNoNewlines (String tag, int line) { if (tag.indexOf("\n") != -1 || tag.indexOf("\r") != -1) { throw new MustacheParseException( "Invalid tag name: contains newline '" + tag + "'", line); } } protected static void requireSameName (String name1, String name2, int line) { if (!name1.equals(name2)) { throw new MustacheParseException("Section close tag with mismatched open tag '" + name2 + "' != '" + name1 + "'", line); } } protected Compiler _compiler; 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 (Template tmpl, Template.Context ctx, Writer out) { write(out, _text); } protected final String _text; } protected static class IncludedTemplateSegment extends Template.Segment { public IncludedTemplateSegment (final String templateName, final Compiler compiler) { try { template = compiler.compile(compiler.loader.getTemplate(templateName)); } catch (Exception e) { throw new IllegalArgumentException("unable to load " + templateName, e); } } @Override public void execute (Template tmpl, Template.Context ctx, Writer out) { template.execute(ctx.data, out); } private final Template template; } /** A helper class for named segments. */ protected static abstract class NamedSegment extends Template.Segment { protected NamedSegment (String name, int line) { _name = name.intern(); _line = line; } protected final String _name; protected final int _line; } /** A segment that substitutes the contents of a variable. */ protected static class VariableSegment extends NamedSegment { public VariableSegment (String name, boolean escapeHTML, int line) { super(name, line); _escapeHTML = escapeHTML; } @Override public void execute (Template tmpl, Template.Context ctx, Writer out) { Object value = tmpl.getValue(ctx, _name, _line); // TODO: configurable behavior on missing values if (value == null) { throw new MustacheException( "No key, method or field with name '" + _name + "' on line " + _line); } String text = String.valueOf(value); write(out, _escapeHTML ? escapeHTML(text) : text); } protected boolean _escapeHTML; } /** A helper class for compound segments. */ protected static abstract class CompoundSegment extends NamedSegment { protected CompoundSegment (String name, Template.Segment[] segs, int line) { super(name, line); _segs = segs; } protected void executeSegs (Template tmpl, Template.Context ctx, Writer out) { for (Template.Segment seg : _segs) { seg.execute(tmpl, ctx, out); } } protected final Template.Segment[] _segs; } /** A segment that represents a section. */ protected static class SectionSegment extends CompoundSegment { public SectionSegment (String name, Template.Segment[] segs, int line) { 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 } if (value instanceof Iterable) { value = ((Iterable)value).iterator(); } if (value instanceof Iterator) { Template.Mode mode = null; int index = 0; for (Iterator iter = (Iterator)value; iter.hasNext(); ) { Object elem = iter.next(); mode = (mode == null) ? Template.Mode.FIRST : (iter.hasNext() ? Template.Mode.OTHER : Template.Mode.LAST); executeSegs(tmpl, ctx.nest(elem, ++index, mode), out); } } else if (value instanceof Boolean) { if ((Boolean)value) { executeSegs(tmpl, ctx, out); } } else if (value.getClass().isArray()) { for (int ii = 0, ll = Array.getLength(value); ii < ll; ii++) { Template.Mode mode = (ii == 0) ? Template.Mode.FIRST : ((ii == ll-1) ? Template.Mode.LAST : Template.Mode.OTHER); executeSegs(tmpl, ctx.nest(Array.get(value, ii), ii+1, mode), out); } } else { executeSegs(tmpl, ctx.nest(value, 0, Template.Mode.OTHER), out); } } } /** A segment that represents an inverted section. */ protected static class InvertedSectionSegment extends CompoundSegment { public InvertedSectionSegment (String name, Template.Segment[] segs, int line) { 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) { Iterable iable = (Iterable)value; if (!iable.iterator().hasNext()) { executeSegs(tmpl, ctx, out); } } else if (value instanceof Boolean) { if (!(Boolean)value) { executeSegs(tmpl, ctx, out); } } else if (value.getClass().isArray()) { if (Array.getLength(value) == 0) { executeSegs(tmpl, ctx, out); } } else if (value instanceof Iterator) { Iterator iter = (Iterator)value; if (!iter.hasNext()) { executeSegs(tmpl, ctx, out); } } // TODO: fail? } } /** 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 = { { "&", "&" }, { "'", "'" }, { "\"", """ }, { "<", "<" }, { ">", ">" }, }; protected static final TemplateLoader FAILING_LOADER = new TemplateLoader() { public Reader getTemplate (String name) { throw new UnsupportedOperationException("Template loading not configured"); } }; }