diff --git a/README.md b/README.md index 466a347..7f2d3e9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A lightweight Java string template engine. While it is intended to be used with will work with any text content. While small, it has some unique features and is fast and flexible. -It requires just 4 HTML-like tags, and a bash-like variable expression syntax. +It requires just 5 HTML-like tags, and a bash-like variable expression syntax. ## Status @@ -25,7 +25,7 @@ Feature complete. Just some test coverage to complete and addition of Javadoc. * Fast. Single pass parser, use lambdas to compute template components only when they are actually needed. * Simple Java. Public API consists of just 2 main classes, `TemplateModel` and `TemplateProcessor`. - * Simple Content. Just `` (and ``), `` and ``. Bash like variable such as `${myVar}`. + * Simple Content. Just `` (and ``), ``, `` and ``. Bash like variable such as `${myVar}`. * Internationalisation features. ## Quick Start diff --git a/pom.xml b/pom.xml index 8a44693..93f31ef 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ 4.0.0 com.sshtools tinytemplate - 0.9.2 + 0.9.3-SNAPSHOT TinyTemplate UTF-8 diff --git a/src/main/java/com/sshtools/tinytemplate/Templates.java b/src/main/java/com/sshtools/tinytemplate/Templates.java index 3b5d780..c844279 100644 --- a/src/main/java/com/sshtools/tinytemplate/Templates.java +++ b/src/main/java/com/sshtools/tinytemplate/Templates.java @@ -45,8 +45,16 @@ import java.util.function.Supplier; import java.util.regex.Pattern; import java.util.stream.Collectors; +import java.util.stream.Stream; public class Templates { + + @FunctionalInterface + public interface VariableStore extends Function { + default boolean contains(String key) { + return apply(key) != null; + } + } public final static class VariableExpander { @@ -56,7 +64,7 @@ public final static class Builder { private Set> bundles = new LinkedHashSet<>(); private Function conditionEvaluator; private Optional logger = Optional.of(defaultStdOutLogger()); - private Function> variableSupplier; + private Function variableSupplier; public Builder withNullsAsNull() { return withNullsAreEmpty(false); @@ -100,7 +108,7 @@ public Builder withConditionEvaluator(Function conditionEvaluat return this; } - public Builder withVariableSupplier(Function> variableSupplier) { + public Builder withVariableSupplier(Function variableSupplier) { this.variableSupplier = variableSupplier; return this; } @@ -115,10 +123,7 @@ public Builder withLogger(Optional logger) { } public Builder fromSimpleMap(Map map) { - withVariableSupplier(k -> { - var val = map.get(k); - return val == null ? null : () -> val; - }); + withVariableSupplier(map::get); withConditionEvaluator(k -> eval(map, k)); return this; } @@ -144,7 +149,7 @@ private boolean eval(Map map, String k) { private final static String TERN_REGEXP = "([^:]*)(:)(.*)"; private final Pattern exprPattern; - private final Function> variableSupplier; + private final Function variableSupplier; private final boolean nullsAreEmpty; private final boolean missingThrowsException; private final Optional logger; @@ -276,11 +281,10 @@ public String expand(String input) { } private Object supplyVal(String param) { - var supplier = variableSupplier.apply(param); - if (missingThrowsException && supplier == null) + var val = variableSupplier.apply(param); + if (missingThrowsException && val == null) throw new IllegalArgumentException( MessageFormat.format("Required variable ''{0}'' is missing", param)); - var val = supplier == null ? null : supplier.get(); return val; } @@ -337,7 +341,7 @@ public final static Logger defaultStdOutLogger() { return LazyDefaultStdOutLogger.DEFAULT; } - public static class TemplateModel implements Closeable { + public final static class TemplateModel implements Closeable { public static TemplateModel ofContent(String content) { return new TemplateModel(new StringReader(content)); @@ -375,16 +379,35 @@ public static TemplateModel ofResource(String resource, Optional lo final Reader text; final Map> conditions = new HashMap<>(); - final Map> variables = new HashMap<>(); final Map>> lists = new HashMap<>(); + final Map> templates = new HashMap<>(); final Map> includes = new HashMap<>(); final List> bundles = new ArrayList<>(); - private Optional> locale = Optional.empty(); + final List variables = new ArrayList<>(); + final VariableStore defaultVariableStore; + final Map> defaultVariables = new HashMap<>(); + Optional> locale = Optional.empty(); + Optional parent = Optional.empty(); public final static Object[] NO_ARGS = new Object[0]; private TemplateModel(Reader text) { this.text = text; + + defaultVariableStore = new VariableStore() { + + @Override + public boolean contains(String key) { + return defaultVariables.containsKey(key); + } + + @Override + public Object apply(String key) { + var sup = defaultVariables.get(key); + return sup == null ? null : sup.get(); + } + }; + variables.add(defaultVariableStore); } @Override @@ -471,6 +494,15 @@ public TemplateModel include(String key, Supplier model) { return this; } + public TemplateModel template(String key, TemplateModel template) { + return template(key, (content) -> template); + } + + public TemplateModel template(String key, Function template) { + templates.put(key, template); + return this; + } + public TemplateModel list(String key, List list) { return list(key, (content) -> list); } @@ -489,10 +521,18 @@ public List list(String key, String content) { @SuppressWarnings("unchecked") public V variable(String key) { - var val = variables.get(key); - if (val == null) - throw new IllegalArgumentException(MessageFormat.format("No variable with key {0}", key)); - return (V) val.get(); + for(var vars : variables) { + if(vars.contains(key)) { + return (V)vars.apply(key); + } + } + return null; +// throw new IllegalArgumentException(MessageFormat.format("No variable with key {0}", key)); + } + + public TemplateModel variables(VariableStore store) { + variables.add(store); + return this; } public TemplateModel variable(String key, Object variable) { @@ -500,7 +540,7 @@ public TemplateModel variable(String key, Object variable) { } public TemplateModel variable(String key, Supplier variable) { - variables.put(key, variable); + defaultVariables.put(key, variable); return this; } @@ -517,7 +557,7 @@ public TemplateModel i18n(String key, Supplier variable, Supplier ol.get()).orElse(Locale.getDefault()); } + + public Stream> resolveBundles() { + if(parent.isPresent()) + return Stream.concat(bundles.stream(), parent.get().resolveBundles()); + else + return bundles.stream(); + } } /** @@ -641,7 +698,7 @@ private final static class Block { boolean match = true; boolean capture = false; boolean inElse = false; - int ifDepth = 0; + int nestDepth = 0; Block(TemplateModel model, VariableExpander expander, Reader reader/* , String scope */) { this(model, expander, reader, null, true); @@ -676,14 +733,14 @@ private VariableExpander getExpanderForModel(TemplateModel model) { var exp = expander.orElseGet(() -> { return new VariableExpander.Builder().withMissingThrowsException(missingThrowsException) .withNullsAreEmpty(nullsAreEmpty).withLogger(logger) - .withBundleSuppliers(model.bundles.stream().map(f -> { + .withBundleSuppliers(model.resolveBundles().map(f -> { return new Supplier() { @Override public ResourceBundle get() { return f.apply(locale); } }; - }).collect(Collectors.toList())).withVariableSupplier(model.variables::get) + }).collect(Collectors.toList())).withVariableSupplier(model::variable) .withConditionEvaluator(cond -> conditionOrVariable(model, cond, "").orElseGet(() -> { logger.ifPresent(l -> l.debug("Missing condition {0}, assuming {1}", cond, false)); return false; @@ -726,7 +783,7 @@ private void read(Block block) { // Vars // case VAR_START: - if (ch == '{') { + if (process && ch == '{') { block.state = State.VAR_BRACE; buf.append(ch); } else { @@ -772,8 +829,8 @@ else if (ch == 't') { case T_TAG_NAME: if (ch == '>') { var directive = buf.toString().substring(1).trim(); - if(directive.startsWith("t:if ")) { - block.ifDepth++; + if(directive.startsWith("t:if ") || directive.startsWith("t:list ")) { + block.nestDepth++; } if(process) { if(processDirective(block, directive)) { @@ -816,15 +873,15 @@ else if (ch == 't') { case T_TAG_END: if(ch == '>') { var directive = buf.toString().substring(4).trim(); - var isIf = directive.equals("if"); - if(isIf) { - block.ifDepth--; + var isNest = directive.equals("if") || directive.equals("list"); + if(isNest) { + block.nestDepth--; } - if(directive.equals(block.scope) && (!isIf || (isIf && block.ifDepth == 0))) { + if(directive.equals(block.scope) && (!isNest || (isNest && block.nestDepth == 0))) { logger.ifPresent(lg -> lg.debug("Leaving scope {0}", block.scope)); return; } else { - if(directive.equals(block.scope) && isIf) { + if(directive.equals(block.scope) && isNest && process) { buf.setLength(0); } else { @@ -892,7 +949,7 @@ private boolean processDirective(Block block, String directive) { match = !match; var ifBlock = new Block(block.model, getExpanderForModel(block.model), block.model.text, "if", match); - ifBlock.ifDepth = 1; + ifBlock.nestDepth = 1; read(ifBlock); @@ -905,7 +962,7 @@ private boolean processDirective(Block block, String directive) { else if(dir.equals("t:include")) { var includeModel = block.model.includes.get(var); if (includeModel == null) { - logger.ifPresent(l -> l.warning("No include in model named {0}", var)); + logger.ifPresent(l -> l.debug("No include in model named {0}", var)); return false; } else { var include = includeModel.get(); @@ -922,6 +979,34 @@ else if(dir.equals("t:else")) { block.state = State.START; return true; } + else if(dir.equals("t:template")) { + var templateSupplier = block.model.templates.get(var); + if (templateSupplier == null) { + logger.ifPresent(l -> l.warning("Missing template {0} in message template", var)); + return false; + } + else { + var templBlock = new Block(block.model, block.expander, block.reader, "template", true); + templBlock.nestDepth = 1; + templBlock.capture = true; + read(templBlock); + + var templ = templateSupplier.apply(templBlock.out.toString()); + var was = templ.parent; + try { + templ.parent = Optional.of(block.model); + var listBlock = new Block(templ, getExpanderForModel(templ), templ.text); + read(listBlock); + block.out.append(listBlock.out.toString()); + } + finally { + templ.parent = was; + } + + block.state = State.START; + return true; + } + } else if(dir.equals("t:list")) { var listSupplier = block.model.lists.get(var); if (listSupplier == null) { @@ -931,13 +1016,28 @@ else if(dir.equals("t:list")) { else { /* Temporary block to read all the content we must repeat */ var tempBlock = new Block(block.model, block.expander, block.reader, "list", true); + tempBlock.nestDepth = 1; tempBlock.capture = true; read(tempBlock); - for(var templ : listSupplier.apply(tempBlock.out.toString())) { - var listBlock = new Block(templ, getExpanderForModel(templ), templ.text); - read(listBlock); - block.out.append(listBlock.out.toString()); + var templates = listSupplier.apply(tempBlock.out.toString()); + var index = 0; + for(var templ : templates) { + var was = templ.parent; + templ.parent = Optional.of(block.model); + try { + templ.variable("_index", index); + templ.variable("_first", index == 0); + templ.variable("_last", index == templates.size() - 1); + var listBlock = new Block(templ, getExpanderForModel(templ), templ.text); + read(listBlock); + block.out.append(listBlock.out.toString()); + } + finally { + templ.parent = was; + } + + index++; } block.state = State.START; @@ -958,8 +1058,10 @@ private static Optional conditionOrVariable(TemplateModel model, String return Optional.of(true); } else if (content != null && model.lists.containsKey(attributeName)) { return Optional.of(!(model.lists.get(attributeName).apply(content).isEmpty())); - } else if (model.variables.containsKey(attributeName)) { - var var = model.variables.get(attributeName).get(); + } else if (content != null && model.templates.containsKey(attributeName)) { + return Optional.of(true); + } else if (model.hasVariable(attributeName)) { + var var = model.variable(attributeName); if (var instanceof Optional) { var = ((Optional) var).isEmpty() ? null : ((Optional) var).get(); @@ -974,6 +1076,8 @@ private static Optional conditionOrVariable(TemplateModel model, String } else if (var != null) { return Optional.of(Boolean.TRUE); } + } else if(model.parent.isPresent()) { + return conditionOrVariable(model.parent.get(), attributeName, content); } return Optional.empty(); } diff --git a/src/test/java/com/sshtools/tinytemplate/TemplatesTest.java b/src/test/java/com/sshtools/tinytemplate/TemplatesTest.java index 1659d0a..dc4f8d7 100644 --- a/src/test/java/com/sshtools/tinytemplate/TemplatesTest.java +++ b/src/test/java/com/sshtools/tinytemplate/TemplatesTest.java @@ -29,6 +29,7 @@ import java.util.Optional; import java.util.ResourceBundle; import java.util.stream.IntStream; +import java.util.stream.Stream; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -420,6 +421,133 @@ public void testTemplateWithList() { variable("var1", "Some Name"))); } + @Test + public void testTemplateNestedList() { + Assertions.assertEquals(""" + + + +
Row 1
+
more text
+ \t\t +
some inner text
+
A
+
more inner text
+ \t\t +
some inner text
+
B
+
more inner text
+ \t\t +
some inner text
+
C
+
more inner text
+ \t\t +
yet more text
+
and yet more text
+ +
Row 2
+
more text
+ \t\t +
some inner text
+
A
+
more inner text
+ \t\t +
some inner text
+
B
+
more inner text
+ \t\t +
some inner text
+
C
+
more inner text
+ \t\t +
yet more text
+
and yet more text
+ +
Row 3
+
more text
+ \t\t +
some inner text
+
A
+
more inner text
+ \t\t +
some inner text
+
B
+
more inner text
+ \t\t +
some inner text
+
C
+
more inner text
+ \t\t +
yet more text
+
and yet more text
+ +
Row 4
+
more text
+ \t\t +
some inner text
+
A
+
more inner text
+ \t\t +
some inner text
+
B
+
more inner text
+ \t\t +
some inner text
+
C
+
more inner text
+ \t\t +
yet more text
+
and yet more text
+ +
Row 5
+
more text
+ \t\t +
some inner text
+
A
+
more inner text
+ \t\t +
some inner text
+
B
+
more inner text
+ \t\t +
some inner text
+
C
+
more inner text
+ \t\t +
yet more text
+
and yet more text
+ + + + """, + createParser().process(TemplateModel.ofContent(""" + + + +
${row}
+
more text
+ +
some inner text
+
${innerRow}
+
more inner text
+
+
yet more text
+
and yet more text
+
+ + + """). + list("aList", (content) -> IntStream.of(1,2,3,4,5).mapToObj(i -> { + return TemplateModel.ofContent(content). + variable("row", "Row " + i). + list("bList", (bcontent) -> Stream.of("A", "B", "C").map(a -> { + return TemplateModel.ofContent(bcontent). + variable("innerRow", a); + }).toList()); + }).toList()). + variable("var1", "Some Name"))); + } + @Test public void testTemplateWithConditionsInList() { Assertions.assertEquals("""