/** * Copyright (c) 2012-2015 Edgar Espina * * This file is part of Handlebars.java. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.github.jknack.handlebars.internal; import static org.apache.commons.lang3.StringUtils.isEmpty; import static org.apache.commons.lang3.Validate.notNull; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import org.antlr.v4.runtime.CommonToken; import org.antlr.v4.runtime.Token; import org.antlr.v4.runtime.tree.ParseTree; import org.antlr.v4.runtime.tree.TerminalNode; import org.apache.commons.lang3.math.NumberUtils; import com.github.jknack.handlebars.Context; import com.github.jknack.handlebars.Decorator; import com.github.jknack.handlebars.Handlebars; import com.github.jknack.handlebars.HandlebarsError; import com.github.jknack.handlebars.HandlebarsException; import com.github.jknack.handlebars.Helper; import com.github.jknack.handlebars.HelperRegistry; import com.github.jknack.handlebars.PathCompiler; import com.github.jknack.handlebars.TagType; import com.github.jknack.handlebars.Template; import com.github.jknack.handlebars.internal.HbsParser.AmpvarContext; import com.github.jknack.handlebars.internal.HbsParser.BlockContext; import com.github.jknack.handlebars.internal.HbsParser.BlockParamsContext; import com.github.jknack.handlebars.internal.HbsParser.BodyContext; import com.github.jknack.handlebars.internal.HbsParser.BoolParamContext; import com.github.jknack.handlebars.internal.HbsParser.CharParamContext; import com.github.jknack.handlebars.internal.HbsParser.CommentContext; import com.github.jknack.handlebars.internal.HbsParser.DynamicPathContext; import com.github.jknack.handlebars.internal.HbsParser.ElseBlockContext; import com.github.jknack.handlebars.internal.HbsParser.ElseStmtChainContext; import com.github.jknack.handlebars.internal.HbsParser.ElseStmtContext; import com.github.jknack.handlebars.internal.HbsParser.EscapeContext; import com.github.jknack.handlebars.internal.HbsParser.HashContext; import com.github.jknack.handlebars.internal.HbsParser.IntParamContext; import com.github.jknack.handlebars.internal.HbsParser.LiteralPathContext; import com.github.jknack.handlebars.internal.HbsParser.NewlineContext; import com.github.jknack.handlebars.internal.HbsParser.ParamContext; import com.github.jknack.handlebars.internal.HbsParser.PartialBlockContext; import com.github.jknack.handlebars.internal.HbsParser.PartialContext; import com.github.jknack.handlebars.internal.HbsParser.RawBlockContext; import com.github.jknack.handlebars.internal.HbsParser.RefParamContext; import com.github.jknack.handlebars.internal.HbsParser.SexprContext; import com.github.jknack.handlebars.internal.HbsParser.SpacesContext; import com.github.jknack.handlebars.internal.HbsParser.StatementContext; import com.github.jknack.handlebars.internal.HbsParser.StaticPathContext; import com.github.jknack.handlebars.internal.HbsParser.StringParamContext; import com.github.jknack.handlebars.internal.HbsParser.SubParamExprContext; import com.github.jknack.handlebars.internal.HbsParser.TemplateContext; import com.github.jknack.handlebars.internal.HbsParser.TextContext; import com.github.jknack.handlebars.internal.HbsParser.TvarContext; import com.github.jknack.handlebars.internal.HbsParser.UnlessContext; import com.github.jknack.handlebars.internal.HbsParser.VarContext; import com.github.jknack.handlebars.io.TemplateSource; /** * Traverse the parse tree and build templates. * * @author edgar.espina * @param <it> * @since 0.10.0 */ abstract class TemplateBuilder<it> extends HbsParserBaseVisitor<Object> { /** * Get partial info: static vs dynamic. * * @author edgar * @since 2.2.0 */ private static class PartialInfo { /** Token to report errors. */ private Token token; /** Partial params. */ private Map<String, Param> hash; /** Partial path: static vs subexpression. */ private Template path; /** Template context. */ private String context; } /** * A handlebars object. required. */ private Handlebars handlebars; /** * The template source. Required. */ private TemplateSource source; /** * Flag to track dead spaces and lines. */ private Boolean hasTag; /** * Keep track of the current line. */ protected StringBuilder line = new StringBuilder(); /** * Keep track of block helpers. */ private LinkedList<String> qualifier = new LinkedList<String>(); /** * Keep track of block helpers params. */ private LinkedList<String> paramStack = new LinkedList<String>(); /** Keep track of block level, required for top level decorators. */ private int level; /** * Creates a new {@link TemplateBuilder}. * * @param handlebars A handlbars object. required. * @param source The template source. required. */ public TemplateBuilder(final Handlebars handlebars, final TemplateSource source) { this.handlebars = notNull(handlebars, "The handlebars can't be null."); this.source = notNull(source, "The template source is requied."); } @Override public Template visit(final ParseTree tree) { return (Template) super.visit(tree); } @Override public Template visitRawBlock(final RawBlockContext ctx) { level += 1; SexprContext sexpr = ctx.sexpr(); Token nameStart = sexpr.QID().getSymbol(); String name = nameStart.getText(); qualifier.addLast(name); String nameEnd = ctx.nameEnd.getText(); if (!name.equals(nameEnd)) { reportError(null, ctx.nameEnd.getLine(), ctx.nameEnd.getCharPositionInLine(), String.format("found: '%s', expected: '%s'", nameEnd, name)); } hasTag(true); Block block = new Block(handlebars, name, false, "{{", params(sexpr.param()), hash(sexpr.hash()), Collections.<String> emptyList()); if (block.paramSize > 0) { paramStack.addLast(block.params.get(0).toString()); } block.filename(source.filename()); block.position(nameStart.getLine(), nameStart.getCharPositionInLine()); String startDelim = ctx.start.getText(); startDelim = startDelim.substring(0, startDelim.length() - 2); block.startDelimiter(startDelim); block.endDelimiter(ctx.stop.getText()); Template body = visitBody(ctx.thenBody); if (body != null) { // rewrite raw template block.body(new Text(handlebars, body.text())); } hasTag(true); qualifier.removeLast(); if (block.paramSize > 0) { paramStack.removeLast(); } level -= 1; return block; } @Override public Template visitBlock(final BlockContext ctx) { level += 1; SexprContext sexpr = ctx.sexpr(); boolean decorator = ctx.DECORATOR() != null; Token nameStart = sexpr.QID().getSymbol(); String name = nameStart.getText(); qualifier.addLast(name); String nameEnd = ctx.nameEnd.getText(); if (!name.equals(nameEnd)) { reportError(null, ctx.nameEnd.getLine(), ctx.nameEnd.getCharPositionInLine(), String.format("found: '%s', expected: '%s'", nameEnd, name)); } hasTag(true); Block block = null; if (decorator) { block = new BlockDecorator(handlebars, name, false, params(sexpr.param()), hash(sexpr.hash()), blockParams(ctx.blockParams()), level == 1); } else { block = new Block(handlebars, name, false, "#", params(sexpr.param()), hash(sexpr.hash()), blockParams(ctx.blockParams())); } if (block.paramSize > 0) { paramStack.addLast(block.params.get(0).toString()); } block.filename(source.filename()); block.position(nameStart.getLine(), nameStart.getCharPositionInLine()); String startDelim = ctx.start.getText(); startDelim = startDelim.substring(0, startDelim.length() - 1); block.startDelimiter(startDelim); block.endDelimiter(ctx.stop.getText()); Template body = visitBody(ctx.thenBody); if (body != null) { block.body(body); } // else Block elseroot = block; for (ElseBlockContext elseBlock : ctx.elseBlock()) { ElseStmtContext elseStmt = elseBlock.elseStmt(); if (elseStmt != null) { // basic else Template unless = visitBody(elseStmt.unlessBody); if (unless != null) { String inverseLabel = elseStmt.inverseToken.getText(); if (inverseLabel.startsWith(startDelim)) { inverseLabel = inverseLabel.substring(startDelim.length()); } elseroot.inverse(inverseLabel, unless); } } else { // else chain ElseStmtChainContext elseStmtChain = elseBlock.elseStmtChain(); SexprContext elseexpr = elseStmtChain.sexpr(); Token elsenameStart = elseexpr.QID().getSymbol(); String elsename = elsenameStart.getText(); Block elseblock = new Block(handlebars, elsename, false, "#", params(elseexpr.param()), hash(elseexpr.hash()), blockParams(elseStmtChain.blockParams())); elseblock.filename(source.filename()); elseblock.position(elsenameStart.getLine(), elsenameStart.getCharPositionInLine()); elseblock.startDelimiter(startDelim); elseblock.endDelimiter(elseBlock.stop.getText()); Template elsebody = visitBody(elseStmtChain.unlessBody); elseblock.body(elsebody); String inverseLabel = elseStmtChain.inverseToken.getText(); if (inverseLabel.startsWith(startDelim)) { inverseLabel = inverseLabel.substring(startDelim.length()); } elseroot.inverse(inverseLabel, elseblock); elseroot = elseblock; } } hasTag(true); qualifier.removeLast(); if (block.paramSize > 0) { paramStack.removeLast(); } level -= 1; return block; } @Override public Template visitUnless(final UnlessContext ctx) { level += 1; hasTag(true); SexprContext sexpr = ctx.sexpr(); Token nameStart = sexpr.QID().getSymbol(); String name = nameStart.getText(); qualifier.addLast(name); String nameEnd = ctx.nameEnd.getText(); if (!name.equals(nameEnd)) { reportError(null, ctx.nameEnd.getLine(), ctx.nameEnd.getCharPositionInLine(), String.format("found: '%s', expected: '%s'", nameEnd, name)); } Block block = new Block(handlebars, name, true, "^", Collections.<Param> emptyList(), Collections.<String, Param> emptyMap(), blockParams(ctx.blockParams())); block.filename(source.filename()); block.position(nameStart.getLine(), nameStart.getCharPositionInLine()); String startDelim = ctx.start.getText(); block.startDelimiter(startDelim.substring(0, startDelim.length() - 1)); block.endDelimiter(ctx.stop.getText()); Template body = visitBody(ctx.body()); if (body != null) { block.body(body); } hasTag(true); level -= 1; return block; } @Override public Template visitVar(final VarContext ctx) { hasTag(false); SexprContext sexpr = ctx.sexpr(); return newVar(sexpr.QID().getSymbol(), TagType.VAR, params(sexpr.param()), hash(sexpr.hash()), ctx.start.getText(), ctx.stop.getText(), ctx.DECORATOR() != null); } @Override public Object visitEscape(final EscapeContext ctx) { Token token = ctx.ESC_VAR().getSymbol(); String text = token.getText().substring(1); line.append(text); return new Text(handlebars, text, "\\") .filename(source.filename()) .position(token.getLine(), token.getCharPositionInLine()); } @Override public Template visitTvar(final TvarContext ctx) { hasTag(false); SexprContext sexpr = ctx.sexpr(); return newVar(sexpr.QID().getSymbol(), TagType.TRIPLE_VAR, params(sexpr.param()), hash(sexpr.hash()), ctx.start.getText(), ctx.stop.getText(), false); } @Override public Template visitAmpvar(final AmpvarContext ctx) { hasTag(false); SexprContext sexpr = ctx.sexpr(); return newVar(sexpr.QID().getSymbol(), TagType.AMP_VAR, params(sexpr.param()), hash(sexpr.hash()), ctx.start.getText(), ctx.stop.getText(), false); } /** * Build a new {@link Variable}. * * @param name The var's name. * @param varType The var's type. * @param params The var params. * @param hash The var hash. * @param startDelimiter The current start delimiter. * @param endDelimiter The current end delimiter. * @param decorator True, for var decorators. * @return A new {@link Variable}. */ private Variable newVar(final Token name, final TagType varType, final List<Param> params, final Map<String, Param> hash, final String startDelimiter, final String endDelimiter, final boolean decorator) { String varName = name.getText(); boolean isHelper = ((params.size() > 0 || hash.size() > 0) || varType == TagType.SUB_EXPRESSION); if (!isHelper && qualifier.size() > 0 && "with".equals(qualifier.getLast()) && !varName.startsWith(".")) { // HACK to qualified 'with' in order to improve handlebars.js compatibility if (paramStack.size() > 0) { String scope = paramStack.getLast(); if (varName.equals(scope) || varName.startsWith(scope + ".")) { varName = "this." + varName; } } } String[] parts = varName.split("\\./"); // TODO: try to catch this with ANTLR... // foo.0 isn't allowed, it must be foo.0. if (parts.length > 0 && NumberUtils.isNumber(parts[parts.length - 1]) && !varName.endsWith(".")) { String evidence = varName; String reason = "found: " + varName + ", expecting: " + varName + "."; String message = source.filename() + ":" + name.getLine() + ":" + name.getChannel() + ": " + reason + "\n"; throw new HandlebarsException(new HandlebarsError(source.filename(), name.getLine(), name.getCharPositionInLine(), reason, evidence, message)); } if (decorator) { Decorator dec = handlebars.decorator(varName); if (dec == null) { reportError(null, name.getLine(), name.getCharPositionInLine(), "could not find decorator: '" + varName + "'"); } } else { Helper<Object> helper = handlebars.helper(varName); if (helper == null && isHelper) { Helper<Object> helperMissing = handlebars.helper(HelperRegistry.HELPER_MISSING); if (helperMissing == null) { reportError(null, name.getLine(), name.getCharPositionInLine(), "could not find helper: '" + varName + "'"); } } } Variable var = decorator ? new VarDecorator(handlebars, varName, TagType.STAR_VAR, params, hash, level == 0) : new Variable(handlebars, varName, varType, params, hash); var .startDelimiter(startDelimiter) .endDelimiter(endDelimiter) .filename(source.filename()) .position(name.getLine(), name.getCharPositionInLine()); return var; } /** * Build a hash. * * @param ctx The hash context. * @return A new hash. */ private Map<String, Param> hash(final List<HashContext> ctx) { if (ctx == null || ctx.size() == 0) { return Collections.emptyMap(); } Map<String, Param> result = new LinkedHashMap<>(); for (HashContext hc : ctx) { result.put(hc.QID().getText(), (Param) super.visit(hc.param())); } return result; } /** * Build a hash. * * @param ctx The hash context. * @return A new hash. */ private List<String> blockParams(final BlockParamsContext ctx) { if (ctx == null) { return Collections.emptyList(); } List<TerminalNode> ids = ctx.QID(); if (ids == null || ids.size() == 0) { return Collections.emptyList(); } List<String> result = new ArrayList<String>(); for (TerminalNode id : ids) { result.add(id.getText()); } return result; } /** * Build a param list. * * @param params The param context. * @return A new param list. */ private List<Param> params(final List<ParamContext> params) { if (params == null || params.size() == 0) { return Collections.emptyList(); } List<Param> result = new ArrayList<>(); for (ParamContext param : params) { result.add((Param) super.visit(param)); } return result; } @Override public Object visitBoolParam(final BoolParamContext ctx) { return new DefParam(Boolean.valueOf(ctx.getText())); } @Override public Object visitSubParamExpr(final SubParamExprContext ctx) { SexprContext sexpr = ctx.sexpr(); return new VarParam( newVar(sexpr.QID().getSymbol(), TagType.SUB_EXPRESSION, params(sexpr.param()), hash(sexpr.hash()), "(", ")", false)); } @Override public Object visitStringParam(final StringParamContext ctx) { return new StrParam(ctx.getText().replace("\\\"", "\"")); } @Override public Object visitCharParam(final CharParamContext ctx) { return new StrParam(ctx.getText().replace("\\\'", "\'")); } @Override public Object visitRefParam(final RefParamContext ctx) { return new RefParam(PathCompiler.compile(ctx.getText(), handlebars.parentScopeResolution())); } @Override public Object visitIntParam(final IntParamContext ctx) { return new DefParam(Integer.parseInt(ctx.getText())); } @Override public Template visitTemplate(final TemplateContext ctx) { Template template = visitBody(ctx.body()); if (!handlebars.infiniteLoops() && template instanceof BaseTemplate) { template = infiniteLoop(source, (BaseTemplate) template); } destroy(); return template; } /** * Creates a {@link Template} that detects recursively calls. * * @param source The template source. * @param template The original template. * @return A new {@link Template} that detects recursively calls. */ private static Template infiniteLoop(final TemplateSource source, final BaseTemplate template) { return new ForwardingTemplate(template) { @Override protected void beforeApply(final Context context) { LinkedList<TemplateSource> invocationStack = context.data(Context.INVOCATION_STACK); invocationStack.addLast(source); } @Override protected void afterApply(final Context context) { LinkedList<TemplateSource> invocationStack = context.data(Context.INVOCATION_STACK); if (!invocationStack.isEmpty()) { invocationStack.removeLast(); } } }; } @Override public Template visitPartial(final PartialContext ctx) { hasTag(true); String indent = line.toString(); if (hasTag()) { if (isEmpty(indent) || !isEmpty(indent.trim())) { indent = null; } } else { indent = null; } PartialInfo info = (PartialInfo) super.visit(ctx.pexpr()); String startDelim = ctx.start.getText(); Template partial = new Partial(handlebars, info.path, info.context, info.hash) .startDelimiter(startDelim.substring(0, startDelim.length() - 1)) .endDelimiter(ctx.stop.getText()) .indent(indent) .filename(source.filename()) .position(info.token.getLine(), info.token.getCharPositionInLine()); return partial; } @Override public Object visitPartialBlock(final PartialBlockContext ctx) { hasTag(true); String indent = line.toString(); if (hasTag()) { if (isEmpty(indent) || !isEmpty(indent.trim())) { indent = null; } } else { indent = null; } PartialInfo info = (PartialInfo) super.visit(ctx.pexpr()); Template fn = visitBody(ctx.thenBody); String startDelim = ctx.start.getText(); Template partial = new Partial(handlebars, info.path, info.context, info.hash) .setPartial(fn) .startDelimiter(startDelim.substring(0, startDelim.length() - 1)) .endDelimiter(ctx.stop.getText()) .indent(indent) .filename(source.filename()) .position(info.token.getLine(), info.token.getCharPositionInLine()); return partial; } @Override public PartialInfo visitStaticPath(final StaticPathContext ctx) { return staticPath(ctx.path, ctx.QID(1), ctx.hash()); } /** * Collect partial data. * * @param pathToken Path token. * @param partialContext Optional partial context. * @param hash Optional partial arguments. * @return Partial info. */ private PartialInfo staticPath(final Token pathToken, final TerminalNode partialContext, final List<HashContext> hash) { String uri = pathToken.getText(); if (uri.charAt(0) == '[' || uri.charAt(0) == '"' || uri.charAt(0) == '\'') { uri = uri.substring(1, uri.length() - 1); } if (uri.startsWith("/")) { String message = "found: '/', partial shouldn't start with '/'"; reportError(null, pathToken.getLine(), pathToken.getCharPositionInLine(), message); } PartialInfo partial = new PartialInfo(); partial.token = pathToken; partial.path = new Text(handlebars, uri); partial.hash = hash(hash); partial.context = partialContext != null ? partialContext.getText() : null; return partial; } @Override public PartialInfo visitLiteralPath(final LiteralPathContext ctx) { return staticPath(ctx.path, ctx.QID(), ctx.hash()); } @Override public PartialInfo visitDynamicPath(final DynamicPathContext ctx) { SexprContext sexpr = ctx.sexpr(); TerminalNode qid = sexpr.QID(); Template expression = newVar(qid.getSymbol(), TagType.SUB_EXPRESSION, params(sexpr.param()), hash(sexpr.hash()), "(", ")", false); PartialInfo partial = new PartialInfo(); partial.path = expression; partial.hash = hash(ctx.hash()); TerminalNode scope = ctx.QID(); partial.context = scope != null ? scope.getText() : null; partial.token = qid.getSymbol(); return partial; } @Override public Template visitBody(final BodyContext ctx) { List<StatementContext> stats = ctx.statement(); if (stats.size() == 0 || (stats.size() == 1 && stats.get(0) == Template.EMPTY)) { return Template.EMPTY; } TemplateList list = new TemplateList(handlebars); list.filename(source.filename()); Template prev = null; boolean setMd = false; for (StatementContext statement : stats) { Template candidate = visit(statement); if (candidate != null) { if (!setMd) { list.filename(candidate.filename()) .position(candidate.position()[0], candidate.position()[1]); setMd = true; } // join consecutive text if (candidate instanceof Text) { if (!(prev instanceof Text)) { list.add(candidate); prev = candidate; } else { ((Text) prev).append(((Text) candidate).textWithoutEscapeChar()); } } else { list.add(candidate); prev = candidate; } } } return list; } @Override public Object visitComment(final CommentContext ctx) { return Template.EMPTY; } @Override public Template visitStatement(final StatementContext ctx) { return visit(ctx.getChild(0)); } @Override public Template visitText(final TextContext ctx) { String text = ctx.getText(); line.append(text); return new Text(handlebars, text) .filename(source.filename()) .position(ctx.start.getLine(), ctx.start.getCharPositionInLine()); } @Override public Template visitSpaces(final SpacesContext ctx) { Token space = ctx.SPACE().getSymbol(); String text = space.getText(); line.append(text); if (space.getChannel() == Token.HIDDEN_CHANNEL) { return null; } return new Text(handlebars, text) .filename(source.filename()) .position(ctx.start.getLine(), ctx.start.getCharPositionInLine()); } @Override public BaseTemplate visitNewline(final NewlineContext ctx) { Token newline = ctx.NL().getSymbol(); line.setLength(0); hasTag = null; if (newline.getChannel() == Token.HIDDEN_CHANNEL) { return null; } return new Text(handlebars, newline.getText()) .filename(source.filename()) .position(newline.getLine(), newline.getCharPositionInLine()); } /** * True, if tag instruction was processed. * * @return True, if tag instruction was processed. */ private boolean hasTag() { if (handlebars.prettyPrint()) { return hasTag == null ? false : hasTag.booleanValue(); } return false; } /** * Set if a new tag instruction was processed. * * @param hasTag True, if a new tag instruction was processed. */ private void hasTag(final boolean hasTag) { if (this.hasTag != Boolean.FALSE) { this.hasTag = hasTag; } } /** * Cleanup resources. */ private void destroy() { this.handlebars = null; this.source = null; this.hasTag = null; this.line.delete(0, line.length()); this.line = null; } /** * Report a semantic error. * * @param offendingToken The offending token. * @param message An error message. */ protected void reportError(final CommonToken offendingToken, final String message) { reportError(offendingToken, offendingToken.getLine(), offendingToken.getCharPositionInLine(), message); } /** * Report a semantic error. * * @param offendingToken The offending token. * @param line The offending line. * @param column The offending column. * @param message An error message. */ protected abstract void reportError(final CommonToken offendingToken, final int line, final int column, final String message); }