ce389bd293
into inner interface Mustache.TemplateLoader, eliminated top-level UnsupportedTemplateLoader. Eliminated static configuration of active template loader in favor of per-Compiler configured template loader, which is how all other configuration is handled.More complex tests are needed, but will have to wait until I'm less sleep deprived.
529 lines
20 KiB
Java
529 lines
20 KiB
Java
//
|
|
// 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 <a href="http://mustache.github.com/">Mustache</a> templating services.
|
|
* <p> Basic usage: <pre>{@code
|
|
* String source = "Hello {{arg}}!";
|
|
* Template tmpl = Mustache.compiler().compile(source);
|
|
* Map<String, Object> context = new HashMap<String, Object>();
|
|
* context.put("arg", "world");
|
|
* tmpl.execute(context); // returns "Hello world!" }</pre>
|
|
* <p> Limitations:
|
|
* <ul><li> Only one or two character delimiters are supported when using {{=ab cd=}} to change
|
|
* delimiters.
|
|
* <li> {{< include}} is not supported. We specifically do not want the complexity of handling the
|
|
* automatic loading of dependent templates. </ul>
|
|
*/
|
|
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<Template.Segment> _segs = new ArrayList<Template.Segment>();
|
|
}
|
|
|
|
/** 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");
|
|
}
|
|
};
|
|
}
|