From ad00295a055195a2b267e0b236a0dd5ece886c50 Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Mon, 13 Jan 2020 16:27:00 +0100 Subject: [PATCH 1/2] Qute "if" section - support multiple conditions --- .../java/io/quarkus/qute/IfSectionHelper.java | 538 +++++++++++++----- .../src/main/java/io/quarkus/qute/Parser.java | 63 +- .../java/io/quarkus/qute/SectionBlock.java | 51 +- .../io/quarkus/qute/SectionHelperFactory.java | 14 +- .../quarkus/qute/SectionInitContextImpl.java | 10 +- .../java/io/quarkus/qute/SectionNode.java | 17 +- .../java/io/quarkus/qute/IfSectionTest.java | 88 ++- .../test/java/io/quarkus/qute/ParserTest.java | 29 + 8 files changed, 617 insertions(+), 193 deletions(-) diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/IfSectionHelper.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/IfSectionHelper.java index db13e7220e90d..7aca06158b18f 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/IfSectionHelper.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/IfSectionHelper.java @@ -1,29 +1,30 @@ package io.quarkus.qute; +import io.quarkus.qute.Results.Result; +import io.quarkus.qute.SectionHelperFactory.ParserDelegate; import io.quarkus.qute.SectionHelperFactory.SectionInitContext; import java.math.BigDecimal; import java.math.BigInteger; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Iterator; import java.util.List; +import java.util.ListIterator; import java.util.Map; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; -import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; /** * Basic {@code if} statement. */ public class IfSectionHelper implements SectionHelper { - static final String CONDITION = "condition"; - private static final String OPERATOR = "operator"; - private static final String OPERAND = "operand"; private static final String ELSE = "else"; private static final String IF = "if"; - private static final String NEGATE = "!"; + private static final String LOGICAL_COMPLEMENT = "!"; private final List blocks; @@ -45,67 +46,20 @@ public CompletionStage resolve(SectionResolutionContext context) { private CompletionStage resolveCondition(SectionResolutionContext context, Iterator blocks) { IfBlock block = blocks.next(); - if (block.condition == null) { - // else without condition + if (block.condition.isEmpty()) { + // else without operands return context.execute(block.block, context.resolutionContext()); } - if (block.operator != null) { - // If operator is used we need to compare the results of condition and operand - CompletableFuture result = new CompletableFuture(); - CompletableFuture cf1 = context.resolutionContext().evaluate(block.condition).toCompletableFuture(); - CompletableFuture cf2 = context.resolutionContext().evaluate(block.operand).toCompletableFuture(); - CompletableFuture.allOf(cf1, cf2).whenComplete((v, t1) -> { - if (t1 != null) { - result.completeExceptionally(t1); - } else { - Object op1; - Object op2; - try { - op1 = cf1.get(); - op2 = cf2.get(); - } catch (InterruptedException | ExecutionException e) { - throw new IllegalStateException(e); - } - try { - if (block.operator.evaluate(op1, op2)) { - context.execute(block.block, context.resolutionContext()).whenComplete((r, t2) -> { - if (t2 != null) { - result.completeExceptionally(t2); - } else { - result.complete(r); - } - }); - } else { - if (blocks.hasNext()) { - resolveCondition(context, blocks).whenComplete((r, t2) -> { - if (t2 != null) { - result.completeExceptionally(t2); - } else { - result.complete(r); - } - }); - } else { - result.complete(ResultNode.NOOP); - } - } - } catch (Exception e) { - result.completeExceptionally(e); - } - } - }); - return result; - } else { - return context.resolutionContext().evaluate(block.condition).thenCompose(r -> { - if (Boolean.TRUE.equals(r)) { - return context.execute(block.block, context.resolutionContext()); - } else { - if (blocks.hasNext()) { - return resolveCondition(context, blocks); - } - return CompletableFuture.completedFuture(ResultNode.NOOP); + return block.condition.evaluate(context).thenCompose(r -> { + if (Boolean.TRUE.equals(r)) { + return context.execute(block.block, context.resolutionContext()); + } else { + if (blocks.hasNext()) { + return resolveCondition(context, blocks); } - }); - } + return CompletableFuture.completedFuture(ResultNode.NOOP); + } + }); } public static class Factory implements SectionHelperFactory { @@ -118,16 +72,8 @@ public List getDefaultAliases() { @Override public ParametersInfo getParameters() { ParametersInfo.Builder builder = ParametersInfo.builder(); - // if params - builder.addParameter(CONDITION); - builder.addParameter(new Parameter(OPERATOR, null, true)); - builder.addParameter(new Parameter(OPERAND, null, true)); - // else parts - // dummy "if" param first - builder.addParameter(ELSE, new Parameter(IF, null, true)); - builder.addParameter(ELSE, new Parameter(CONDITION, null, true)); - builder.addParameter(ELSE, new Parameter(OPERATOR, null, true)); - builder.addParameter(ELSE, new Parameter(OPERAND, null, true)); + // {#if} must declare at least one condition param + builder.addParameter("condition"); return builder .build(); } @@ -143,27 +89,32 @@ public IfSectionHelper initialize(SectionInitContext context) { @Override public Map initializeBlock(Map outerNameTypeInfos, BlockInfo block) { - if (MAIN_BLOCK_NAME.equals(block.getLabel()) || ELSE.equals(block.getLabel())) { - if (MAIN_BLOCK_NAME.equals(block.getLabel()) && !block.hasParameter(CONDITION)) { - throw new IllegalStateException("Condition param must be present"); + List params = null; + if (MAIN_BLOCK_NAME.equals(block.getLabel())) { + params = parseParams(new ArrayList<>(block.getParameters().values()), block); + } else if (ELSE.equals(block.getLabel())) { + params = parseParams(new ArrayList<>(block.getParameters().values()), block); + if (!params.isEmpty()) { + // else if <-- remove "if" + params.remove(0); } - String conditionParam = block.getParameter(CONDITION); - if (conditionParam != null) { - if (conditionParam.startsWith(NEGATE)) { - if (block.hasParameter(OPERAND)) { - throw new IllegalArgumentException( - "Logical complement operator may not be used for multiple operands"); - } else { - conditionParam = conditionParam.substring(1, conditionParam.length()); - } - } else if (block.hasParameter(OPERAND)) { - block.addExpression(OPERAND, block.getParameter(OPERAND)); + } + addExpressions(params, block); + // {#if} never changes the scope + return Collections.emptyMap(); + } + + @SuppressWarnings("unchecked") + private void addExpressions(List params, BlockInfo block) { + if (params != null && !params.isEmpty()) { + for (Object param : params) { + if (param instanceof String) { + block.addExpression(param.toString(), param.toString()); + } else if (param instanceof List) { + addExpressions((List) param, block); } - block.addExpression(CONDITION, conditionParam); } } - // If section never changes the scope - return Collections.emptyMap(); } } @@ -171,69 +122,238 @@ public Map initializeBlock(Map outerNameTypeInfo static class IfBlock { final SectionBlock block; - final Expression condition; - final Expression operand; - final Operator operator; + final Condition condition; public IfBlock(SectionBlock block, SectionInitContext context) { this.block = block; - Operator operator = Operator.from(block.parameters.get(OPERATOR)); - Expression operand; - if (operator != null) { - operand = block.expressions.get(OPERAND); - if (operand == null) { - throw new IllegalArgumentException("Operator set but no operand param present"); - } - } else { - operand = null; + List params = parseParams(new ArrayList<>(block.parameters.values()), context); + if (!params.isEmpty() && !SectionHelperFactory.MAIN_BLOCK_NAME.equals(block.label)) { + params = params.subList(1, params.size()); } - if (block.parameters.containsKey(CONDITION) && block.parameters.get(CONDITION).startsWith(NEGATE)) { - operator = Operator.EQ; - operand = Expression.literal("false"); - } - this.condition = block.expressions.get(CONDITION); - this.operand = operand; + this.condition = createCondition(params, block, null, context); + } + + } + + interface Condition { + + CompletionStage evaluate(SectionResolutionContext context); + + Operator getOperator(); + + /** + * Short-circuiting evaluation. + * + * @return null if evaluation should continue + */ + default Boolean evaluate(Object value) { + return getOperator() != null ? getOperator().evaluate(value) : null; + } + + default boolean isLogicalComplement() { + return Operator.NOT.equals(getOperator()); + } + + default boolean isEmpty() { + return false; + } + + } + + static class OperandCondition implements Condition { + + final Operator operator; + final Expression expression; + + OperandCondition(Operator operator, Expression expression) { this.operator = operator; + this.expression = expression; + } + + @Override + public CompletionStage evaluate(SectionResolutionContext context) { + return context.resolutionContext().evaluate(expression); + } + + @Override + public Operator getOperator() { + return operator; + } + + } + + static class CompositeCondition implements Condition { + + final List conditions; + final Operator operator; + + public CompositeCondition(Operator operator, List conditions) { + this.operator = operator; + this.conditions = conditions; + } + + @Override + public CompletionStage evaluate(SectionResolutionContext context) { + return evaluateNext(context, null, conditions.iterator()); + } + + CompletionStage evaluateNext(SectionResolutionContext context, Object value, Iterator iter) { + CompletableFuture result = new CompletableFuture<>(); + if (!iter.hasNext()) { + result.complete(value); + } else { + Condition next = iter.next(); + Boolean shortResult = null; + Operator operator = next.getOperator(); + if (operator != null && operator.isShortCircuiting()) { + shortResult = operator.evaluate(value); + } + if (shortResult != null) { + // There is no need to continue with the next operand + result.complete(shortResult); + } else { + next.evaluate(context).whenComplete((r, t) -> { + if (t != null) { + result.completeExceptionally(t); + } else { + Object val; + if (next.isLogicalComplement()) { + r = Boolean.TRUE.equals(r) ? Boolean.FALSE : Boolean.TRUE; + } + if (operator == null || !operator.isBinary()) { + val = r; + } else { + try { + if (Result.NOT_FOUND.equals(r)) { + r = null; + } + Object localValue = value; + if (Result.NOT_FOUND.equals(localValue)) { + localValue = null; + } + val = operator.evaluate(localValue, r); + } catch (Exception e) { + result.completeExceptionally(e); + throw e; + } + } + evaluateNext(context, val, iter).whenComplete((r2, t2) -> { + if (t2 != null) { + result.completeExceptionally(t2); + } else { + result.complete(r2); + } + }); + } + }); + } + } + return result; + } + + @Override + public Operator getOperator() { + return operator; + } + + @Override + public boolean isEmpty() { + return conditions.isEmpty(); } } enum Operator { - EQ("eq", "==", "is"), - NE("ne", "!="), - GT("gt", ">"), - GE("ge", ">="), - LE("le", "<="), - LT("lt", "<"), - ; + EQ(2, "eq", "==", "is"), + NE(2, "ne", "!="), + GT(3, "gt", ">"), + GE(3, "ge", ">="), + LE(3, "le", "<="), + LT(3, "lt", "<"), + AND(1, "and", "&&"), + OR(1, "or", "||"), + NOT(4, IfSectionHelper.LOGICAL_COMPLEMENT); - private List aliases; + private final List aliases; + private final int precedence; - Operator(String... aliases) { + Operator(int precedence, String... aliases) { this.aliases = Arrays.asList(aliases); + this.precedence = precedence; + } + + int getPrecedence() { + return precedence; } boolean evaluate(Object op1, Object op2) { - // TODO better handling of Comparable, numbers, etc. switch (this) { case EQ: return Objects.equals(op1, op2); case NE: return !Objects.equals(op1, op2); case GE: - return getDecimal(op1).compareTo(getDecimal(op2)) >= 0; case GT: - return getDecimal(op1).compareTo(getDecimal(op2)) > 0; case LE: - return getDecimal(op1).compareTo(getDecimal(op2)) <= 0; case LT: - return getDecimal(op1).compareTo(getDecimal(op2)) < 0; + return compare(op1, op2); + case AND: + case OR: + return Boolean.TRUE.equals(op2); + default: + throw new TemplateException("Not a binary operator: " + this); + } + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + boolean compare(Object op1, Object op2) { + if (op1 == null || op2 == null) { + throw new TemplateException("Unable to compare null operands [op1=" + op1 + ", op2=" + op2 + "]"); + } + Comparable c1; + Comparable c2; + if (op1 instanceof Comparable && op1.getClass().equals(op2.getClass())) { + c1 = (Comparable) op1; + c2 = (Comparable) op2; + } else { + c1 = getDecimal(op1); + c2 = getDecimal(op2); + } + int result = c1.compareTo(c2); + switch (this) { + case GE: + return result >= 0; + case GT: + return result > 0; + case LE: + return result <= 0; + case LT: + return result < 0; default: return false; } } + Boolean evaluate(Object op1) { + switch (this) { + case AND: + return Boolean.TRUE.equals(op1) ? null : Boolean.FALSE; + case OR: + return Boolean.TRUE.equals(op1) ? Boolean.TRUE : null; + default: + throw new TemplateException("Not a short-circuiting operator: " + this); + } + } + + boolean isShortCircuiting() { + return AND.equals(this) || OR.equals(this); + } + + boolean isBinary() { + return !NOT.equals(this); + } + static Operator from(String value) { if (value == null || value.isEmpty()) { return null; @@ -246,26 +366,168 @@ static Operator from(String value) { return null; } + static BigDecimal getDecimal(Object value) { + BigDecimal decimal; + if (value instanceof BigDecimal) { + decimal = (BigDecimal) value; + } else if (value instanceof BigInteger) { + decimal = new BigDecimal((BigInteger) value); + } else if (value instanceof Long) { + decimal = new BigDecimal((Long) value); + } else if (value instanceof Integer) { + decimal = new BigDecimal((Integer) value); + } else if (value instanceof Double) { + decimal = new BigDecimal((Double) value); + } else if (value instanceof String) { + decimal = new BigDecimal(value.toString()); + } else { + throw new TemplateException("Not a valid number: " + value); + } + return decimal; + } + } - static BigDecimal getDecimal(Object value) { - BigDecimal decimal; - if (value instanceof BigDecimal) { - decimal = (BigDecimal) value; - } else if (value instanceof BigInteger) { - decimal = new BigDecimal((BigInteger) value); - } else if (value instanceof Long) { - decimal = new BigDecimal((Long) value); - } else if (value instanceof Integer) { - decimal = new BigDecimal((Integer) value); - } else if (value instanceof Double) { - decimal = new BigDecimal((Double) value); - } else if (value instanceof String) { - decimal = new BigDecimal(value.toString()); + static List parseParams(List params, ParserDelegate parserDelegate) { + + int highestPrecedence = 0; + // Replace operators and composite params if needed + for (ListIterator iterator = params.listIterator(); iterator.hasNext();) { + Object param = iterator.next(); + if (param instanceof String) { + String stringParam = param.toString(); + Operator operator = Operator.from(stringParam); + if (operator != null) { + // Binary operator + if (operator.getPrecedence() > highestPrecedence) { + highestPrecedence = operator.getPrecedence(); + } + if (operator.isBinary() && !iterator.hasNext()) { + throw parserDelegate.createParserError( + "binary operator [" + operator + "] set but the second operand not present for {#if} section"); + } + iterator.set(operator); + } else { + if (stringParam.length() > 1 && stringParam.startsWith(LOGICAL_COMPLEMENT)) { + // !item.active + iterator.set(Operator.NOT); + stringParam = stringParam.substring(1); + if (stringParam.charAt(0) == Parser.START_COMPOSITE_PARAM) { + iterator.add(processCompositeParam(stringParam, parserDelegate)); + } else { + iterator.add(stringParam); + } + } else { + if (stringParam.charAt(0) == Parser.START_COMPOSITE_PARAM) { + iterator.set(processCompositeParam(stringParam, parserDelegate)); + } + } + } + } + } + + if (params.stream().filter(p -> p instanceof Operator).map(p -> ((Operator) p).getPrecedence()) + .collect(Collectors.toSet()).size() <= 1) { + // No binary operators or all of the same precedence + return params; + } + + // Take the operators with highest precedence and form groups + List highestGroup = null; + List ret = new ArrayList<>(); + int lastGroupdIdx = 0; + + for (ListIterator iterator = params.listIterator(); iterator.hasNext();) { + int prevIdx = iterator.previousIndex(); + Object param = iterator.next(); + if (isBinaryOperatorEq(param, highestPrecedence)) { + if (highestGroup == null) { + highestGroup = new ArrayList<>(); + highestGroup.add(params.get(prevIdx)); + } + highestGroup.add(param); + // Add non-grouped elements + if (prevIdx > lastGroupdIdx) { + params.subList(lastGroupdIdx > 0 ? lastGroupdIdx + 1 : 0, prevIdx).forEach(ret::add); + } + } else if (isBinaryOperatorLt(param, highestPrecedence)) { + if (highestGroup != null) { + ret.add(highestGroup); + lastGroupdIdx = prevIdx; + highestGroup = null; + } + } else if (highestGroup != null) { + highestGroup.add(param); + } + } + if (highestGroup != null) { + ret.add(highestGroup); + } else { + // Add all remaining non-grouped elements + if (lastGroupdIdx + 1 != params.size()) { + params.subList(lastGroupdIdx + 1, params.size()).forEach(ret::add); + } + } + return parseParams(ret, parserDelegate); + } + + static List processCompositeParam(String stringParam, ParserDelegate parserDelegate) { + // Composite params + if (!stringParam.endsWith("" + Parser.END_COMPOSITE_PARAM)) { + throw new TemplateException("Invalid composite parameter found: " + stringParam); + } + List split = new ArrayList<>(); + Parser.splitSectionParams(stringParam.substring(1, stringParam.length() - 1), TemplateException::new) + .forEachRemaining(split::add); + return parseParams(split, parserDelegate); + } + + private static boolean isBinaryOperatorEq(Object val, int precedence) { + return val instanceof Operator && ((Operator) val).getPrecedence() == precedence; + } + + private static boolean isBinaryOperatorLt(Object val, int precedence) { + return val instanceof Operator && ((Operator) val).getPrecedence() < precedence; + } + + @SuppressWarnings("unchecked") + static Condition createCondition(Object param, SectionBlock block, Operator operator, SectionInitContext context) { + + Condition condition; + + if (param instanceof String) { + String stringParam = param.toString(); + boolean logicalComplement = stringParam.startsWith(LOGICAL_COMPLEMENT); + if (logicalComplement) { + stringParam = stringParam.substring(1); + } + Expression expr = block.expressions.get(stringParam); + if (expr == null) { + throw new TemplateException("Expression not found for param [" + stringParam + "]: " + block); + } + condition = new OperandCondition(operator, expr); + } else if (param instanceof List) { + List params = (List) param; + if (params.size() == 1) { + return createCondition(params.get(0), block, operator, context); + } + + List conditions = new ArrayList<>(); + Operator nextOperator = null; + + for (Object p : params) { + if (p instanceof Operator) { + nextOperator = (Operator) p; + } else { + conditions.add(createCondition(p, block, nextOperator, context)); + nextOperator = null; + } + } + condition = new CompositeCondition(operator, conditions); } else { - throw new IllegalArgumentException("Not a valid number: " + value); + throw new TemplateException("Unsupported param type: " + param); } - return decimal; + return condition; } } diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/Parser.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/Parser.java index 7aafa4435f2dd..3db90dbc79b45 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/Parser.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/Parser.java @@ -11,6 +11,7 @@ import java.util.Deque; import java.util.HashMap; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -38,6 +39,9 @@ class Parser implements Function { private static final char LINE_SEPARATOR_CR = '\r'; // DOS, OS/2, Microsoft Windows, etc. use CRLF + static final char START_COMPOSITE_PARAM = '('; + static final char END_COMPOSITE_PARAM = ')'; + private StringBuilder buffer; private State state; private int line; @@ -74,7 +78,7 @@ public CompletionStage resolve(SectionResolutionContext context) { })); this.sectionBlockStack = new ArrayDeque<>(); - this.sectionBlockStack.addFirst(SectionBlock.builder(SectionHelperFactory.MAIN_BLOCK_NAME, this)); + this.sectionBlockStack.addFirst(SectionBlock.builder(SectionHelperFactory.MAIN_BLOCK_NAME, this, this::parserError)); this.sectionBlockIdx = 0; this.paramsStack = new ArrayDeque<>(); this.paramsStack.addFirst(ParametersInfo.EMPTY); @@ -222,7 +226,7 @@ private void flushTag() { isEmptySection = true; } - Iterator iter = splitSectionParams(content); + Iterator iter = splitSectionParams(content, this::parserError); if (!iter.hasNext()) { throw parserError("no helper name declared"); } @@ -242,7 +246,8 @@ private void flushTag() { sectionStack.peek().addBlock(sectionBlockStack.pop().build()); } // Add the new block - SectionBlock.Builder block = SectionBlock.builder("" + sectionBlockIdx++, this); + SectionBlock.Builder block = SectionBlock.builder("" + sectionBlockIdx++, this, this::parserError) + .setOrigin(origin()); sectionBlockStack.addFirst(block.setLabel(sectionName)); processParams(tag, sectionName, iter); @@ -267,7 +272,9 @@ private void flushTag() { throw parserError("no section helper found for " + tag); } paramsStack.addFirst(factory.getParameters()); - SectionBlock.Builder mainBlock = SectionBlock.builder(SectionHelperFactory.MAIN_BLOCK_NAME, this); + SectionBlock.Builder mainBlock = SectionBlock + .builder(SectionHelperFactory.MAIN_BLOCK_NAME, this, this::parserError) + .setOrigin(origin()); sectionBlockStack.addFirst(mainBlock); processParams(tag, SectionHelperFactory.MAIN_BLOCK_NAME, iter); @@ -356,7 +363,7 @@ private TemplateException parserError(String message) { } private void processParams(String tag, String label, Iterator iter) { - Map params = new HashMap<>(); + Map params = new LinkedHashMap<>(); List factoryParams = paramsStack.peek().get(label); List paramValues = new ArrayList<>(); @@ -384,6 +391,7 @@ private void processParams(String tag, String label, Iterator iter) { } } } else { + int generatedIdx = 0; for (String param : paramValues) { int equalsPosition = getFirstDeterminingEqualsCharPosition(param); if (equalsPosition != -1) { @@ -392,12 +400,17 @@ private void processParams(String tag, String label, Iterator iter) { param.length())); } else { // Positional param - first non-default section param + Parameter found = null; for (Parameter factoryParam : factoryParams) { if (!params.containsKey(factoryParam.name)) { + found = factoryParam; params.put(factoryParam.name, param); break; } } + if (found == null) { + params.put("" + generatedIdx++, param); + } } } } @@ -438,58 +451,60 @@ static int getFirstDeterminingEqualsCharPosition(String part) { return -1; } - Iterator splitSectionParams(String content) { + static Iterator splitSectionParams(String content, Function errorFun) { boolean stringLiteral = false; - boolean listLiteral = false; + short composite = 0; boolean space = false; List parts = new ArrayList<>(); StringBuilder buffer = new StringBuilder(); for (int i = 0; i < content.length(); i++) { - if (content.charAt(i) == ' ') { + char c = content.charAt(i); + if (c == ' ') { if (!space) { - if (!stringLiteral && !listLiteral) { + if (!stringLiteral && composite == 0) { if (buffer.length() > 0) { parts.add(buffer.toString()); buffer = new StringBuilder(); } space = true; } else { - buffer.append(content.charAt(i)); + buffer.append(c); } } } else { - if (!listLiteral - && isStringLiteralSeparator(content.charAt(i))) { + if (composite == 0 + && isStringLiteralSeparator(c)) { stringLiteral = !stringLiteral; } else if (!stringLiteral - && isListLiteralStart(content.charAt(i))) { - listLiteral = true; + && isCompositeStart(c) && (i == 0 || space || composite > 0 + || (buffer.length() > 0 && buffer.charAt(buffer.length() - 1) == '!'))) { + composite++; } else if (!stringLiteral - && isListLiteralEnd(content.charAt(i))) { - listLiteral = false; + && isCompositeEnd(c) && composite > 0) { + composite--; } space = false; - buffer.append(content.charAt(i)); + buffer.append(c); } } if (buffer.length() > 0) { - if (stringLiteral || listLiteral) { - throw parserError("unterminated string or array literal detected"); + if (stringLiteral || composite > 0) { + throw errorFun.apply("unterminated string literal or composite parameter detected for [" + content + "]"); } parts.add(buffer.toString()); } return parts.iterator(); } - static boolean isListLiteralStart(char character) { - return character == '['; + static boolean isCompositeStart(char character) { + return character == START_COMPOSITE_PARAM; } - static boolean isListLiteralEnd(char character) { - return character == ']'; + static boolean isCompositeEnd(char character) { + return character == END_COMPOSITE_PARAM; } enum Tag { @@ -657,7 +672,7 @@ public boolean equals(Object obj) { @Override public String toString() { StringBuilder builder = new StringBuilder(); - builder.append(templateId).append(" at line ").append(line); + builder.append("Template ").append(templateId).append(" at line ").append(line); return builder.toString(); } diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionBlock.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionBlock.java index be048dc168026..8996545fa68c4 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionBlock.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionBlock.java @@ -1,10 +1,11 @@ package io.quarkus.qute; import io.quarkus.qute.SectionHelperFactory.BlockInfo; +import io.quarkus.qute.TemplateNode.Origin; import java.util.ArrayList; import java.util.Collections; -import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -12,10 +13,12 @@ /** * Each section tag consists of one or more blocks. The main block is always present. Additional blocks start with a label - * definition: {:label param1}. + * definition: {#label param1}. */ public class SectionBlock { + public final Origin origin; + /** * Id generated by the parser. {@code main} for the main block. */ @@ -36,8 +39,10 @@ public class SectionBlock { */ final List nodes; - public SectionBlock(String id, String label, Map parameters, Map expressions, + public SectionBlock(Origin origin, String id, String label, Map parameters, + Map expressions, List nodes) { + this.origin = origin; this.id = id; this.label = label; this.parameters = parameters; @@ -54,25 +59,42 @@ Set getExpressions() { return expressions; } - static SectionBlock.Builder builder(String id, Function expressionFunc) { - return new Builder(id, expressionFunc).setLabel(id); + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("SectionBlock [origin=").append(origin).append(", id=").append(id).append(", label=").append(label) + .append("]"); + return builder.toString(); + } + + static SectionBlock.Builder builder(String id, Function expressionFunc, + Function errorFun) { + return new Builder(id, expressionFunc, errorFun).setLabel(id); } static class Builder implements BlockInfo { private final String id; + private Origin origin; private String label; private final Map parameters; private final List nodes; private final Map expressions; - private final Function expressionFunc; + private final Function expressionFun; + private final Function errorFun; - public Builder(String id, Function expressionFunc) { + public Builder(String id, Function expressionFun, Function errorFun) { this.id = id; - this.parameters = new HashMap<>(); + this.parameters = new LinkedHashMap<>(); this.nodes = new ArrayList<>(); - this.expressions = new HashMap<>(); - this.expressionFunc = expressionFunc; + this.expressions = new LinkedHashMap<>(); + this.expressionFun = expressionFun; + this.errorFun = errorFun; + } + + SectionBlock.Builder setOrigin(Origin origin) { + this.origin = origin; + return this; } SectionBlock.Builder addNode(TemplateNode node) { @@ -97,7 +119,7 @@ SectionBlock.Builder addParameter(String name, String value) { @Override public Expression addExpression(String param, String value) { - Expression expression = expressionFunc.apply(value); + Expression expression = expressionFun.apply(value); expressions.put(param, expression); return expression; } @@ -110,8 +132,13 @@ public String getLabel() { return label; } + @Override + public TemplateException createParserError(String message) { + return errorFun.apply(message); + } + SectionBlock build() { - return new SectionBlock(id, label, parameters, expressions, nodes); + return new SectionBlock(origin, id, label, parameters, expressions, nodes); } } diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionHelperFactory.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionHelperFactory.java index c47a97c14ac66..61ad002405b57 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionHelperFactory.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionHelperFactory.java @@ -56,15 +56,19 @@ default boolean treatUnknownSectionsAsBlocks() { * Initialize a section block. * * @return a map of name to type infos + * @see BlockInfo#addExpression(String, String) */ default Map initializeBlock(Map outerNameTypeInfos, BlockInfo block) { return Collections.emptyMap(); } - /** - * - */ - interface BlockInfo { + interface ParserDelegate { + + TemplateException createParserError(String message); + + } + + interface BlockInfo extends ParserDelegate { String getLabel(); @@ -85,7 +89,7 @@ default boolean hasParameter(String name) { /** * Section initialization context. */ - public interface SectionInitContext { + public interface SectionInitContext extends ParserDelegate { default Map getParameters() { return getBlocks().get(0).parameters; diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionInitContextImpl.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionInitContextImpl.java index f306fae6d31a5..3903e9510649b 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionInitContextImpl.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionInitContextImpl.java @@ -3,6 +3,7 @@ import io.quarkus.qute.SectionHelperFactory.SectionInitContext; import java.util.List; import java.util.Map; +import java.util.function.Function; /** * @@ -11,10 +12,12 @@ final class SectionInitContextImpl implements SectionInitContext { private final EngineImpl engine; private final List blocks; + private final Function errorFun; - public SectionInitContextImpl(EngineImpl engine, List blocks) { + public SectionInitContextImpl(EngineImpl engine, List blocks, Function errorFun) { this.engine = engine; this.blocks = blocks; + this.errorFun = errorFun; } /** @@ -52,4 +55,9 @@ public EngineImpl getEngine() { return engine; } + @Override + public TemplateException createParserError(String message) { + return errorFun.apply(message); + } + } diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionNode.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionNode.java index 3dd319cf8ec50..9c266765b158f 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionNode.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/SectionNode.java @@ -41,7 +41,8 @@ public Origin getOrigin() { @Override public String toString() { StringBuilder builder = new StringBuilder(); - builder.append("SectionNode [helper=").append(helper.getClass().getSimpleName()).append("]"); + builder.append("SectionNode [helper=").append(helper.getClass().getSimpleName()).append(", origin= ").append(origin) + .append("]"); return builder.toString(); } @@ -83,7 +84,19 @@ Builder setEngine(EngineImpl engine) { } SectionNode build() { - return new SectionNode(blocks, factory.initialize(new SectionInitContextImpl(engine, blocks)), origin); + return new SectionNode(blocks, + factory.initialize(new SectionInitContextImpl(engine, blocks, this::createParserError)), origin); + } + + TemplateException createParserError(String message) { + StringBuilder builder = new StringBuilder("Parser error"); + if (!origin.getTemplateId().equals(origin.getTemplateGeneratedId())) { + builder.append(" in template [").append(origin.getTemplateId()).append("]"); + } + builder.append(" on line ").append(origin.getLine()).append(": ") + .append(message); + return new TemplateException(origin, + builder.toString()); } } diff --git a/independent-projects/qute/core/src/test/java/io/quarkus/qute/IfSectionTest.java b/independent-projects/qute/core/src/test/java/io/quarkus/qute/IfSectionTest.java index 550c76a509ede..3f35182aea09a 100644 --- a/independent-projects/qute/core/src/test/java/io/quarkus/qute/IfSectionTest.java +++ b/independent-projects/qute/core/src/test/java/io/quarkus/qute/IfSectionTest.java @@ -1,8 +1,13 @@ package io.quarkus.qute; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; +import io.quarkus.qute.IfSectionHelper.Operator; +import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -10,9 +15,7 @@ public class IfSectionTest { @Test public void tesIfElse() { - Engine engine = Engine.builder().addSectionHelper(new IfSectionHelper.Factory()) - .addValueResolver(ValueResolvers.mapResolver()) - .build(); + Engine engine = Engine.builder().addDefaults().build(); Template template = engine.parse("{#if isActive}ACTIVE{#else}INACTIVE{/if}"); Map data = new HashMap<>(); @@ -26,9 +29,7 @@ public void tesIfElse() { @Test public void tesIfOperator() { - Engine engine = Engine.builder().addSectionHelper(new IfSectionHelper.Factory()) - .addValueResolver(ValueResolvers.mapResolver()) - .build(); + Engine engine = Engine.builder().addDefaults().build(); Map data = new HashMap<>(); data.put("name", "foo"); @@ -48,19 +49,84 @@ public void tesIfOperator() { assertEquals("OK", engine.parse("{#if name != null}OK{/if}").render(data)); assertEquals("OK", engine.parse("{#if name is null}NOK{#else}OK{/if}").render(data)); assertEquals("OK", engine.parse("{#if !false}OK{/if}").render(data)); + assertEquals("OK", engine.parse("{#if true && true}OK{/if}").render(data)); + assertEquals("OK", engine.parse("{#if name is 'foo' && true}OK{/if}").render(data)); + assertEquals("OK", engine.parse("{#if true && true && true}OK{/if}").render(data)); + assertEquals("OK", engine.parse("{#if false || true}OK{/if}").render(data)); + assertEquals("OK", engine.parse("{#if false || false || true}OK{/if}").render(data)); + assertEquals("OK", engine.parse("{#if name or true}OK{/if}").render(data)); + assertEquals("OK", engine.parse("{#if !(true && false)}OK{/if}").render(data)); + assertEquals("OK", engine.parse("{#if two > 1 && two < 10}OK{/if}").render(data)); } @Test public void testNestedIf() { - Engine engine = Engine.builder().addSectionHelper(new IfSectionHelper.Factory()) - .addValueResolver(ValueResolvers.mapResolver()) - .build(); - + Engine engine = Engine.builder().addDefaults().build(); Map data = new HashMap<>(); data.put("ok", true); data.put("nok", false); + assertEquals("OK", engine.parse("{#if ok}{#if !nok}OK{/}{#else}NOK{/if}").render(data)); + } + + @Test + public void testCompositeParameters() { + Engine engine = Engine.builder().addDefaults().build(); + assertEquals("OK", engine.parse("{#if (true || false) && true}OK{/if}").render()); + assertEquals("OK", engine.parse("{#if (foo || false || true) && (true)}OK{/if}").render()); + assertEquals("NOK", engine.parse("{#if foo || false}OK{#else}NOK{/if}").render()); + assertEquals("OK", engine.parse("{#if false || (foo || (false || true))}OK{#else}NOK{/if}").render()); + } + + @Test + public void testParserErrors() { + // Missing operand + assertParserError("{#if foo >}{/}", + "Parser error on line 1: binary operator [GT] set but the second operand not present for {#if} section", + 1); + } + + @Test + public void testParameterParsing() { + List params = IfSectionHelper + .parseParams(Arrays.asList("item.price", ">", "10", "&&", "item.price", "<", "20"), null); + assertEquals(3, params.size()); + assertEquals(Arrays.asList("item.price", Operator.GT, "10"), params.get(0)); + assertEquals(Operator.AND, params.get(1)); + assertEquals(Arrays.asList("item.price", Operator.LT, "20"), params.get(2)); + + params = IfSectionHelper + .parseParams(Arrays.asList("(item.price > 10)", "&&", "item.price", "<", "20"), null); + assertEquals(3, params.size()); + assertEquals(Arrays.asList("item.price", Operator.GT, "10"), params.get(0)); + assertEquals(Operator.AND, params.get(1)); + assertEquals(Arrays.asList("item.price", Operator.LT, "20"), params.get(2)); + + params = IfSectionHelper + .parseParams(Arrays.asList("(item.price > 10)", "&&", "(item.price < 20)"), null); + assertEquals(3, params.size()); + assertEquals(Arrays.asList("item.price", Operator.GT, "10"), params.get(0)); + assertEquals(Operator.AND, params.get(1)); + assertEquals(Arrays.asList("item.price", Operator.LT, "20"), params.get(2)); + + params = IfSectionHelper + .parseParams(Arrays.asList("name", "is", "'foo'", "&&", "true"), null); + assertEquals(3, params.size()); + assertEquals(Arrays.asList("name", Operator.EQ, "'foo'"), params.get(0)); + assertEquals(Operator.AND, params.get(1)); + assertEquals("true", params.get(2)); + } - assertEquals("OK", engine.parse("{#if ok}{#if ok}OK{/}{#else}NOK{/if}").render(data)); + private void assertParserError(String template, String message, int line) { + Engine engine = Engine.builder().addDefaultSectionHelpers().build(); + try { + engine.parse(template); + fail("No parser error found"); + } catch (TemplateException expected) { + assertNotNull(expected.getOrigin()); + assertEquals(line, expected.getOrigin().getLine(), "Wrong line"); + assertEquals(message, + expected.getMessage()); + } } } diff --git a/independent-projects/qute/core/src/test/java/io/quarkus/qute/ParserTest.java b/independent-projects/qute/core/src/test/java/io/quarkus/qute/ParserTest.java index 8c7fde42df145..b2fc918e454fe 100644 --- a/independent-projects/qute/core/src/test/java/io/quarkus/qute/ParserTest.java +++ b/independent-projects/qute/core/src/test/java/io/quarkus/qute/ParserTest.java @@ -2,12 +2,17 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import io.quarkus.qute.TemplateLocator.TemplateLocation; import io.quarkus.qute.TemplateNode.Origin; import java.io.Reader; import java.io.StringReader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.Test; @@ -128,6 +133,20 @@ public Optional getVariant() { } } + @Test + public void testSectionParameters() { + assertParams("item.active || item.sold", "item.active", "||", "item.sold"); + assertParams("!(item.active || item.sold) || true", "!(item.active || item.sold)", "||", "true"); + assertParams("(item.active && (item.sold || false)) || user.loggedIn", "(item.active && (item.sold || false))", "||", + "user.loggedIn"); + assertParams("this.get('name') is null", "this.get('name')", "is", "null"); + assertParserError("{#if 'foo is null}{/}", + "Parser error on line 1: unterminated string literal or composite parameter detected for [#if 'foo is null]", + 1); + assertParserError("{#if (foo || bar}{/}", + "Parser error on line 1: unterminated string literal or composite parameter detected for [#if (foo || bar]", 1); + } + private void assertParserError(String template, String message, int line) { Engine engine = Engine.builder().addDefaultSectionHelpers().build(); try { @@ -152,4 +171,14 @@ private Expression find(Set expressions, String val) { return expressions.stream().filter(e -> e.toOriginalString().equals(val)).findAny().get(); } + private void assertParams(String content, String... expectedParams) { + Iterator iter = Parser.splitSectionParams(content, s -> new RuntimeException(s)); + List params = new ArrayList<>(); + while (iter.hasNext()) { + params.add(iter.next()); + } + assertTrue(params.containsAll(Arrays.asList(expectedParams)), + params + " should contain " + Arrays.toString(expectedParams)); + } + } From ddac355b2dd2072ad886b7397e856a943e8ef2b2 Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Wed, 15 Jan 2020 22:37:54 +0100 Subject: [PATCH 2/2] Qute "if" section - update docs --- docs/src/main/asciidoc/qute-reference.adoc | 54 ++++++++++++++++++---- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/src/main/asciidoc/qute-reference.adoc b/docs/src/main/asciidoc/qute-reference.adoc index fd01f0a49b544..efc3681c1e379 100644 --- a/docs/src/main/asciidoc/qute-reference.adoc +++ b/docs/src/main/asciidoc/qute-reference.adoc @@ -277,28 +277,47 @@ The simplest possible version accepts a single parameter and renders the content You can also use the following operators: |=== -|Operator |Aliases +|Operator |Aliases |Precedence (higher wins) -|equals -|`eq`, `==`, `is` - -|not equals -|`ne`, `!=` +|logical complement +|`!` +| 4 |greater than |`gt`, `>` +| 3 |greater than or equal to |`ge`, `>=` +| 3 |less than |`lt`, `<` +| 3 |less than or equal to |`le`, `\<=` +| 3 + +|equals +|`eq`, `==`, `is` +| 2 + +|not equals +|`ne`, `!=` +| 2 + +|logical AND (short-circuiting) +|`&&`, `and` +| 1 + +|logical OR (short-circuiting) +|`\|\|`, `or` +| 1 |=== +.A simple operator example [source] ---- {#if item.age > 10} @@ -306,9 +325,28 @@ You can also use the following operators: {/if} ---- -NOTE: Multiple conditions are not supported. +Multiple conditions are also supported. + +.Multiple conditions example +[source] +---- +{#if item.age > 10 && item.price > 500} + This item is very old and expensive. +{/if} +---- + +Precedence rules can be overridden by parentheses. + +.Parentheses example +[source] +---- +{#if (item.age > 10 || item.price > 500) && user.loggedIn} + User must be logged in and item age must be > 10 or price must be > 500. +{/if} +---- + -You can add any number of "else" blocks: +You can add any number of `else` blocks: [source] ----