diff --git a/compiler/src/main/java/com/github/mustachejava/DefaultMustacheFactory.java b/compiler/src/main/java/com/github/mustachejava/DefaultMustacheFactory.java index 9140c0536..20a9fd789 100644 --- a/compiler/src/main/java/com/github/mustachejava/DefaultMustacheFactory.java +++ b/compiler/src/main/java/com/github/mustachejava/DefaultMustacheFactory.java @@ -35,7 +35,7 @@ public class DefaultMustacheFactory implements MustacheFactory { /** * This parser should work with any MustacheFactory */ - protected final MustacheParser mc = new MustacheParser(this); + protected final MustacheParser mc = createParser(); /** * New templates that are generated at runtime are cached here. The template key @@ -259,6 +259,10 @@ public Mustache compilePartial(String s) { } } + protected MustacheParser createParser() { + return new MustacheParser(this); + } + protected Function getMustacheCacheFunction() { return mc::compile; } diff --git a/compiler/src/main/java/com/github/mustachejava/DefaultMustacheVisitor.java b/compiler/src/main/java/com/github/mustachejava/DefaultMustacheVisitor.java index 69b71cd37..786c4fa9e 100644 --- a/compiler/src/main/java/com/github/mustachejava/DefaultMustacheVisitor.java +++ b/compiler/src/main/java/com/github/mustachejava/DefaultMustacheVisitor.java @@ -64,7 +64,7 @@ public void checkName(TemplateContext templateContext, String variable, Mustache } @Override - public void partial(TemplateContext tc, final String variable) { + public void partial(TemplateContext tc, final String variable, String indent) { TemplateContext partialTC = new TemplateContext("{{", "}}", tc.file(), tc.line(), tc.startOfLine()); list.add(new PartialCode(partialTC, df, variable)); } diff --git a/compiler/src/main/java/com/github/mustachejava/DeferringMustacheFactory.java b/compiler/src/main/java/com/github/mustachejava/DeferringMustacheFactory.java index 945717d9b..0aec6379a 100644 --- a/compiler/src/main/java/com/github/mustachejava/DeferringMustacheFactory.java +++ b/compiler/src/main/java/com/github/mustachejava/DeferringMustacheFactory.java @@ -70,7 +70,7 @@ public MustacheVisitor createMustacheVisitor() { final AtomicLong id = new AtomicLong(0); return new DefaultMustacheVisitor(this) { @Override - public void partial(TemplateContext tc, final String variable) { + public void partial(TemplateContext tc, final String variable, final String indent) { TemplateContext partialTC = new TemplateContext("{{", "}}", tc.file(), tc.line(), tc.startOfLine()); final Long divid = id.incrementAndGet(); list.add(new PartialCode(partialTC, df, variable) { diff --git a/compiler/src/main/java/com/github/mustachejava/MustacheParser.java b/compiler/src/main/java/com/github/mustachejava/MustacheParser.java index 928132385..59f82ee00 100644 --- a/compiler/src/main/java/com/github/mustachejava/MustacheParser.java +++ b/compiler/src/main/java/com/github/mustachejava/MustacheParser.java @@ -16,10 +16,21 @@ public class MustacheParser { public static final String DEFAULT_SM = "{{"; public static final String DEFAULT_EM = "}}"; + + /** + * For legacy reasons we keep the non-spec conform parsing of whitespace unless this flag is true. + */ + private final boolean specConformWhitespace; + private MustacheFactory mf; - protected MustacheParser(MustacheFactory mf) { + protected MustacheParser(MustacheFactory mf, boolean specConformWhitespace) { this.mf = mf; + this.specConformWhitespace = specConformWhitespace; + } + + protected MustacheParser(MustacheFactory mf) { + this(mf, false); } public Mustache compile(String file) { @@ -74,7 +85,7 @@ protected Mustache compile(final Reader reader, String tag, final AtomicInteger // Increment the line if (c == '\n') { currentLine.incrementAndGet(); - if (!iterable || (iterable && !onlywhitespace)) { + if (specConformWhitespace || !iterable || (iterable && !onlywhitespace)) { if (sawCR) out.append("\r"); out.append("\n"); } @@ -140,13 +151,19 @@ protected Mustache compile(final Reader reader, String tag, final AtomicInteger case '$': { boolean oldStartOfLine = startOfLine; startOfLine = startOfLine & onlywhitespace; + + boolean nextStartOfLine = specConformWhitespace && trimNewline(startOfLine, br); + int line = currentLine.get(); final Mustache mustache = compile(br, variable, currentLine, file, sm, em, startOfLine); int lines = currentLine.get() - line; - if (!onlywhitespace || lines == 0) { + + if ((specConformWhitespace && !nextStartOfLine) || + (!specConformWhitespace && (!onlywhitespace || lines == 0))) { write(mv, out, file, currentLine.intValue(), oldStartOfLine); } out = new StringBuilder(); + switch (ch) { case '#': mv.iterable(new TemplateContext(sm, em, file, line, startOfLine), variable, mustache); @@ -164,14 +181,21 @@ protected Mustache compile(final Reader reader, String tag, final AtomicInteger mv.checkName(new TemplateContext(sm, em, file, line, startOfLine), variable, mustache); break; } + + startOfLine = nextStartOfLine; iterable = lines != 0; break; } case '/': { // Tag end - if (!startOfLine || !onlywhitespace) { + if (specConformWhitespace) { + if (!trimNewline(onlywhitespace & startOfLine, br)) { + write(mv, out, file, currentLine.intValue(), startOfLine); + } + } else if (!startOfLine || !onlywhitespace) { write(mv, out, file, currentLine.intValue(), startOfLine); } + if (!variable.equals(tag)) { TemplateContext tc = new TemplateContext(sm, em, file, currentLine.get(), startOfLine); throw new MustacheException( @@ -181,9 +205,13 @@ protected Mustache compile(final Reader reader, String tag, final AtomicInteger return mv.mustache(new TemplateContext(sm, em, file, 0, startOfLine)); } case '>': { + String indent = (onlywhitespace && startOfLine) ? out.toString() : ""; out = write(mv, out, file, currentLine.intValue(), startOfLine); startOfLine = startOfLine & onlywhitespace; - mv.partial(new TemplateContext(sm, em, file, currentLine.get(), startOfLine), variable); + mv.partial(new TemplateContext(sm, em, file, currentLine.get(), startOfLine), variable, indent); + + // a new line following a partial is dropped + startOfLine = specConformWhitespace && trimNewline(startOfLine, br); break; } case '{': { @@ -200,12 +228,16 @@ protected Mustache compile(final Reader reader, String tag, final AtomicInteger } } mv.value(new TemplateContext(sm, em, file, currentLine.get(), false), name, false); + + startOfLine = false; break; } case '&': { // Not escaped out = write(mv, out, file, currentLine.intValue(), startOfLine); mv.value(new TemplateContext(sm, em, file, currentLine.get(), false), variable, false); + + startOfLine = false; break; } case '%': @@ -222,15 +254,30 @@ protected Mustache compile(final Reader reader, String tag, final AtomicInteger args = variable.substring(index + 1); } mv.pragma(new TemplateContext(sm, em, file, currentLine.get(), startOfLine), pragma, args); + + startOfLine = false; break; case '!': // Comment mv.comment(new TemplateContext(sm, em, file, currentLine.get(), startOfLine), variable); - out = write(mv, out, file, currentLine.intValue(), startOfLine); + + if (specConformWhitespace) { + boolean sol = trimNewline(startOfLine & onlywhitespace, br); + + if (!sol) { + write(mv, out, file, currentLine.intValue(), startOfLine); + } + + startOfLine = sol; + } else { + write(mv, out, file, currentLine.intValue(), startOfLine); + startOfLine = false; + } + out = new StringBuilder(); + break; case '=': // Change delimiters - out = write(mv, out, file, currentLine.intValue(), startOfLine); String trimmed = command.substring(1).trim(); String[] split = trimmed.split("\\s+"); if (split.length != 2) { @@ -239,6 +286,23 @@ protected Mustache compile(final Reader reader, String tag, final AtomicInteger } sm = split[0]; em = split[1]; + + + if (specConformWhitespace) { + boolean sol = trimNewline(startOfLine & onlywhitespace, br); + + if (!sol) { + write(mv, out, file, currentLine.intValue(), startOfLine); + } + + startOfLine = sol; + } else { + write(mv, out, file, currentLine.intValue(), startOfLine); + startOfLine = false; + } + out = new StringBuilder(); + + break; default: { if (c == -1) { @@ -248,11 +312,16 @@ protected Mustache compile(final Reader reader, String tag, final AtomicInteger // Reference out = write(mv, out, file, currentLine.intValue(), startOfLine); mv.value(new TemplateContext(sm, em, file, currentLine.get(), false), command.trim(), true); + startOfLine = false; break; } } - // Additional text is no longer at the start of the line - startOfLine = false; + if (!specConformWhitespace) { + // Additional text is no longer at the start of the line + // in spec-conform whitespace parsing we sometimes chop a whole line so we let the individual commands + // decide wether we are at the start of a line + startOfLine = false; + } continue; } else { // Only one @@ -297,4 +366,33 @@ private StringBuilder write(MustacheVisitor mv, StringBuilder out, String file, return new StringBuilder(); } + /** + * Some statements such as partials are treated as "standalone". + * This means that if they are the only content on this line (except whitespace) then the following newline is + * chopped. + * For backwards compatibility we only do this if the parser is explicitly configured so. + * @param firstStmt If the statement that was just read was at the start of line with only whitespace preceding it + * @param br The reader + * @return true if trimming was allowed and a following new line was removed or the buffer was finished; + * @throws IOException + */ + private boolean trimNewline(boolean firstStmt, Reader br) throws IOException { + boolean trimmed = false; + + if (firstStmt) { + br.mark(2); + int ca = br.read(); + if (ca == '\r') { + ca = br.read(); + } + + if (ca == '\n' || ca == -1) { + trimmed = true; + } else { + br.reset(); + } + } + + return trimmed; + } } diff --git a/compiler/src/main/java/com/github/mustachejava/MustacheVisitor.java b/compiler/src/main/java/com/github/mustachejava/MustacheVisitor.java index a8083b7d4..5866e5698 100644 --- a/compiler/src/main/java/com/github/mustachejava/MustacheVisitor.java +++ b/compiler/src/main/java/com/github/mustachejava/MustacheVisitor.java @@ -12,7 +12,7 @@ public interface MustacheVisitor { void notIterable(TemplateContext templateContext, String variable, Mustache mustache); - void partial(TemplateContext templateContext, String variable); + void partial(TemplateContext templateContext, String variable, String indent); void value(TemplateContext templateContext, String variable, boolean encoded); diff --git a/compiler/src/main/java/com/github/mustachejava/SpecMustacheFactory.java b/compiler/src/main/java/com/github/mustachejava/SpecMustacheFactory.java new file mode 100644 index 000000000..d4d9065cd --- /dev/null +++ b/compiler/src/main/java/com/github/mustachejava/SpecMustacheFactory.java @@ -0,0 +1,47 @@ +package com.github.mustachejava; + +import com.github.mustachejava.resolver.DefaultResolver; + +import java.io.File; + +/** + * This factory is similar to DefaultMustacheFactory but handles whitespace according to the mustache specification. + * Therefore the rendering is less performant than with the DefaultMustacheFactory. + */ +public class SpecMustacheFactory extends DefaultMustacheFactory { + @Override + public MustacheVisitor createMustacheVisitor() { + return new SpecMustacheVisitor(this); + } + + public SpecMustacheFactory() { + super(); + } + + public SpecMustacheFactory(MustacheResolver mustacheResolver) { + super(mustacheResolver); + } + + /** + * Use the classpath to resolve mustache templates. + * + * @param classpathResourceRoot the location in the resources where templates are stored + */ + public SpecMustacheFactory(String classpathResourceRoot) { + super(classpathResourceRoot); + } + + /** + * Use the file system to resolve mustache templates. + * + * @param fileRoot the root of the file system where templates are stored + */ + public SpecMustacheFactory(File fileRoot) { + super(fileRoot); + } + + @Override + protected MustacheParser createParser() { + return new MustacheParser(this, true); + } +} diff --git a/compiler/src/main/java/com/github/mustachejava/SpecMustacheVisitor.java b/compiler/src/main/java/com/github/mustachejava/SpecMustacheVisitor.java new file mode 100644 index 000000000..5d59d2281 --- /dev/null +++ b/compiler/src/main/java/com/github/mustachejava/SpecMustacheVisitor.java @@ -0,0 +1,63 @@ +package com.github.mustachejava; + +import com.github.mustachejava.codes.PartialCode; +import com.github.mustachejava.codes.ValueCode; +import com.github.mustachejava.util.IndentWriter; + +import java.io.IOException; +import java.io.Writer; +import java.util.List; + +public class SpecMustacheVisitor extends DefaultMustacheVisitor { + public SpecMustacheVisitor(DefaultMustacheFactory df) { + super(df); + } + + @Override + public void partial(TemplateContext tc, final String variable, String indent) { + TemplateContext partialTC = new TemplateContext("{{", "}}", tc.file(), tc.line(), tc.startOfLine()); + list.add(new SpecPartialCode(partialTC, df, variable, indent)); + } + + @Override + public void value(TemplateContext tc, final String variable, boolean encoded) { + list.add(new SpecValueCode(tc, df, variable, encoded)); + } + + static class SpecPartialCode extends PartialCode { + private final String indent; + + public SpecPartialCode(TemplateContext tc, DefaultMustacheFactory cf, String variable, String indent) { + super(tc, cf, variable); + this.indent = indent; + } + + @Override + protected Writer executePartial(Writer writer, final List scopes) { + partial.execute(new IndentWriter(writer, indent), scopes); + return writer; + } + } + + static class SpecValueCode extends ValueCode { + + public SpecValueCode(TemplateContext tc, DefaultMustacheFactory df, String variable, boolean encoded) { + super(tc, df, variable, encoded); + } + + @Override + protected void execute(Writer writer, final String value) throws IOException { + if (writer instanceof IndentWriter) { + IndentWriter iw = (IndentWriter) writer; + iw.flushIndent(); + writer = iw.inner; + while (writer instanceof IndentWriter) { + ((IndentWriter) writer).flushIndent(); + writer = ((IndentWriter) writer).inner; + } + } + + super.execute(writer, value); + } + } +} diff --git a/compiler/src/main/java/com/github/mustachejava/codes/PartialCode.java b/compiler/src/main/java/com/github/mustachejava/codes/PartialCode.java index a10861f69..b0dac3f9b 100644 --- a/compiler/src/main/java/com/github/mustachejava/codes/PartialCode.java +++ b/compiler/src/main/java/com/github/mustachejava/codes/PartialCode.java @@ -15,6 +15,7 @@ public class PartialCode extends DefaultCode { protected PartialCode(TemplateContext tc, DefaultMustacheFactory df, Mustache mustache, String type, String variable) { super(tc, df, mustache, variable, type); + // Use the name of the parent to get the name of the partial String file = tc.file(); int dotindex = file.lastIndexOf("."); @@ -67,7 +68,7 @@ public Writer execute(Writer writer, final List scopes) { } writer = depthLimitedWriter; } - Writer execute = partial.execute(writer, scopes); + Writer execute = executePartial(writer, scopes); if (isRecursive) { assert depthLimitedWriter != null; depthLimitedWriter.decr(); @@ -75,6 +76,10 @@ public Writer execute(Writer writer, final List scopes) { return appendText(execute); } + protected Writer executePartial(Writer writer, final List scopes) { + return partial.execute(writer, scopes); + } + @Override public synchronized void init() { filterText(); diff --git a/compiler/src/main/java/com/github/mustachejava/util/IndentWriter.java b/compiler/src/main/java/com/github/mustachejava/util/IndentWriter.java new file mode 100644 index 000000000..f44bb1b64 --- /dev/null +++ b/compiler/src/main/java/com/github/mustachejava/util/IndentWriter.java @@ -0,0 +1,57 @@ +package com.github.mustachejava.util; + +import java.io.IOException; +import java.io.Writer; + +public class IndentWriter extends Writer { + + public final Writer inner; + private final String indent; + private boolean prependIndent = false; + + public IndentWriter(Writer inner, String indent) { + this.inner = inner; + this.indent = indent; + } + + @Override + public void write(char[] chars, int off, int len) throws IOException { + int newOff = off; + for (int i = newOff; i < len; ++i) { + if (chars[i] == '\n') { + // write character up to newline + writeLine(chars, newOff, i + 1 - newOff); + this.prependIndent = true; + + newOff = i + 1; + } + } + writeLine(chars, newOff, len - (newOff - off)); + } + + public void flushIndent() throws IOException { + if (this.prependIndent) { + inner.append(indent); + this.prependIndent = false; + } + } + + private void writeLine(char[] chars, int off, int len) throws IOException { + if (len <= 0) { + return; + } + + this.flushIndent(); + inner.write(chars, off, len); + } + + @Override + public void flush() throws IOException { + inner.flush(); + } + + @Override + public void close() throws IOException { + inner.close(); + } +} diff --git a/compiler/src/test/java/com/github/mustachejava/FullSpecTest.java b/compiler/src/test/java/com/github/mustachejava/FullSpecTest.java new file mode 100644 index 000000000..393736130 --- /dev/null +++ b/compiler/src/test/java/com/github/mustachejava/FullSpecTest.java @@ -0,0 +1,24 @@ +package com.github.mustachejava; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.io.Reader; +import java.io.StringReader; + +public class FullSpecTest extends SpecTest { + @Override + protected DefaultMustacheFactory createMustacheFactory(final JsonNode test) { + return new SpecMustacheFactory("/spec/specs") { + @Override + public Reader getReader(String resourceName) { + JsonNode partial = test.get("partials").get(resourceName); + return new StringReader(partial == null ? "" : partial.asText()); + } + }; + } + + @Override + protected String transformOutput(String output) { + return output; + } +} diff --git a/compiler/src/test/java/com/github/mustachejava/InterpreterTest.java b/compiler/src/test/java/com/github/mustachejava/InterpreterTest.java index 4edfef430..264144471 100644 --- a/compiler/src/test/java/com/github/mustachejava/InterpreterTest.java +++ b/compiler/src/test/java/com/github/mustachejava/InterpreterTest.java @@ -684,7 +684,7 @@ public void testDynamicPartial() throws MustacheException, IOException { public MustacheVisitor createMustacheVisitor() { return new DefaultMustacheVisitor(this) { @Override - public void partial(TemplateContext tc, String variable) { + public void partial(TemplateContext tc, String variable, String indent) { if (variable.startsWith("+")) { // This is a dynamic partial rather than a static one TemplateContext partialTC = new TemplateContext("{{", "}}", tc.file(), tc.line(), tc.startOfLine()); @@ -713,7 +713,7 @@ public Writer execute(Writer writer, List scopes) { } }); } else { - super.partial(tc, variable); + super.partial(tc, variable, indent); } } }; @@ -1222,7 +1222,7 @@ public void testOverrideExtension() throws IOException { public MustacheVisitor createMustacheVisitor() { return new DefaultMustacheVisitor(this) { @Override - public void partial(TemplateContext tc, String variable) { + public void partial(TemplateContext tc, String variable, String indent) { TemplateContext partialTC = new TemplateContext("{{", "}}", tc.file(), tc.line(), tc.startOfLine()); list.add(new PartialCode(partialTC, df, variable) { @Override diff --git a/compiler/src/test/java/com/github/mustachejava/SpecTest.java b/compiler/src/test/java/com/github/mustachejava/SpecTest.java index 438a85dc3..7c795d29c 100644 --- a/compiler/src/test/java/com/github/mustachejava/SpecTest.java +++ b/compiler/src/test/java/com/github/mustachejava/SpecTest.java @@ -22,6 +22,11 @@ */ public class SpecTest { + @Test + public void comments() throws IOException { + run(getSpec("comments.yml")); + } + @Test public void interpolations() throws IOException { run(getSpec("interpolation.yml")); @@ -147,7 +152,7 @@ Function lambda() { StringWriter writer = new StringWriter(); compile.execute(writer, new Object[]{new ObjectMapper().readValue(data.toString(), Map.class), functionMap.get(file)}); String expected = test.get("expected").asText(); - if (writer.toString().replaceAll("\\s+", "").equals(expected.replaceAll("\\s+", ""))) { + if (transformOutput(writer.toString()).equals(transformOutput(expected))) { System.out.print(": success"); if (writer.toString().equals(expected)) { System.out.println("!"); @@ -174,6 +179,10 @@ Function lambda() { assertFalse(fail > 0); } + protected String transformOutput(String output) { + return output.replaceAll("\\s+", ""); + } + protected DefaultMustacheFactory createMustacheFactory(final JsonNode test) { return new DefaultMustacheFactory("/spec/specs") { @Override